diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.ts similarity index 52% rename from assets/js/autocomplete.js rename to assets/js/autocomplete.ts index 8ec0f278..19bdfff0 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.ts @@ -6,52 +6,67 @@ import { LocalAutocompleter } from './utils/local-autocompleter'; import { handleError } from './utils/requests'; import { getTermContexts } from './match_query'; import store from './utils/store'; +import { TermContext } from './query/lex.ts'; +import { $, $$, makeEl, removeEl } from './utils/dom.ts'; -const cache = {}; -/** @type {HTMLInputElement} */ -let inputField, - /** @type {string} */ - originalTerm, - /** @type {string} */ - originalQuery, - /** @type {TermContext} */ - selectedTerm; +type TermSuggestion = { + label: string; + value: string; +}; + +const cachedSuggestions: Record = {}; +let inputField: HTMLInputElement | null = null, + originalTerm: string | undefined, + originalQuery: string | undefined, + selectedTerm: TermContext | null = null; function removeParent() { - const parent = document.querySelector('.autocomplete'); - if (parent) parent.parentNode.removeChild(parent); + const parent = $('.autocomplete'); + if (parent) removeEl(parent); } function removeSelected() { - const selected = document.querySelector('.autocomplete__item--selected'); + const selected = $('.autocomplete__item--selected'); if (selected) selected.classList.remove('autocomplete__item--selected'); } -function isSearchField() { - return inputField && inputField.dataset.acMode === 'search'; +function isSearchField(targetInput: HTMLElement) { + return targetInput && targetInput.dataset.acMode === 'search'; } function restoreOriginalValue() { - inputField.value = isSearchField() ? originalQuery : originalTerm; + if (!inputField) { + return; + } + + if (isSearchField(inputField) && originalQuery) { + inputField.value = originalQuery; + } + + if (originalTerm) { + inputField.value = originalTerm; + } } -function applySelectedValue(selection) { - if (!isSearchField()) { +function applySelectedValue(selection: string) { + if (!inputField) { + return; + } + + if (!isSearchField(inputField)) { inputField.value = selection; return; } - if (!selectedTerm) { - return; + if (selectedTerm && originalQuery) { + const [startIndex, endIndex] = selectedTerm[0]; + inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex); + inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length); + inputField.focus(); } - - const [startIndex, endIndex] = selectedTerm[0]; - inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex); - inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length); - inputField.focus(); } -function changeSelected(firstOrLast, current, sibling) { +function changeSelected(firstOrLast: Element | null, current: Element | null, sibling: Element | null) { if (current && sibling) { // if the currently selected item has a sibling, move selection to it current.classList.remove('autocomplete__item--selected'); @@ -67,18 +82,21 @@ function changeSelected(firstOrLast, current, sibling) { } function isSelectionOutsideCurrentTerm() { + if (!inputField || !selectedTerm) return true; + if (inputField.selectionStart === null || inputField.selectionEnd === null) return true; + const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); const [startIndex, endIndex] = selectedTerm[0]; return startIndex > selectionIndex || endIndex < selectionIndex; } -function keydownHandler(event) { - const selected = document.querySelector('.autocomplete__item--selected'), - firstItem = document.querySelector('.autocomplete__item:first-of-type'), - lastItem = document.querySelector('.autocomplete__item:last-of-type'); +function keydownHandler(event: KeyboardEvent) { + const selected = $('.autocomplete__item--selected'), + firstItem = $('.autocomplete__item:first-of-type'), + lastItem = $('.autocomplete__item:last-of-type'); - if (isSearchField()) { + if (inputField && isSearchField(inputField)) { // Prevent submission of the search field when Enter was hit if (selected && event.keyCode === 13) event.preventDefault(); // Enter @@ -91,20 +109,21 @@ function keydownHandler(event) { } } - if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp - if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown + if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousElementSibling); // ArrowUp + if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextElementSibling); // ArrowDown if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown - const newSelected = document.querySelector('.autocomplete__item--selected'); - if (newSelected) applySelectedValue(newSelected.dataset.value); + const newSelected = $('.autocomplete__item--selected'); + if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value); event.preventDefault(); } } -function createItem(list, suggestion) { - const item = document.createElement('li'); - item.className = 'autocomplete__item'; +function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { + const item = makeEl('li', { + className: 'autocomplete__item', + }); item.textContent = suggestion.label; item.dataset.value = suggestion.value; @@ -119,7 +138,10 @@ function createItem(list, suggestion) { }); item.addEventListener('click', () => { + if (!inputField || !item.dataset.value) return; + applySelectedValue(item.dataset.value); + inputField.dispatchEvent( new CustomEvent('autocomplete', { detail: { @@ -134,66 +156,71 @@ function createItem(list, suggestion) { list.appendChild(item); } -function createList(suggestions) { - const parent = document.querySelector('.autocomplete'), - list = document.createElement('ul'); - list.className = 'autocomplete__list'; +function createList(parentElement: HTMLElement, suggestions: TermSuggestion[]) { + const list = makeEl('ul', { + className: 'autocomplete__list', + }); suggestions.forEach(suggestion => createItem(list, suggestion)); - parent.appendChild(list); + parentElement.appendChild(list); } -function createParent() { - const parent = document.createElement('div'); +function createParent(): HTMLElement { + const parent = makeEl('div'); parent.className = 'autocomplete'; - // Position the parent below the inputfield - parent.style.position = 'absolute'; - parent.style.left = `${inputField.offsetLeft}px`; - // Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled - parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentNode.scrollTop}px`; + if (inputField && inputField.parentElement) { + // Position the parent below the inputfield + parent.style.position = 'absolute'; + parent.style.left = `${inputField.offsetLeft}px`; + // Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled + parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentElement.scrollTop}px`; + } // We append the parent at the end of body document.body.appendChild(parent); + + return parent; } -function showAutocomplete(suggestions, fetchedTerm, targetInput) { +function showAutocomplete(suggestions: TermSuggestion[], fetchedTerm: string, targetInput: HTMLInputElement) { // Remove old autocomplete suggestions removeParent(); // Save suggestions in cache - cache[fetchedTerm] = suggestions; + cachedSuggestions[fetchedTerm] = suggestions; // If the input target is not empty, still visible, and suggestions were found if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) { - createParent(); - createList(suggestions); - inputField.addEventListener('keydown', keydownHandler); + createList(createParent(), suggestions); + targetInput.addEventListener('keydown', keydownHandler); } } -function getSuggestions(term) { +async function getSuggestions(term: string): Promise { // In case source URL was not given at all, do not try sending the request. - if (!inputField.dataset.acSource) return []; - return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json()); + if (!inputField?.dataset.acSource) return []; + + return await fetch(`${inputField.dataset.acSource}${term}`) + .then(handleError) + .then(response => response.json()); } -function getSelectedTerm() { - if (!inputField || !originalQuery) { - return null; - } +function getSelectedTerm(): TermContext | null { + if (!inputField || !originalQuery) return null; + if (inputField.selectionStart === null || inputField.selectionEnd === null) return null; const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd); const terms = getTermContexts(originalQuery); - return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex); + return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null; } function toggleSearchAutocomplete() { const enable = store.get('enable_search_ac'); - for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) { + for (const searchField of $$('input[data-ac-mode=search]')) { if (enable) { searchField.autocomplete = 'off'; } else { @@ -204,10 +231,9 @@ function toggleSearchAutocomplete() { } function listenAutocomplete() { - let timeout; + let timeout: number | undefined; - /** @type {LocalAutocompleter} */ - let localAc = null; + let localAc: LocalAutocompleter | null = null; let localFetched = false; document.addEventListener('focusin', fetchLocalAutocomplete); @@ -217,11 +243,15 @@ function listenAutocomplete() { fetchLocalAutocomplete(event); window.clearTimeout(timeout); - if (localAc !== null && 'ac' in event.target.dataset) { - inputField = event.target; + if (!(event.target instanceof HTMLInputElement)) return; + + const targetedInput = event.target; + + if (localAc !== null && 'ac' in targetedInput.dataset) { + inputField = targetedInput; let suggestionsCount = 5; - if (isSearchField()) { + if (isSearchField(inputField)) { originalQuery = inputField.value; selectedTerm = getSelectedTerm(); suggestionsCount = 10; @@ -242,29 +272,31 @@ function listenAutocomplete() { .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); if (suggestions.length) { - return showAutocomplete(suggestions, originalTerm, event.target); + return showAutocomplete(suggestions, originalTerm, targetedInput); } } // Use a timeout to delay requests until the user has stopped typing timeout = window.setTimeout(() => { - inputField = event.target; + inputField = targetedInput; originalTerm = inputField.value; const fetchedTerm = inputField.value; const { ac, acMinLength, acSource } = inputField.dataset; - if (ac && acSource && fetchedTerm.length >= acMinLength) { - if (cache[fetchedTerm]) { - showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target); - } else { - // inputField could get overwritten while the suggestions are being fetched - use event.target - getSuggestions(fetchedTerm).then(suggestions => { - if (fetchedTerm === event.target.value) { - showAutocomplete(suggestions, fetchedTerm, event.target); - } - }); - } + if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) { + return; + } + + if (cachedSuggestions[fetchedTerm]) { + showAutocomplete(cachedSuggestions[fetchedTerm], fetchedTerm, targetedInput); + } else { + // inputField could get overwritten while the suggestions are being fetched - use event.target + getSuggestions(fetchedTerm).then(suggestions => { + if (fetchedTerm === targetedInput.value) { + showAutocomplete(suggestions, fetchedTerm, targetedInput); + } + }); } }, 300); }); @@ -272,10 +304,14 @@ function listenAutocomplete() { // If there's a click outside the inputField, remove autocomplete document.addEventListener('click', event => { if (event.target && event.target !== inputField) removeParent(); - if (event.target === inputField && isSearchField() && isSelectionOutsideCurrentTerm()) removeParent(); + if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) { + removeParent(); + } }); - function fetchLocalAutocomplete(event) { + function fetchLocalAutocomplete(event: Event) { + if (!(event.target instanceof HTMLInputElement)) return; + if (!localFetched && event.target.dataset && 'ac' in event.target.dataset) { const now = new Date(); const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;