@brandup/ui-dropdown 1.0.52 → 1.0.54

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.52",
27
+ "version": "1.0.54",
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.52",
34
- "@brandup/ui-kit": "^1.0.52"
33
+ "@brandup/ui-input": "^1.0.54",
34
+ "@brandup/ui-kit": "^1.0.54"
35
35
  },
36
36
  "files": [
37
37
  "source",
@@ -145,12 +145,21 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
145
145
 
146
146
  this.__renderItems();
147
147
  this.__initLogic();
148
+
149
+ this.__applyAutoFocus(); // автофокус — вместе с прокруткой к контролу; условия у базового класса
150
+ }
151
+
152
+ /**
153
+ * Поле-носитель уведено с экрана и фокус не принимает — ведём его в кнопку показа списка:
154
+ * с неё же начинается работа с клавиатуры (пробел и Enter открывают список).
155
+ */
156
+ protected override __focusValue(): void {
157
+ this.__focusView();
148
158
  }
149
159
 
150
- // рендер элементов и выбор текущего
160
+ // рендер элементов; текущий выбор отмечает __renderSelection, когда список уже в DOM
151
161
  private __renderItems() {
152
162
  const optionsCount = this.__valueElem.options.length;
153
- const selectedIndex = this.__valueElem.selectedIndex;
154
163
 
155
164
  if (!optionsCount) this.__textElem.innerText = this.placeholder;
156
165
 
@@ -200,22 +209,16 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
200
209
 
201
210
  itemTranscripts.set(itemElem, transcriptText(itemText));
202
211
 
203
- const isSelected = selectedIndex === i;
204
- if (isSelected) itemElem.classList.add("hasvalue");
205
-
206
212
  popupItemsFragment.append(itemElem);
207
213
 
208
- if (isSelected) {
209
- this.__container.classList.add("hasvalue");
210
- this.__textElem.innerText = itemText;
211
- }
212
-
213
214
  elemCount++;
214
215
  }
215
216
 
216
217
  if (this.__hasEmptyValue && !elemCount) this.element.classList.add("empty");
217
218
 
218
219
  this.__listElem.append(popupItemsFragment);
220
+
221
+ this.__renderSelection(); // список уже в DOM — отметку и текст ставит общий с setValue путь
219
222
  }
220
223
 
221
224
  private __initLogic() {
@@ -223,39 +226,14 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
223
226
  this.registerCommand("close-popup", () => this.__closePopup());
224
227
 
225
228
  this.registerCommand("select", (context) => {
226
- const newIndex = context.target.dataset.index;
227
-
228
- this.element.classList.remove("invalid");
229
229
  this.__clearSearch();
230
230
  this.__closePopup();
231
+ this.__focusView();
231
232
 
232
- const currentSelect = this.__getSelectedElem();
233
- if (currentSelect && newIndex === currentSelect.own.dataset.index) return; // если выбор остался таким же
233
+ const index = Number(context.target.dataset.index);
234
+ if (!Number.isInteger(index)) return; // пункт без своего индекса выбрать нечем
234
235
 
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));
244
-
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();
236
+ this.__applySelection(index);
259
237
  });
260
238
 
261
239
  this.__searchInput.addEventListener("input", () => {
@@ -292,7 +270,7 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
292
270
  case "Escape": {
293
271
  e.preventDefault();
294
272
  this.__closePopup();
295
- this.__focusPopup();
273
+ this.__focusView();
296
274
  break;
297
275
  }
298
276
  case "Tab": {
@@ -319,7 +297,8 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
319
297
  });
320
298
  }
321
299
 
322
- private __focusPopup() {
300
+ /** Фокус в кнопку показа списка — она первым элементом контейнера, поле-носитель последним. */
301
+ private __focusView() {
323
302
  (<HTMLElement>this.__container.firstElementChild).focus();
324
303
  }
325
304
 
@@ -332,9 +311,23 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
332
311
  });
333
312
  }
334
313
 
