vgapp 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,16 @@
1
1
  import BaseModule from "../../base-module";
2
2
  import {
3
3
  isDisabled,
4
- isEmptyObj, isObject, isVisible,
4
+ isEmptyObj,
5
5
  mergeDeepObject,
6
- noop,
7
6
  normalizeData,
8
- transliterate
9
7
  } from "../../../utils/js/functions";
10
- import {Manipulator} from "../../../utils/js/dom/manipulator";
8
+ import {Classes, Manipulator} from "../../../utils/js/dom/manipulator";
11
9
  import EventHandler from "../../../utils/js/dom/event";
12
10
  import Selectors from "../../../utils/js/dom/selectors";
11
+ import _handlersVGSelect from "./handlers";
12
+ import {lang_buttons, lang_messages, lang_titles} from "../../../utils/js/components/lang";
13
13
 
14
- /**
15
- * Constants
16
- */
17
14
  const NAME = 'select';
18
15
  const NAME_KEY = 'vg.select';
19
16
 
@@ -28,468 +25,956 @@ const CLASS_NAME_OPTGROUP_TITLE = 'vg-select-list--optgroup-title';
28
25
  const CLASS_NAME_CURRENT = 'vg-select-current';
29
26
  const CLASS_NAME_PLACEHOLDER = 'vg-select-current--placeholder';
30
27
  const CLASS_NAME_SEARCH = 'vg-select-search';
28
+ const CLASS_NAME_TAGS = 'vg-select-tags';
29
+ const CLASS_NAME_TAG = 'vg-select-tag';
30
+ const CLASS_NAME_TAG_REMOVE = 'vg-select-tag-remove';
31
+ const CLASS_NAME_LOAD_MORE = 'vg-select-load-more';
32
+ const CLASS_NAME_LOADING = 'vg-select-loading';
31
33
 
32
- const EVENT_CLICK_DATA_API = `click.${NAME_KEY}.data.api`;
33
- const EVENT_KEY_UP_DATA_API = `keyup.${NAME_KEY}.data.api`;
34
- const EVENT_RESET_DATA_API = `reset.${NAME_KEY}.data.api`;
35
34
  const EVENT_KEY_CHANGE = `${NAME_KEY}.change`;
36
35
  const EVENT_KEY_HIDE = `${NAME_KEY}.hide`;
37
36
  const EVENT_KEY_HIDDEN = `${NAME_KEY}.hidden`;
38
37
  const EVENT_KEY_SHOW = `${NAME_KEY}.show`;
39
38
  const EVENT_KEY_SHOWN = `${NAME_KEY}.shown`;
39
+ const EVENT_KEY_INIT = `${NAME_KEY}.init`;
40
+ const EVENT_KEY_REBUILD = `${NAME_KEY}.rebuild`;
41
+ const EVENT_KEY_OPEN = `${NAME_KEY}.open`;
42
+ const EVENT_KEY_CLOSE = `${NAME_KEY}.close`;
43
+ const EVENT_KEY_SELECT = `${NAME_KEY}.select`;
44
+ const EVENT_KEY_CLEAR = `${NAME_KEY}.clear`;
45
+ const EVENT_KEY_ERROR = `${NAME_KEY}.error`;
46
+ const EVENT_KEY_LOAD_NEXT = `${NAME_KEY}.loadNext`;
47
+
48
+ const SELECTOR_DATA_TOGGLE = '[data-vg-toggle="select"]';
49
+ const SELECTOR_CURRENT = `.${CLASS_NAME_CURRENT}`;
50
+ const SELECTOR_DROPDOWN = `.${CLASS_NAME_DROPDOWN}`;
51
+ const SELECTOR_SEARCH_INPUT = `.${CLASS_NAME_SEARCH} input`;
52
+ const SELECTOR_LIST = `.${CLASS_NAME_LIST}`;
53
+ const SELECTOR_LOAD_MORE_BTN = `.${CLASS_NAME_LOAD_MORE}`;
40
54
 
