]> BookStack Code Mirror - bookstack/blob - resources/js/components/auto-suggest.js
Started migrating tag manager JS to HTML-first component
[bookstack] / resources / js / components / auto-suggest.js
1 import {escapeHtml} from "../services/util";
2 import {onChildEvent} from "../services/dom";
3
4 const ajaxCache = {};
5
6 /**
7  * AutoSuggest
8  * @extends {Component}
9  */
10 class AutoSuggest {
11     setup() {
12         this.parent = this.$el.parentElement;
13         this.container = this.$el;
14         this.type = this.$opts.type;
15         this.url = this.$opts.url;
16         this.input = this.$refs.input;
17         this.list = this.$refs.list;
18
19         this.setupListeners();
20     }
21
22     setupListeners() {
23         this.input.addEventListener('input', this.requestSuggestions.bind(this));
24         this.input.addEventListener('focus', this.requestSuggestions.bind(this));
25         this.input.addEventListener('keydown', event => {
26             if (event.key === 'Tab') {
27                 this.hideSuggestions();
28             }
29         });
30
31         this.input.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
32         this.container.addEventListener('keydown', this.containerKeyDown.bind(this));
33
34         onChildEvent(this.list, 'button', 'click', (event, el) => {
35             this.selectSuggestion(el.textContent);
36         });
37         onChildEvent(this.list, 'button', 'keydown', (event, el) => {
38             if (event.key === 'Enter') {
39                 this.selectSuggestion(el.textContent);
40             }
41         });
42
43     }
44
45     selectSuggestion(value) {
46         this.input.value = value;
47         this.input.focus();
48         this.hideSuggestions();
49     }
50
51     containerKeyDown(event) {
52         if (event.key === 'Enter') event.preventDefault();
53         if (this.list.classList.contains('hidden')) return;
54
55         // Down arrow
56         if (event.key === 'ArrowDown') {
57             this.moveFocus(true);
58             event.preventDefault();
59         }
60         // Up Arrow
61         else if (event.key === 'ArrowUp') {
62             this.moveFocus(false);
63             event.preventDefault();
64         }
65         // Escape key
66         else if (event.key === 'Escape') {
67             this.hideSuggestions();
68             event.preventDefault();
69         }
70     }
71
72     moveFocus(forward = true) {
73         const focusables = Array.from(this.container.querySelectorAll('input,button'));
74         const index = focusables.indexOf(document.activeElement);
75         const newFocus = focusables[index + (forward ? 1 : -1)];
76         if (newFocus) {
77             newFocus.focus()
78         }
79     }
80
81     async requestSuggestions() {
82         const nameFilter = this.getNameFilterIfNeeded();
83         const search = this.input.value.slice(0, 3);
84         const suggestions = await this.loadSuggestions(search, nameFilter);
85         let toShow = suggestions.slice(0, 6);
86         if (search.length > 0) {
87             toShow = suggestions.filter(val => {
88                 return val.toLowerCase().includes(search);
89             }).slice(0, 6);
90         }
91
92         this.displaySuggestions(toShow);
93     }
94
95     getNameFilterIfNeeded() {
96         if (this.type !== 'value') return null;
97         return this.parent.querySelector('input').value;
98     }
99
100     /**
101      * @param {String} search
102      * @param {String|null} nameFilter
103      * @returns {Promise<Object|String|*>}
104      */
105     async loadSuggestions(search, nameFilter = null) {
106         const params = {search, name: nameFilter};
107         const cacheKey = `${this.url}:${JSON.stringify(params)}`;
108
109         if (ajaxCache[cacheKey]) {
110             return ajaxCache[cacheKey];
111         }
112
113         const resp = await window.$http.get(this.url, params);
114         ajaxCache[cacheKey] = resp.data;
115         return resp.data;
116     }
117
118     /**
119      * @param {String[]} suggestions
120      */
121     displaySuggestions(suggestions) {
122         if (suggestions.length === 0) {
123             return this.hideSuggestions();
124         }
125
126         this.list.innerHTML = suggestions.map(value => `<li><button type="button">${escapeHtml(value)}</button></li>`).join('');
127         this.list.style.display = 'block';
128         for (const button of this.list.querySelectorAll('button')) {
129             button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
130         }
131     }
132
133     hideSuggestions() {
134         this.list.style.display = 'none';
135     }
136
137     hideSuggestionsIfFocusedLost(event) {
138         if (!this.container.contains(event.relatedTarget)) {
139             this.hideSuggestions();
140         }
141     }
142 }
143
144 export default AutoSuggest;