Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Search autocomplete #31

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 109 additions & 34 deletions assets/js/autocomplete.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* Autocomplete.
*/
import { throttle } from './utils/events.js';

const cache = {};
let inputField, originalTerm;
const termsAllowingUnderscores = /^artist:/;
let inputField, originalValue;

function removeParent() {
const parent = document.querySelector('.autocomplete');
Expand All @@ -20,8 +22,8 @@ function changeSelected(firstOrLast, current, sibling) {
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
inputField.value = originalTerm;
else if (current) { // if the next keypress will take the user outside the list, restore the original term
inputField.value = originalValue;
removeSelected();
}
else if (firstOrLast) { // if no item in the list is selected, select the first or last
Expand All @@ -39,11 +41,21 @@ function keydownHandler(event) {
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown
const newSelected = document.querySelector('.autocomplete__item--selected');
if (newSelected) inputField.value = newSelected.dataset.value;
if (newSelected) previewSelected(newSelected.dataset.value);
event.preventDefault();
}
}

function previewSelected(value) {
const { start, end } = getTermPosition(inputField);
const prefix = start === 0 ? '' : originalValue.slice(0, start);
const suffix = end === originalValue.length ? '' : originalValue.slice(end);
inputField.value = prefix + value + suffix;
const valueEnd = start + value.length;
inputField.selectionStart = valueEnd;
inputField.selectionEnd = valueEnd;
}

function createItem(list, suggestion) {
const item = document.createElement('li');
item.className = 'autocomplete__item';
Expand All @@ -61,7 +73,7 @@ function createItem(list, suggestion) {
});

item.addEventListener('click', () => {
inputField.value = item.dataset.value;
previewSelected(item.dataset.value);
inputField.dispatchEvent(
new CustomEvent('autocomplete', {
detail: {
Expand Down Expand Up @@ -90,10 +102,10 @@ function createParent() {
const parent = document.createElement('div');
parent.className = 'autocomplete';

// Position the parent below the inputfield
// 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
// 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`;

// We append the parent at the end of body
Expand All @@ -104,9 +116,6 @@ function showAutocomplete(suggestions, targetInput) {
// Remove old autocomplete suggestions
removeParent();

// Save suggestions in cache
cache[targetInput.value] = suggestions;

// If the input target is not empty, still visible, and suggestions were found
if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) {
createParent();
Expand All @@ -115,41 +124,107 @@ function showAutocomplete(suggestions, targetInput) {
}
}

function getSuggestions() {
return fetch(inputField.dataset.acSource + inputField.value).then(response => response.json());
function getSuggestions(searchTerm) {
return fetch(inputField.dataset.acSource + encodeURIComponent(searchTerm))
.then(response => response.json())
.then(suggestions => {
// Save suggestions in cache
cache[searchTerm] = suggestions;
return suggestions;
});
}

function listenAutocomplete() {
let timeout;
/**
* @param {HTMLInputElement} input
* @param {string|number} [start] term start position to force, pass undefined or omit to clear
* @param {string|number} [end] term end position to force, pass undefined or omit to clear
*/
function setTermPosition(input, start, end) {
input.dataset.acTermStart = start;
input.dataset.acTermEnd = end;
}

document.addEventListener('input', event => {
removeParent();
/**
* @param {HTMLInputElement} input
* @returns {TokenPosition}
*/
function getTermPosition(input) {
const { acTermStart = 0, acTermEnd = input.value.length } = input.dataset;
return { start: Number(acTermStart), end: Number(acTermEnd) };
}

window.clearTimeout(timeout);
// Use a timeout to delay requests until the user has stopped typing
timeout = window.setTimeout(() => {
inputField = event.target;
originalTerm = inputField.value;
const {ac, acMinLength} = inputField.dataset;
/**
* @param {HTMLInputElement} input
* @param {string} [term] term to force, pass undefined or omit to clear
*/
function setAutocompleteTerm(input, term = '') {
input.dataset.acTerm = term;
}

if (ac && (inputField.value.length >= acMinLength)) {

if (cache[inputField.value]) {
showAutocomplete(cache[inputField.value], event.target);
}
else {
// inputField could get overwritten while the suggestions are being fetched - use event.target
getSuggestions().then(suggestions => showAutocomplete(suggestions, event.target));
}
/**
* Apply additional transformations to the term before passing to the endpoint
*
* @param {string} term
* @returns {string}
*/
function postprocessTerm(term) {
let processedTerm = term;
// Replaces underscores with spaces where applicable
if (!termsAllowingUnderscores.test(term)) {
processedTerm = processedTerm.replace(/_/g, ' ');
}
// Remove spaces before/after namespace
processedTerm = processedTerm.replace(/^([^:\s]+)\s*:\s*(.*)$/g, '$1:$2');
return processedTerm;
}

}
}, 300);
});
function getSearchTerm(target) {
const term = typeof target.dataset.acTerm !== 'undefined' ? target.dataset.acTerm : originalValue;
return postprocessTerm(term);
}

const handleAutocompleteInner = throttle(300, target => {
inputField = target;
originalValue = target.value;
const searchTerm = getSearchTerm(target);
const { ac, acMinLength } = target.dataset;

if (!ac) return;
if (isNaN(acMinLength)) throw new Error(`Autocomplete minimum length "${acMinLength}" is invalid`);

if (searchTerm.length >= acMinLength) {
if (cache[searchTerm]) {
showAutocomplete(cache[searchTerm], target);
}
else {
getSuggestions(searchTerm).then(suggestions => showAutocomplete(suggestions, target));
}
}
});

/**
* @typedef ObjectWithTarget
* @property {EventTarget} target
*/

/**
* @param {ObjectWithTarget} event
*/
function handleAutocomplete(event) {
removeParent();

handleAutocompleteInner(event.target);
}

function listenAutocomplete() {
// Use a timeout to delay requests until the user has stopped typing
document.addEventListener('input', handleAutocomplete);

// If there's a click outside the inputField, remove autocomplete
document.addEventListener('click', event => {
if (event.target && event.target !== inputField) removeParent();
});
}

export { listenAutocomplete };
export { listenAutocomplete, handleAutocomplete, setTermPosition, getTermPosition, setAutocompleteTerm };
6 changes: 4 additions & 2 deletions assets/js/search.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { $, $$ } from './utils/dom';
import { addTag } from './tagsinput';

let form;

function showHelp(subject, type) {
$$('[data-search-help]').forEach(helpBox => {
if (helpBox.getAttribute('data-search-help') === type) {
Expand All @@ -27,7 +29,7 @@ function selectLast(field, characterCount) {
}

function executeFormHelper(e) {
const searchField = $('.js-search-field');
const searchField = $('.js-search-field', form);
const attr = name => e.target.getAttribute(name);

attr('data-search-add') && addTag(searchField, attr('data-search-add'));
Expand All @@ -37,7 +39,7 @@ function executeFormHelper(e) {
}

function setupSearch() {
const form = $('.js-search-form');
form = $('.js-search-form');

form && form.addEventListener('click', executeFormHelper);
}
Expand Down
Loading