PageSourceSearch

https://www.hymer-steigtechnik.de/_assets/e799cca3073eabb617dfebf5…/suggest_controller.js?1784301410

js hymer-steigtechnik.de collected 2026-10-09 22:43:58 UTC 12,717 bytes, 401 lines download raw bytes

1class SuggestController {
2  constructor(element) {
3    this.form = element;
4    this.suggestURL = this.form.getAttribute('data-suggest')
5    this.documentSuggestionHeader = this.form.getAttribute('data-suggest-header')
6    this.suggestionItems = []
7    this.documentItems = []
8    this.lastSelectedIndex = null
9    this.isMouseNavigation = false
10    this.autoCompleteInstance = null
11  }
12
13  /**
14   * Initialize autocomplete/suggestions
15   */
16  init() {
17    this.buildSuggestionBox();
18    this.setSuggestionBoxWidthAndPosition();
19  }
20
21  /**
22   * Reset autocomplete and document suggestions
23   */
24  resetSuggestions() {
25    this.suggestionItems = []
26    this.documentItems = []
27  }
28
29  /**
30   * Format search query. Avoid XSS
31   * @param query
32   * @returns {string}
33   */
34  formatQuery(query) {
35    if (typeof query !== "string") {
36      return "";
37    }
38    // Escape HTML characters (XSS-Protection)
39    const htmlEscaped = query
40      .replace(/&/g, "&")
41      .replace(/</g, "&lt;")
42      .replace(/>/g, "&gt;")
43      .replace(/"/g, "&quot;")
44      .replace(/'/g, "&#x27;")
45      .replace(/\//g, "&#x2F;");
46    // Only keep letters, numbers, spaces, hyphens, underscore, period, comma
47    const cleaned = htmlEscaped.replace(/[^\p{L}\p{N}\s\-_.,]/gu, "");
48
49    // Remove multiple spaces
50    return cleaned.trim().replace(/\s+/g, " ");
51  }
52
53  /**
54   * Format result to avoid display errors
55   * @param result
56   * @returns {string}
57   */
58  formatResult(result) {
59    if (typeof result !== "string") {
60      return "";
61    }
62    // Escape HTML characters. Only allow <strong>
63    const htmlEscaped = result
64      .replace(/&/g, "&amp;")
65      .replace(/</g, "&lt;")
66      .replace(/>/g, "&gt;")
67      .replace(/"/g, "&quot;")
68      .replace(/'/g, "&#x27;")
69      .replace(/&lt;\/?mark&gt;/g, (match) => match.includes('/') ? '</strong>' : '<strong>');
70
71    return htmlEscaped;
72  }
73
74  /**
75   * Fetch autocomplete and document suggestions
76   * @param query
77   * @returns {Promise<[]|*|*[]>}
78   */
79  async fetchSuggestions(query) {
80    // Cancel request if there is one
81    if (this.abortController) {
82      this.abortController.abort();
83    }
84    this.resetSuggestions()
85    // Create AbortController to avoid multiple requests
86    this.abortController = new AbortController();
87
88    try {
89      const separator = this.suggestURL.includes('?') ? '&' : '?';
90      const url = `${this.suggestURL}${separator}tx_solr%5BqueryString%5D=${encodeURIComponent(query)}`;
91      const response = await fetch(
92        url,
93        { signal: this.abortController.signal }
94      );
95      const data = await response.json();
96      // Define autocomplete and document suggestions
97      this.suggestionItems = data.suggestions ? Object.keys(data.suggestions) : [];
98      this.documentItems = Array.isArray(data.documents) ? data.documents : [];
99
100      return this.suggestionItems;
101    } catch (error) {
102      if (error.name === 'AbortError') {
103        // Aborted request
104        return [];
105      }
106      // Real error
107      throw error;
108    }
109  }
110
111  /**
112   * Set input value to initial query, set cursor behind search term, remove active-state from items
113   */
114  resetInput() {
115    const list = this.autoCompleteInstance.list;
116    const selected = list.querySelector(`.${this.autoCompleteInstance.resultItem.selected}`);
117
118    // Remove selected state
119    if (selected) {
120      selected.removeAttribute("aria-selected");
121      selected.classList.remove(this.autoCompleteInstance.resultItem.selected);
122    }
123
124    this.autoCompleteInstance.cursor = -1
125    // Set input value to initial query
126    this.autoCompleteInstance.input.value = this.autoCompleteInstance.feedback.query;
127    // Set cursor behind the search term
128    this.setCursorBehindTerm()
129  }
130
131  /**
132   * Set cursor at the end of the search term
133   */
134  setCursorBehindTerm() {
135    const queryLength = this.autoCompleteInstance.input.value.length
136
137    setTimeout(() => {
138      this.autoCompleteInstance.input.setSelectionRange(queryLength, queryLength)
139    }, 0);
140  }
141
142  /**
143   * Handler for arrow up key
144   */
145  arrowUpHandler() {
146    if (this.autoCompleteInstance.isOpen && this.lastSelectedIndex !== null) {
147      // If first result item is selected and arrow up is pressed then
148      // replace input value with initial query and reset suggestion box.
149      if (this.lastSelectedIndex === 0) {
150        this.resetInput()
151      } else {
152        this.autoCompleteInstance.previous()
153        this.setCursorBehindTerm()
154      }
155    }
156  }
157
158  /**
159   * Handler for arrow down key
160   */
161  arrowDownHandler() {
162    const list = this.autoCompleteInstance.list;
163
164    if (this.autoCompleteInstance.isOpen && list) {
165      const items = list.querySelectorAll("li");
166
167      // Navigate to next item as long as it's not last item
168      if (this.lastSelectedIndex < items.length - 1) {
169        this.autoCompleteInstance.next()
170      }
171    } else if (list) {
172      // Open suggestion box on arrow down key
173      this.autoCompleteInstance.open()
174    }
175  }
176
177  /**
178   * Handler for enter key
179   */
180  enterHandler() {
181    const cursor = this.autoCompleteInstance.cursor
182    // If nothing is selected or suggestion box is closed then submit form
183    if ((cursor === undefined || cursor === -1) || !this.autoCompleteInstance.isOpen) {
184      if (this.autoCompleteInstance.input.value.length) {
185        this.form.requestSubmit()
186      }
187    } else {
188      this.autoCompleteInstance.select(cursor)
189    }
190  }
191
192  /**
193   * Handler for escape key
194   */
195  escapeHandler() {
196    if (this.autoCompleteInstance.isOpen) {
197      this.autoCompleteInstance.close()
198      this.resetInput()
199    }
200  }
201
202  /**
203   * Create document suggestion item under "Top Results"
204   * @param documentItem
205   * @returns {HTMLLIElement}
206   */
207  createDocumentSuggestionItem(documentItem) {
208    const documentElement = document.createElement("li");
209    const formattedDocument = this.formatResult(documentItem.title)
210    const link = document.createElement("a");
211    const index = this.autoCompleteInstance.list.querySelectorAll('li').length;
212
213    link.href = documentItem.link;
214    link.innerHTML = formattedDocument;
215    documentElement.appendChild(link);
216    documentElement.classList.add('autocomplete-suggestion')
217    this.onResultItemHover(documentElement, index)
218
219    return documentElement
220  }
221
222  /**
223   * Create document suggestion header (Top results)
224   * @returns {HTMLParagraphElement}
225   */
226  createDocumentSuggestionHeader() {
227    const documentHeader = document.createElement("p");
228
229    documentHeader.classList.add('autocomplete-group')
230    documentHeader.innerHTML = `<span>${this.documentSuggestionHeader}</span>`;
231
232    return documentHeader
233  }
234
235  /**
236   * Set hovered result items as selected
237   * @param item
238   * @param index
239   */
240  onResultItemHover(item, index) {
241    item.onmouseenter = () => {
242      this.lastSelectedIndex = index
243      // Set navigateVia to mouse to prevent input value replacement via hover later on. Only via keyboard.
244      this.isMouseNavigation = true
245      // Set hovered autocomplete suggestion as selected
246      this.autoCompleteInstance.goTo(index)
247    }
248  }
249
250  /**
251   * Set suggestion box position and width
252   */
253  setSuggestionBoxWidthAndPosition() {
254    const list = this.autoCompleteInstance.list;
255    if (!list) return;
256
257    // Move suggestion box to end of body tag to prevent problems with overflow-hidden parents
258    document.body.appendChild(list);
259
260    const input = this.autoCompleteInstance.input;
261    const inputRect = input.getBoundingClientRect();
262
263    list.style.top = `${inputRect.bottom + window.scrollY}px`;
264    list.style.left = `${inputRect.left + window.scrollX}px`;
265    list.style.width = `${inputRect.width}px`;
266  }
267
268  /**
269   * Build suggestion box with autoCompleteJS
270   * https://tarekraafat.github.io/autoComplete.js/#/configuration
271   */
272  buildSuggestionBox() {
273    if (this.autoCompleteInstance) {
274      this.autoCompleteInstance.unInit();
275    }
276    this.autoCompleteInstance = new autoComplete(
277      {
278        selector: ".tx-solr-suggest",
279        data: {
280          src: async (query) => {
281            const formattedQuery = this.formatQuery(query)
282            return await this.fetchSuggestions(formattedQuery);
283          },
284          cache: false,
285        },
286        debounce: 300,
287        wrapper: false,
288        resultsList: {
289          class: 'autocomplete-suggestions tx-solr-autosuggest',
290          element: (list) => {
291            // Add document suggestions (link) to result list under "Top results"
292            if (this.documentItems.length) {
293              list.append(this.createDocumentSuggestionHeader());
294              this.documentItems.forEach((documentItem) => {
295                list.append(this.createDocumentSuggestionItem(documentItem));
296              })
297            }
298          }
299        },
300        resultItem: {
301          highlight: true,
302          class: 'autocomplete-suggestion',
303          selected: 'autocomplete-selected',
304          element: (item, data) => {
305            item.innerHTML = `${this.formatResult(data.match)}`;
306            // Set hovered autocomplete suggestion as selected
307            const index = this.suggestionItems.indexOf(data.value);
308            this.onResultItemHover(item, index)
309          },
310        },
311        events: {
312          input: {
313            focus: () => {
314              const inputValueLength = this.autoCompleteInstance.input.value.length
315              const resultItemsLength = this.autoCompleteInstance.list.children.length
316              // Reopen suggestion list when results are still the same. Otherwise, start new request
317              inputValueLength && resultItemsLength ? this.autoCompleteInstance.open() : this.autoCompleteInstance.start();
318
319            },
320            selection: (event) => {
321              if (!this.autoCompleteInstance.isOpen) return;
322              const selectionIndex = event.detail.selection.index
323
324              this.autoCompleteInstance.input.blur();
325              // Replace input value with the selected value
326              this.autoCompleteInstance.input.value = this.formatQuery(event.detail.selection.value);
327
328              // If selected element comes after the autocomplete suggestions (document suggestion)
329              if (selectionIndex >= event.detail.results.length) {
330                event.preventDefault();
331                const resultItems = this.autoCompleteInstance.list.querySelectorAll('li')
332
333                // Get href from selected item and open it
334                window.location.href = resultItems[selectionIndex].children[0].href
335              } else {
336                this.form.requestSubmit()
337              }
338            },
339            navigate: (event) => {
340              if (!this.autoCompleteInstance.isOpen) return;
341              if (!this.isMouseNavigation) {
342                // Replace value of focused result item with input field value (only keyboard)
343                if (event.detail.selection.value) {
344                  this.autoCompleteInstance.input.value = event.detail.selection.value;
345                }
346              }
347              this.isMouseNavigation = false
348            },
349            open: () => {
350              this.lastSelectedIndex = null
351              this.setSuggestionBoxWidthAndPosition();
352            },
353            close: () => {
354              this.lastSelectedIndex = null
355              // Abort all running requests
356              this.abortController.abort();
357            },
358            keydown: (event) => {
359              const cursor = this.autoCompleteInstance.cursor;
360              this.lastSelectedIndex = (cursor !== undefined && cursor >= 0) ? cursor : null;
361
362              switch (event.key) {
363                case 'ArrowDown':
364                  this.arrowDownHandler();
365                  break;
366                case 'ArrowUp':
367                  this.arrowUpHandler();
368                  break;
369                case 'Enter':
370                  this.enterHandler();
371                  break;
372                case 'Escape':
373                  this.escapeHandler();
374                  break;
375              }
376            }
377          }
378        }
379      }
380    )
381  }
382}
383
384const suggestControllers = [];
385const initSuggestControllers = () => {
386  suggestControllers.length = 0;
387  const searchForms = document.querySelectorAll('form[data-suggest]')
388  if (searchForms.length) {
389    searchForms.forEach(element => {
390      const controller = new SuggestController(element);
391      controller.init();
392      suggestControllers.push(controller);
393    });
394  }
395};
396
397initSuggestControllers();
398window.addEventListener('resize', () => {
399  suggestControllers.forEach(controller => controller.setSuggestionBoxWidthAndPosition());
400});
401document.body.addEventListener("tx_solr_updated", initSuggestControllers);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.