@brandup/ui-dropdown 1.0.26 → 1.0.28

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
@@ -1,21 +1,94 @@
1
- # brandup-ui-dropdown
1
+ # @brandup/ui-dropdown
2
2
 
3
- [![Build Status](https://dev.azure.com/brandup/BrandUp%20Core/_apis/build/status%2FBrandUp%2Fbrandup-ui-kit?branchName=master)]()
3
+ [![Build Status](https://dev.azure.com/brandup/BrandUp%20Core/_apis/build/status%2FBrandUp%2Fbrandup-ui-kit?branchName=main)](https://dev.azure.com/brandup/BrandUp%20Core/_build/latest?definitionId=81&branchName=main)
4
4
 
5
- ## Installation
5
+ Компонент выпадающего списка, заменяющий стандартный `<select>`. Поддерживает поиск с транслитерацией (EN ↔ RU), адаптивный режим для планшетов и валидацию через форму.
6
6
 
7
- Install NPM package [@brandup/ui](https://www.npmjs.com/package/@brandup/ui-dropdown).
7
+ ## Установка
8
8
 
9
9
  ```
10
- npm i @brandup/ui-dropdown@latest
10
+ npm i @brandup/ui-dropdown
11
11
  ```
12
12
 
13
- ## DropDown
13
+ ## Использование
14
14
 
15
- `DropDown` - это классовый компонент, который расширяет возможности стандартных элементов `select`.
16
- Наследуется от `InputControl`.
15
+ ```html
16
+ <select id="city" data-placeholder="Выберите город" data-search-on="5">
17
+ <option value="">— не выбрано —</option>
18
+ <option value="msk">Москва</option>
19
+ <option value="spb">Санкт-Петербург</option>
20
+ </select>
21
+ ```
22
+
23
+ ```typescript
24
+ import DropDown from "@brandup/ui-dropdown";
25
+ import { CHANGE_EVENT, ChangeEventData } from "@brandup/ui-dropdown";
26
+
27
+ const selectElem = document.getElementById("city") as HTMLSelectElement;
28
+ const dropdown = new DropDown(selectElem);
17
29
 
18
- ```JS
19
- const dropDownElem = document.getElementById("name");
20
- const dropdown = new DropDown(dropDownElem);
30
+ dropdown.on(CHANGE_EVENT, (data: ChangeEventData) => {
31
+ console.log(data.value, data.title, data.index);
32
+ });
21
33
  ```
34
+
35
+ ## Data-атрибуты
36
+
37
+ | Атрибут | По умолчанию | Описание |
38
+ |---|---|---|
39
+ | `data-placeholder` | `"Select"` | Текст при отсутствии выбранного значения |
40
+ | `data-emptytext` | `"Empty list"` | Текст при пустом списке опций |
41
+ | `data-search-placeholder` | `"Search"` | Плейсхолдер строки поиска |
42
+ | `data-search-empty` | `"Not found"` | Текст при отсутствии результатов поиска |
43
+ | `data-cancel` | `"Cancel"` | Текст кнопки закрытия (адаптивный режим) |
44
+ | `data-search-on` | `15` | Порог отображения поиска: число (минимальное кол-во опций), `"true"` — всегда, `"false"` — никогда |
45
+
46
+ ## API
47
+
48
+ ### Методы (унаследованы от InputControl)
49
+
50
+ | Метод | Описание |
51
+ |---|---|
52
+ | `validate(): boolean` | Проверяет значение через нативный `checkValidity()` |
53
+ | `focus(): void` | Устанавливает фокус |
54
+ | `destroy(): void` | Восстанавливает исходный `<select>` и освобождает ресурсы |
55
+
56
+ ### Свойства
57
+
58
+ | Свойство | Тип | Описание |
59
+ |---|---|---|
60
+ | `placeholder` | `string` | Текст-заглушка |
61
+ | `emptyText` | `string` | Текст при пустом списке |
62
+ | `searchOn` | `number \| boolean` | Настройка отображения строки поиска |
63
+
64
+ ### Событие dropdown-change
65
+
66
+ Генерируется при выборе значения из списка.
67
+
68
+ ```typescript
69
+ import { CHANGE_EVENT, ChangeEventData } from "@brandup/ui-dropdown";
70
+
71
+ dropdown.on(CHANGE_EVENT, (data: ChangeEventData) => {
72
+ console.log(data.value); // значение выбранного <option>
73
+ console.log(data.title); // текст выбранного <option>
74
+ console.log(data.index); // индекс выбранного элемента в списке
75
+ });
76
+ ```
77
+
78
+ ## Поиск и транслитерация
79
+
80
+ Когда количество опций превышает порог `data-search-on`, в попапе отображается строка поиска. Поиск выполняется по тексту опций и поддерживает **автоматическую транслитерацию**:
81
+
82
+ - Ввод на русской раскладке → поиск по английскому тексту
83
+ - Ввод на английской раскладке → поиск по русскому тексту
84
+
85
+ ```typescript
86
+ import { detectLanguage, transcriptText } from "@brandup/ui-dropdown";
87
+
88
+ const lang = detectLanguage("hello"); // "english"
89
+ const variants = transcriptText("руку"); // { english: "reue" }
90
+ ```
91
+
92
+ ## Адаптивный режим
93
+
94
+ На экранах шириной менее `1030px` попап открывается в полноэкранном режиме с кнопкой «Отмена».
package/package.json CHANGED
@@ -21,15 +21,14 @@
21
21
  "email": "it@brandup.online"
22
22
  },
23
23
  "license": "Apache-2.0",
24
- "version": "1.0.26",
24
+ "version": "1.0.28",
25
25
  "main": "source/index.ts",
26
26
  "types": "source/index.ts",
27
27
  "dependencies": {
28
- "@brandup/ui": "^1.0.44",
29
- "@brandup/ui-dom": "^1.0.44",
30
- "@brandup/ui-helpers": "^1.0.44",
31
- "@brandup/ui-input": "^1.0.26",
32
- "@brandup/ui-kit": "^1.0.26"
28
+ "@brandup/ui": "^2.0.2",
29
+ "@brandup/ui-helpers": "^2.0.2",
30
+ "@brandup/ui-input": "^1.0.28",
31
+ "@brandup/ui-kit": "^1.0.28"
33
32
  },
34
33
  "files": [
35
34
  "source",
@@ -1,5 +1,5 @@
1
1
  import { InputControl } from "@brandup/ui-input";
2
- import { DOM } from "@brandup/ui-dom";
2
+ import { DOM } from "@brandup/ui";
3
3
  import { detectLanguage, transcriptText } from "./utils/utilities";
4
4
 
5
5
  import "./dropdown.less"; // стили компонента
@@ -17,7 +17,15 @@ export const CHANGE_EVENT = "dropdown-change";
17
17
  const TABLET_WIDTH = 1030;
18
18
  const BODY_EXPANDED = "ui-dropdown-opened";
19
19
 
20
- class DropDown extends InputControl<HTMLSelectElement> {
20
+ // Метаданные транслитерации, привязанные к <li>-элементам. WeakMap не мешает GC очищать удалённые li,
21
+ // в отличие от прежнего `(elem as any)['wsdd_transcript'] = ...` (грязно и без типов).
22
+ const itemTranscripts = new WeakMap<Element, ReturnType<typeof transcriptText>>();
23
+
24
+ type DropDownEvents = {
25
+ [CHANGE_EVENT]: (data: ChangeEventData) => void;
26
+ };
27
+
28
+ class DropDown extends InputControl<HTMLSelectElement, DropDownEvents> {
21
29
  private __container: HTMLElement;
22
30
  private __popupElem: HTMLElement;
23
31
  private __listElem: HTMLElement;
@@ -25,78 +33,111 @@ class DropDown extends InputControl<HTMLSelectElement> {
25
33
  private __emptyElem: HTMLElement;
26
34
  private __searchInput: HTMLInputElement;
27
35
  private __closePopupFunc: (e: MouseEvent) => void;
28
- private __invalidTimeout?: number;
36
+ private __reposAbort?: AbortController;
29
37
  private __hasEmptyValue: boolean = false;
30
38
 
31
39
  readonly placeholder: string;
32
40
  readonly emptyText: string;
33
41
  readonly searchPlaceholder: string;
34
42
  readonly searchEmpty: string;
35
- readonly searchOn: number | boolean = 15;
36
-
37
- get typeName(): string { return "BrandUp.DropDown"; }
43
+ readonly cancelText: string;
44
+ readonly searchOn: number | boolean;
38
45
 
39
46
  constructor(selectElem: HTMLSelectElement) {
40
- super(selectElem);
41
-
42
- this.__valueElem.classList.add(INPUT_CLASS);
47
+ selectElem.classList.add(INPUT_CLASS);
43
48
 
44
- this.placeholder = this.__valueElem.getAttribute("data-placeholder") || "Select";
45
- this.emptyText = this.__valueElem.getAttribute("data-emptytext") || "Empty list";
46
- this.searchPlaceholder = this.__valueElem.getAttribute("data-search-placeholder") || "Search";
47
- this.searchEmpty = this.__valueElem.getAttribute("data-search-empty") || "Not found";
49
+ const placeholder = selectElem.getAttribute("data-placeholder") || "Select";
50
+ const emptyText = selectElem.getAttribute("data-emptytext") || "Empty list";
51
+ const searchPlaceholder = selectElem.getAttribute("data-search-placeholder") || "Search";
52
+ const searchEmpty = selectElem.getAttribute("data-search-empty") || "Not found";
53
+ const cancelText = selectElem.getAttribute("data-cancel") || "Cancel";
48
54
 
49
- const se = this.__valueElem.getAttribute("data-search-on");
55
+ let searchOn: number | boolean = 15;
56
+ const se = selectElem.getAttribute("data-search-on");
50
57
  if (se) {
51
58
  switch (se.toLowerCase()) {
52
59
  case "true":
53
- this.searchOn = true;
60
+ searchOn = true;
54
61
  break;
55
62
  case "false":
56
- this.searchOn = false;
63
+ searchOn = false;
57
64
  break;
58
65
  default:
59
- this.searchOn = parseInt(se);
66
+ searchOn = parseInt(se);
60
67
  break;
61
68
  }
62
69
  }
63
70
 
64
- // __renderUI
65
- this.__container = DOM.tag("div", { class: [ROOT_CLASS].concat(Array.from(this.__valueElem.classList)) }, [
66
- DOM.tag("button", { class: "view", command: "open-popup" }, [this.__textElem = DOM.tag("span", null, this.placeholder ?? ''), arrowBottomIcon]),
67
- this.__popupElem = DOM.tag("div", { class: "popup", tabindex: 0 }, [
68
- DOM.tag("div", "content", [
69
- DOM.tag("div", "header", [
70
- DOM.tag("span", null, this.placeholder ?? ''),
71
- DOM.tag("button", { command: "close-popup" }, closeIcon)]
72
- ),
73
- DOM.tag("div", { class: "search" }, [
74
- searchIcon,
75
- this.__searchInput = <HTMLInputElement>DOM.tag("input", { type: "search", maxlength: 50, placeholder: this.searchPlaceholder })
76
- ]),
77
- this.__listElem = DOM.tag("ul"),
78
- this.__emptyElem = DOM.tag("div", { class: "empty" }, this.emptyText),
79
- DOM.tag("button", { class: "cancel", command: "close-popup" }, "Отмена")
80
- ])
81
- ])
71
+ // текст из option/data-* атрибутов вставляем через textContent, чтобы не получить XSS через DOM.tag
72
+ const textElem = DOM.tag("span", null);
73
+ textElem.textContent = placeholder;
74
+
75
+ const headerLabel = DOM.tag("span", null);
76
+ headerLabel.textContent = placeholder;
77
+
78
+ const emptyElem = DOM.tag("div", { class: "empty" });
79
+ emptyElem.textContent = emptyText;
80
+
81
+ const cancelButton = DOM.tag("button", { class: "cancel", command: "close-popup" });
82
+ cancelButton.textContent = cancelText;
83
+
84
+ const searchInput = DOM.tag("input", { type: "search", maxlength: 50, placeholder: searchPlaceholder });
85
+ const listElem = DOM.tag("ul");
86
+
87
+ const popupElem = DOM.tag("div", { class: "popup", tabindex: 0 }, [
88
+ DOM.tag("div", { class: "content" }, [
89
+ DOM.tag("div", { class: "header" }, [
90
+ headerLabel,
91
+ DOM.tag("button", { command: "close-popup" }, closeIcon),
92
+ ]),
93
+ DOM.tag("div", { class: "search" }, [searchIcon, searchInput]),
94
+ listElem,
95
+ emptyElem,
96
+ cancelButton,
97
+ ]),
82
98
  ]);
83
99
 
84
- this.__container.classList.remove(INPUT_CLASS);
100
+ const container = DOM.tag("div", { class: [ROOT_CLASS].concat(Array.from(selectElem.classList)) }, [
101
+ DOM.tag("button", { class: "view", command: "open-popup" }, [textElem, arrowBottomIcon]),
102
+ popupElem,
103
+ ]);
85
104
 
86
- this.setElement(this.__container);
105
+ container.classList.remove(INPUT_CLASS);
87
106
 
88
- if (this.__valueElem.nextElementSibling) {
89
- const nextElem = <HTMLElement>this.__valueElem.nextElementSibling;
107
+ if (selectElem.nextElementSibling) {
108
+ const nextElem = selectElem.nextElementSibling as HTMLElement;
90
109
  if (nextElem.classList.contains(MINIATURE_CLASS)) nextElem.remove();
91
110
  }
92
111
 
93
- this.__valueElem.insertAdjacentElement("beforebegin", this.__container);
94
- this.__container.insertAdjacentElement("beforeend", this.__valueElem);
112
+ selectElem.insertAdjacentElement("beforebegin", container);
113
+ container.insertAdjacentElement("beforeend", selectElem);
114
+
115
+ super("BrandUp.DropDown", container, selectElem);
116
+
117
+ this.placeholder = placeholder;
118
+ this.emptyText = emptyText;
119
+ this.searchPlaceholder = searchPlaceholder;
120
+ this.searchEmpty = searchEmpty;
121
+ this.cancelText = cancelText;
122
+ this.searchOn = searchOn;
123
+
124
+ this.__container = container;
125
+ this.__popupElem = popupElem;
126
+ this.__listElem = listElem;
127
+ this.__textElem = textElem;
128
+ this.__emptyElem = emptyElem;
129
+ this.__searchInput = searchInput;
95
130
 
96
131
  this.__closePopupFunc = (e: MouseEvent) => {
97
- const t = <HTMLElement>e.target;
132
+ const t = e.target as HTMLElement;
98
133
  const dd = t.closest(`.${ROOT_CLASS}`);
99
- if (!dd || (dd === this.__container && !t.closest("li[data-index]") && t !== this.__searchInput && !t.closest(".search"))) {
134
+ if (
135
+ !dd ||
136
+ (dd === this.__container &&
137
+ !t.closest("li[data-index]") &&
138
+ t !== this.__searchInput &&
139
+ !t.closest(".search"))
140
+ ) {
100
141
  this.__closePopup();
101
142
  this.__clearSearch();
102
143
  }
@@ -111,20 +152,18 @@ class DropDown extends InputControl<HTMLSelectElement> {
111
152
  const optionsCount = this.__valueElem.options.length;
112
153
  const selectedIndex = this.__valueElem.selectedIndex;
113
154
 
114
- if (!optionsCount)
115
- this.__textElem.innerText = this.placeholder;
155
+ if (!optionsCount) this.__textElem.innerText = this.placeholder;
116
156
 
117
157
  if (!optionsCount) {
118
- this.element?.classList.add("empty");
158
+ this.element.classList.add("empty");
119
159
  return;
120
160
  }
121
161
 
122
162
  // определяем можно ли делать поиск по элементам в списке
123
- let isSearchable = false;
124
- if (this.searchOn === true || optionsCount >= <number>this.searchOn) {
125
- this.element?.classList.add("searchable");
126
- isSearchable = true;
127
- }
163
+ // явная проверка типа: для false `optionsCount >= false` коэрсится в `>= 0` и всегда true
164
+ const isSearchable =
165
+ this.searchOn === true || (typeof this.searchOn === "number" && optionsCount >= this.searchOn);
166
+ if (isSearchable) this.element.classList.add("searchable");
128
167
 
129
168
  // вставляем элементы меню в фрагмент, чтобы не нагружать процессор
130
169
  const popupItemsFragment = document.createDocumentFragment();
@@ -132,8 +171,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
132
171
  let elemCount = 0;
133
172
  for (let i = 0; i < optionsCount; i++) {
134
173
  const optionElem = this.__valueElem.options.item(i);
135
- if (!optionElem)
136
- continue;
174
+ if (!optionElem) continue;
137
175
 
138
176
  const itemText = optionElem.textContent?.trim() || "";
139
177
  const itemValue = optionElem.value;
@@ -145,25 +183,18 @@ class DropDown extends InputControl<HTMLSelectElement> {
145
183
  continue;
146
184
  }
147
185
 
148
- let itemElem = DOM.tag("li", { command: "select", dataset: { value: itemValue, index: i.toString() } }, [
149
- DOM.tag("span", { tabindex: "0" }, itemText),
150
- checkIcon
151
- ]
152
- );
186
+ const itemSpan = DOM.tag("span", { tabindex: "0" });
187
+ itemSpan.textContent = itemText; // безопасно: textContent не парсит HTML
153
188
 
154
- const transcript = (<any>itemElem)['wsdd_transcript'] = transcriptText(itemText);
155
-
156
- const isSelected = selectedIndex === i;
157
- if (isSelected)
158
- itemElem.classList.add("hasvalue");
189
+ const itemElem = DOM.tag("li", { command: "select", dataset: { value: itemValue, index: i.toString() } }, [
190
+ itemSpan,
191
+ checkIcon,
192
+ ]);
159
193
 
160
- if (isSearchable) {
161
- // дублируем быстрый элемент в общем списке, чтобы находить его при поиске
194
+ itemTranscripts.set(itemElem, transcriptText(itemText));
162
195
 
163
- itemElem = itemElem.cloneNode(true) as HTMLLIElement;
164
- (<any>itemElem)['wsdd_transcript'] = transcript;
165
- popupItemsFragment.append(itemElem);
166
- }
196
+ const isSelected = selectedIndex === i;
197
+ if (isSelected) itemElem.classList.add("hasvalue");
167
198
 
168
199
  popupItemsFragment.append(itemElem);
169
200
 
@@ -175,8 +206,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
175
206
  elemCount++;
176
207
  }
177
208
 
178
- if (this.__hasEmptyValue && !elemCount)
179
- this.element?.classList.add("empty");
209
+ if (this.__hasEmptyValue && !elemCount) this.element.classList.add("empty");
180
210
 
181
211
  this.__listElem.append(popupItemsFragment);
182
212
  }
@@ -185,10 +215,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
185
215
  this.registerCommand("open-popup", () => this.__togglePopup());
186
216
  this.registerCommand("close-popup", () => this.__closePopup());
187
217
 
188
- this.registerCommand("select", context => {
189
- if (!this.element)
190
- return;
191
-
218
+ this.registerCommand("select", (context) => {
192
219
  const newIndex = context.target.dataset.index;
193
220
 
194
221
  this.element.classList.remove("invalid");
@@ -196,13 +223,12 @@ class DropDown extends InputControl<HTMLSelectElement> {
196
223
  this.__closePopup();
197
224
 
198
225
  const currentSelect = this.__getSelectedElem();
199
- if (currentSelect && newIndex === currentSelect.own.dataset.index)
200
- return; // если выбор остался таким же
226
+ if (currentSelect && newIndex === currentSelect.own.dataset.index) return; // если выбор остался таким же
201
227
 
202
- DOM.removeClass(this.element, '.hasvalue', 'hasvalue');
228
+ DOM.removeClass(this.element, ".hasvalue", "hasvalue");
203
229
 
204
230
  if (currentSelect && currentSelect.own.closest(`.${ROOT_CLASS}`))
205
- this.__textElem.innerText = this.placeholder ?? '';
231
+ this.__textElem.innerText = this.placeholder ?? "";
206
232
 
207
233
  // делаем новый выбор
208
234
  this.__valueElem.value = context.target.dataset.value || "";
@@ -240,8 +266,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
240
266
  if (isSpan) {
241
267
  // Если есть предыдущий элемент, то переводим фокус на него
242
268
  const prevItemElem = target.parentElement?.previousElementSibling;
243
- if (prevItemElem)
244
- (<HTMLElement>prevItemElem.firstElementChild).focus();
269
+ if (prevItemElem) (<HTMLElement>prevItemElem.firstElementChild).focus();
245
270
 
246
271
  e.preventDefault();
247
272
  }
@@ -251,8 +276,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
251
276
  if (isSpan) {
252
277
  // Если есть следующий элемент, то переводим фокус на него
253
278
  const nextItemElem = target.parentElement?.nextElementSibling;
254
- if (nextItemElem)
255
- (<HTMLElement>nextItemElem.firstElementChild).focus();
279
+ if (nextItemElem) (<HTMLElement>nextItemElem.firstElementChild).focus();
256
280
 
257
281
  e.preventDefault();
258
282
  }
@@ -265,22 +289,22 @@ class DropDown extends InputControl<HTMLSelectElement> {
265
289
  break;
266
290
  }
267
291
  case "Tab": {
268
- if (target == this.__popupElem && this.element?.classList.contains("empty")) {
292
+ if (target == this.__popupElem && this.element.classList.contains("empty")) {
269
293
  // если список пустой, то фокус уйдёт от компанента на следующий и нужно закрыть popup
270
294
  this.__closePopup();
271
- }
272
- else if (target == this.__searchInput && this.__listElem.classList.contains("notfound")) {
295
+ } else if (target == this.__searchInput && this.__listElem.classList.contains("notfound")) {
273
296
  // если не найдено, то фокус уйдёт от компанента на следующий и нужно закрыть popup
274
297
  this.__closePopup();
275
- }
276
- else if (isSpan && !target.parentElement?.nextElementSibling) {
298
+ } else if (isSpan && !target.parentElement?.nextElementSibling) {
277
299
  // если фокус на последнем элементе списка, то фокус уйдёт от компанента на следующий и нужно закрыть popup
278
300
  this.__closePopup();
279
301
  }
280
302
  break;
281
303
  }
282
- case "Enter": { // так как теперь мы обрабатываем не <a>
283
- target.click();
304
+ case "Enter": {
305
+ // так как теперь мы обрабатываем не <a>
306
+ e.preventDefault(); // чтобы Enter в поле поиска не сабмитил форму, в которой может находиться dropdown
307
+ if (isSpan) target.click();
284
308
  this.__closePopup();
285
309
  break;
286
310
  }
@@ -297,21 +321,25 @@ class DropDown extends InputControl<HTMLSelectElement> {
297
321
  dropdown: this,
298
322
  index: this.getSelectedIndex(),
299
323
  value: this.getValue(),
300
- title: this.getSelectedTitle()
324
+ title: this.getSelectedTitle(),
301
325
  });
302
326
  }
303
327
 
304
328
  private __togglePopup() {
305
- if (this.element?.classList.contains("disabled"))
306
- return;
329
+ if (this.element.classList.contains("disabled")) return;
307
330
 
308
- if (!this.element?.classList.toggle("expanded"))
331
+ if (this.element.classList.contains("expanded")) {
332
+ // уже открыт — закрываем чисто, чтобы и body-класс, и mouseup-листенер ушли
333
+ this.__closePopup();
309
334
  return;
335
+ }
336
+
337
+ this.element.classList.add("expanded");
310
338
 
311
339
  // закрываем все открытые попапы, кроме текущего
312
- document.querySelectorAll('.ui-dropdown.expanded').forEach((dropdown) => {
340
+ document.querySelectorAll(".ui-dropdown.expanded").forEach((dropdown) => {
313
341
  if (dropdown !== this.element) {
314
- dropdown.classList.remove('expanded');
342
+ dropdown.classList.remove("expanded");
315
343
  }
316
344
  });
317
345
 
@@ -319,17 +347,25 @@ class DropDown extends InputControl<HTMLSelectElement> {
319
347
 
320
348
  this.__positionPopup();
321
349
 
350
+ // пока popup открыт, перепозиционируем при изменении окна/скролле страницы
351
+ this.__reposAbort = new AbortController();
352
+ const reposition = () => this.__positionPopup();
353
+ window.addEventListener("resize", reposition, { signal: this.__reposAbort.signal });
354
+ window.addEventListener("scroll", reposition, {
355
+ signal: this.__reposAbort.signal,
356
+ passive: true,
357
+ capture: true,
358
+ });
359
+
322
360
  document.body.classList.add(BODY_EXPANDED);
323
361
 
324
- let top = 0;
325
362
  const selectedElem = this.__getSelectedElem();
326
363
  const itemHeight = selectedElem?.own?.clientHeight || 0;
327
364
  const itemTop = selectedElem?.own?.offsetTop || 0;
328
365
 
329
- top = itemTop - this.__listElem.clientHeight / 2 + itemHeight;
366
+ const top = itemTop - this.__listElem.clientHeight / 2 + itemHeight;
330
367
 
331
- if (top !== null)
332
- this.__listElem.scrollTo({ left: 0, top: top, behavior: 'instant' });
368
+ this.__listElem.scrollTo({ left: 0, top: top, behavior: "instant" });
333
369
 
334
370
  document.body.addEventListener("mouseup", this.__closePopupFunc);
335
371
  }
@@ -337,8 +373,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
337
373
  private __positionPopup() {
338
374
  this.__popupElem.classList.remove("top", "right");
339
375
 
340
- if (document.body.clientWidth <= TABLET_WIDTH)
341
- return;
376
+ if (document.body.clientWidth <= TABLET_WIDTH) return;
342
377
 
343
378
  const bodyHeight = document.body.clientHeight;
344
379
  const popupRect = this.__popupElem.getBoundingClientRect();
@@ -354,8 +389,10 @@ class DropDown extends InputControl<HTMLSelectElement> {
354
389
 
355
390
  private __closePopup() {
356
391
  document.body.classList.remove(BODY_EXPANDED);
357
- this.element?.classList.remove("expanded");
392
+ this.element.classList.remove("expanded");
358
393
  document.body.removeEventListener("mouseup", this.__closePopupFunc);
394
+ this.__reposAbort?.abort();
395
+ this.__reposAbort = undefined;
359
396
  }
360
397
 
361
398
  private __search(query: string) {
@@ -369,19 +406,16 @@ class DropDown extends InputControl<HTMLSelectElement> {
369
406
  this.__listElem.classList.add("result");
370
407
  const items = DOM.queryElements(this.__listElem, "li span");
371
408
 
372
-
373
409
  let findedCount = 0;
374
- items.forEach(it => {
410
+ items.forEach((it) => {
375
411
  const item = it.parentElement;
376
- if (!item)
377
- return;
412
+ if (!item) return;
378
413
 
379
- if (it.innerText.toLowerCase().startsWith(query)) {
414
+ // textContent надёжнее: innerText в браузерах может возвращать пусто/неожиданное для скрытых элементов
415
+ if ((it.textContent ?? "").toLowerCase().startsWith(query)) {
380
416
  item.classList.add("ok");
381
417
  findedCount++;
382
- }
383
- else
384
- item.classList.remove("ok");
418
+ } else item.classList.remove("ok");
385
419
  });
386
420
 
387
421
  if (!findedCount) {
@@ -392,16 +426,12 @@ class DropDown extends InputControl<HTMLSelectElement> {
392
426
  if (queryLang) {
393
427
  for (let i = 0; i < this.__listElem.children.length; i++) {
394
428
  const item = this.__listElem.children.item(i);
395
- if (!item)
396
- continue;
397
- const transcript = (<any>item)['wsdd_transcript'];
429
+ if (!item) continue;
430
+ const transcript = itemTranscripts.get(item);
398
431
  if (transcript && transcript[queryLang] && transcript[queryLang].startsWith(query)) {
399
-
400
432
  item.classList.add("ok");
401
433
  findedCount++;
402
- }
403
- else
404
- item.classList.remove("ok");
434
+ } else item.classList.remove("ok");
405
435
  }
406
436
  }
407
437
  }
@@ -409,33 +439,24 @@ class DropDown extends InputControl<HTMLSelectElement> {
409
439
  if (findedCount > 0) {
410
440
  this.__emptyElem.innerText = this.emptyText;
411
441
  this.__listElem.classList.remove("notfound");
412
- }
413
- else {
442
+ } else {
414
443
  this.__emptyElem.innerText = this.searchEmpty;
415
444
  this.__listElem.classList.add("notfound");
416
445
  }
417
446
  }
418
447
 
419
448
  private __clearSearch() {
420
- if (!this.__listElem.classList.contains("result"))
421
- return;
449
+ if (!this.__listElem.classList.contains("result")) return;
422
450
 
423
451
  this.__emptyElem.innerText = this.emptyText;
424
452
  this.__listElem.classList.remove("result", "notfound");
425
453
  DOM.removeClass(this.__listElem, "li.ok", "ok");
426
- this.__searchInput.value = '';
454
+ this.__searchInput.value = "";
427
455
  }
428
456
 
429
457
  private __getElems(selector: string): { own: HTMLElement } | null {
430
- if (!this.element)
431
- return null;
432
-
433
- const elems = DOM.queryElements(this.element, selector);
434
- if (elems.length === 2)
435
- return { own: elems.item(0) };
436
- else if (elems.length === 1)
437
- return { own: elems.item(0) };
438
- return null;
458
+ const elem = DOM.queryElement<HTMLElement>(this.element, selector);
459
+ return elem ? { own: elem } : null;
439
460
  }
440
461
 
441
462
  private __getElemsByIndex(index: number) {
@@ -460,21 +481,16 @@ class DropDown extends InputControl<HTMLSelectElement> {
460
481
  }
461
482
 
462
483
  override validate(): boolean {
463
- window.clearTimeout(this.__invalidTimeout);
464
-
465
- let value = this.getValue();
484
+ const value = this.getValue();
466
485
  let isInvalid = !this.__valueElem.validity.valid;
467
486
 
468
- if (this.required && !value)
469
- isInvalid = true;
487
+ if (this.required && !value) isInvalid = true;
470
488
 
471
- const clearInvalid = () => this.element?.classList.remove("invalid");
489
+ const clearInvalid = () => this.element.classList.remove("invalid");
472
490
 
473
491
  if (isInvalid) {
474
- this.element?.classList.add("invalid");
475
- }
476
- else
477
- clearInvalid();
492
+ this.element.classList.add("invalid");
493
+ } else clearInvalid();
478
494
 
479
495
  return !isInvalid;
480
496
  }
@@ -482,8 +498,8 @@ class DropDown extends InputControl<HTMLSelectElement> {
482
498
  override destroy(): void {
483
499
  this.__closePopup();
484
500
 
485
- this.element?.insertAdjacentElement("afterend", this.__valueElem);
486
- this.element?.remove();
501
+ this.element.insertAdjacentElement("afterend", this.__valueElem);
502
+ this.element.remove();
487
503
 
488
504
  super.destroy();
489
505
  }
@@ -496,4 +512,4 @@ export interface ChangeEventData {
496
512
  title: string | null;
497
513
  }
498
514
 
499
- export default DropDown;
515
+ export default DropDown;
package/source/index.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { default } from './dropdown';
2
- export * from './utils/utilities';
1
+ export { default } from "./dropdown";
2
+ export * from "./utils/utilities";
@@ -1,4 +1,4 @@
1
1
  declare module "*.svg" {
2
- const content: any;
3
- export default content;
4
- }
2
+ const content: any;
3
+ export default content;
4
+ }
@@ -1,8 +1,8 @@
1
- const REGEX_CHAR = /\p{L}/u
1
+ const REGEX_CHAR = /\p{L}/u;
2
2
 
3
3
  const languages: { [keys in Language]: string } = {
4
- english: "qwertyuiopasdfghjklzxcvbnm",
5
- russian: "йцукенгшщзхъфывапролджэячсмитьбю",
4
+ english: "qwertyuiopasdfghjklzxcvbnm",
5
+ russian: "йцукенгшщзхъфывапролджэячсмитьбю",
6
6
  };
7
7
 
8
8
  // prettier-ignore
@@ -22,51 +22,48 @@ const russian_english_dict: ICharMap = {
22
22
  };
23
23
 
24
24
  interface ICharMap {
25
- [keys: string]: string;
25
+ [keys: string]: string;
26
26
  }
27
27
 
28
28
  type ITranscriptMap = {
29
- // {целевой язык: {язык ввода: словарь}}
30
- [keys in Language]: { [keys: string]: ICharMap };
29
+ // {целевой язык: {язык ввода: словарь}}
30
+ [keys in Language]: { [keys: string]: ICharMap };
31
31
  };
32
32
 
33
33
  const dictionariesMap: ITranscriptMap = {
34
- russian: { english: russian_english_dict },
35
- english: { russian: english_russian_dict },
34
+ russian: { english: russian_english_dict },
35
+ english: { russian: english_russian_dict },
36
36
  };
37
37
 
38
38
  export type Language = "english" | "russian";
39
39
 
40
40
  export const detectLanguage = (text: string) => {
41
- if (!text)
42
- return null;
41
+ if (!text) return null;
43
42
 
44
- const match = text.match(REGEX_CHAR);
45
- if (!match)
46
- return null;
43
+ const match = text.match(REGEX_CHAR);
44
+ if (!match) return null;
47
45
 
48
- const firstChar = match[0].toLocaleLowerCase();
49
- for (const lang in languages) {
50
- if (languages[<Language>lang].includes(firstChar))
51
- return lang as Language;
52
- }
46
+ const firstChar = match[0].toLocaleLowerCase();
47
+ for (const lang in languages) {
48
+ if (languages[<Language>lang].includes(firstChar)) return lang as Language;
49
+ }
53
50
 
54
- return null;
51
+ return null;
55
52
  };
56
53
 
57
54
  export const transcriptText = (text: string) => {
58
- const transcriptVariants: { [keys: string]: string; } = {};
55
+ const transcriptVariants: { [keys: string]: string } = {};
59
56
 
60
- const textLanguage = detectLanguage(text);
61
- if (textLanguage) {
62
- const textArr: string[] = Array.from(text.toLowerCase());
63
- for (const lang in dictionariesMap[textLanguage]) {
64
- const langVariant = dictionariesMap[textLanguage][lang];
57
+ const textLanguage = detectLanguage(text);
58
+ if (textLanguage) {
59
+ const textArr: string[] = Array.from(text.toLowerCase());
60
+ for (const lang in dictionariesMap[textLanguage]) {
61
+ const langVariant = dictionariesMap[textLanguage][lang];
65
62
 
66
- const transcript = textArr.reduce((prev, current) => prev + (langVariant[current] || current), "");
67
- transcriptVariants[lang] = transcript;
68
- }
69
- }
63
+ const transcript = textArr.reduce((prev, current) => prev + (langVariant[current] || current), "");
64
+ transcriptVariants[lang] = transcript;
65
+ }
66
+ }
70
67
 
71
- return transcriptVariants;
72
- };
68
+ return transcriptVariants;
69
+ };