]> BookStack Code Mirror - bookstack/blob - resources/js/components/auto-suggest.js
Comments: Updated to show form in expected location
[bookstack] / resources / js / components / auto-suggest.js
1 import {escapeHtml} from '../services/util';
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                 this.selectSuggestion(event.target.textContent);
35             },
36         );
37         navHandler.shareHandlingToEl(this.input);
38
39         onChildEvent(this.list, '.text-item', 'click', (event, el) => {
40             this.selectSuggestion(el.textContent);
41         });
42
43         this.input.addEventListener('input', this.requestSuggestions.bind(this));
44         this.input.addEventListener('focus', this.requestSuggestions.bind(this));
45         this.input.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
46         this.input.addEventListener('keydown', event => {
47             if (event.key === 'Tab') {
48                 this.hideSuggestions();
49             }
50         });
51     }
52
53     selectSuggestion(value) {
54         this.input.value = value;
55         this.lastPopulated = Date.now();
56         this.input.focus();
57         this.input.dispatchEvent(new Event('input', {bubbles: true}));
58         this.input.dispatchEvent(new Event('change', {bubbles: true}));
59         this.hideSuggestions();
60     }
61
62     async requestSuggestions() {
63         if (Date.now() - this.lastPopulated < 50) {
64             return;
65         }
66
67         const nameFilter = this.getNameFilterIfNeeded();
68         const search = this.input.value.toLowerCase();
69         const suggestions = await this.loadSuggestions(search, nameFilter);
70
71         const toShow = suggestions.filter(val => search === '' || val.toLowerCase().startsWith(search)).slice(0, 10);
72
73         this.displaySuggestions(toShow);
74     }
75
76     getNameFilterIfNeeded() {
77         if (this.type !== 'value') return null;
78         return this.parent.querySelector('input').value;
79     }
80
81     /**
82      * @param {String} search
83      * @param {String|null} nameFilter
84      * @returns {Promise<Object|String|*>}
85      */
86     async loadSuggestions(search, nameFilter = null) {
87         // Truncate search to prevent over numerous lookups
88         search = search.slice(0, 4);
89
90         const params = {search, name: nameFilter};
91         const cacheKey = `${this.url}:${JSON.stringify(params)}`;
92
93         if (ajaxCache[cacheKey]) {
94             return ajaxCache[cacheKey];
95         }
96
97         const resp = await window.$http.get(this.url, params);
98         ajaxCache[cacheKey] = resp.data;
99         return resp.data;
100     }
101
102     /**
103      * @param {String[]} suggestions
104      */
105     displaySuggestions(suggestions) {
106         if (suggestions.length === 0) {
107             this.hideSuggestions();
108             return;
109         }
110
111         // This used to use <button>s but was changed to div elements since Safari would not focus on buttons
112         // on which causes a range of other complexities related to focus handling.
113         this.list.innerHTML = suggestions.map(value => `<li><div tabindex="0" class="text-item">${escapeHtml(value)}</div></li>`).join('');
114         this.list.style.display = 'block';
115         for (const button of this.list.querySelectorAll('.text-item')) {
116             button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
117         }
118     }
119
120     hideSuggestions() {
121         this.list.style.display = 'none';
122     }
123
124     hideSuggestionsIfFocusedLost(event) {
125         if (!this.container.contains(event.relatedTarget)) {
126             this.hideSuggestions();
127         }
128     }
129
130 }