2024-08-31 16:48:30 +02:00
|
|
|
import { makeEl } from './dom.ts';
|
|
|
|
import { mouseMoveThenOver } from './events.ts';
|
|
|
|
import { handleError } from './requests.ts';
|
2024-08-31 19:11:53 +02:00
|
|
|
import { LocalAutocompleter } from './local-autocompleter.ts';
|
2024-08-31 16:48:30 +02:00
|
|
|
|
|
|
|
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) {
|
|
|
|
if (this.listElement.childElementCount === 0 || direction === 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2024-08-31 21:36:07 +02:00
|
|
|
if (!(nextTargetElement instanceof HTMLElement)) {
|
2024-08-31 16:48:30 +02:00
|
|
|
this.clearSelection();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.updateSelection(nextTargetElement);
|
|
|
|
}
|
|
|
|
|
|
|
|
selectNext() {
|
|
|
|
return this.changeSelection(1);
|
|
|
|
}
|
|
|
|
|
|
|
|
selectPrevious() {
|
|
|
|
return this.changeSelection(-1);
|
|
|
|
}
|
|
|
|
|
|
|
|
showForField(targetElement: HTMLElement): SuggestionsPopup {
|
|
|
|
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);
|
|
|
|
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
onItemSelected(callback: (event: CustomEvent<TermSuggestion>) => void) {
|
|
|
|
this.container.addEventListener('item_selected', callback as EventListener);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const cachedSuggestions = new Map<string, Promise<TermSuggestion[]>>();
|
|
|
|
|
|
|
|
export async function fetchSuggestions(endpoint: string, targetTerm: string) {
|
|
|
|
const normalizedTerm = targetTerm.trim().toLowerCase();
|
|
|
|
|
|
|
|
if (cachedSuggestions.has(normalizedTerm)) {
|
|
|
|
return cachedSuggestions.get(normalizedTerm)!;
|
|
|
|
}
|
|
|
|
|
|
|
|
const promisedSuggestions: Promise<TermSuggestion[]> = 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;
|
|
|
|
}
|
2024-08-31 19:11:53 +02:00
|
|
|
|
|
|
|
export function purgeSuggestionsCache() {
|
|
|
|
cachedSuggestions.clear();
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function fetchLocalAutocomplete(): Promise<LocalAutocompleter> {
|
|
|
|
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));
|
|
|
|
}
|