]> BookStack Code Mirror - bookstack/blob - resources/js/components/auto-suggest.js
Merge pull request #3617 from BookStackApp/codemirror6
[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     setup() {
13         this.parent = this.$el.parentElement;
14         this.container = this.$el;
15         this.type = this.$opts.type;
16         this.url = this.$opts.url;
17         this.input = this.$refs.input;
18         this.list = this.$refs.list;
19
20         this.lastPopulated = 0;
21         this.setupListeners();
22     }
23
24     setupListeners() {
25         const navHandler = new KeyboardNavigationHandler(
26             this.list,
27             event => {
28                 this.input.focus();
29                 setTimeout(() => this.hideSuggestions(), 1);
30             },
31             event => {
32                 event.preventDefault();
33                 this.selectSuggestion(event.target.textContent);
34             },
35         );
36         navHandler.shareHandlingToEl(this.input);
37
38         onChildEvent(this.list, '.text-item', 'click', (event, el) => {
39             this.selectSuggestion(el.textContent);
40         });
41
42         this.input.addEventListener('input', this.requestSuggestions.bind(this));
43         this.input.addEventListener('focus', this.requestSuggestions.bind(this));
44         this.input.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
45         this.input.addEventListener('keydown', event => {
46             if (event.key === 'Tab') {
47                 this.hideSuggestions();
48             }
49         });
50     }
51
52     selectSuggestion(value) {
53         this.input.value = value;
54         this.lastPopulated = Date.now();
55         this.input.focus();
56         this.input.dispatchEvent(new Event('input', {bubbles: true}));
57         this.input.dispatchEvent(new Event('change', {bubbles: true}));
58         this.hideSuggestions();
59     }
60
61     async requestSuggestions() {
62         if (Date.now() - this.lastPopulated < 50) {
63             return;
64         }
65
66         const nameFilter = this.getNameFilterIfNeeded();
67         const search = this.input.value.toLowerCase();
68         const suggestions = await this.loadSuggestions(search, nameFilter);
69
70         const toShow = suggestions.filter(val => {
71             return search === '' || val.toLowerCase().startsWith(search);
72         }).slice(0, 10);
73
74         this.displaySuggestions(toShow);
75     }
76
77     getNameFilterIfNeeded() {
78         if (this.type !== 'value') return null;
79         return this.parent.querySelector('input').value;
80     }
81
82     /**
83      * @param {String} search
84      * @param {String|null} nameFilter
85      * @returns {Promise<Object|String|*>}
86      */
87     async loadSuggestions(search, nameFilter = null) {
88         // Truncate search to prevent over numerous lookups
89         search = search.slice(0, 4);
90
91         const params = {search, name: nameFilter};
92         const cacheKey = `${this.url}:${JSON.stringify(params)}`;
93
94         if (ajaxCache[cacheKey]) {
95             return ajaxCache[cacheKey];
96         }
97
98         const resp = await window.$http.get(this.url, params);
99         ajaxCache[cacheKey] = resp.data;
100         return resp.data;
101     }
102
103     /**
104      * @param {String[]} suggestions
105      */
106     displaySuggestions(suggestions) {
107         if (suggestions.length === 0) {
108             return this.hideSuggestions();
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 }