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