@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 +84 -11
- package/package.json +5 -6
- package/source/dropdown.ts +161 -145
- package/source/index.ts +2 -2
- package/source/typings/svg.d.ts +3 -3
- package/source/utils/utilities.ts +28 -31
package/README.md
CHANGED
|
@@ -1,21 +1,94 @@
|
|
|
1
|
-
# brandup
|
|
1
|
+
# @brandup/ui-dropdown
|
|
2
2
|
|
|
3
|
-
[](https://dev.azure.com/brandup/BrandUp%20Core/_build/latest?definitionId=81&branchName=main)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Компонент выпадающего списка, заменяющий стандартный `<select>`. Поддерживает поиск с транслитерацией (EN ↔ RU), адаптивный режим для планшетов и валидацию через форму.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Установка
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
npm i @brandup/ui-dropdown
|
|
10
|
+
npm i @brandup/ui-dropdown
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Использование
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
24
|
+
"version": "1.0.28",
|
|
25
25
|
"main": "source/index.ts",
|
|
26
26
|
"types": "source/index.ts",
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@brandup/ui": "^
|
|
29
|
-
"@brandup/ui-
|
|
30
|
-
"@brandup/ui-
|
|
31
|
-
"@brandup/ui-
|
|
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",
|
package/source/dropdown.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { InputControl } from "@brandup/ui-input";
|
|
2
|
-
import { DOM } from "@brandup/ui
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
this.__valueElem.classList.add(INPUT_CLASS);
|
|
47
|
+
selectElem.classList.add(INPUT_CLASS);
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
searchOn = true;
|
|
54
61
|
break;
|
|
55
62
|
case "false":
|
|
56
|
-
|
|
63
|
+
searchOn = false;
|
|
57
64
|
break;
|
|
58
65
|
default:
|
|
59
|
-
|
|
66
|
+
searchOn = parseInt(se);
|
|
60
67
|
break;
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
container.classList.remove(INPUT_CLASS);
|
|
87
106
|
|
|
88
|
-
if (
|
|
89
|
-
const nextElem =
|
|
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
|
-
|
|
94
|
-
|
|
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 =
|
|
132
|
+
const t = e.target as HTMLElement;
|
|
98
133
|
const dd = t.closest(`.${ROOT_CLASS}`);
|
|
99
|
-
if (
|
|
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
|
|
158
|
+
this.element.classList.add("empty");
|
|
119
159
|
return;
|
|
120
160
|
}
|
|
121
161
|
|
|
122
162
|
// определяем можно ли делать поиск по элементам в списке
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.
|
|
126
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
checkIcon
|
|
151
|
-
]
|
|
152
|
-
);
|
|
186
|
+
const itemSpan = DOM.tag("span", { tabindex: "0" });
|
|
187
|
+
itemSpan.textContent = itemText; // безопасно: textContent не парсит HTML
|
|
153
188
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
// дублируем быстрый элемент в общем списке, чтобы находить его при поиске
|
|
194
|
+
itemTranscripts.set(itemElem, transcriptText(itemText));
|
|
162
195
|
|
|
163
|
-
|
|
164
|
-
|
|
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,
|
|
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
|
|
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": {
|
|
283
|
-
|
|
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
|
|
306
|
-
return;
|
|
329
|
+
if (this.element.classList.contains("disabled")) return;
|
|
307
330
|
|
|
308
|
-
if (
|
|
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(
|
|
340
|
+
document.querySelectorAll(".ui-dropdown.expanded").forEach((dropdown) => {
|
|
313
341
|
if (dropdown !== this.element) {
|
|
314
|
-
dropdown.classList.remove(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
|
489
|
+
const clearInvalid = () => this.element.classList.remove("invalid");
|
|
472
490
|
|
|
473
491
|
if (isInvalid) {
|
|
474
|
-
this.element
|
|
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
|
|
486
|
-
this.element
|
|
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
|
|
2
|
-
export * from
|
|
1
|
+
export { default } from "./dropdown";
|
|
2
|
+
export * from "./utils/utilities";
|
package/source/typings/svg.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
declare module "*.svg" {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
25
|
+
[keys: string]: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
type ITranscriptMap = {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// {целевой язык: {язык ввода: словарь}}
|
|
30
|
+
[keys in Language]: { [keys: string]: ICharMap };
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
const dictionariesMap: ITranscriptMap = {
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
42
|
-
return null;
|
|
41
|
+
if (!text) return null;
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return null;
|
|
43
|
+
const match = text.match(REGEX_CHAR);
|
|
44
|
+
if (!match) return null;
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
51
|
+
return null;
|
|
55
52
|
};
|
|
56
53
|
|
|
57
54
|
export const transcriptText = (text: string) => {
|
|
58
|
-
|
|
55
|
+
const transcriptVariants: { [keys: string]: string } = {};
|
|
59
56
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const transcript = textArr.reduce((prev, current) => prev + (langVariant[current] || current), "");
|
|
64
|
+
transcriptVariants[lang] = transcript;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
70
67
|
|
|
71
|
-
|
|
72
|
-
};
|
|
68
|
+
return transcriptVariants;
|
|
69
|
+
};
|