]> BookStack Code Mirror - bookstack/blob - resources/js/components/auto-suggest.js
Started refactor and alignment of component system
[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
5 const ajaxCache = {};
6
7 /**
8  * AutoSuggest
9  */
10 export class AutoSuggest extends Component {
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.toLowerCase();
92         const suggestions = await this.loadSuggestions(search, nameFilter);
93
94         const toShow = suggestions.filter(val => {
95             return search === '' || val.toLowerCase().startsWith(search);
96         }).slice(0, 10);
97
98         this.displaySuggestions(toShow);
99     }
100
101     getNameFilterIfNeeded() {
102         if (this.type !== 'value') return null;
103         return this.parent.querySelector('input').value;
104     }
105
106     /**
107      * @param {String} search
108      * @param {String|null} nameFilter
109      * @returns {Promise<Object|String|*>}
110      */
111     async loadSuggestions(search, nameFilter = null) {
112         // Truncate search to prevent over numerous lookups
113         search = search.slice(0, 4);
114
115         const params = {search, name: nameFilter};
116         const cacheKey = `${this.url}:${JSON.stringify(params)}`;
117
118         if (ajaxCache[cacheKey]) {
119             return ajaxCache[cacheKey];
120         }
121
122         const resp = await window.$http.get(this.url, params);
123         ajaxCache[cacheKey] = resp.data;
124         return resp.data;
125     }
126
127     /**
128      * @param {String[]} suggestions
129      */
130     displaySuggestions(suggestions) {
131         if (suggestions.length === 0) {
132             return this.hideSuggestions();
133         }
134
135         this.list.innerHTML = suggestions.map(value => `<li><button type="button" class="text-item">${escapeHtml(value)}</button></li>`).join('');
136         this.list.style.display = 'block';
137         for (const button of this.list.querySelectorAll('button')) {
138             button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
139         }
140     }
141
142     hideSuggestions() {
143         this.list.style.display = 'none';
144     }
145
146     hideSuggestionsIfFocusedLost(event) {
147         if (!this.container.contains(event.relatedTarget)) {
148             this.hideSuggestions();
149         }
150     }
151 }