@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 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.53",
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.53",
34
- "@brandup/ui-kit": "^1.0.53"
33
+ "@brandup/ui-input": "^1.0.55",
34
+ "@brandup/ui-kit": "^1.0.55"
35
35
  },
36
36
  "files": [
37
37
  "source",
@@ -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
- this.__closePopupFunc = (e: MouseEvent) => {
132
- const t = e.target as HTMLElement;
133
- const dd = t.closest(`.${ROOT_CLASS}`);
134
- if (
135
- !dd ||
136
- (dd === this.__container &&
137
- !t.closest("li[data-index]") &&
138
- t !== this.__searchInput &&
139
- !t.closest(".search"))
140
- ) {
141
- this.__closePopup();
142
- this.__clearSearch();
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 currentSelect = this.__getSelectedElem();
233
- if (currentSelect && newIndex === currentSelect.own.dataset.index) return; // если выбор остался таким же
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
- if (newSelected) {
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.__focusPopup();
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
- private __focusPopup() {
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
- private __togglePopup() {
336
- if (this.element.classList.contains("disabled")) return;
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
  }