@brandup/ui-dropdown 1.0.25 → 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
@@ -11,25 +11,24 @@
11
11
  "name": "Dmitry Kovyazin",
12
12
  "email": "it@brandup.online"
13
13
  },
14
- "homepage": "https://github.com/brandup-online/brandup-ui/npm/brandup-ui-dropdown",
14
+ "homepage": "https://github.com/brandup-online/brandup-ui-kit/npm/brandup-ui-dropdown",
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "https://github.com/brandup-online/brandup-ui.git"
17
+ "url": "https://github.com/brandup-online/brandup-ui-kit.git"
18
18
  },
19
19
  "bugs": {
20
- "url": "https://github.com/brandup-online/brandup-ui/issues",
20
+ "url": "https://github.com/brandup-online/brandup-ui-kit/issues",
21
21
  "email": "it@brandup.online"
22
22
  },
23
23
  "license": "Apache-2.0",
24
- "version": "1.0.25",
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.41",
29
- "@brandup/ui-dom": "^1.0.41",
30
- "@brandup/ui-helpers": "^1.0.41",
31
- "@brandup/ui-input": "^1.0.25",
32
- "@brandup/ui-kit": "^1.0.25"
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,77 +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);
47
+ selectElem.classList.add(INPUT_CLASS);
41
48
 
42
- this.__valueElem.classList.add(INPUT_CLASS);
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";
43
54
 
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";
48
-
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;
64
+ break;
57
65
  default:
58
- this.searchOn = parseInt(se);
66
+ searchOn = parseInt(se);
59
67
  break;
60
68
  }
61
69
  }
62
70
 
63
- // __renderUI
64
- this.__container = DOM.tag("div", { class: [ROOT_CLASS].concat(Array.from(this.__valueElem.classList)) }, [
65
- DOM.tag("button", { class: "view", command: "open-popup" }, [this.__textElem = DOM.tag("span", null, this.placeholder ?? ''), arrowBottomIcon]),
66
- this.__popupElem = DOM.tag("div", { class: "popup", tabindex: 0 }, [
67
- DOM.tag("div", "content", [
68
- DOM.tag("div", "header", [
69
- DOM.tag("span", null, this.placeholder ?? ''),
70
- DOM.tag("button", { command: "close-popup" }, closeIcon)]
71
- ),
72
- DOM.tag("div", { class: "search" }, [
73
- searchIcon,
74
- this.__searchInput = <HTMLInputElement>DOM.tag("input", { type: "search", maxlength: 50, placeholder: this.searchPlaceholder })
75
- ]),
76
- this.__listElem = DOM.tag("ul"),
77
- this.__emptyElem = DOM.tag("div", { class: "empty" }, this.emptyText),
78
- DOM.tag("button", { class: "cancel", command: "close-popup" }, "Отмена")
79
- ])
80
- ])
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
+ ]),
81
98
  ]);
82
99
 
83
- 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
+ ]);
84
104
 
85
- this.setElement(this.__container);
105
+ container.classList.remove(INPUT_CLASS);
86
106
 