335
- private __togglePopup() {
336
- if (this.element.classList.contains("disabled")) return;
314
+ /**
315
+ * Выключенный контрол не работает целиком, а только-для-чтения — показывает значение, но
316
+ * менять его не даёт: гейт закрывает открытие списка и выбор пункта. Состояние читаем
317
+ * с поля-носителя, а не по классу-отражению: атрибут могли переключить после инициализации.
318
+ *
319
+ * Закрытие списка не запрещаем никогда: гейт в `@brandup/ui` общий на все команды элемента,
320
+ * а поле могли выключить уже с открытым списком (например, форма гасит поля на время отправки) —
321
+ * тогда запрет запер бы открытый список, и на узком экране, где это лист во весь экран,
322
+ * выхода с клавиатуры не осталось бы вовсе.
323
+ */
324
+ protected override _onCanExecCommand(name: string): boolean {
325
+ if (name.toLowerCase() === "close-popup") return true;
326
+
327
+ return !this.disabled && !this.readonly;
328
+ }
337
329
 
330
+ private __togglePopup() {
338
331
  if (this.element.classList.contains("expanded")) {
339
332
  // уже открыт — закрываем чисто, чтобы и body-класс, и mouseup-листенер ушли
340
333
  this.__closePopup();
@@ -474,10 +467,69 @@ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
474
467
  return this.__getElems(".hasvalue[data-index]");
475
468
  }
476
469
 
470
+ /** Индекс пункта, отмеченного сейчас в списке; -1 — контрол показывает placeholder. */
471
+ private __shownIndex(): number {
472
+ const index = this.__getSelectedElem()?.own.dataset.index;
473
+ return index === undefined ? -1 : Number(index);
474
+ }
475
+
476
+ /**
477
+ * Переносит выбор в поле-носитель и показывает его; событие изменения поднимается только
478
+ * при действительной смене показанного пункта.
479
+ *
480
+ * Работаем индексом, а не значением: значение в списке может повторяться — пустой пункт-подсказка
481
+ * и свой вариант вроде «Не указано» оба с пустым value, — а присваивание `value` выбрало бы ПЕРВЫЙ
482
+ * совпавший option, то есть не тот пункт, что нажали.
483
+ *
484
+ * С показанным пунктом сравниваем, а не с полем: значение могли записать в поле напрямую
485
+ * (так делает восстановление черновика формы), и сравнение с полем сделало бы такой вызов пустым.
486
+ */
487
+ private __applySelection(index: number) {
488
+ // Пункта в списке может и не быть: пустой пункт-подсказка своего <li> не получает, как и
489
+ // дубликат уже добавленного значения. На экране это то же самое, что «не выбрано ничего»,
490
+ // — приводим к одному виду, иначе повторная установка выглядела бы сменой выбора.
491
+ const target = this.__getElemsByIndex(index) ? index : -1;
492
+ if (target === this.__shownIndex()) return; // показанный выбор остался таким же
493
+
494
+ this.__valueElem.selectedIndex = index;
495
+
496
+ this.element.classList.remove("invalid");
497
+ this.__renderSelection();
498
+ this.__onChange();
499
+ }
500
+
501
+ /** Отражает текущее значение поля-носителя в контроле: отметка в списке и текст на кнопке. */
502
+ private __renderSelection() {
503
+ DOM.removeClass(this.element, ".hasvalue", "hasvalue"); // removeClass обходит потомков — класс контейнера снимаем отдельно
504
+ this.__container.classList.remove("hasvalue");
505
+ this.__textElem.innerText = this.placeholder;
506
+
507
+ const selected = this.__getElemsByIndex(this.__valueElem.selectedIndex);
508
+ if (!selected) return; // пустое или неизвестное значение — контрол показывает placeholder
509
+
510
+ selected.own.classList.add("hasvalue");
511
+ this.__container.classList.add("hasvalue");
512
+ this.__textElem.innerText = (selected.own.firstElementChild?.textContent ?? "").trim();
513
+ }
514
+
477
515
  getValue(): string | null {
478
516
  return this.__valueElem.value || null;
479
517
  }
480
518
 
519
+ /**
520
+ * Программная установка значения — например, восстановление черновика формы. Пишет значение
521
+ * в поле-носитель и показывает его в контроле; значение без своего пункта в списке даёт
522
+ * пустой выбор. Событие изменения поднимается, только когда показанный выбор действительно
523
+ * сменился: сравниваем с UI, а не с полем — вызывающий мог записать значение в поле сам.
524
+ */
525
+ setValue(value: string | null): void {
526
+ // значение ищем средствами самого поля: оно встанет на первый подходящий option,
527
+ // а дальше выбор переносится и показывается уже по индексу
528
+ this.__valueElem.value = value ?? "";
529
+
530
+ this.__applySelection(this.__valueElem.selectedIndex);
531
+ }
532
+
481
533
  getSelectedIndex(): number {
482
534
  return this.__valueElem.selectedIndex;
483
535
  }