vgapp 0.8.0 → 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.
- package/CHANGELOG.md +16 -1
- package/app/langs/en/buttons.json +14 -0
- package/app/langs/en/messages.json +3 -0
- package/app/langs/en/titles.json +3 -0
- package/app/langs/ru/buttons.json +14 -0
- package/app/langs/ru/messages.json +3 -0
- package/app/langs/ru/titles.json +3 -0
- package/app/modules/vgloadmore/js/vgloadmore.js +363 -112
- package/app/modules/vgloadmore/readme.md +145 -0
- package/app/modules/vgrollup/js/vgrollup.js +328 -160
- package/app/modules/vgrollup/readme.md +196 -0
- package/app/modules/vgselect/js/handlers.js +220 -0
- package/app/modules/vgselect/js/vgselect.js +783 -298
- package/app/modules/vgselect/readme.md +180 -0
- package/app/modules/vgselect/scss/_variables.scss +20 -0
- package/app/modules/vgselect/scss/vgselect.scss +42 -2
- package/app/modules/vgsidebar/js/vgsidebar.js +194 -84
- package/app/modules/vgsidebar/readme.md +157 -0
- package/app/modules/vgspy/js/vgspy.js +236 -132
- package/app/modules/vgspy/readme.md +105 -0
- package/app/modules/vgtabs/js/vgtabs.js +290 -182
- package/app/modules/vgtabs/readme.md +156 -0
- package/app/modules/vgtoast/js/vgtoast.js +260 -156
- package/app/modules/vgtoast/readme.md +145 -0
- package/build/vgapp.css +1 -1
- package/build/vgapp.css.map +1 -1
- package/package.json +1 -1
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import BaseModule from "../../base-module";
|
|
2
2
|
import {
|
|
3
3
|
isDisabled,
|
|
4
|
-
isEmptyObj,
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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.
|
|
58
|
-
this.
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
li.classList.add(CLASS_NAME_OPTION);
|
|
180
|
+
[...options].forEach((option, i) => {
|
|
181
|
+
if (option.hidden) return;
|
|
125
182
|
|
|
126
|
-
|
|
183
|
+
const value = option.value || '';
|
|
184
|
+
const text = option.textContent.trim();
|
|
185
|
+
const isEmptyOption = value === '' && text === '';
|
|
127
186
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
193
|
+
if (i === selectedIndex) {
|
|
194
|
+
li.classList.add('selected');
|
|
195
|
+
}
|
|
138
196
|
|
|
139
|
-
|
|
140
|
-
|
|
197
|
+
if (option.disabled) {
|
|
198
|
+
li.classList.add('disabled');
|
|
199
|
+
}
|
|
141
200
|
|
|
142
|
-
|
|
201
|
+
if (isEmptyOption) {
|
|
202
|
+
li.hidden = true;
|
|
203
|
+
li.style.display = 'none';
|
|
204
|
+
}
|
|
143
205
|
|
|
144
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
192
|
-
|
|
257
|
+
if (selector.classList.length) {
|
|
258
|
+
[...selector.classList].forEach(cls => container.classList.add(cls));
|
|
193
259
|
}
|
|
194
260
|
|
|
195
|
-
if (
|
|
261
|
+
if (isDisabled(selector)) {
|
|
262
|
+
container.classList.add('disabled');
|
|
263
|
+
}
|
|
196
264
|
|
|
197
|
-
|
|
265
|
+
const elData = Manipulator.get(selector);
|
|
198
266
|
if (!isEmptyObj(elData)) {
|
|
199
|
-
|
|
200
|
-
Manipulator.set(
|
|
201
|
-
}
|
|
267
|
+
Object.keys(elData).forEach(key => {
|
|
268
|
+
Manipulator.set(container, `data-${key}`, elData[key]);
|
|
269
|
+
});
|
|
202
270
|
}
|
|
203
271
|
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
current.
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
359
|
+
return container;
|
|
241
360
|
}
|
|
242
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Переключает открытие/закрытие выпадающего списка
|
|
364
|
+
* @param {EventTarget} [relatedTarget] - Элемент, вызвавший событие
|
|
365
|
+
*/
|
|
243
366
|
toggle(relatedTarget) {
|
|
244
|
-
return
|
|
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
|
|
251
|
-
if (
|
|
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
|
-
|
|
255
|
-
EventHandler.on(element, 'mouseover', noop);
|
|
256
|
-
}
|
|
383
|
+
document.body.style.pointerEvents = 'none';
|
|
257
384
|
}
|
|
258
385
|
|
|
259
|
-
this._element.
|
|
386
|
+
const toggle = this._element.querySelector(SELECTOR_DATA_TOGGLE);
|
|
387
|
+
toggle.setAttribute('aria-expanded', 'true');
|
|
260
388
|
|
|
261
|
-
if (this._params.search) {
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
Manipulator.set(toggle, 'aria-expanded', 'false');
|
|
420
|
+
this._element.querySelector(SELECTOR_DATA_TOGGLE).setAttribute('aria-expanded', 'false');
|
|
287
421
|
|
|
288
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
474
|
+
this._updateFromMutation();
|
|
475
|
+
|
|
476
|
+
if (wasShown) {
|
|
477
|
+
requestAnimationFrame(() => {
|
|
478
|
+
this.show();
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}, 100);
|
|
313
482
|
});
|
|
314
483
|
|
|
315
|
-
|
|
316
|
-
|
|
484
|
+
this._observer.observe(select, {
|
|
485
|
+
attributes: true,
|
|
486
|
+
attributeFilter: ['disabled', 'required', 'style', 'hidden'],
|
|
317
487
|
childList: true,
|
|
318
488
|
subtree: true,
|
|
319
|
-
|
|
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
|
-
|
|
329
|
-
|
|
530
|
+
const container = select.nextElementSibling;
|
|
531
|
+
if (container && container.classList.contains(CLASS_NAME_CONTAINER)) {
|
|
532
|
+
container.remove();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
330
535
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
612
|
+
const oldValue = select.value;
|
|
613
|
+
const wasSelected = opt.selected;
|
|
614
|
+
const selectedText = opt.textContent.trim();
|
|
360
615
|
|
|
361
|
-
|
|
616
|
+
[...select.options].forEach(o => o.selected = false);
|
|
617
|
+
opt.selected = true;
|
|
618
|
+
select.value = opt.value;
|
|
362
619
|
|
|
363
|
-
|
|
364
|
-
relatedTarget.clickEvent = event
|
|
365
|
-
}
|
|
620
|
+
this.updateUI(select);
|
|
366
621
|
|
|
367
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
383
|
-
|
|
676
|
+
const targetIsToggle = event.target.closest(SELECTOR_DATA_TOGGLE);
|
|
677
|
+
if (targetIsToggle && targetIsToggle.closest(`.${CLASS_NAME_CONTAINER}`) === open) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
384
680
|
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
688
|
+
const instance = VGSelect.getInstance(open);
|
|
689
|
+
if (instance) {
|
|
690
|
+
instance._completeHide({ relatedTarget: event.target });
|
|
691
|
+
}
|
|
393
692
|
}
|
|
394
693
|
|
|
395
694
|
/**
|
|
396
|
-
*
|
|
397
|
-
* @
|
|
398
|
-
* @param params
|
|
399
|
-
* @param isRebuild
|
|
695
|
+
* Инициализирует кнопку "Загрузить ещё"
|
|
696
|
+
* @private
|
|
400
697
|
*/
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
option.classList.remove('selected');
|
|
433
|
-
}
|
|
434
|
-
}
|
|
840
|
+
this._callCallback('onSearch', { term, data });
|
|
841
|
+
this._triggerEvent(EVENT_KEY_REBUILD, { term, data });
|
|
435
842
|
|
|
436
|
-
|
|
843
|
+
if (data.pagination) {
|
|
844
|
+
this._currentPage = data.pagination.current_page || 1;
|
|
845
|
+
this._totalPages = data.pagination.total_pages || null;
|
|
846
|
+
}
|
|
437
847
|
|
|
438
|
-
|
|
439
|
-
|
|
848
|
+
// Показать кнопку "Загрузить ещё", если есть следующая страница
|
|
849
|
+
if (this._totalPages === null || this._currentPage < this._totalPages) {
|
|
850
|
+
this._hideLoadMoreButton(false);
|
|
851
|
+
}
|
|
440
852
|
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
965
|
+
if (!term) {
|
|
966
|
+
options.forEach(el => el.hidden = false);
|
|
967
|
+
return;
|
|
484
968
|
}
|
|
485
|
-
}
|
|
486
|
-
});
|
|
487
969
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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;
|