87
- if (this.__valueElem.nextElementSibling) {
88
- const nextElem = <HTMLElement>this.__valueElem.nextElementSibling;
107
+ if (selectElem.nextElementSibling) {
108
+ const nextElem = selectElem.nextElementSibling as HTMLElement;
89
109
  if (nextElem.classList.contains(MINIATURE_CLASS)) nextElem.remove();
90
110
  }
91
111
 
92
- this.__valueElem.insertAdjacentElement("beforebegin", this.__container);
93
- 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;
94
130
 
95
131
  this.__closePopupFunc = (e: MouseEvent) => {
96
- const t = <HTMLElement>e.target;
132
+ const t = e.target as HTMLElement;
97
133
  const dd = t.closest(`.${ROOT_CLASS}`);
98
- 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
+ ) {
99
141
  this.__closePopup();
100
142
  this.__clearSearch();
101
143
  }
@@ -110,20 +152,18 @@ class DropDown extends InputControl<HTMLSelectElement> {
110
152
  const optionsCount = this.__valueElem.options.length;
111
153
  const selectedIndex = this.__valueElem.selectedIndex;
112
154
 
113
- if (!optionsCount)
114
- this.__textElem.innerText = this.placeholder;
155
+ if (!optionsCount) this.__textElem.innerText = this.placeholder;
115
156
 
116
157
  if (!optionsCount) {
117
- this.element?.classList.add("empty");
158
+ this.element.classList.add("empty");
118
159
  return;
119
160
  }
120
161
 
121
162
  // определяем можно ли делать поиск по элементам в списке
122
- let isSearchable = false;
123
- if (this.searchOn === true || optionsCount >= <number>this.searchOn) {
124
- this.element?.classList.add("searchable");
125
- isSearchable = true;
126
- }
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");
127
167
 
128
168
  // вставляем элементы меню в фрагмент, чтобы не нагружать процессор
129
169
  const popupItemsFragment = document.createDocumentFragment();
@@ -131,8 +171,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
131
171
  let elemCount = 0;
132
172
  for (let i = 0; i < optionsCount; i++) {
133
173
  const optionElem = this.__valueElem.options.item(i);
134
- if (!optionElem)
135
- continue;
174
+ if (!optionElem) continue;
136
175
 
137
176
  const itemText = optionElem.textContent?.trim() || "";
138
177
  const itemValue = optionElem.value;
@@ -144,25 +183,18 @@ class DropDown extends InputControl<HTMLSelectElement> {
144
183
  continue;
145
184
  }
146
185
 
147
- let itemElem = DOM.tag("li", { command: "select", dataset: { value: itemValue, index: i.toString() } }, [
148
- DOM.tag("span", { tabindex: "0" }, itemText),
149
- checkIcon
150
- ]
151
- );
186
+ const itemSpan = DOM.tag("span", { tabindex: "0" });
187
+ itemSpan.textContent = itemText; // безопасно: textContent не парсит HTML
152
188
 
153
- const transcript = (<any>itemElem)['wsdd_transcript'] = transcriptText(itemText);
154
-
155
- const isSelected = selectedIndex === i;
156
- if (isSelected)
157
- itemElem.classList.add("hasvalue");
189
+ const itemElem = DOM.tag("li", { command: "select", dataset: { value: itemValue, index: i.toString() } }, [
190
+ itemSpan,
191
+ checkIcon,
192
+ ]);
158
193
 
159
- if (isSearchable) {
160
- // дублируем быстрый элемент в общем списке, чтобы находить его при поиске
194
+ itemTranscripts.set(itemElem, transcriptText(itemText));
161
195
 
162
- itemElem = itemElem.cloneNode(true) as HTMLLIElement;
163
- (<any>itemElem)['wsdd_transcript'] = transcript;
164
- popupItemsFragment.append(itemElem);
165
- }
196
+ const isSelected = selectedIndex === i;
197
+ if (isSelected) itemElem.classList.add("hasvalue");
166
198
 
167
199
  popupItemsFragment.append(itemElem);
168
200
 
@@ -174,8 +206,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
174
206
  elemCount++;
175
207
  }
176
208
 
177
- if (this.__hasEmptyValue && !elemCount)
178
- this.element?.classList.add("empty");
209
+ if (this.__hasEmptyValue && !elemCount) this.element.classList.add("empty");
179
210
 
180
211
  this.__listElem.append(popupItemsFragment);
181
212
  }
@@ -184,10 +215,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
184
215
  this.registerCommand("open-popup", () => this.__togglePopup());
185
216
  this.registerCommand("close-popup", () => this.__closePopup());
186
217
 
187
- this.registerCommand("select", context => {
188
- if (!this.element)
189
- return;
190
-
218
+ this.registerCommand("select", (context) => {
191
219
  const newIndex = context.target.dataset.index;
192
220
 
193
221
  this.element.classList.remove("invalid");
@@ -195,13 +223,12 @@ class DropDown extends InputControl<HTMLSelectElement> {
195
223
  this.__closePopup();
196
224
 
197
225
  const currentSelect = this.__getSelectedElem();
198
- if (currentSelect && newIndex === currentSelect.own.dataset.index)
199
- return; // если выбор остался таким же
226
+ if (currentSelect && newIndex === currentSelect.own.dataset.index) return; // если выбор остался таким же
200
227
 
201
- DOM.removeClass(this.element, '.hasvalue', 'hasvalue');
228
+ DOM.removeClass(this.element, ".hasvalue", "hasvalue");
202
229
 
203
230
  if (currentSelect && currentSelect.own.closest(`.${ROOT_CLASS}`))
204
- this.__textElem.innerText = this.placeholder ?? '';
231
+ this.__textElem.innerText = this.placeholder ?? "";
205
232
 
206
233
  // делаем новый выбор
207
234
  this.__valueElem.value = context.target.dataset.value || "";
@@ -239,8 +266,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
239
266
  if (isSpan) {
240
267
  // Если есть предыдущий элемент, то переводим фокус на него
241
268
  const prevItemElem = target.parentElement?.previousElementSibling;
242
- if (prevItemElem)
243
- (<HTMLElement>prevItemElem.firstElementChild).focus();
269
+ if (prevItemElem) (<HTMLElement>prevItemElem.firstElementChild).focus();
244
270
 
245
271
  e.preventDefault();
246
272
  }
@@ -250,8 +276,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
250
276
  if (isSpan) {
251
277
  // Если есть следующий элемент, то переводим фокус на него
252
278
  const nextItemElem = target.parentElement?.nextElementSibling;
253
- if (nextItemElem)
254
- (<HTMLElement>nextItemElem.firstElementChild).focus();
279
+ if (nextItemElem) (<HTMLElement>nextItemElem.firstElementChild).focus();
255
280
 
256
281
  e.preventDefault();
257
282
  }
@@ -264,22 +289,22 @@ class DropDown extends InputControl<HTMLSelectElement> {
264
289
  break;
265
290
  }
266
291
  case "Tab": {
267
- if (target == this.__popupElem && this.element?.classList.contains("empty")) {
292
+ if (target == this.__popupElem && this.element.classList.contains("empty")) {
268
293
  // если список пустой, то фокус уйдёт от компанента на следующий и нужно закрыть popup
269
294
  this.__closePopup();
270
- }
271
- else if (target == this.__searchInput && this.__listElem.classList.contains("notfound")) {
295
+ } else if (target == this.__searchInput && this.__listElem.classList.contains("notfound")) {
272
296
  // если не найдено, то фокус уйдёт от компанента на следующий и нужно закрыть popup
273
297
  this.__closePopup();
274
- }
275
- else if (isSpan && !target.parentElement?.nextElementSibling) {
298
+ } else if (isSpan && !target.parentElement?.nextElementSibling) {
276
299
  // если фокус на последнем элементе списка, то фокус уйдёт от компанента на следующий и нужно закрыть popup
277
300
  this.__closePopup();
278
301
  }
279
302
  break;
280
303
  }
281
- case "Enter": { // так как теперь мы обрабатываем не <a>
282
- target.click();
304
+ case "Enter": {
305
+ // так как теперь мы обрабатываем не <a>
306
+ e.preventDefault(); // чтобы Enter в поле поиска не сабмитил форму, в которой может находиться dropdown
307
+ if (isSpan) target.click();
283
308
  this.__closePopup();
284
309
  break;
285
310
  }
@@ -296,21 +321,25 @@ class DropDown extends InputControl<HTMLSelectElement> {
296
321
  dropdown: this,
297
322
  index: this.getSelectedIndex(),
298
323
  value: this.getValue(),
299
- title: this.getSelectedTitle()
324
+ title: this.getSelectedTitle(),
300
325
  });
301
326
  }
302
327
 
303
328
  private __togglePopup() {
304
- if (this.element?.classList.contains("disabled"))
305
- return;
329
+ if (this.element.classList.contains("disabled")) return;
306
330
 
307
- if (!this.element?.classList.toggle("expanded"))
331
+ if (this.element.classList.contains("expanded")) {
332
+ // уже открыт — закрываем чисто, чтобы и body-класс, и mouseup-листенер ушли
333
+ this.__closePopup();
308
334
  return;
335
+ }
336
+
337
+ this.element.classList.add("expanded");
309
338
 
310
339
  // закрываем все открытые попапы, кроме текущего
311
- document.querySelectorAll('.ui-dropdown.expanded').forEach((dropdown) => {
340
+ document.querySelectorAll(".ui-dropdown.expanded").forEach((dropdown) => {
312
341
  if (dropdown !== this.element) {
313
- dropdown.classList.remove('expanded');
342
+ dropdown.classList.remove("expanded");
314
343
  }
315
344
  });
316
345
 
@@ -318,17 +347,25 @@ class DropDown extends InputControl<HTMLSelectElement> {
318
347
 
319
348
  this.__positionPopup();
320
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
+
321
360
  document.body.classList.add(BODY_EXPANDED);
322
361
 
323
- let top = 0;
324
362
  const selectedElem = this.__getSelectedElem();
325
363
  const itemHeight = selectedElem?.own?.clientHeight || 0;
326
364
  const itemTop = selectedElem?.own?.offsetTop || 0;
327
365
 
328
- top = itemTop - this.__listElem.clientHeight / 2 + itemHeight;
366
+ const top = itemTop - this.__listElem.clientHeight / 2 + itemHeight;
329
367
 
330
- if (top !== null)
331
- this.__listElem.scrollTo({ left: 0, top: top, behavior: 'instant' });
368
+ this.__listElem.scrollTo({ left: 0, top: top, behavior: "instant" });
332
369
 
333
370
  document.body.addEventListener("mouseup", this.__closePopupFunc);
334
371
  }
@@ -336,8 +373,7 @@ class DropDown extends InputControl<HTMLSelectElement> {
336
373
  private __positionPopup() {
337
374
  this.__popupElem.classList.remove("top", "right");
338
375
 
339
- if (document.body.clientWidth <= TABLET_WIDTH)
340
- return;
376
+ if (document.body.clientWidth <= TABLET_WIDTH) return;
341
377
 
342
378
  const bodyHeight = document.body.clientHeight;
343
379
  const popupRect = this.__popupElem.getBoundingClientRect();
@@ -353,8 +389,10 @@ class DropDown extends InputControl<HTMLSelectElement> {
353
389
 
354
390
  private __closePopup() {
355
391
  document.body.classList.remove(BODY_EXPANDED);
356
- this.element?.classList.remove("expanded");
392
+ this.element.classList.remove("expanded");
357
393
  document.body.removeEventListener("mouseup", this.__closePopupFunc);
394
+ this.__reposAbort?.abort();
395
+ this.__reposAbort = undefined;
358
396
  }
359
397
 
360
398
  private __search(query: string) {
@@ -368,19 +406,16 @@ class DropDown extends InputControl<HTMLSelectElement> {
368
406
  this.__listElem.classList.add("result");
369
407
  const items = DOM.queryElements(this.__listElem, "li span");
370
408
 
371
-
372
409
  let findedCount = 0;
373
- items.forEach(it => {
410
+ items.forEach((it) => {
374
411
  const item = it.parentElement;
375
- if (!item)
376
- return;
412
+ if (!item) return;
377
413
 
378
- if (it.innerText.toLowerCase().startsWith(query)) {
414
+ // textContent надёжнее: innerText в браузерах может возвращать пусто/неожиданное для скрытых элементов
415
+ if ((it.textContent ?? "").toLowerCase().startsWith(query)) {
379
416
  item.classList.add("ok");
380
417
  findedCount++;
381
- }
382
- else
383
- item.classList.remove("ok");
418
+ } else item.classList.remove("ok");
384
419
  });
385
420
 
386
421
  if (!findedCount) {
@@ -391,16 +426,12 @@ class DropDown extends InputControl<HTMLSelectElement> {
391
426
  if (queryLang) {
392
427
  for (let i = 0; i < this.__listElem.children.length; i++) {
393
428
  const item = this.__listElem.children.item(i);
394
- if (!item)
395
- continue;
396
- const transcript = (<any>item)['wsdd_transcript'];
429
+ if (!item) continue;
430
+ const transcript = itemTranscripts.get(item);
397
431
  if (transcript && transcript[queryLang] && transcript[queryLang].startsWith(query)) {
398
-
399
432
  item.classList.add("ok");
400
433
  findedCount++;
401
- }
402
- else
403
- item.classList.remove("ok");
434
+ } else item.classList.remove("ok");
404
435
  }
405
436
  }
406
437
  }
@@ -408,33 +439,24 @@ class DropDown extends InputControl<HTMLSelectElement> {
408
439
  if (findedCount > 0) {
409
440
  this.__emptyElem.innerText = this.emptyText;
410
441
  this.__listElem.classList.remove("notfound");
411
- }
412
- else {
442
+ } else {
413
443
  this.__emptyElem.innerText = this.searchEmpty;
414
444
  this.__listElem.classList.add("notfound");
415
445
  }
416
446
  }
417
447
 
418
448
  private __clearSearch() {
419
- if (!this.__listElem.classList.contains("result"))
420
- return;
449
+ if (!this.__listElem.classList.contains("result")) return;
421
450
 
422
451
  this.__emptyElem.innerText = this.emptyText;
423
452
  this.__listElem.classList.remove("result", "notfound");
424
453
  DOM.removeClass(this.__listElem, "li.ok", "ok");
425
- this.__searchInput.value = '';
454
+ this.__searchInput.value = "";
426
455
  }
427
456
 
428
457
  private __getElems(selector: string): { own: HTMLElement } | null {
429
- if (!this.element)
430
- return null;
431
-
432
- const elems = DOM.queryElements(this.element, selector);
433
- if (elems.length === 2)
434
- return { own: elems.item(0) };
435
- else if (elems.length === 1)
436
- return { own: elems.item(0) };
437
- return null;
458
+ const elem = DOM.queryElement<HTMLElement>(this.element, selector);
459
+ return elem ? { own: elem } : null;
438
460
  }
439
461
 
440
462
  private __getElemsByIndex(index: number) {
@@ -459,21 +481,16 @@ class DropDown extends InputControl<HTMLSelectElement> {
459
481
  }
460
482
 
461
483
  override validate(): boolean {
462
- window.clearTimeout(this.__invalidTimeout);
463
-
464
- let value = this.getValue();
484
+ const value = this.getValue();
465
485
  let isInvalid = !this.__valueElem.validity.valid;
466
486
 
467
- if (this.required && !value)
468
- isInvalid = true;
487
+ if (this.required && !value) isInvalid = true;
469
488
 
470
- const clearInvalid = () => this.element?.classList.remove("invalid");
489
+ const clearInvalid = () => this.element.classList.remove("invalid");
471
490
 
472
491
  if (isInvalid) {
473
- this.element?.classList.add("invalid");
474
- }
475
- else
476
- clearInvalid();
492
+ this.element.classList.add("invalid");
493
+ } else clearInvalid();
477
494
 
478
495
  return !isInvalid;
479
496
  }
@@ -481,8 +498,8 @@ class DropDown extends InputControl<HTMLSelectElement> {
481
498
  override destroy(): void {
482
499
  this.__closePopup();
483
500
 
484
- this.element?.insertAdjacentElement("afterend", this.__valueElem);
485
- this.element?.remove();
501
+ this.element.insertAdjacentElement("afterend", this.__valueElem);
502
+ this.element.remove();
486
503
 
487
504
  super.destroy();
488
505
  }
@@ -495,4 +512,4 @@ export interface ChangeEventData {
495
512
  title: string | null;
496
513
  }
497
514
 
498
- 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";
@@ -0,0 +1 @@
1
+ declare module "*.less";
@@ -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
+ };