Support search field autocompletion, enabled autocomplete for header

This commit is contained in:
KoloMl 2024-05-29 23:57:36 +04:00
parent c0ddf55b48
commit 8c988b002d
2 changed files with 67 additions and 6 deletions

View file

@ -4,9 +4,19 @@
import { LocalAutocompleter } from './utils/local-autocompleter'; import { LocalAutocompleter } from './utils/local-autocompleter';
import { handleError } from './utils/requests'; import { handleError } from './utils/requests';
import { getTermContexts } from "./match_query";
const cache = {}; const cache = {};
let inputField, originalTerm; /** @type {HTMLInputElement} */
let inputField,
/** @type {string} */
originalTerm,
/** @type {string} */
originalQuery,
/** @type {TermContext[]} */
searchTokens,
/** @type {TermContext} */
selectedTerm;
function removeParent() { function removeParent() {
const parent = document.querySelector('.autocomplete'); const parent = document.querySelector('.autocomplete');
@ -18,13 +28,37 @@ function removeSelected() {
if (selected) selected.classList.remove('autocomplete__item--selected'); if (selected) selected.classList.remove('autocomplete__item--selected');
} }
function isSearchField() {
return inputField && inputField.name === 'q';
}
function restoreOriginalValue() {
inputField.value = isSearchField() ? originalQuery : originalTerm;
}
function applySelectedValue(selection) {
if (!isSearchField()) {
inputField.value = selection;
return;
}
if (!selectedTerm) {
return;
}
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, current, sibling) {
if (current && sibling) { // if the currently selected item has a sibling, move selection to it if (current && sibling) { // if the currently selected item has a sibling, move selection to it
current.classList.remove('autocomplete__item--selected'); current.classList.remove('autocomplete__item--selected');
sibling.classList.add('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 else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term
inputField.value = originalTerm; restoreOriginalValue();
removeSelected(); removeSelected();
} }
else if (firstOrLast) { // if no item in the list is selected, select the first or last else if (firstOrLast) { // if no item in the list is selected, select the first or last
@ -37,12 +71,15 @@ function keydownHandler(event) {
firstItem = document.querySelector('.autocomplete__item:first-of-type'), firstItem = document.querySelector('.autocomplete__item:first-of-type'),
lastItem = document.querySelector('.autocomplete__item:last-of-type'); lastItem = document.querySelector('.autocomplete__item:last-of-type');
// Prevent submission of the search field when Enter was hit
if (event.keyCode === 13 && isSearchField() && selected) event.preventDefault(); // Enter
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp 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 === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // 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) { // ArrowUp || ArrowDown if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown
const newSelected = document.querySelector('.autocomplete__item--selected'); const newSelected = document.querySelector('.autocomplete__item--selected');
if (newSelected) inputField.value = newSelected.dataset.value; if (newSelected) applySelectedValue(newSelected.dataset.value);
event.preventDefault(); event.preventDefault();
} }
} }
@ -64,7 +101,7 @@ function createItem(list, suggestion) {
}); });
item.addEventListener('click', () => { item.addEventListener('click', () => {
inputField.value = item.dataset.value; applySelectedValue(item.dataset.value);
inputField.dispatchEvent( inputField.dispatchEvent(
new CustomEvent('autocomplete', { new CustomEvent('autocomplete', {
detail: { detail: {
@ -122,6 +159,17 @@ function getSuggestions(term) {
return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json()); return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json());
} }
function getSelectedTerm() {
if (!inputField || !originalQuery) {
return null;
}
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
const terms = getTermContexts(originalQuery);
return terms.find(([range]) => range[0] < selectionIndex && range[1] >= selectionIndex);
}
function listenAutocomplete() { function listenAutocomplete() {
let timeout; let timeout;
@ -138,7 +186,20 @@ function listenAutocomplete() {
if (localAc !== null && 'ac' in event.target.dataset) { if (localAc !== null && 'ac' in event.target.dataset) {
inputField = event.target; inputField = event.target;
originalTerm = `${inputField.value}`.toLowerCase();
if (isSearchField()) {
originalQuery = inputField.value;
selectedTerm = getSelectedTerm();
// We don't need to run auto-completion if user is not selecting tag at all
if (!selectedTerm) {
return;
}
originalTerm = selectedTerm[1];
} else {
originalTerm = `${inputField.value}`.toLowerCase();
}
const suggestions = localAc.topK(originalTerm, 5).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); const suggestions = localAc.topK(originalTerm, 5).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));

View file

@ -12,7 +12,7 @@ header.header
i.fa.fa-upload i.fa.fa-upload
= form_for @conn, Routes.search_path(@conn, :index), [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f -> = form_for @conn, Routes.search_path(@conn, :index), [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f ->
input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" autocomplete="off" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term="
= if present?(@conn.params["sf"]) do = if present?(@conn.params["sf"]) do
input type="hidden" name="sf" value=@conn.params["sf"] input type="hidden" name="sf" value=@conn.params["sf"]