diff --git a/assets/js/autocomplete.ts b/assets/js/autocomplete.ts index 033176c8..489392c3 100644 --- a/assets/js/autocomplete.ts +++ b/assets/js/autocomplete.ts @@ -3,42 +3,25 @@ */ 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'; -import { mouseMoveThenOver } from './utils/events.ts'; +import { TermContext } from './query/lex'; +import { $$ } from './utils/dom'; +import { fetchLocalAutocomplete, fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions'; -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 = $('.autocomplete'); - if (parent) removeEl(parent); -} - -function removeSelected() { - const selected = $('.autocomplete__item--selected'); - if (selected) selected.classList.remove('autocomplete__item--selected'); -} +const popup = new SuggestionsPopup(); function isSearchField(targetInput: HTMLElement): boolean { return targetInput && targetInput.dataset.acMode === 'search'; } function restoreOriginalValue() { - if (!inputField) { - return; - } + if (!inputField) return; if (isSearchField(inputField) && originalQuery) { inputField.value = originalQuery; @@ -50,9 +33,7 @@ function restoreOriginalValue() { } function applySelectedValue(selection: string) { - if (!inputField) { - return; - } + if (!inputField) return; if (!isSearchField(inputField)) { inputField.value = selection; @@ -67,21 +48,6 @@ function applySelectedValue(selection: string) { } } -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'); - sibling.classList.add('autocomplete__item--selected'); - } else if (current) { - // if the next keypress will take the user outside the list, restore the unautocompleted term - restoreOriginalValue(); - removeSelected(); - } else if (firstOrLast) { - // if no item in the list is selected, select the first or last - firstOrLast.classList.add('autocomplete__item--selected'); - } -} - function isSelectionOutsideCurrentTerm(): boolean { if (!inputField || !selectedTerm) return true; if (inputField.selectionStart === null || inputField.selectionEnd === null) return true; @@ -93,127 +59,43 @@ function isSelectionOutsideCurrentTerm(): boolean { } function keydownHandler(event: KeyboardEvent) { - const selected = $('.autocomplete__item--selected'), - firstItem = $('.autocomplete__item:first-of-type'), - lastItem = $('.autocomplete__item:last-of-type'); + if (inputField !== event.currentTarget) return; if (inputField && isSearchField(inputField)) { // Prevent submission of the search field when Enter was hit - if (selected && event.keyCode === 13) event.preventDefault(); // Enter + if (popup.selectedTerm && event.keyCode === 13) event.preventDefault(); // Enter // Close autocompletion popup when text cursor is outside current tag - if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { + if (selectedTerm && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight requestAnimationFrame(() => { - if (isSelectionOutsideCurrentTerm()) removeParent(); + if (isSelectionOutsideCurrentTerm()) popup.hide(); }); } } - 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 (!popup.isActive) return; + + if (event.keyCode === 38) popup.selectPrevious(); // ArrowUp + if (event.keyCode === 40) popup.selectNext(); // ArrowDown + if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) popup.hide(); // Enter || Esc || Comma if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown - const newSelected = $('.autocomplete__item--selected'); - if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value); + if (popup.selectedTerm) { + applySelectedValue(popup.selectedTerm); + } else { + restoreOriginalValue(); + } + event.preventDefault(); } } -function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { - const item = makeEl('li', { - className: 'autocomplete__item', - }); +function findSelectedTerm(targetInput: HTMLInputElement, searchQuery: string): TermContext | null { + if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null; - item.textContent = suggestion.label; - item.dataset.value = suggestion.value; - - mouseMoveThenOver(item, () => { - removeSelected(); - item.classList.add('autocomplete__item--selected'); - }); - - item.addEventListener('mouseout', () => { - removeSelected(); - }); - - item.addEventListener('click', () => { - if (!inputField || !item.dataset.value) return; - - applySelectedValue(item.dataset.value); - - inputField.dispatchEvent( - new CustomEvent('autocomplete', { - detail: { - type: 'click', - label: suggestion.label, - value: suggestion.value, - }, - }), - ); - }); - - list.appendChild(item); -} - -function createList(parentElement: HTMLElement, suggestions: TermSuggestion[]) { - const list = makeEl('ul', { - className: 'autocomplete__list', - }); - - suggestions.forEach(suggestion => createItem(list, suggestion)); - - parentElement.appendChild(list); -} - -function createParent(): HTMLElement { - const parent = makeEl('div'); - parent.className = 'autocomplete'; - - 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: TermSuggestion[], fetchedTerm: string, targetInput: HTMLInputElement) { - // Remove old autocomplete suggestions - removeParent(); - - // Save suggestions in cache - 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) { - createList(createParent(), suggestions); - targetInput.addEventListener('keydown', keydownHandler); - } -} - -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 await fetch(`${inputField.dataset.acSource}${term}`) - .then(handleError) - .then(response => response.json()); -} - -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); + const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd); + const terms = getTermContexts(searchQuery); return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null; } @@ -232,29 +114,33 @@ function toggleSearchAutocomplete() { } function listenAutocomplete() { - let timeout: number | undefined; + let serverSideSuggestionsTimeout: number | undefined; let localAc: LocalAutocompleter | null = null; - let localFetched = false; + let isLocalLoading = false; - document.addEventListener('focusin', fetchLocalAutocomplete); + document.addEventListener('focusin', loadAutocompleteFromEvent); document.addEventListener('input', event => { - removeParent(); - fetchLocalAutocomplete(event); - window.clearTimeout(timeout); + popup.hide(); + loadAutocompleteFromEvent(event); + window.clearTimeout(serverSideSuggestionsTimeout); if (!(event.target instanceof HTMLInputElement)) return; const targetedInput = event.target; - if (localAc !== null && 'ac' in targetedInput.dataset) { + if (!targetedInput.dataset.ac) return; + + targetedInput.addEventListener('keydown', keydownHandler); + + if (localAc !== null) { inputField = targetedInput; let suggestionsCount = 5; if (isSearchField(inputField)) { originalQuery = inputField.value; - selectedTerm = getSelectedTerm(); + selectedTerm = findSelectedTerm(inputField, originalQuery); suggestionsCount = 10; // We don't need to run auto-completion if user is not selecting tag at all @@ -273,62 +159,72 @@ function listenAutocomplete() { .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); if (suggestions.length) { - return showAutocomplete(suggestions, originalTerm, targetedInput); + popup.renderSuggestions(suggestions).showForField(targetedInput); + return; } } + const { acMinLength: minTermLength, acSource: endpointUrl } = targetedInput.dataset; + + if (!endpointUrl) return; + // Use a timeout to delay requests until the user has stopped typing - timeout = window.setTimeout(() => { + serverSideSuggestionsTimeout = window.setTimeout(() => { inputField = targetedInput; originalTerm = inputField.value; const fetchedTerm = inputField.value; - const { ac, acMinLength, acSource } = inputField.dataset; - if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) { - return; - } + if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 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); - } - }); - } + fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => { + // inputField could get overwritten while the suggestions are being fetched - use previously targeted input + if (fetchedTerm === targetedInput.value) { + popup.renderSuggestions(suggestions).showForField(targetedInput); + } + }); }, 300); }); // If there's a click outside the inputField, remove autocomplete document.addEventListener('click', event => { - if (event.target && event.target !== inputField) removeParent(); + if (event.target && event.target !== inputField) popup.hide(); if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) { - removeParent(); + popup.hide(); } }); - function fetchLocalAutocomplete(event: Event) { + function loadAutocompleteFromEvent(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()}`; + if (!isLocalLoading && event.target.dataset.ac) { + isLocalLoading = true; - localFetched = true; - - fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' }) - .then(handleError) - .then(resp => resp.arrayBuffer()) - .then(buf => { - localAc = new LocalAutocompleter(buf); - }); + fetchLocalAutocomplete().then(autocomplete => { + localAc = autocomplete; + }); } } toggleSearchAutocomplete(); + + popup.onItemSelected((event: CustomEvent) => { + if (!event.detail || !inputField) return; + + const originalSuggestion = event.detail; + applySelectedValue(originalSuggestion.value); + + inputField.dispatchEvent( + new CustomEvent('autocomplete', { + detail: Object.assign( + { + type: 'click', + }, + originalSuggestion, + ), + }), + ); + }); } export { listenAutocomplete }; diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts new file mode 100644 index 00000000..59102d2b --- /dev/null +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -0,0 +1,334 @@ +import { fetchMock } from '../../../test/fetch-mock.ts'; +import { + fetchLocalAutocomplete, + fetchSuggestions, + purgeSuggestionsCache, + SuggestionsPopup, + TermSuggestion, +} from '../suggestions.ts'; +import fs from 'fs'; +import path from 'path'; +import { LocalAutocompleter } from '../local-autocompleter.ts'; +import { afterEach } from 'vitest'; +import { fireEvent } from '@testing-library/dom'; + +const mockedSuggestionsEndpoint = '/endpoint?term='; +const mockedSuggestionsResponse = [ + { label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' }, + { label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' }, + { label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' }, + { label: 'artist:devinian (1)', value: 'artist:devinian' }, + { label: 'artist:moe (1)', value: 'artist:moe' }, +]; + +function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] { + const input = document.createElement('input'); + const popup = new SuggestionsPopup(); + + document.body.append(input); + popup.showForField(input); + + if (includeMockedSuggestions) { + popup.renderSuggestions(mockedSuggestionsResponse); + } + + return [popup, input]; +} + +const selectedItemClassName = 'autocomplete__item--selected'; + +describe('Suggestions', () => { + let mockedAutocompleteBuffer: ArrayBuffer; + let popup: SuggestionsPopup | undefined; + let input: HTMLInputElement | undefined; + + beforeAll(async () => { + fetchMock.enableMocks(); + + mockedAutocompleteBuffer = await fs.promises + .readFile(path.join(__dirname, 'autocomplete-compiled-v2.bin')) + .then(fileBuffer => fileBuffer.buffer); + }); + + afterAll(() => { + fetchMock.disableMocks(); + }); + + beforeEach(() => { + purgeSuggestionsCache(); + fetchMock.resetMocks(); + }); + + afterEach(() => { + if (input) { + input.remove(); + input = undefined; + } + + if (popup) { + popup.hide(); + popup = undefined; + } + }); + + describe('SuggestionsPopup', () => { + it('should create the popup container', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement); + expect(popup.isActive).toBe(true); + }); + + it('should be removed when hidden', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + popup.hide(); + + expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement); + expect(popup.isActive).toBe(false); + }); + + it('should render suggestions', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + expect(document.querySelectorAll('.autocomplete__item').length).toBe(mockedSuggestionsResponse.length); + }); + + it('should initially select first element when selectNext called', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + popup.selectNext(); + + expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); + }); + + it('should initially select last element when selectPrevious called', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + popup.selectPrevious(); + + expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName); + }); + + it('should select and de-select items when hovering items over', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + const firstItem = document.querySelector('.autocomplete__item:first-child'); + const lastItem = document.querySelector('.autocomplete__item:last-child'); + + if (firstItem) { + fireEvent.mouseOver(firstItem); + fireEvent.mouseMove(firstItem); + } + + expect(firstItem).toHaveClass(selectedItemClassName); + + if (lastItem) { + fireEvent.mouseOver(lastItem); + fireEvent.mouseMove(lastItem); + } + + expect(firstItem).not.toHaveClass(selectedItemClassName); + expect(lastItem).toHaveClass(selectedItemClassName); + + if (lastItem) { + fireEvent.mouseOut(lastItem); + } + + expect(lastItem).not.toHaveClass(selectedItemClassName); + }); + + it('should allow switching between mouse and selection', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + const secondItem = document.querySelector('.autocomplete__item:nth-child(2)'); + const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)'); + + if (secondItem) { + fireEvent.mouseOver(secondItem); + fireEvent.mouseMove(secondItem); + } + + expect(secondItem).toHaveClass(selectedItemClassName); + + popup.selectNext(); + + expect(secondItem).not.toHaveClass(selectedItemClassName); + expect(thirdItem).toHaveClass(selectedItemClassName); + }); + + it('should loop around when selecting next on last and previous on first', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + const firstItem = document.querySelector('.autocomplete__item:first-child'); + const lastItem = document.querySelector('.autocomplete__item:last-child'); + + if (lastItem) { + fireEvent.mouseOver(lastItem); + fireEvent.mouseMove(lastItem); + } + + expect(lastItem).toHaveClass(selectedItemClassName); + + popup.selectNext(); + + expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); + + popup.selectNext(); + + expect(firstItem).toHaveClass(selectedItemClassName); + + popup.selectPrevious(); + + expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); + + popup.selectPrevious(); + + expect(lastItem).toHaveClass(selectedItemClassName); + }); + + it('should return selected item value', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + expect(popup.selectedTerm).toBe(null); + + popup.selectNext(); + + expect(popup.selectedTerm).toBe(mockedSuggestionsResponse[0].value); + }); + + it('should emit an event when item was clicked with mouse', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + let clickEvent: CustomEvent | undefined; + + const itemSelectedHandler = vi.fn((event: CustomEvent) => { + clickEvent = event; + }); + + popup.onItemSelected(itemSelectedHandler); + + const firstItem = document.querySelector('.autocomplete__item'); + + if (firstItem) { + fireEvent.click(firstItem); + } + + expect(itemSelectedHandler).toBeCalledTimes(1); + expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]); + }); + + it('should not emit selection on items without value', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + popup.renderSuggestions([{ label: 'Option without value', value: '' }]); + + const itemSelectionHandler = vi.fn(); + + popup.onItemSelected(itemSelectionHandler); + + const firstItem = document.querySelector('.autocomplete__item:first-child')!; + + if (firstItem) { + fireEvent.click(firstItem); + } + + expect(itemSelectionHandler).not.toBeCalled(); + }); + }); + + describe('fetchSuggestions', () => { + it('should only call fetch once per single term', () => { + fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should be case-insensitive to terms and trim spaces', () => { + fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + fetchSuggestions(mockedSuggestionsEndpoint, 'Art'); + fetchSuggestions(mockedSuggestionsEndpoint, ' ART '); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should return the same suggestions from cache', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); + + const firstSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + const secondSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + + expect(firstSuggestions).toBe(secondSuggestions); + }); + + it('should parse and return array of suggestions', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); + + const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + + expect(resolvedSuggestions).toBeInstanceOf(Array); + expect(resolvedSuggestions.length).toBe(mockedSuggestionsResponse.length); + expect(resolvedSuggestions).toEqual(mockedSuggestionsResponse); + }); + + it('should return empty array on server error', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); + + const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'unknown tag'); + + expect(resolvedSuggestions).toBeInstanceOf(Array); + expect(resolvedSuggestions.length).toBe(0); + }); + + it('should return empty array on invalid response format', async () => { + fetchMock.mockResolvedValueOnce(new Response('invalid non-JSON response', { status: 200 })); + + const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'invalid response'); + + expect(resolvedSuggestions).toBeInstanceOf(Array); + expect(resolvedSuggestions.length).toBe(0); + }); + }); + + describe('purgeSuggestionsCache', () => { + it('should clear cached responses', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); + + const firstResult = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + purgeSuggestionsCache(); + const resultAfterPurge = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); + + expect(fetch).toBeCalledTimes(2); + expect(firstResult).not.toBe(resultAfterPurge); + }); + }); + + describe('fetchLocalAutocomplete', () => { + it('should request binary with date-related cache key', () => { + fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 })); + + const now = new Date(); + const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; + const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`; + + fetchLocalAutocomplete(); + + expect(fetch).toBeCalledWith(expectedEndpoint, { credentials: 'omit', cache: 'force-cache' }); + }); + + it('should return auto-completer instance', async () => { + fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 })); + + const autocomplete = await fetchLocalAutocomplete(); + + expect(autocomplete).toBeInstanceOf(LocalAutocompleter); + }); + + it('should throw generic server error on failing response', async () => { + fetchMock.mockResolvedValue(new Response('error', { status: 500 })); + + expect(() => fetchLocalAutocomplete()).rejects.toThrowError('Received error from server'); + }); + }); +}); diff --git a/assets/js/utils/suggestions.ts b/assets/js/utils/suggestions.ts new file mode 100644 index 00000000..fb810be3 --- /dev/null +++ b/assets/js/utils/suggestions.ts @@ -0,0 +1,177 @@ +import { makeEl } from './dom.ts'; +import { mouseMoveThenOver } from './events.ts'; +import { handleError } from './requests.ts'; +import { LocalAutocompleter } from './local-autocompleter.ts'; + +export interface TermSuggestion { + label: string; + value: string; +} + +const selectedSuggestionClassName = 'autocomplete__item--selected'; + +export class SuggestionsPopup { + private readonly container: HTMLElement; + private readonly listElement: HTMLUListElement; + private selectedElement: HTMLElement | null = null; + + constructor() { + this.container = makeEl('div', { + className: 'autocomplete', + }); + + this.listElement = makeEl('ul', { + className: 'autocomplete__list', + }); + + this.container.appendChild(this.listElement); + } + + get selectedTerm(): string | null { + return this.selectedElement?.dataset.value || null; + } + + get isActive(): boolean { + return this.container.isConnected; + } + + hide() { + this.clearSelection(); + this.container.remove(); + } + + private clearSelection() { + if (!this.selectedElement) return; + + this.selectedElement.classList.remove(selectedSuggestionClassName); + this.selectedElement = null; + } + + private updateSelection(targetItem: HTMLElement) { + this.clearSelection(); + + this.selectedElement = targetItem; + this.selectedElement.classList.add(selectedSuggestionClassName); + } + + renderSuggestions(suggestions: TermSuggestion[]): SuggestionsPopup { + this.clearSelection(); + + this.listElement.innerHTML = ''; + + for (const suggestedTerm of suggestions) { + const listItem = makeEl('li', { + className: 'autocomplete__item', + innerText: suggestedTerm.label, + }); + + listItem.dataset.value = suggestedTerm.value; + + this.watchItem(listItem, suggestedTerm); + this.listElement.appendChild(listItem); + } + + return this; + } + + private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) { + mouseMoveThenOver(listItem, () => this.updateSelection(listItem)); + + listItem.addEventListener('mouseout', () => this.clearSelection()); + + listItem.addEventListener('click', () => { + if (!listItem.dataset.value) { + return; + } + + this.container.dispatchEvent(new CustomEvent('item_selected', { detail: suggestion })); + }); + } + + private changeSelection(direction: number) { + let nextTargetElement: Element | null; + + if (!this.selectedElement) { + nextTargetElement = direction > 0 ? this.listElement.firstElementChild : this.listElement.lastElementChild; + } else { + nextTargetElement = + direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling; + } + + if (!(nextTargetElement instanceof HTMLElement)) { + this.clearSelection(); + return; + } + + this.updateSelection(nextTargetElement); + } + + selectNext() { + this.changeSelection(1); + } + + selectPrevious() { + this.changeSelection(-1); + } + + showForField(targetElement: HTMLElement) { + this.container.style.position = 'absolute'; + this.container.style.left = `${targetElement.offsetLeft}px`; + + let topPosition = targetElement.offsetTop + targetElement.offsetHeight; + + if (targetElement.parentElement) { + topPosition -= targetElement.parentElement.scrollTop; + } + + this.container.style.top = `${topPosition}px`; + + document.body.appendChild(this.container); + } + + onItemSelected(callback: (event: CustomEvent) => void) { + this.container.addEventListener('item_selected', callback as EventListener); + } +} + +const cachedSuggestions = new Map>(); + +export async function fetchSuggestions(endpoint: string, targetTerm: string): Promise { + const normalizedTerm = targetTerm.trim().toLowerCase(); + + if (cachedSuggestions.has(normalizedTerm)) { + return cachedSuggestions.get(normalizedTerm)!; + } + + const promisedSuggestions: Promise = fetch(`${endpoint}${targetTerm}`) + .then(handleError) + .then(response => response.json()) + .catch(() => { + // Deleting the promised result from cache to allow retrying + cachedSuggestions.delete(normalizedTerm); + + // And resolve failed promise with empty array + return []; + }); + + cachedSuggestions.set(normalizedTerm, promisedSuggestions); + + return promisedSuggestions; +} + +export function purgeSuggestionsCache() { + cachedSuggestions.clear(); +} + +export async function fetchLocalAutocomplete(): Promise { + const now = new Date(); + const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; + + return await fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { + credentials: 'omit', + cache: 'force-cache', + }) + .then(handleError) + .then(resp => resp.arrayBuffer()) + .then(buf => new LocalAutocompleter(buf)); +}