41
- const SELECTOR_DATA_TOGGLE = '[data-vg-toggle="select"]';
42
- const SELECTOR_OPTION_TOGGLE = '[data-vg-toggle="select-option"]';
43
- const SELECTOR_SEARCH_TOGGLE = '[name=vg-select-search]';
44
-
45
-
46
- let observerTimout;
47
-
55
+ /**
56
+ * Класс VGSelect
57
+ * Кастомный <select> с поддержкой поиска, мультивыбора, динамической загрузки, i18n, пагинации и обновления через MutationObserver.
58
+ * @extends BaseModule
59
+ */
48
60
  class VGSelect extends BaseModule {
61
+ /**
62
+ * Создаёт экземпляр VGSelect
63
+ * @param {HTMLSelectElement} element - Исходный <select> элемент
64
+ * @param {Object} [params] - Параметры инициализации
65
+ */
49
66
  constructor(element, params = {}) {
50
67
  super(element, params);
51
68
 
52
69
  this._params = this._getParams(element, mergeDeepObject({
53
- search: false,
70
+ lang: document.documentElement.lang || 'ru',
71
+ search: {
72
+ enabled: true,
73
+ route: '',
74
+ remote: false,
75
+ delay: 300,
76
+ minTerm: 1,
77
+ pagination: false,
78
+ pageParam: 'page',
79
+ termParam: 'q',
80
+ perPage: 20,
81
+ loadMoreText: 'Загрузить ещё',
82
+ },
54
83
  placeholder: '',
84
+ onInit: null,
85
+ onShow: null,
86
+ onHide: null,
87
+ onChange: null,
88
+ onSelect: null,
89
+ onDeselect: null,
90
+ onClear: null,
91
+ onLoadNext: null,
55
92
  }, params));
56
93
 
57
- this._drop = Selectors.find('.' + CLASS_NAME_DROPDOWN, this._element);
58
- this.refresh();
94
+ this._observer = null;
95
+ this._observerTimeout = null;
96
+ this._drop = Selectors.find(SELECTOR_DROPDOWN, this._element);
97
+ this._searchTerm = '';
98
+ this._currentPage = 1;
99
+ this._totalPages = null;
100
+ this._loading = false;
101
+
102
+ if (typeof params.search === 'object') {
103
+ this._params.search.loadMoreText = lang_buttons(this._params.lang, NAME)['load-more'] || 'Загрузить еще'
104
+ }
105
+
106
+ this._initObserver();
107
+ this._initLoadMoreButton();
108
+
109
+ this._triggerEvent(EVENT_KEY_INIT);
110
+ this._callCallback('onInit');
59
111
  }
60
112
 
113
+ /**
114
+ * Возвращает имя компонента
115
+ * @returns {string}
116
+ */
61
117
  static get NAME() {
62
118
  return NAME;
63
119
  }
64
120
 
121
+ /**
122
+ * Возвращает ключ события компонента
123
+ * @returns {string}
124
+ */
65
125
  static get NAME_KEY() {
66
126
  return NAME_KEY;
67
127
  }
68
128
 
69
- static buildListOptions(selector, drop, isPlaceholder) {
70
- let options = selector.options,
129
+ /**
130
+ * Перестраивает список опций в выпадающем меню
131
+ * @param {HTMLSelectElement} selector - Исходный <select>
132
+ * @param {HTMLElement} drop - Контейнер выпадающего списка
133
+ * @returns {HTMLElement} - Обновлённый список
134
+ */
135
+ static buildListOptions(selector, drop) {
136
+ let list = drop.querySelector(`.${CLASS_NAME_LIST}`);
137
+ if (!list) {
71
138
  list = document.createElement('ul');
72
-
73
- if (isPlaceholder) {
74
- let isSelectedOption = [...options].filter(el => Manipulator.has(el, 'selected')).length > 0;
75
-
76
- if (!isSelectedOption) {
77
- let option = document.createElement('option');
78
- Manipulator.set(option, 'hidden', '');
79
- Manipulator.set(option, 'selected', '');
80
- options.add(option, 0)
81
- }
139
+ Classes.add(list, CLASS_NAME_LIST);
140
+ drop.appendChild(list);
141
+ } else {
142
+ list.innerHTML = '';
82
143
  }
83
144
 
145
+ const optGroups = Selectors.findAll('optgroup', selector);
146
+ const fragment = document.createDocumentFragment();
84
147
 
85
- list.classList.add(CLASS_NAME_LIST);
86
-
87
- let optGroup = selector.querySelectorAll('optgroup');
88
-
89
- if (optGroup.length) {
90
- let isSelected = false;
91
- [...optGroup].forEach(function (el) {
92
- let olOptGroup = document.createElement('ol');
93
- olOptGroup.classList.add(CLASS_NAME_OPTGROUP);
94
-
95
- let liLabel = document.createElement('li');
96
- liLabel.innerHTML = el.label.trim();
97
- liLabel.classList.add(CLASS_NAME_OPTGROUP_TITLE)
98
-
99
- olOptGroup.prepend(liLabel)
148
+ if (optGroups.length > 0) {
149
+ optGroups.forEach(optGroup => {
150
+ const ol = document.createElement('ol');
151
+ Classes.add(ol, CLASS_NAME_OPTGROUP);
100
152
 
101
- let optGroupOptions = Selectors.findAll('option', el);
153
+ const label = document.createElement('li');
154
+ label.textContent = optGroup.label.trim();
155
+ Classes.add(label, CLASS_NAME_OPTGROUP_TITLE);
156
+ ol.appendChild(label);
102
157
 
103
- createLi(optGroupOptions, olOptGroup, isSelected);
104
-
105
- list.append(olOptGroup);
106
- isSelected = true;
158
+ VGSelect._createListItems(Selectors.findAll('option', optGroup), ol, selector);
159
+ fragment.appendChild(ol);
107
160
  });
161
+ list.appendChild(fragment);
108
162
  } else {
109
- let isSelected = false;
110
- createLi(options, list, isSelected);
163
+ VGSelect._createListItems(selector.options, list, selector);
111
164
  }
112
165
 
113
- drop.append(list);
114
-
115
166
  return list;
167
+ }
116
168
 
117
- function createLi(options, list, isSelected) {
118
- let i = 0;
119
- for (const option of options) {
120
- let li = document.createElement('li');
169
+ /**
170
+ * Создаёт <li> элементы из списка <option>
171
+ * @param {HTMLCollection|NodeList} options - Коллекция <option>
172
+ * @param {HTMLElement} parent - Родительский контейнер (ul/ol)
173
+ * @param {HTMLSelectElement} selector - Исходный <select>
174
+ * @private
175
+ */
176
+ static _createListItems(options, parent, selector) {
177
+ const frag = document.createDocumentFragment();
178
+ const selectedIndex = selector.selectedIndex;
121
179
 
122
- li.innerHTML = option.innerHTML.trim().replace(/<\/[^>]+(>|$)/g, "")
123
- li.dataset.value = Manipulator.get(option, 'value');
124
- li.classList.add(CLASS_NAME_OPTION);
180
+ [...options].forEach((option, i) => {
181
+ if (option.hidden) return;
125
182
 
126
- Manipulator.set(li, 'data-vg-toggle', 'select-option');
183
+ const value = option.value || '';
184
+ const text = option.textContent.trim();
185
+ const isEmptyOption = value === '' && text === '';
127
186
 
128
- let liData = Manipulator.get(option);
129
- if (!isEmptyObj(liData)) {
130
- for (const key of Object.keys(liData)) {
131
- Manipulator.set(li, 'data-' + key, liData[key]);
132
- }
133
- }
187
+ const li = document.createElement('li');
188
+ li.textContent = text;
189
+ li.dataset.value = value;
190
+ li.classList.add(CLASS_NAME_OPTION);
191
+ Manipulator.set(li, 'data-vg-toggle', 'select-option');
134
192
 
135
- if (i === selector.selectedIndex && !isSelected) {
136
- li.classList.add('selected');
137
- }
193
+ if (i === selectedIndex) {
194
+ li.classList.add('selected');
195
+ }
138
196
 
139
- if (Manipulator.has(option, 'disabled')) li.classList.add('disabled');
140
- if (Manipulator.has(option, 'hidden')) li.classList.add('hidden');
197
+ if (option.disabled) {
198
+ li.classList.add('disabled');
199
+ }
141
200
 
142
- list.append(li);
201
+ if (isEmptyOption) {
202
+ li.hidden = true;
203
+ li.style.display = 'none';
204
+ }
143
205
 
144
- i++;
206
+ const dataAttrs = Manipulator.get(option);
207
+ if (!isEmptyObj(dataAttrs)) {
208
+ Object.keys(dataAttrs).forEach(key => {
209
+ Manipulator.set(li, `data-${key}`, dataAttrs[key]);
210
+ });
145
211
  }
146
- }
212
+
213
+ frag.appendChild(li);
214
+ });
215
+
216
+ parent.appendChild(frag);
147
217
  }
148
218
 
149
- static build(selector, reBuild = false) {
150
- let option_selected,
151
- placeholder = selector.dataset.placeholder || '',
152
- isPlaceholder = !!placeholder,
153
- isSearch = selector.dataset.search || false;
219
+ /**
220
+ * Проверяет, является ли значение "пустым" (соответствует placeholder)
221
+ * @param {HTMLSelectElement} select - Элемент <select>
222
+ * @param {string} value - Значение для проверки
223
+ * @returns {boolean}
224
+ */
225
+ static isPlaceholderValue(select, value) {
226
+ const attr = select.dataset.placeholderValue;
227
+ if (!attr) return value == null || String(value).trim() === '';
228
+ return attr.split(',').map(v => v.trim()).includes(String(value));
229
+ }
154
230
 
155
- if (selector.dataset?.inited === 'true' || reBuild) {
156
- VGSelect.destroy(selector);
157
- }
231
+ /**
232
+ * Проверяет, выбрана ли допустимая опция (не placeholder)
233
+ * @param {HTMLSelectElement} select - Элемент <select>
234
+ * @returns {boolean}
235
+ */
236
+ static hasSelectedValidOption(select) {
237
+ const index = select.selectedIndex;
238
+ if (index === -1) return false;
239
+ return !this.isPlaceholderValue(select, select.options[index]?.value);
240
+ }
158
241
 
159
- selector.parentElement.style.position = 'relative';
160
-
161
- let isSelectedOption = [... selector.options].filter(el => {
162
- return Manipulator.has(el, 'selected') && el.value !== ''
163
- }).length > 0;
164
-
165
- if (isPlaceholder && selector.selectedIndex === -1) {
166
- option_selected = '<span class="' + CLASS_NAME_PLACEHOLDER + '">' + placeholder + '<span>';
167
- Manipulator.set(selector, 'disabled', '');
168
- } else if (!isPlaceholder && selector.selectedIndex === -1) {
169
- option_selected = '<span class="' + CLASS_NAME_PLACEHOLDER + '">-<span>';
170
- Manipulator.set(selector, 'disabled', '');
171
- } else if (isPlaceholder) {
172
- if (isPlaceholder && isSelectedOption) {
173
- option_selected = selector.options[selector.selectedIndex].innerText;
174
- } else if (isPlaceholder && !isSelectedOption) {
175
- option_selected = '<span class="' + CLASS_NAME_PLACEHOLDER + '">' + placeholder + '<span>';
176
- } else if(!isPlaceholder && !isSelectedOption) {
177
- option_selected = selector.options[selector.selectedIndex].innerText;
178
- } else {
179
- option_selected = '<span class="' + CLASS_NAME_PLACEHOLDER + '">-<span>';
180
- }
181
- } else {
182
- option_selected = selector.options[selector.selectedIndex].innerText;
242
+ /**
243
+ * Строит кастомный UI для <select>
244
+ * @param {HTMLSelectElement} selector - Исходный <select>
245
+ * @param {boolean} [reBuild=false] - Пересоздать, если уже инициализирован
246
+ * @returns {HTMLElement} - Обёртка (.vg-select)
247
+ */
248
+ static build(selector, reBuild = false) {
249
+ if (reBuild || selector.dataset.inited === 'true') {
250
+ this.destroy(selector);
183
251
  }
184
252
 
185
- // Создаем основной элемент с классами селекта
186
- let classes = Manipulator.get(selector,'class'),
187
- element = document.createElement('div');
188
-
189
- classes = classes.split(' ');
253
+ const container = document.createElement('div');
254
+ container.classList.add(CLASS_NAME_CONTAINER);
255
+ container.style.position = 'relative';
190
256
 
191
- for (const _class of classes) {
192
- element.classList.add(_class)
257
+ if (selector.classList.length) {
258
+ [...selector.classList].forEach(cls => container.classList.add(cls));
193
259
  }
194
260
 
195
- if (Manipulator.has(selector, 'disabled')) element.classList.add('disabled');
261
+ if (isDisabled(selector)) {
262
+ container.classList.add('disabled');
263
+ }
196
264
 
197
- let elData = Manipulator.get(selector);
265
+ const elData = Manipulator.get(selector);
198
266
  if (!isEmptyObj(elData)) {
199
- for (const key of Object.keys(elData)) {
200
- Manipulator.set(element,'data-' + key, elData[key]);
201
- }
267
+ Object.keys(elData).forEach(key => {
268
+ Manipulator.set(container, `data-${key}`, elData[key]);
269
+ });
202
270
  }
203
271
 
204
- // Создаем элемент с отображением выбранного варианта
205
- let current = document.createElement('div');
272
+ const placeholder = selector.dataset.placeholder || '';
273
+ const isMultiple = selector.multiple;
274
+
275
+ const current = document.createElement('div');
206
276
  current.classList.add(CLASS_NAME_CURRENT);
207
- Manipulator.set(current, 'data-vg-toggle', 'select');
208
- Manipulator.set(current, 'aria-expanded', 'false');
209
- current.innerHTML = option_selected.trim();
210
- element.append(current);
277
+ current.setAttribute('data-vg-toggle', 'select');
278
+ current.setAttribute('aria-expanded', 'false');
279
+ current.setAttribute('role', 'button');
280
+ current.tabIndex = isMultiple ? -1 : 0;
281
+
282
+ if (isMultiple) {
283
+ const tags = document.createElement('div');
284
+ tags.classList.add(CLASS_NAME_TAGS);
285
+ current.appendChild(tags);
286
+
287
+ const input = document.createElement('input');
288
+ input.type = 'text';
289
+ input.className = 'vg-select-multiple-input';
290
+ input.style.cssText = 'border:none;outline:none;background:transparent;padding:0;margin:0;min-width:40px;font:inherit;';
291
+ tags.appendChild(input);
292
+
293
+ input.addEventListener('focus', () => {
294
+ const inst = VGSelect.getInstance(input.closest(`.${CLASS_NAME_CONTAINER}`));
295
+ if (inst && !inst._isShown()) inst.show();
296
+ });
297
+ } else {
298
+ const index = selector.selectedIndex;
299
+ const option = index >= 0 ? selector.options[index] : null;
300
+ const text = option?.textContent.trim() || '';
301
+ const showPlaceholder = placeholder && !this.hasSelectedValidOption(selector);
302
+
303
+ current.innerHTML = showPlaceholder
304
+ ? `<span class="${CLASS_NAME_PLACEHOLDER}">${placeholder}</span>`
305
+ : text || '-';
306
+ }
211
307
 
212
- // Создаем элемент выпадающего списка
213
- let dropdown = document.createElement('div');
214
- dropdown.classList.add(CLASS_NAME_DROPDOWN);
215
- element.append(dropdown);
308
+ container.appendChild(current);
216
309
 
217
- // Создаем список и варианты селекта
218
- VGSelect.buildListOptions(selector, dropdown, isPlaceholder);
310
+ const dropdown = document.createElement('div');
311
+ dropdown.classList.add(CLASS_NAME_DROPDOWN);
312
+ container.appendChild(dropdown);
219
313
 
220
- // Добавляем все созданный контейнер после селекта
221
- selector.insertAdjacentElement('afterend', element);
314
+ this.buildListOptions(selector, dropdown);
222
315
 
223
- // помечаем элемент инициализированным
316
+ selector.insertAdjacentElement('afterend', container);
224
317
  selector.dataset.inited = 'true';
225
318
 
226
- if (isSearch) {
227
- let search_container = document.createElement('div');
228
- search_container.classList.add(CLASS_NAME_SEARCH);
319
+ this.getOrCreateInstance(container);
320
+ this.updateUI(selector);
321
+ const instance = VGSelect.getInstance(container);
322
+
323
+ let searchInput = null;
324
+ if (Manipulator.has(selector, 'data-search-enabled')) {
325
+ const search = document.createElement('div');
326
+ search.classList.add(CLASS_NAME_SEARCH);
327
+ searchInput = document.createElement('input');
328
+ searchInput.name = 'vg-select-search';
329
+ searchInput.type = 'text';
330
+ searchInput.placeholder = lang_titles(instance._params.lang, NAME)['search'];
331
+ searchInput.autocomplete = 'off';
332
+ searchInput.setAttribute('role', 'searchbox');
333
+ search.appendChild(searchInput);
334
+ dropdown.insertBefore(search, dropdown.firstChild);
335
+ }
229
336
 
230
- let input = document.createElement('input');
231
- Manipulator.set(input, 'name', 'vg-select-search');
232
- Manipulator.set(input, 'type', 'text');
233
- Manipulator.set(input, 'placeholder', 'Поиск...');
234
- Manipulator.set(input, 'autocomplete', 'off');
337
+ if (searchInput && instance) {
338
+ let searchTimeout;
235
339
 
236
- search_container.append(input);
237
- dropdown.prepend(search_container);
340
+ searchInput.addEventListener('input', (e) => {
341
+ const term = e.target.value.trim();
342
+ const params = instance._params;
343
+
344
+ instance._callCallback('onSearch', { term });
345
+
346
+ if (params.search.remote && params.search.route) {
347
+ if (term.length < (params.search.minTerm || 1)) return;
348
+
349
+ clearTimeout(searchTimeout);
350
+ searchTimeout = setTimeout(() => {
351
+ instance._fetchRemoteData(term);
352
+ }, params.search.delay || 300);
353
+ } else {
354
+ instance._filterLocalOptions(term);
355
+ }
356
+ });
238
357
  }
239
358
 
240
- return element;
359
+ return container;
241
360
  }
242
361
 
362
+ /**
363
+ * Переключает открытие/закрытие выпадающего списка
364
+ * @param {EventTarget} [relatedTarget] - Элемент, вызвавший событие
365
+ */
243
366
  toggle(relatedTarget) {
244
- return !this._isShown() ? this.show(relatedTarget) : this.hide();
367
+ return this._isShown() ? this.hide() : this.show(relatedTarget);
245
368
  }
246
369
 
370
+ /**
371
+ * Открывает выпадающий список
372
+ * @param {EventTarget} [relatedTarget] - Элемент, вызвавший событие
373
+ */
247
374
  show(relatedTarget) {
248
375
  if (isDisabled(this._element)) return;
249
376
 
250
- const showEvent = EventHandler.trigger(this._element, EVENT_KEY_SHOW, { relatedTarget })
251
- if (showEvent.defaultPrevented) return;
377
+ const e = EventHandler.trigger(this._element, EVENT_KEY_SHOW, { relatedTarget });
378
+ if (e.defaultPrevented) return;
379
+
380
+ this._element.classList.add(CLASS_NAME_SHOW);
252
381
 
253
382
  if ('ontouchstart' in document.documentElement) {
254
- for (const element of [].concat(...document.body.children)) {
255
- EventHandler.on(element, 'mouseover', noop);
256
- }
383
+ document.body.style.pointerEvents = 'none';
257
384
  }
258
385
 
259
- this._element.classList.add(CLASS_NAME_SHOW);
386
+ const toggle = this._element.querySelector(SELECTOR_DATA_TOGGLE);
387
+ toggle.setAttribute('aria-expanded', 'true');
260
388
 
261
- if (this._params.search) {
262
- let input = Selectors.find('input', this._element);
389
+ if (this._params.search?.enabled) {
390
+ const input = this._element.querySelector(SELECTOR_SEARCH_INPUT);
263
391
  if (input) input.focus();
264
392
  }
265
393
 
266
- const completeCallBack = () => {
394
+ return this._queueCallback(() => {
267
395
  this._element.classList.add(CLASS_NAME_ACTIVE);
268
396
  EventHandler.trigger(this._element, EVENT_KEY_SHOWN, { relatedTarget });
269
- }
270
-
271
- this._queueCallback(completeCallBack, this._drop, true, 50)
397
+ this._triggerEvent(EVENT_KEY_OPEN);
398
+ this._callCallback('onShow');
399
+ }, this._drop, true, 50);
272
400
  }
273
401
 
402
+ /**
403
+ * Закрывает выпадающий список
404
+ */
274
405
  hide() {
275
406
  if (isDisabled(this._element) || !this._isShown()) return;
276
-
277
407
  this._completeHide();
278
408
  }
279
409
 
280
- _completeHide() {
281
- const hideEvent = EventHandler.trigger(this._element, EVENT_KEY_HIDE, {})
282
- if (hideEvent.defaultPrevented) return;
410
+ /**
411
+ * Полностью завершает процесс закрытия
412
+ * @param {Object} [relatedTarget] - Доп. данные
413
+ * @private
414
+ */
415
+ _completeHide(relatedTarget = {}) {
416
+ const e = EventHandler.trigger(this._element, EVENT_KEY_HIDE, relatedTarget);
417
+ if (e.defaultPrevented) return;
283
418
 
284
419
  this._element.classList.remove(CLASS_NAME_ACTIVE);
285
- let toggle = Selectors.find(SELECTOR_DATA_TOGGLE, this._element);
286
- Manipulator.set(toggle, 'aria-expanded', 'false');
420
+ this._element.querySelector(SELECTOR_DATA_TOGGLE).setAttribute('aria-expanded', 'false');
287
421
 
288
- if ('ontouchstart' in document.documentElement) {
289
- for (const element of [].concat(...document.body.children)) {
290
- EventHandler.off(element, 'mouseover', noop);
291
- }
292
- }
293
-
294
- const completeCallback = () => {
422
+ this._queueCallback(() => {
295
423
  this._element.classList.remove(CLASS_NAME_SHOW);
296
- EventHandler.trigger(this._element, EVENT_KEY_HIDDEN, {});
424
+ EventHandler.trigger(this._element, EVENT_KEY_HIDDEN, relatedTarget);
425
+ this._triggerEvent(EVENT_KEY_CLOSE);
426
+ this._callCallback('onHide');
427
+ }, this._drop, true, 10);
428
+
429
+ if ('ontouchstart' in document.documentElement) {
430
+ document.body.style.pointerEvents = '';
297
431
  }
298
- this._queueCallback(completeCallback, this._drop, true, 10);
299
432
  }
300
433
 
434
+ /**
435
+ * Проверяет, открыт ли список
436
+ * @returns {boolean}
437
+ * @private
438
+ */
301
439
  _isShown() {
302
440
  return this._element.classList.contains(CLASS_NAME_SHOW);
303
441
  }
304
442
 
305
- refresh() {
306
- const select = this._element.previousSibling;
443
+ /**
444
+ * Инициализирует MutationObserver для отслеживания изменений в <select>
445
+ * @private
446
+ */
447
+ _initObserver() {
448
+ if (this._observer) {
449
+ this._observer.disconnect();
450
+ }
451
+
452
+ const select = this._element.previousElementSibling;
453
+ if (!select || select.tagName !== 'SELECT') return;
454
+
455
+ this._observer = new MutationObserver((mutations) => {
456
+ console.debug('VGSelect: Mutation detected', mutations);
457
+ if (select.hasAttribute('data-updating')) return;
458
+
459
+ const shouldUpdate = mutations.some(mutation => {
460
+ if (mutation.type === 'attributes') {
461
+ return ['disabled', 'required', 'style', 'hidden'].includes(mutation.attributeName);
462
+ }
463
+ return mutation.type === 'childList' || mutation.type === 'characterData';
464
+ });
465
+
466
+ if (!shouldUpdate) return;
467
+
468
+ const wasShown = this._isShown();
469
+
470
+ clearTimeout(this._observerTimeout);
471
+ this._observerTimeout = setTimeout(() => {
472
+ if (this._element.previousElementSibling !== select || select.hasAttribute('data-updating')) return;
307
473
 
308
- let observer = new MutationObserver(() => {
309
- clearTimeout(observerTimout);
310
- observerTimout = setTimeout(() => {
311
- VGSelect.build(select, true);
312
- }, 10);
474
+ this._updateFromMutation();
475
+
476
+ if (wasShown) {
477
+ requestAnimationFrame(() => {
478
+ this.show();
479
+ });
480
+ }
481
+ }, 100);
313
482
  });
314
483
 
315
- observer.observe(select, {
316
- attributeFilter: ['disabled', 'required', 'style', 'hidden', 'value', 'selected'],
484
+ this._observer.observe(select, {
485
+ attributes: true,
486
+ attributeFilter: ['disabled', 'required', 'style', 'hidden'],
317
487
  childList: true,
318
488
  subtree: true,
319
- characterDataOldValue: true,
489
+ characterData: true
320
490
  });
321
491
  }
322
492
 
493
+ /**
494
+ * Обновляет UI после изменения в DOM
495
+ * @private
496
+ */
497
+ _updateFromMutation() {
498
+ const select = this._element.previousElementSibling;
499
+ if (!select) return;
500
+
501
+ if (isDisabled(select)) {
502
+ this._element.classList.add('disabled');
503
+ } else {
504
+ this._element.classList.remove('disabled');
505
+ }
506
+
507
+ const drop = this._element.querySelector(SELECTOR_DROPDOWN);
508
+ VGSelect.buildListOptions(select, drop);
509
+ VGSelect.updateUI(select);
510
+ }
511
+
512
+ /**
513
+ * Освобождает ресурсы (отключает observer, очищает таймеры)
514
+ */
323
515
  dispose() {
516
+ if (this._observer) {
517
+ this._observer.disconnect();
518
+ this._observer = null;
519
+ }
520
+ clearTimeout(this._observerTimeout);
521
+ this._observerTimeout = null;
324
522
  super.dispose();
325
523
  }
326
524
 
525
+ /**
526
+ * Удаляет кастомный UI
527
+ * @param {HTMLSelectElement} select - Исходный <select>
528
+ */
327
529
  static destroy(select) {
328
- let element = Selectors.next(select, '.' + CLASS_NAME_CONTAINER);
329
- element = element.shift();
530
+ const container = select.nextElementSibling;
531
+ if (container && container.classList.contains(CLASS_NAME_CONTAINER)) {
532
+ container.remove();
533
+ }
534
+ }
330
535
 
331
- if (element) {
332
- if (element.classList.contains(CLASS_NAME_CONTAINER)) {
333
- element.remove();
536
+ /**
537
+ * Обновляет отображаемое значение (текст, теги)
538
+ * @param {HTMLSelectElement} select - Исходный <select>
539
+ */
540
+ static updateUI(select) {
541
+ const container = select.nextElementSibling;
542
+ if (!container || !container.classList.contains(CLASS_NAME_CONTAINER)) return;
543
+
544
+ const current = container.querySelector(SELECTOR_CURRENT);
545
+ const placeholder = select.dataset.placeholder || '';
546
+ const isMultiple = select.multiple;
547
+ const instance = VGSelect.getInstance(container);
548
+
549
+ if (isMultiple) {
550
+ const tags = current.querySelector(`.${CLASS_NAME_TAGS}`);
551
+ const input = tags.querySelector('input');
552
+ const prevCount = tags.querySelectorAll(`.${CLASS_NAME_TAG}`).length;
553
+ tags.innerHTML = '';
554
+ tags.appendChild(input);
555
+
556
+ const selected = Array.from(select.selectedOptions);
557
+ const newCount = selected.length;
558
+
559
+ if (newCount === 0 && prevCount > 0) {
560
+ instance?._triggerEvent(EVENT_KEY_CLEAR);
561
+ instance?._callCallback('onClear');
562
+ }
334
563
 
335
- select.selectedIndex = 0;
336
- [...select.querySelectorAll('option')].forEach(function (el, index) {
337
- if (el.hasAttribute('selected')) {
338
- select.selectedIndex = index;
339
- }
564
+ if (selected.length === 0) {
565
+ input.placeholder = placeholder;
566
+ } else {
567
+ input.placeholder = '';
568
+ selected.forEach(opt => {
569
+ const tag = document.createElement('div');
570
+ tag.classList.add(CLASS_NAME_TAG);
571
+ tag.innerHTML = `<span>${opt.textContent}</span><svg class="${CLASS_NAME_TAG_REMOVE}" data-value="${opt.value}" width="14" height="14" viewBox="0 0 14 14"><line x1="2" y1="2" x2="12" y2="12" stroke="currentColor"/><line x1="12" y1="2" x2="2" y2="12" stroke="currentColor"/></svg>`;
572
+ tags.insertBefore(tag, input);
340
573
  });
341
574
  }
575
+ } else {
576
+ const index = select.selectedIndex;
577
+ const option = index >= 0 ? select.options[index] : null;
578
+ const text = option?.textContent.trim() || '';
579
+ const value = option?.value;
580
+ const showPlaceholder = placeholder && (!value || this.isPlaceholderValue(select, value) || !text);
581
+
582
+ const oldText = current.textContent;
583
+ const newText = showPlaceholder ? placeholder : text || '-';
584
+
585
+ if (oldText !== newText) {
586
+ current.innerHTML = showPlaceholder
587
+ ? `<span class="${CLASS_NAME_PLACEHOLDER}">${placeholder}</span>`
588
+ : newText;
589
+ }
342
590
  }
343
591
  }
344
592
 
345
- static hideOpenToggles(event) {
346
- const openToggles = Selectors.findAll('.vg-select:not(.disabled):not(:disabled).show');
347
-
348
- for (const toggle of openToggles) {
349
- const context = VGSelect.getInstance(toggle);
350
- if (!context) continue;
351
-
352
- if (event.target.closest('.' + CLASS_NAME_CONTAINER) === context._element) {
593
+ /**
594
+ * Программно устанавливает значение <select>
595
+ * @param {HTMLSelectElement} select - Исходный <select>
596
+ * @param {string} value - Значение для выбора
597
+ * @param {Object} [data] - Дополнительные данные
598
+ */
599
+ static changeSelector(select, value, data = {}) {
600
+ const container = select.nextElementSibling;
601
+ const instance = container ? VGSelect.getInstance(container) : null;
602
+ const prevValue = select.value;
603
+
604
+ select.setAttribute('data-updating', 'true');
605
+ try {
606
+ const opt = select.querySelector(`option[value="${CSS.escape(normalizeData(value))}"]`);
607
+ if (!opt) {
608
+ instance?._triggerEvent(EVENT_KEY_ERROR, { error: 'Option not found', value });
353
609
  return;
354
610
  }
355
611
 
356
- const composedPath = event.composedPath();
357
- if (composedPath.includes(context._element)) {
358
- continue
359
- }
612
+ const oldValue = select.value;
613
+ const wasSelected = opt.selected;
614
+ const selectedText = opt.textContent.trim();
360
615
 
361
- const relatedTarget = { relatedTarget: context._element }
616
+ [...select.options].forEach(o => o.selected = false);
617
+ opt.selected = true;
618
+ select.value = opt.value;
362
619
 
363
- if (event.type === 'click') {
364
- relatedTarget.clickEvent = event
365
- }
620
+ this.updateUI(select);
366
621
 
367
- context._completeHide(relatedTarget)
622
+ const e = new Event('change', { bubbles: true, cancelable: true });
623
+ select.dispatchEvent(e);
624
+
625
+ if (!wasSelected) {
626
+ EventHandler.trigger(select, EVENT_KEY_CHANGE, { data });
627
+ instance?._triggerEvent(EVENT_KEY_SELECT, { value, text: selectedText, data });
628
+ instance?._callCallback('onSelect', { value, text: selectedText, data });
629
+ }
630
+ } finally {
631
+ select.removeAttribute('data-updating');
368
632
  }
369
633
  }
370
634
 
371
- static clearDrops(event) {
372
- if (event.button === 2 || (event.type === 'keyup' && event.key !== 'Tab')) {
373
- return
635
+ /**
636
+ * Вызывает кастомное событие
637
+ * @param {string} eventName - Имя события
638
+ * @param {Object} [detail] - Данные события
639
+ * @private
640
+ */
641
+ _triggerEvent(eventName, detail = {}) {
642
+ EventHandler.trigger(this._element, eventName, detail);
643
+ }
644
+
645
+ /**
646
+ * Выполняет пользовательский коллбэк
647
+ * @param {string} name - Имя коллбэка
648
+ * @param {*} [arg] - Аргумент
649
+ * @private
650
+ */
651
+ _callCallback(name, arg = null) {
652
+ const callback = this._params[name];
653
+ if (typeof callback === 'function') {
654
+ callback.call(this, this._element, arg);
374
655
  }
656
+ }
375
657
 
376
- VGSelect.hideOpenToggles(event)
658
+ /**
659
+ * Инициализирует или пересоздаёт компонент
660
+ * @param {HTMLSelectElement} element - <select>
661
+ * @param {Object} [params] - Параметры
662
+ * @param {boolean} [isRebuild=false] - Пересоздать
663
+ */
664
+ static init(element, params = {}, isRebuild = false) {
665
+ this.build(element, isRebuild);
377
666
  }
378
667
 
379
- static changeSelector(select, value, data = {}) {
380
- if (!isObject(data) && isEmptyObj(data)) return;
668
+ /**
669
+ * Закрывает все открытые выпадающие списки при клике вне
670
+ * @param {MouseEvent} event
671
+ */
672
+ static clearDrops(event) {
673
+ const open = Selectors.find(`.${CLASS_NAME_CONTAINER}.${CLASS_NAME_SHOW}`);
674
+ if (!open) return;
381
675
 
382
- [... select.options].forEach(el => {
383
- Manipulator.remove(el, 'selected');
676
+ const targetIsToggle = event.target.closest(SELECTOR_DATA_TOGGLE);
677
+ if (targetIsToggle && targetIsToggle.closest(`.${CLASS_NAME_CONTAINER}`) === open) {
678
+ return;
679
+ }
384
680
 
385
- if (el.value === value) {
386
- Manipulator.set(el, 'selected', true);
387
- }
388
- })
681
+ const targetInDropdown = event.composedPath().some(el =>
682
+ el.classList?.contains(CLASS_NAME_DROPDOWN)
683
+ );
684
+ if (targetInDropdown) {
685
+ return;
686
+ }
389
687
 
390
- select.value = normalizeData(value);
391
- EventHandler.trigger(select, EVENT_KEY_CHANGE, {data: data});
392
- EventHandler.trigger(select, 'change', {data: data});
688
+ const instance = VGSelect.getInstance(open);
689
+ if (instance) {
690
+ instance._completeHide({ relatedTarget: event.target });
691
+ }
393
692
  }
394
693
 
395
694
  /**
396
- * Инициализация
397
- * @param element
398
- * @param params
399
- * @param isRebuild
695
+ * Инициализирует кнопку "Загрузить ещё"
696
+ * @private
400
697
  */
401
- static init(element, params = {}, isRebuild = false) {
402
- let elm = VGSelect.build(element);
403
- VGSelect.getOrCreateInstance(elm, params);
698
+ _initLoadMoreButton() {
699
+ if (!this._params.search?.pagination || !this._params.search.remote) return;
700
+
701
+ const list = this._element.querySelector(SELECTOR_LIST);
702
+ if (!list) return;
703
+
704
+ const btn = document.createElement('li');
705
+ btn.className = CLASS_NAME_LOAD_MORE;
706
+ btn.style.textAlign = 'center';
707
+ btn.style.padding = '8px';
708
+ btn.style.cursor = 'pointer';
709
+ btn.style.color = '#007bff';
710
+ btn.style.fontSize = '14px';
711
+ btn.style.fontWeight = '500';
712
+ btn.textContent = this._params.search.loadMoreText;
713
+
714
+ btn.addEventListener('click', () => {
715
+ if (!this._loading && (this._totalPages === null || this._currentPage < this._totalPages)) {
716
+ this._loadNextPage();
717
+ }
718
+ });
719
+
720
+ list.appendChild(btn);
721
+ this._hideLoadMoreButton(true); // скрыта до первого запроса
404
722
  }
405
- }
406
723
 
407
- EventHandler.on(document, EVENT_CLICK_DATA_API, VGSelect.clearDrops);
724
+ /**
725
+ * Показывает или скрывает кнопку "Загрузить ещё"
726
+ * @param {boolean} hide
727
+ * @private
728
+ */
729
+ _hideLoadMoreButton(hide) {
730
+ const btn = this._element.querySelector(SELECTOR_LOAD_MORE_BTN);
731
+ if (btn) {
732
+ btn.style.display = hide ? 'none' : 'block';
733
+ }
734
+ }
408
735
 
409
- EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function () {
410
- const target = this.closest('.' + CLASS_NAME_CONTAINER);
736
+ /**
737
+ * Показывает или скрывает индикатор загрузки
738
+ * @param {boolean} show
739
+ * @private
740
+ */
741
+ _showLoading(show) {
742
+ const list = this._element.querySelector(SELECTOR_LIST);
743
+ let loader = list.querySelector(`.${CLASS_NAME_LOADING}`);
744
+ if (show && !loader) {
745
+ loader = document.createElement('li');
746
+ loader.className = CLASS_NAME_LOADING;
747
+ loader.style.textAlign = 'center';
748
+ loader.style.padding = '8px';
749
+ loader.textContent = lang_messages(this._params.lang, NAME)['loading'];
750
+ list.appendChild(loader);
751
+ } else if (!show && loader) {
752
+ loader.remove();
753
+ }
754
+ }
411
755
 
412
- Manipulator.set(this, 'aria-expanded', true);
756
+ /**
757
+ * Загружает следующую страницу данных по клику
758
+ * @private
759
+ */
760
+ async _loadNextPage() {
761
+ const { route, pageParam = 'page', termParam = 'q', perPage = 20 } = this._params.search;
762
+ const nextPage = this._currentPage + 1;
763
+
764
+ const url = new URL(route, window.location.origin);
765
+ url.searchParams.set(termParam, this._searchTerm);
766
+ url.searchParams.set(pageParam, nextPage);
767
+ url.searchParams.set('per_page', perPage);
768
+
769
+ this._loading = true;
770
+ this._showLoading(true);
771
+ this._hideLoadMoreButton(true);
772
+
773
+ try {
774
+ const res = await fetch(url, { headers: { 'Content-Type': 'application/json' } });
775
+ const data = await res.json();
776
+
777
+ if (Array.isArray(data.results)) {
778
+ VGSelect.addOptions(this._element.previousElementSibling, data, { preserve: true });
779
+ this._currentPage = data.pagination?.current_page || nextPage;
780
+ this._totalPages = data.pagination?.total_pages || null;
781
+
782
+ // Показать кнопку снова, если есть следующая страница
783
+ if (this._totalPages === null || this._currentPage < this._totalPages) {
784
+ this._hideLoadMoreButton(false);
785
+ }
413
786
 
414
- const alreadyOpen = Selectors.find('.vg-select.show')
415
- if (alreadyOpen && alreadyOpen !== target) {
416
- VGSelect.getInstance(alreadyOpen).hide();
787
+ this._callCallback('onLoadNext', { page: this._currentPage, data });
788
+ this._triggerEvent(EVENT_KEY_LOAD_NEXT, { page: this._currentPage, term: this._searchTerm });
789
+ }
790
+ } catch (err) {
791
+ console.error('VGSelect: Failed to load next page', err);
792
+ this._triggerEvent(EVENT_KEY_ERROR, { error: 'Pagination fetch failed', term: this._searchTerm });
793
+ this._hideLoadMoreButton(false); // оставить кнопку при ошибке
794
+ } finally {
795
+ this._showLoading(false);
796
+ this._loading = false;
797
+ }
417
798
  }
418
799
 
419
- const instance = VGSelect.getOrCreateInstance(target);
420
- instance.toggle(this);
421
- });
422
-
423
- EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_OPTION_TOGGLE, function (e) {
424
- let el = e.target;
800
+ /**
801
+ * Выполняет удалённый запрос для поиска
802
+ * @param {string} term - Поисковый запрос
803
+ * @private
804
+ */
805
+ _fetchRemoteData(term) {
806
+ const { route, method = 'GET', pageParam = 'page', termParam = 'q', perPage = 20 } = this._params.search;
807
+ const url = new URL(route, window.location.origin);
808
+ url.searchParams.set(termParam, term);
809
+ url.searchParams.set(pageParam, 1);
810
+ url.searchParams.set('per_page', perPage);
811
+
812
+ const searchInput = this._element.querySelector(SELECTOR_SEARCH_INPUT);
813
+ const wasOpen = this._isShown();
814
+
815
+ this._searchTerm = term;
816
+ this._currentPage = 1;
817
+ this._totalPages = null;
818
+
819
+ this._showLoading(true);
820
+ this._hideLoadMoreButton(true);
821
+
822
+ fetch(url, {
823
+ method,
824
+ headers: { 'Content-Type': 'application/json' }
825
+ })
826
+ .then(res => res.json())
827
+ .then(data => {
828
+ const select = this._element.previousElementSibling;
829
+ if (!select) return;
830
+
831
+ // Очистка старых опций
832
+ [...select.querySelectorAll('option')].forEach(opt => {
833
+ if (!opt.hasAttribute('data-preserve') && !opt.closest('optgroup[data-preserve]')) {
834
+ opt.remove();
835
+ }
836
+ });
425
837
 
426
- if (!el.classList.contains('disabled')) {
427
- let container = el.closest('.' + CLASS_NAME_CONTAINER),
428
- options = container.querySelectorAll('.' + CLASS_NAME_OPTION);
838
+ VGSelect.addOptions(select, data, { preserve: true });
429
839
 
430
- if (options.length) {
431
- for (const option of options) {
432
- option.classList.remove('selected');
433
- }
434
- }
840
+ this._callCallback('onSearch', { term, data });
841
+ this._triggerEvent(EVENT_KEY_REBUILD, { term, data });
435
842
 
436
- el.classList.add('selected');
843
+ if (data.pagination) {
844
+ this._currentPage = data.pagination.current_page || 1;
845
+ this._totalPages = data.pagination.total_pages || null;
846
+ }
437
847
 
438
- container.querySelector('.' + CLASS_NAME_CURRENT).innerText = el.innerText;
439
- container.classList.remove('show');
848
+ // Показать кнопку "Загрузить ещё", если есть следующая страница
849
+ if (this._totalPages === null || this._currentPage < this._totalPages) {
850
+ this._hideLoadMoreButton(false);
851
+ }
440
852
 
441
- let select = container.previousSibling;
442
- VGSelect.changeSelector(select, el.dataset.value, {value: el.dataset.value, title: el.innerHTML})
853
+ if (wasOpen && searchInput) {
854
+ searchInput.value = term;
855
+ searchInput.focus();
856
+ this._element.classList.add(CLASS_NAME_SHOW, CLASS_NAME_ACTIVE);
857
+ }
858
+ })
859
+ .catch(err => {
860
+ console.error('VGSelect: Remote search error', err);
861
+ this._triggerEvent(EVENT_KEY_ERROR, { error: 'Search request failed', term });
862
+ this._hideLoadMoreButton(false);
863
+ })
864
+ .finally(() => {
865
+ this._showLoading(false);
866
+ });
443
867
  }
444
- });
445
868
 
446
- EventHandler.on(document, EVENT_KEY_UP_DATA_API, SELECTOR_SEARCH_TOGGLE, function (e) {
447
- let el = this;
869
+ /**
870
+ * Добавляет опции в <select> и обновляет UI
871
+ * @param {HTMLSelectElement} select - Исходный <select>
872
+ * @param {Array|Object} data - Данные (массив или { results: [...] })
873
+ * @param {Object} [options] - Опции
874
+ * @param {boolean} [options.preserve] - Сохранить существующие опции
875
+ */
876
+ static addOptions(select, data, options = {}) {
877
+ const { preserve = false } = options;
878
+ const container = select.nextElementSibling;
879
+ const isRebuild = container && container.classList.contains(CLASS_NAME_CONTAINER);
880
+ const instance = isRebuild ? VGSelect.getInstance(container) : null;
881
+
882
+ let optionsData = data;
883
+ if (data && data.results && Array.isArray(data.results)) {
884
+ optionsData = data.results;
885
+ }
448
886
 
449
- let selectList = el?.closest('.' + CLASS_NAME_DROPDOWN).querySelector('.' + CLASS_NAME_LIST);
450
- if (selectList) {
451
- let options = [...selectList.querySelectorAll('.' + CLASS_NAME_OPTION)],
452
- optionsGroup = [...selectList.querySelectorAll('.' + CLASS_NAME_OPTGROUP)],
453
- value = el?.value;
887
+ if (!Array.isArray(optionsData)) {
888
+ instance?._triggerEvent(EVENT_KEY_ERROR, { error: 'Invalid data format: expected array' });
889
+ return;
890
+ }
454
891
 
455
- options = options.concat(optionsGroup);
892
+ if (!preserve) {
893
+ // Удаление только не помеченных как data-preserve
894
+ [...select.querySelectorAll('option')].forEach(option => {
895
+ const parentOptGroup = option.closest('optgroup');
896
+ if (option.value === '') return;
897
+ if (option.hasAttribute('data-preserve')) return;
898
+ if (parentOptGroup && parentOptGroup.hasAttribute('data-preserve')) return;
899
+ option.remove();
900
+ });
456
901
 
457
- for (const option of options) {
458
- Manipulator.show(option);
902
+ [...select.querySelectorAll('optgroup')].forEach(og => {
903
+ if (og.children.length === 0 && !og.hasAttribute('data-preserve')) {
904
+ og.remove();
905
+ }
906
+ });
459
907
  }
460
908
 
461
- if (value.length) {
462
- value = value.trim();
463
- value = value.toLowerCase();
909
+ optionsData.forEach(item => {
910
+ if (item.children && Array.isArray(item.children)) {
911
+ const optgroup = document.createElement('optgroup');
912
+ optgroup.label = item.text || '';
913
+ if (item.disabled) optgroup.disabled = true;
914
+
915
+ item.children.forEach(child => {
916
+ const option = document.createElement('option');
917
+ option.value = child.id || '';
918
+ option.textContent = child.text || '';
919
+ if (child.selected) option.selected = true;
920
+ if (child.disabled) option.disabled = true;
921
+
922
+ const dataAttrs = Object.keys(child).filter(k => !['id', 'text', 'selected', 'disabled'].includes(k));
923
+ dataAttrs.forEach(key => {
924
+ option.setAttribute(`data-${key}`, child[key]);
925
+ });
926
+
927
+ optgroup.appendChild(option);
928
+ });
464
929
 
465
- let arrOptions = [];
930
+ select.appendChild(optgroup);
931
+ } else {
932
+ const option = document.createElement('option');
933
+ option.value = item.id || '';
934
+ option.textContent = item.text || '';
935
+ if (item.selected) option.selected = true;
936
+ if (item.disabled) option.disabled = true;
937
+
938
+ const dataAttrs = Object.keys(item).filter(k => !['id', 'text', 'selected', 'disabled'].includes(k));
939
+ dataAttrs.forEach(key => {
940
+ option.setAttribute(`data-${key}`, item[key]);
941
+ });
466
942
 
467
- [
468
- value,
469
- transliterate(value),
470
- transliterate(value, true),
471
- ].forEach(val => {
472
- for (const option of options) {
473
- let text = option.innerText.toLowerCase();
943
+ select.appendChild(option);
944
+ }
945
+ });
474
946
 
475
- Manipulator.hide(option)
947
+ if (isRebuild) {
948
+ const drop = container.querySelector(`.${CLASS_NAME_DROPDOWN}`);
949
+ VGSelect.buildListOptions(select, drop);
950
+ instance?._triggerEvent(EVENT_KEY_REBUILD);
951
+ } else {
952
+ this.updateUI(select);
953
+ }
954
+ }
476
955
 
477
- if (text.includes(val)) {
478
- arrOptions.push(option)
479
- }
480
- }
481
- });
956
+ /**
957
+ * Фильтрует опции локально по введённому тексту
958
+ * @param {string} term - Поисковый запрос
959
+ * @private
960
+ */
961
+ _filterLocalOptions(term) {
962
+ const list = this._drop.querySelector(`.${CLASS_NAME_LIST}`);
963
+ const options = list.querySelectorAll(`.${CLASS_NAME_OPTION}`);
482
964
 
483
- arrOptions.forEach(el => Manipulator.show(el))
965
+ if (!term) {
966
+ options.forEach(el => el.hidden = false);
967
+ return;
484
968
  }
485
- }
486
- });
487
969
 
488
- EventHandler.on(document, EVENT_RESET_DATA_API, 'form', function () {
489
- Selectors.findAll('select.' + CLASS_NAME_CONTAINER, this).forEach(el => {
490
- VGSelect.build(el, true)
491
- });
492
- });
970
+ term = term.toLowerCase();
971
+ options.forEach(el => {
972
+ const text = el.textContent.toLowerCase();
973
+ el.hidden = !text.includes(term);
974
+ });
975
+ }
976
+ }
493
977
 
978
+ _handlersVGSelect();
494
979
 
495
980
  export default VGSelect;