@brandup/ui-dropdown 1.0.53 → 1.0.55
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/README.md +12 -1
- package/package.json +3 -3
- package/source/dropdown.ts +116 -56
package/README.md
CHANGED
|
@@ -42,15 +42,26 @@ dropdown.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
42
42
|
| `data-search-empty` | `"Not found"` | Текст при отсутствии результатов поиска |
|
|
43
43
|
| `data-cancel` | `"Cancel"` | Текст кнопки закрытия (адаптивный режим) |
|
|
44
44
|
| `data-search-on` | `15` | Порог отображения поиска: число (минимальное кол-во опций), `"true"` — всегда, `"false"` — никогда |
|
|
45
|
+
| `data-autofocus` | — | Автофокус при инициализации; то же делает нативный `autofocus`. Условия отмены — см. [@brandup/ui-input](../brandup-ui-input/README.md#автофокус) |
|
|
46
|
+
| `data-readonly` | — | Значение показывается, но сменить его нельзя: список не открывается. У `<select>` нативного `readonly` нет, поэтому режим объявляется этим атрибутом |
|
|
45
47
|
|
|
46
48
|
## API
|
|
47
49
|
|
|
50
|
+
### Собственные методы
|
|
51
|
+
|
|
52
|
+
| Метод | Описание |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `getValue(): string \| null` | Значение выбранного `<option>`; `null`, если не выбрано ничего |
|
|
55
|
+
| `getSelectedIndex(): number` | Индекс выбранного `<option>`; `-1`, если не выбрано ничего |
|
|
56
|
+
| `getSelectedTitle(): string \| null` | Текст выбранного пункта списка |
|
|
57
|
+
| `setValue(value: string \| null): void` | Выбирает пункт с таким значением и показывает его. Значения без своего пункта в списке (в том числе пустое) сбрасывают выбор в плейсхолдер. `dropdown-change` поднимается только при действительной смене показанного пункта |
|
|
58
|
+
|
|
48
59
|
### Методы (унаследованы от InputControl)
|
|
49
60
|
|
|
50
61
|
| Метод | Описание |
|
|
51
62
|
| --- | --- |
|
|
52
63
|
| `validate(): boolean` | Проверяет значение через нативный `checkValidity()` |
|
|
53
|
-
| `focus(): void` |
|
|
64
|
+
| `focus(): void` | Ведёт фокус в кнопку показа списка и прокручивает контрол в видимую область |
|
|
54
65
|
| `destroy(): void` | Восстанавливает исходный `<select>` и освобождает ресурсы |
|
|
55
66
|
|
|
56
67
|
### Свойства
|
package/package.json
CHANGED
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
"email": "it@brandup.online"
|
|
25
25
|
},
|
|
26
26
|
"license": "Apache-2.0",
|
|
27
|
-
"version": "1.0.
|
|
27
|
+
"version": "1.0.55",
|
|
28
28
|
"main": "source/index.ts",
|
|
29
29
|
"types": "source/index.ts",
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@brandup/ui": "^2.0.9",
|
|
32
32
|
"@brandup/ui-helpers": "^2.0.9",
|
|
33
|
-
"@brandup/ui-input": "^1.0.
|
|
34
|
-
"@brandup/ui-kit": "^1.0.
|
|
33
|
+
"@brandup/ui-input": "^1.0.55",
|
|
34
|
+
"@brandup/ui-kit": "^1.0.55"
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"source",
|
package/source/dropdown.ts
CHANGED
|
@@ -33,7 +33,9 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
33
33
|
private __textElem: HTMLElement;
|
|
34
34
|
private __emptyElem: HTMLElement;
|
|
35
35
|
private __searchInput: HTMLInputElement;
|
|
36
|
+
private __pressPopupFunc: (e: MouseEvent) => void;
|
|
36
37
|
private __closePopupFunc: (e: MouseEvent) => void;
|
|
38
|
+
private __pressedInPopup = false;
|
|
37
39
|
private __reposAbort?: AbortController;
|
|
38
40
|
private __hasEmptyValue: boolean = false;
|
|
39
41
|
|
|
@@ -128,29 +130,41 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
128
130
|
this.__emptyElem = emptyElem;
|
|
129
131
|
this.__searchInput = searchInput;
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
133
|
+
// Список закрывает нажатие мимо него, и решает это начало нажатия, а не место, где
|
|
134
|
+
// отпустили: перетаскивание полосы прокрутки списка и выделение текста заканчиваются
|
|
135
|
+
// где угодно, а список при этом закрываться не должен.
|
|
136
|
+
this.__pressPopupFunc = (e: MouseEvent) => {
|
|
137
|
+
this.__pressedInPopup = this.__popupElem.contains(e.target as Node);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
this.__closePopupFunc = () => {
|
|
141
|
+
const pressedInPopup = this.__pressedInPopup;
|
|
142
|
+
this.__pressedInPopup = false; // жест закончился, следующий начнётся со своего нажатия
|
|
143
|
+
|
|
144
|
+
// внутри списка работают с ним самим: прокрутка, поиск, промах мимо пункта
|
|
145
|
+
if (pressedInPopup) return;
|
|
146
|
+
|
|
147
|
+
this.__closePopup();
|
|
148
|
+
this.__clearSearch();
|
|
144
149
|
};
|
|
145
150
|
|
|
146
151
|
this.__renderItems();
|
|
147
152
|
this.__initLogic();
|
|
153
|
+
|
|
154
|
+
this.__applyAutoFocus(); // автофокус — вместе с прокруткой к контролу; условия у базового класса
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Поле-носитель уведено с экрана и фокус не принимает — ведём его в кнопку показа списка:
|
|
159
|
+
* с неё же начинается работа с клавиатуры (пробел и Enter открывают список).
|
|
160
|
+
*/
|
|
161
|
+
protected override __focusValue(): void {
|
|
162
|
+
this.__focusView();
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
// рендер
|
|
165
|
+
// рендер элементов; текущий выбор отмечает __renderSelection, когда список уже в DOM
|
|
151
166
|
private __renderItems() {
|
|
152
167
|
const optionsCount = this.__valueElem.options.length;
|
|
153
|
-
const selectedIndex = this.__valueElem.selectedIndex;
|
|
154
168
|
|
|
155
169
|
if (!optionsCount) this.__textElem.innerText = this.placeholder;
|
|
156
170
|
|
|
@@ -200,22 +214,16 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
200
214
|
|
|
201
215
|
itemTranscripts.set(itemElem, transcriptText(itemText));
|
|
202
216
|
|
|
203
|
-
const isSelected = selectedIndex === i;
|
|
204
|
-
if (isSelected) itemElem.classList.add("hasvalue");
|
|
205
|
-
|
|
206
217
|
popupItemsFragment.append(itemElem);
|
|
207
218
|
|
|
208
|
-
if (isSelected) {
|
|
209
|
-
this.__container.classList.add("hasvalue");
|
|
210
|
-
this.__textElem.innerText = itemText;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
219
|
elemCount++;
|
|
214
220
|
}
|
|
215
221
|
|
|
216
222
|
if (this.__hasEmptyValue && !elemCount) this.element.classList.add("empty");
|
|
217
223
|
|
|
218
224
|
this.__listElem.append(popupItemsFragment);
|
|
225
|
+
|
|
226
|
+
this.__renderSelection(); // список уже в DOM — отметку и текст ставит общий с setValue путь
|
|
219
227
|
}
|
|
220
228
|
|
|
221
229
|
private __initLogic() {
|
|
@@ -223,39 +231,14 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
223
231
|
this.registerCommand("close-popup", () => this.__closePopup());
|
|
224
232
|
|
|
225
233
|
this.registerCommand("select", (context) => {
|
|
226
|
-
const newIndex = context.target.dataset.index;
|
|
227
|
-
|
|
228
|
-
this.element.classList.remove("invalid");
|
|
229
234
|
this.__clearSearch();
|
|
230
235
|
this.__closePopup();
|
|
236
|
+
this.__focusView();
|
|
231
237
|
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
DOM.removeClass(this.element, ".hasvalue", "hasvalue");
|
|
236
|
-
|
|
237
|
-
if (currentSelect && currentSelect.own.closest(`.${ROOT_CLASS}`))
|
|
238
|
-
this.__textElem.innerText = this.placeholder ?? "";
|
|
239
|
-
|
|
240
|
-
// делаем новый выбор
|
|
241
|
-
this.__valueElem.value = context.target.dataset.value || "";
|
|
242
|
-
|
|
243
|
-
const newSelected = this.__getElemsByIndex(Number(newIndex));
|
|
238
|
+
const index = Number(context.target.dataset.index);
|
|
239
|
+
if (!Number.isInteger(index)) return; // пункт без своего индекса выбрать нечем
|
|
244
240
|
|
|
245
|
-
|
|
246
|
-
newSelected.own.classList.add("hasvalue");
|
|
247
|
-
|
|
248
|
-
const newDropDown = newSelected.own.closest(`.${ROOT_CLASS}`);
|
|
249
|
-
|
|
250
|
-
if (newDropDown) {
|
|
251
|
-
this.__textElem.innerText = newSelected.own.innerText.trim();
|
|
252
|
-
newDropDown.classList.add("hasvalue");
|
|
253
|
-
this.__closePopup();
|
|
254
|
-
this.__focusPopup();
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
this.__onChange();
|
|
241
|
+
this.__applySelection(index);
|
|
259
242
|
});
|
|
260
243
|
|
|
261
244
|
this.__searchInput.addEventListener("input", () => {
|
|
@@ -292,7 +275,7 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
292
275
|
case "Escape": {
|
|
293
276
|
e.preventDefault();
|
|
294
277
|
this.__closePopup();
|
|
295
|
-
this.
|
|
278
|
+
this.__focusView();
|
|
296
279
|
break;
|
|
297
280
|
}
|
|
298
281
|
case "Tab": {
|
|
@@ -319,7 +302,8 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
319
302
|
});
|
|
320
303
|
}
|
|
321
304
|
|
|
322
|
-
|
|
305
|
+
/** Фокус в кнопку показа списка — она первым элементом контейнера, поле-носитель последним. */
|
|
306
|
+
private __focusView() {
|
|
323
307
|
(<HTMLElement>this.__container.firstElementChild).focus();
|
|
324
308
|
}
|
|
325
309
|
|
|
@@ -332,9 +316,23 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
332
316
|
});
|
|
333
317
|
}
|
|
334
318
|
|
|
335
|
-
|
|
336
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Выключенный контрол не работает целиком, а только-для-чтения — показывает значение, но
|
|
321
|
+
* менять его не даёт: гейт закрывает открытие списка и выбор пункта. Состояние читаем
|
|
322
|
+
* с поля-носителя, а не по классу-отражению: атрибут могли переключить после инициализации.
|
|
323
|
+
*
|
|
324
|
+
* Закрытие списка не запрещаем никогда: гейт в `@brandup/ui` общий на все команды элемента,
|
|
325
|
+
* а поле могли выключить уже с открытым списком (например, форма гасит поля на время отправки) —
|
|
326
|
+
* тогда запрет запер бы открытый список, и на узком экране, где это лист во весь экран,
|
|
327
|
+
* выхода с клавиатуры не осталось бы вовсе.
|
|
328
|
+
*/
|
|
329
|
+
protected override _onCanExecCommand(name: string): boolean {
|
|
330
|
+
if (name.toLowerCase() === "close-popup") return true;
|
|
331
|
+
|
|
332
|
+
return !this.disabled && !this.readonly;
|
|
333
|
+
}
|
|
337
334
|
|
|
335
|
+
private __togglePopup() {
|
|
338
336
|
if (this.element.classList.contains("expanded")) {
|
|
339
337
|
// уже открыт — закрываем чисто, чтобы и body-класс, и mouseup-листенер ушли
|
|
340
338
|
this.__closePopup();
|
|
@@ -374,6 +372,7 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
374
372
|
|
|
375
373
|
this.__listElem.scrollTo({ left: 0, top: top, behavior: "instant" });
|
|
376
374
|
|
|
375
|
+
document.body.addEventListener("mousedown", this.__pressPopupFunc);
|
|
377
376
|
document.body.addEventListener("mouseup", this.__closePopupFunc);
|
|
378
377
|
}
|
|
379
378
|
|
|
@@ -395,8 +394,10 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
395
394
|
}
|
|
396
395
|
|
|
397
396
|
private __closePopup() {
|
|
397
|
+
this.__pressedInPopup = false; // от прошлого показа не наследуем
|
|
398
398
|
document.body.classList.remove(BODY_EXPANDED);
|
|
399
399
|
this.element.classList.remove("expanded");
|
|
400
|
+
document.body.removeEventListener("mousedown", this.__pressPopupFunc);
|
|
400
401
|
document.body.removeEventListener("mouseup", this.__closePopupFunc);
|
|
401
402
|
this.__reposAbort?.abort();
|
|
402
403
|
this.__reposAbort = undefined;
|
|
@@ -474,10 +475,69 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
|
|
|
474
475
|
return this.__getElems(".hasvalue[data-index]");
|
|
475
476
|
}
|
|
476
477
|
|
|
478
|
+
/** Индекс пункта, отмеченного сейчас в списке; -1 — контрол показывает placeholder. */
|
|
479
|
+
private __shownIndex(): number {
|
|
480
|
+
const index = this.__getSelectedElem()?.own.dataset.index;
|
|
481
|
+
return index === undefined ? -1 : Number(index);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Переносит выбор в поле-носитель и показывает его; событие изменения поднимается только
|
|
486
|
+
* при действительной смене показанного пункта.
|
|
487
|
+
*
|
|
488
|
+
* Работаем индексом, а не значением: значение в списке может повторяться — пустой пункт-подсказка
|
|
489
|
+
* и свой вариант вроде «Не указано» оба с пустым value, — а присваивание `value` выбрало бы ПЕРВЫЙ
|
|
490
|
+
* совпавший option, то есть не тот пункт, что нажали.
|
|
491
|
+
*
|
|
492
|
+
* С показанным пунктом сравниваем, а не с полем: значение могли записать в поле напрямую
|
|
493
|
+
* (так делает восстановление черновика формы), и сравнение с полем сделало бы такой вызов пустым.
|
|
494
|
+
*/
|
|
495
|
+
private __applySelection(index: number) {
|
|
496
|
+
// Пункта в списке может и не быть: пустой пункт-подсказка своего <li> не получает, как и
|
|
497
|
+
// дубликат уже добавленного значения. На экране это то же самое, что «не выбрано ничего»,
|
|
498
|
+
// — приводим к одному виду, иначе повторная установка выглядела бы сменой выбора.
|
|
499
|
+
const target = this.__getElemsByIndex(index) ? index : -1;
|
|
500
|
+
if (target === this.__shownIndex()) return; // показанный выбор остался таким же
|
|
501
|
+
|
|
502
|
+
this.__valueElem.selectedIndex = index;
|
|
503
|
+
|
|
504
|
+
this.element.classList.remove("invalid");
|
|
505
|
+
this.__renderSelection();
|
|
506
|
+
this.__onChange();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Отражает текущее значение поля-носителя в контроле: отметка в списке и текст на кнопке. */
|
|
510
|
+
private __renderSelection() {
|
|
511
|
+
DOM.removeClass(this.element, ".hasvalue", "hasvalue"); // removeClass обходит потомков — класс контейнера снимаем отдельно
|
|
512
|
+
this.__container.classList.remove("hasvalue");
|
|
513
|
+
this.__textElem.innerText = this.placeholder;
|
|
514
|
+
|
|
515
|
+
const selected = this.__getElemsByIndex(this.__valueElem.selectedIndex);
|
|
516
|
+
if (!selected) return; // пустое или неизвестное значение — контрол показывает placeholder
|
|
517
|
+
|
|
518
|
+
selected.own.classList.add("hasvalue");
|
|
519
|
+
this.__container.classList.add("hasvalue");
|
|
520
|
+
this.__textElem.innerText = (selected.own.firstElementChild?.textContent ?? "").trim();
|
|
521
|
+
}
|
|
522
|
+
|
|
477
523
|
getValue(): string | null {
|
|
478
524
|
return this.__valueElem.value || null;
|
|
479
525
|
}
|
|
480
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Программная установка значения — например, восстановление черновика формы. Пишет значение
|
|
529
|
+
* в поле-носитель и показывает его в контроле; значение без своего пункта в списке даёт
|
|
530
|
+
* пустой выбор. Событие изменения поднимается, только когда показанный выбор действительно
|
|
531
|
+
* сменился: сравниваем с UI, а не с полем — вызывающий мог записать значение в поле сам.
|
|
532
|
+
*/
|
|
533
|
+
setValue(value: string | null): void {
|
|
534
|
+
// значение ищем средствами самого поля: оно встанет на первый подходящий option,
|
|
535
|
+
// а дальше выбор переносится и показывается уже по индексу
|
|
536
|
+
this.__valueElem.value = value ?? "";
|
|
537
|
+
|
|
538
|
+
this.__applySelection(this.__valueElem.selectedIndex);
|
|
539
|
+
}
|
|
540
|
+
|
|
481
541
|
getSelectedIndex(): number {
|
|
482
542
|
return this.__valueElem.selectedIndex;
|
|
483
543
|
}
|