]> BookStack Code Mirror - bookstack/blob - resources/js/components/auto-suggest.js
07711312f1dfd85efbb1d842d92f1a3efd32d429
[bookstack] / resources / js / components / auto-suggest.js
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';
5
6 const ajaxCache = {};
7
8 /**
9  * AutoSuggest
10  */
11 export class AutoSuggest extends Component {
12
13     setup() {
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;
20
21         this.lastPopulated = 0;
22         this.setupListeners();
23     }
24
25     setupListeners() {
26         const navHandler = new KeyboardNavigationHandler(
27             this.list,
28             () => {
29                 this.input.focus();
30                 setTimeout(() => this.hideSuggestions(), 1);
31             },
32             event => {
33                 event.preventDefault();
34                 const selectionValue = event.target.textContent;
35                 if (selectionValue) {
36                     this.selectSuggestion(selectionValue);
37                 }
38             },
39         );
40         navHandler.shareHandlingToEl(this.input);
41
42         onChildEvent(this.list, '.text-item', 'click', (event, el) => {
43             this.selectSuggestion(el.textContent);
44         });
45
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();
52             }
53         });
54     }
55
56     selectSuggestion(value) {
57         this.input.value = value;
58         this.lastPopulated = Date.now();
59         this.input.focus();
60         this.input.dispatchEvent(new Event('input', {bubbles: true}));
61         this.input.dispatchEvent(new Event('change', {bubbles: true}));
62         this.hideSuggestions();
63     }
64
65     async requestSuggestions() {
66         if (Date.now() - this.lastPopulated < 50) {
67             return;
68         }
69
70         const nameFilter = this.getNameFilterIfNeeded();
71         const search = this.input.value.toLowerCase();
72         const suggestions = await this.loadSuggestions(search, nameFilter);
73
74         const toShow = suggestions.filter(val => search === '' || val.toLowerCase().startsWith(search)).slice(0, 10);
75
76         this.displaySuggestions(toShow);
77     }
78
79     getNameFilterIfNeeded() {
80         if (this.type !== 'value') return null;
81         return this.parent.querySelector('input').value;
82     }
83
84     /**
85      * @param {String} search
86      * @param {String|null} nameFilter
87      * @returns {Promise<Object|String|*>}
88      */
89     async loadSuggestions(search, nameFilter = null) {
90         // Truncate search to prevent over numerous lookups
91         search = search.slice(0, 4);
92
93         const params = {search, name: nameFilter};
94         const cacheKey = `${this.url}:${JSON.stringify(params)}`;
95
96         if (ajaxCache[cacheKey]) {
97             return ajaxCache[cacheKey];
98         }
99
100         const resp = await window.$http.get(this.url, params);
101         ajaxCache[cacheKey] = resp.data;
102         return resp.data;
103     }
104
105     /**
106      * @param {String[]} suggestions
107      */
108     displaySuggestions(suggestions) {
109         if (suggestions.length === 0) {
110             this.hideSuggestions();
111             return;
112         }
113
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));
120         }
121     }
122
123     hideSuggestions() {
124         this.list.style.display = 'none';
125     }
126
127     hideSuggestionsIfFocusedLost(event) {
128         if (!this.container.contains(event.relatedTarget)) {
129             this.hideSuggestions();
130         }
131     }
132
133 }