Merge pull request #348 from koloml/ts-autocomplete

Converting autocomplete.js to TypeScript
This commit is contained in:
liamwhite 2024-08-27 19:20:03 -04:00 committed by GitHub
commit 073ca2881b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

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