PageSourceSearch

https://aist24.com/js/tw-autocomplete.js?v=_wLxcPAlmkyfa-Q9dRTOekipUysJcfEnM2wnepuPCVw

js aist24.com collected 2026-09-24 10:21:48 UTC 12,916 bytes, 353 lines download raw bytes

1/**
2 * TwAutocomplete — lightweight autocomplete without Bootstrap dependency.
3 * API-compatible with the subset of bootstrap5-autocomplete used in this project.
4 */
5class TwAutocomplete {
6    /**
7     * @param {HTMLInputElement} input
8     * @param {Object} options
9     */
10    constructor(input, options) {
11        this._input      = input;
12        this._config     = Object.assign({
13            queryParam:          'query',
14            debounceTime:        300,
15            suggestionsThreshold: 1,
16            highlightTyped:      true,
17            notFoundMessage:     'Ничего не найдено',
18            labelField:          'label',
19            valueField:          'value',
20            server:              '',
21            onBeforeFetch:       null,
22            onServerResponse:    null,
23            onSelectItem:        null,
24        }, options);
25
26        this._items        = [];
27        this._activeIndex  = -1;
28        this._debounceTimer = null;
29        this._abortCtrl    = null;
30        this._dropdown     = null;
31
32        this._build();
33        this._bind();
34    }
35
36    /* ------------------------------------------------------------------ */
37    /*  Build DOM                                                           */
38    /* ------------------------------------------------------------------ */
39
40    _build() {
41        // Wrapper so dropdown can be positioned relative to input
42        const wrapper = document.createElement('div');
43        wrapper.style.cssText = 'position:relative;flex:1;min-width:0;';
44
45        this._input.parentNode.insertBefore(wrapper, this._input);
46        wrapper.appendChild(this._input);
47
48        this._dropdown = document.createElement('div');
49        this._dropdown.setAttribute('role', 'listbox');
50        this._dropdown.style.cssText = [
51            'display:none',
52            'position:absolute',
53            'top:calc(100% + 4px)',
54            'left:0',
55            'right:0',
56            'z-index:9999',
57            'background:#fff',
58            'border:1px solid #e4e7ec',
59            'border-radius:0.5rem',
60            'box-shadow:0 4px 16px -2px rgba(16,24,40,.14),0 2px 6px -2px rgba(16,24,40,.08)',
61            'overflow-y:auto',
62            'max-height:280px',
63            'padding:4px 0',
64        ].join(';');
65
66        wrapper.appendChild(this._dropdown);
67
68        // Spinner element (hidden by default)
69        this._spinner = document.createElement('div');
70        this._spinner.style.cssText = [
71            'display:none',
72            'position:absolute',
73            // Левее нативной кнопки очистки поля type="search", иначе спиннер и крестик налезают
74            'right:28px',
75            'top:50%',
76            'transform:translateY(-50%)',
77            'width:16px',
78            'height:16px',
79            'border:2px solid #d0d5dd',
80            'border-top-color:#465fff',
81            'border-radius:50%',
82            'animation:tw-ac-spin .6s linear infinite',
83            'pointer-events:none',
84        ].join(';');
85        wrapper.appendChild(this._spinner);
86
87        // Inject keyframes once
88        if (!document.getElementById('tw-ac-spin-style')) {
89            const style = document.createElement('style');
90            style.id = 'tw-ac-spin-style';
91            style.textContent = '@keyframes tw-ac-spin{to{transform:translateY(-50%) rotate(360deg)}}';
92            document.head.appendChild(style);
93        }
94
95        // Respect dark mode by watching body class
96        this._syncTheme();
97        const mo = new MutationObserver(() => this._syncTheme());
98        mo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
99    }
100
101    _syncTheme() {
102        const dark = document.body.classList.contains('dark');
103        if (dark) {
104            this._dropdown.style.background    = '#1d2939';
105            this._dropdown.style.borderColor   = '#344054';
106            this._dropdown.style.color         = '#d0d5dd';
107        } else {
108            this._dropdown.style.background    = '#fff';
109            this._dropdown.style.borderColor   = '#e4e7ec';
110            this._dropdown.style.color         = '#344054';
111        }
112    }
113
114    /* ------------------------------------------------------------------ */
115    /*  Events                                                              */
116    /* ------------------------------------------------------------------ */
117
118    _bind() {
119        this._input.setAttribute('autocomplete', 'off');
120
121        this._input.addEventListener('input',   () => this._onInput());
122        this._input.addEventListener('focus',   () => this._onInput());
123        this._input.addEventListener('keydown', (e) => this._onKeydown(e));
124
125        document.addEventListener('click', (e) => {
126            if (!this._dropdown.parentNode.contains(e.target)) {
127                this._hide();
128            }
129        });
130    }
131
132    _onInput() {
133        clearTimeout(this._debounceTimer);
134
135        // Заблокированное поле подсказок не показывает: значение проставляется автоматически.
136        if (this._input.readOnly || this._input.disabled) {
137            this._hide();
138            return;
139        }
140
141        const val = this._input.value;
142
143        if (val.length < this._config.suggestionsThreshold) {
144            // suggestionsThreshold === 0 means: fetch even when empty
145            if (this._config.suggestionsThreshold === 0) {
146                this._debounceTimer = setTimeout(() => this._fetchData(val), this._config.debounceTime);
147            } else {
148                this._hide();
149            }
150            return;
151        }
152
153        this._debounceTimer = setTimeout(() => this._fetchData(val), this._config.debounceTime);
154    }
155
156    _onKeydown(e) {
157        if (!this._visible()) return;
158
159        const rows = this._dropdown.querySelectorAll('[role="option"]');
160        switch (e.key) {
161            case 'ArrowDown':
162                e.preventDefault();
163                this._setActive(Math.min(this._activeIndex + 1, rows.length - 1));
164                break;
165            case 'ArrowUp':
166                e.preventDefault();
167                this._setActive(Math.max(this._activeIndex - 1, 0));
168                break;
169            case 'Enter':
170                e.preventDefault();
171                if (this._activeIndex >= 0) this._select(this._activeIndex);
172                break;
173            case 'Escape':
174                this._hide();
175                break;
176        }
177    }
178
179    /* ------------------------------------------------------------------ */
180    /*  Fetch                                                               */
181    /* ------------------------------------------------------------------ */
182
183    async _fetchData(query) {
184        // Allow caller to switch server URL before fetch
185        if (this._config.onBeforeFetch) {
186            this._config.onBeforeFetch(this);
187        }
188
189        if (this._abortCtrl) this._abortCtrl.abort();
190        this._abortCtrl = new AbortController();
191
192        const params = new URLSearchParams();
193        params.set(this._config.queryParam, query);
194
195        const sep = this._config.server.includes('?') ? '&' : '?';
196        const url = this._config.server + sep + params.toString();
197
198        this._spinner.style.display = 'block';
199
200        try {
201            const response = await fetch(url, { signal: this._abortCtrl.signal });
202
203            let items;
204            if (this._config.onServerResponse) {
205                items = await this._config.onServerResponse(response, this);
206            } else {
207                items = await response.json();
208            }
209
210            this._items       = Array.isArray(items) ? items : [];
211            this._activeIndex = -1;
212            this._render(query);
213            if (!response.ok) {
214                this._showError('Ошибка сервера (' + response.status + ')');
215                return;
216            }
217
218        } catch (err) {
219            if (err.name !== 'AbortError') {
220                console.error('[TwAutocomplete]', err);
221                this._showError('Не удалось загрузить данные');
222            }
223        } finally {
224            this._spinner.style.display = 'none';
225        }
226    }
227
228    /* ------------------------------------------------------------------ */
229    /*  Render                                                              */
230    /* ------------------------------------------------------------------ */
231
232    _render(query) {
233        this._dropdown.innerHTML = '';
234
235        if (this._items.length === 0) {
236            const msg = document.createElement('div');
237            msg.style.cssText = 'padding:10px 14px;font-size:13px;color:#98a2b3;';
238            msg.textContent   = this._config.notFoundMessage;
239            this._dropdown.appendChild(msg);
240            this._show();
241            return;
242        }
243
244        const dark = document.body.classList.contains('dark');
245
246        this._items.forEach((item, index) => {
247            const el = document.createElement('div');
248            el.setAttribute('role', 'option');
249            el.style.cssText = [
250                'padding:8px 14px',
251                'font-size:14px',
252                'line-height:1.45',
253                'cursor:pointer',
254                'white-space:normal',
255                'word-break:break-word',
256                dark ? 'color:#d0d5dd' : 'color:#344054',
257            ].join(';');
258
259            // Get display label
260            const rawLabel = item.label
261                ?? item[this._config.labelField]
262                ?? item[this._config.valueField]
263                ?? '';
264
265            const hasHtml = rawLabel.includes('<');
266
267            if (hasHtml) {
268                el.innerHTML = rawLabel;
269            } else if (this._config.highlightTyped && query) {
270                el.innerHTML = this._highlight(rawLabel, query);
271            } else {
272                el.textContent = rawLabel;
273            }
274
275            el.addEventListener('mouseenter', () => this._setActive(index));
276            el.addEventListener('mouseleave', () => this._clearActive());
277            el.addEventListener('mousedown',  (e) => { e.preventDefault(); this._select(index); });
278
279            this._dropdown.appendChild(el);
280        });
281
282        this._show();
283    }
284
285    _highlight(text, query) {
286        const safe = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
287        return text.replace(
288            new RegExp(`(${safe})`, 'gi'),
289            '<mark style="background:none;color:#465fff;font-weight:600;padding:0">$1</mark>'
290        );
291    }
292
293    /* ------------------------------------------------------------------ */
294    /*  Active item                                                         */
295    /* ------------------------------------------------------------------ */
296
297    _setActive(index) {
298        const rows = this._dropdown.querySelectorAll('[role="option"]');
299        const dark = document.body.classList.contains('dark');
300
301        // Clear previous
302        if (this._activeIndex >= 0 && rows[this._activeIndex]) {
303            rows[this._activeIndex].style.background = '';
304            rows[this._activeIndex].style.color      = dark ? '#d0d5dd' : '#344054';
305        }
306
307        this._activeIndex = index;
308
309        if (index >= 0 && rows[index]) {
310            rows[index].style.background = dark ? '#344054' : '#f2f4f7';
311            rows[index].style.color      = dark ? '#ffffff' : '#101828';
312            rows[index].scrollIntoView({ block: 'nearest' });
313        }
314    }
315
316    _clearActive() {
317        this._setActive(-1);
318    }
319
320    /* ------------------------------------------------------------------ */
321    /*  Select                                                              */
322    /* ------------------------------------------------------------------ */
323
324    _select(index) {
325        const item = this._items[index];
326        if (!item) return;
327
328        const value = item[this._config.valueField] ?? item.value ?? '';
329        this._input.value = value;
330        this._hide();
331
332        if (this._config.onSelectItem) {
333            this._config.onSelectItem(item, this._input);
334        }
335    }
336
337    /* ------------------------------------------------------------------ */
338    /*  Visibility                                                          */
339    /* ------------------------------------------------------------------ */
340
341    _show()    { this._dropdown.style.display = 'block'; }
342    _hide()    { this._dropdown.style.display = 'none'; this._activeIndex = -1; }
343    _visible() { return this._dropdown.style.display !== 'none'; }
344
345    _showError(text) {
346        this._dropdown.innerHTML = '';
347        const msg = document.createElement('div');
348        msg.style.cssText = 'padding:10px 14px;font-size:13px;color:#f04438;';
349        msg.textContent = text;
350        this._dropdown.appendChild(msg);
351        this._show();
352    }
353}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.