Extracting chunks of code & slightly refactoring autocomplete script

This commit is contained in:
KoloMl 2024-08-31 18:48:30 +04:00
parent 42138a219b
commit 0fe6cd7842
2 changed files with 233 additions and 165 deletions

View file

@ -6,39 +6,23 @@ import { LocalAutocompleter } from './utils/local-autocompleter';
import { handleError } from './utils/requests'; import { handleError } from './utils/requests';
import { getTermContexts } from './match_query'; import { getTermContexts } from './match_query';
import store from './utils/store'; import store from './utils/store';
import { TermContext } from './query/lex.ts'; import { TermContext } from './query/lex';
import { $, $$, makeEl, removeEl } from './utils/dom.ts'; import { $$ } from './utils/dom';
import { mouseMoveThenOver } from './utils/events.ts'; import { fetchSuggestions, SuggestionsPopup, TermSuggestion } from './utils/suggestions';
type TermSuggestion = {
label: string;
value: string;
};
const cachedSuggestions: Record<string, TermSuggestion[]> = {};
let inputField: HTMLInputElement | null = null, let inputField: HTMLInputElement | null = null,
originalTerm: string | undefined, originalTerm: string | undefined,
originalQuery: string | undefined, originalQuery: string | undefined,
selectedTerm: TermContext | null = null; selectedTerm: TermContext | null = null;
function removeParent() { const popup = new SuggestionsPopup();
const parent = $<HTMLElement>('.autocomplete');
if (parent) removeEl(parent);
}
function removeSelected() {
const selected = $<HTMLElement>('.autocomplete__item--selected');
if (selected) selected.classList.remove('autocomplete__item--selected');
}
function isSearchField(targetInput: HTMLElement): boolean { function isSearchField(targetInput: HTMLElement): boolean {
return targetInput && targetInput.dataset.acMode === 'search'; return targetInput && targetInput.dataset.acMode === 'search';
} }
function restoreOriginalValue() { function restoreOriginalValue() {
if (!inputField) { if (!inputField) return;
return;
}
if (isSearchField(inputField) && originalQuery) { if (isSearchField(inputField) && originalQuery) {
inputField.value = originalQuery; inputField.value = originalQuery;
@ -50,9 +34,7 @@ function restoreOriginalValue() {
} }
function applySelectedValue(selection: string) { function applySelectedValue(selection: string) {
if (!inputField) { if (!inputField) return;
return;
}
if (!isSearchField(inputField)) { if (!isSearchField(inputField)) {
inputField.value = selection; inputField.value = selection;
@ -67,21 +49,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 { function isSelectionOutsideCurrentTerm(): boolean {
if (!inputField || !selectedTerm) return true; if (!inputField || !selectedTerm) return true;
if (inputField.selectionStart === null || inputField.selectionEnd === null) return true; if (inputField.selectionStart === null || inputField.selectionEnd === null) return true;
@ -93,127 +60,43 @@ function isSelectionOutsideCurrentTerm(): boolean {
} }
function keydownHandler(event: KeyboardEvent) { function keydownHandler(event: KeyboardEvent) {
const selected = $<HTMLElement>('.autocomplete__item--selected'), if (inputField !== event.currentTarget) return;
firstItem = $<HTMLElement>('.autocomplete__item:first-of-type'),
lastItem = $<HTMLElement>('.autocomplete__item:last-of-type');
if (inputField && isSearchField(inputField)) { if (inputField && isSearchField(inputField)) {
// Prevent submission of the search field when Enter was hit // 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 // 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 // ArrowLeft || ArrowRight
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (isSelectionOutsideCurrentTerm()) removeParent(); if (isSelectionOutsideCurrentTerm()) popup.hide();
}); });
} }
} }
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousElementSibling); // ArrowUp if (!popup.isActive) return;
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) 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) { if (event.keyCode === 38 || event.keyCode === 40) {
// ArrowUp || ArrowDown // ArrowUp || ArrowDown
const newSelected = $<HTMLElement>('.autocomplete__item--selected'); if (popup.selectedTerm) {
if (newSelected?.dataset.value) applySelectedValue(newSelected.dataset.value); applySelectedValue(popup.selectedTerm);
} else {
restoreOriginalValue();
}
event.preventDefault(); event.preventDefault();
} }
} }
function createItem(list: HTMLUListElement, suggestion: TermSuggestion) { function findSelectedTerm(targetInput: HTMLInputElement, searchQuery: string): TermContext | null {
const item = makeEl('li', { if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
className: 'autocomplete__item',
});
item.textContent = suggestion.label; const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
item.dataset.value = suggestion.value; const terms = getTermContexts(searchQuery);
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<TermSuggestion[]> {
// 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);
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null; return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex) ?? null;
} }
@ -232,7 +115,7 @@ function toggleSearchAutocomplete() {
} }
function listenAutocomplete() { function listenAutocomplete() {
let timeout: number | undefined; let serverSideSuggestionsTimeout: number | undefined;
let localAc: LocalAutocompleter | null = null; let localAc: LocalAutocompleter | null = null;
let localFetched = false; let localFetched = false;
@ -240,21 +123,25 @@ function listenAutocomplete() {
document.addEventListener('focusin', fetchLocalAutocomplete); document.addEventListener('focusin', fetchLocalAutocomplete);
document.addEventListener('input', event => { document.addEventListener('input', event => {
removeParent(); popup.hide();
fetchLocalAutocomplete(event); fetchLocalAutocomplete(event);
window.clearTimeout(timeout); window.clearTimeout(serverSideSuggestionsTimeout);
if (!(event.target instanceof HTMLInputElement)) return; if (!(event.target instanceof HTMLInputElement)) return;
const targetedInput = event.target; 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; inputField = targetedInput;
let suggestionsCount = 5; let suggestionsCount = 5;
if (isSearchField(inputField)) { if (isSearchField(inputField)) {
originalQuery = inputField.value; originalQuery = inputField.value;
selectedTerm = getSelectedTerm(); selectedTerm = findSelectedTerm(inputField, originalQuery);
suggestionsCount = 10; suggestionsCount = 10;
// We don't need to run auto-completion if user is not selecting tag at all // We don't need to run auto-completion if user is not selecting tag at all
@ -273,40 +160,38 @@ function listenAutocomplete() {
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); .map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
if (suggestions.length) { 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 // Use a timeout to delay requests until the user has stopped typing
timeout = window.setTimeout(() => { serverSideSuggestionsTimeout = window.setTimeout(() => {
inputField = targetedInput; inputField = targetedInput;
originalTerm = inputField.value; originalTerm = inputField.value;
const fetchedTerm = inputField.value; const fetchedTerm = inputField.value;
const { ac, acMinLength, acSource } = inputField.dataset;
if (!ac || !acSource || (acMinLength && fetchedTerm.length < parseInt(acMinLength, 10))) { if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return;
return;
}
if (cachedSuggestions[fetchedTerm]) { fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => {
showAutocomplete(cachedSuggestions[fetchedTerm], fetchedTerm, targetedInput); // inputField could get overwritten while the suggestions are being fetched - use previously targeted input
} else {
// inputField could get overwritten while the suggestions are being fetched - use event.target
getSuggestions(fetchedTerm).then(suggestions => {
if (fetchedTerm === targetedInput.value) { if (fetchedTerm === targetedInput.value) {
showAutocomplete(suggestions, fetchedTerm, targetedInput); popup.renderSuggestions(suggestions).showForField(targetedInput);
} }
}); });
}
}, 300); }, 300);
}); });
// If there's a click outside the inputField, remove autocomplete // If there's a click outside the inputField, remove autocomplete
document.addEventListener('click', event => { 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()) { if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
removeParent(); popup.hide();
} }
}); });
@ -329,6 +214,24 @@ function listenAutocomplete() {
} }
toggleSearchAutocomplete(); toggleSearchAutocomplete();
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
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 }; export { listenAutocomplete };

View file

@ -0,0 +1,165 @@
import { makeEl } from './dom.ts';
import { mouseMoveThenOver } from './events.ts';
import { handleError } from './requests.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) {
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;
}
if (!(nextTargetElement instanceof HTMLElement) || !nextTargetElement.dataset.value) {
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;
}