1 import {escapeHtml} from '../services/util.ts';
2 import {onChildEvent} from '../services/dom';
3 import {Component} from './component';
4 import {KeyboardNavigationHandler} from '../services/keyboard-navigation';
11 export class AutoSuggest extends Component {
14 this.parent = this.$el.parentElement;
15 this.container = this.$el;
16 this.type = this.$opts.type;
17 this.url = this.$opts.url;
18 this.input = this.$refs.input;
19 this.list = this.$refs.list;
21 this.lastPopulated = 0;
22 this.setupListeners();
26 const navHandler = new KeyboardNavigationHandler(
30 setTimeout(() => this.hideSuggestions(), 1);
33 event.preventDefault();
34 const selectionValue = event.target.textContent;
36 this.selectSuggestion(selectionValue);
40 navHandler.shareHandlingToEl(this.input);
42 onChildEvent(this.list, '.text-item', 'click', (event, el) => {
43 this.selectSuggestion(el.textContent);
46 this.input.addEventListener('input', this.requestSuggestions.bind(this));
47 this.input.addEventListener('focus', this.requestSuggestions.bind(this));
48 this.input.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
49 this.input.addEventListener('keydown', event => {
50 if (event.key === 'Tab') {
51 this.hideSuggestions();
56 selectSuggestion(value) {
57 this.input.value = value;
58 this.lastPopulated = Date.now();
60 this.input.dispatchEvent(new Event('input', {bubbles: true}));
61 this.input.dispatchEvent(new Event('change', {bubbles: true}));
62 this.hideSuggestions();
65 async requestSuggestions() {
66 if (Date.now() - this.lastPopulated < 50) {
70 const nameFilter = this.getNameFilterIfNeeded();
71 const search = this.input.value.toLowerCase();
72 const suggestions = await this.loadSuggestions(search, nameFilter);
74 const toShow = suggestions.filter(val => search === '' || val.toLowerCase().startsWith(search)).slice(0, 10);
76 this.displaySuggestions(toShow);
79 getNameFilterIfNeeded() {
80 if (this.type !== 'value') return null;
81 return this.parent.querySelector('input').value;
85 * @param {String} search
86 * @param {String|null} nameFilter
87 * @returns {Promise<Object|String|*>}
89 async loadSuggestions(search, nameFilter = null) {
90 // Truncate search to prevent over numerous lookups
91 search = search.slice(0, 4);
93 const params = {search, name: nameFilter};
94 const cacheKey = `${this.url}:${JSON.stringify(params)}`;
96 if (ajaxCache[cacheKey]) {
97 return ajaxCache[cacheKey];
100 const resp = await window.$http.get(this.url, params);
101 ajaxCache[cacheKey] = resp.data;
106 * @param {String[]} suggestions
108 displaySuggestions(suggestions) {
109 if (suggestions.length === 0) {
110 this.hideSuggestions();
114 // This used to use <button>s but was changed to div elements since Safari would not focus on buttons
115 // on which causes a range of other complexities related to focus handling.
116 this.list.innerHTML = suggestions.map(value => `<li><div tabindex="0" class="text-item">${escapeHtml(value)}</div></li>`).join('');
117 this.list.style.display = 'block';
118 for (const button of this.list.querySelectorAll('.text-item')) {
119 button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
124 this.list.style.display = 'none';
127 hideSuggestionsIfFocusedLost(event) {
128 if (!this.container.contains(event.relatedTarget)) {
129 this.hideSuggestions();