@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 +84 -11
- package/package.json +8 -9
- package/source/dropdown.ts +162 -145
- package/source/index.ts +2 -2
- package/source/typings/less.d.ts +1 -0
- 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
|
@@ -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.
|
|
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.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",
|
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,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
|
|
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
|
-
|
|
47
|
+
selectElem.classList.add(INPUT_CLASS);
|
|
41
48
|
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
60
|
+
searchOn = true;
|
|
54
61
|
break;
|
|
55
62
|
case "false":
|
|
56
|
-
|
|
63
|
+
searchOn = false;
|
|
64
|
+
break;
|
|
57
65
|
default:
|
|
58
|
-
|
|
66
|
+
searchOn = parseInt(se);
|
|
59
67
|
break;
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
container.classList.remove(INPUT_CLASS);
|
|
86
106
|
|
|
87
|
-
if (
|
|
88
|
-
const nextElem =
|
|
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
|
-
|
|
93
|
-
|
|
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 =
|
|
132
|
+
const t = e.target as HTMLElement;
|
|
97
133
|
const dd = t.closest(`.${ROOT_CLASS}`);
|
|
98
|
-
if (
|
|
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
|
|
158
|
+
this.element.classList.add("empty");
|
|
118
159
|
return;
|
|
119
160
|
}
|
|
120
161
|
|
|
121
162
|
// определяем можно ли делать поиск по элементам в списке
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
this.
|
|
125
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
checkIcon
|
|
150
|
-
]
|
|
151
|
-
);
|
|
186
|
+
const itemSpan = DOM.tag("span", { tabindex: "0" });
|
|
187
|
+
itemSpan.textContent = itemText; // безопасно: textContent не парсит HTML
|
|
152
188
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
// дублируем быстрый элемент в общем списке, чтобы находить его при поиске
|
|
194
|
+
itemTranscripts.set(itemElem, transcriptText(itemText));
|
|
161
195
|
|
|
162
|
-
|
|
163
|
-
|
|
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,
|
|
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
|
|
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": {
|
|
282
|
-
|
|
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
|
|
305
|
-
return;
|
|
329
|
+
if (this.element.classList.contains("disabled")) return;
|
|
306
330
|
|
|
307
|
-
if (
|
|
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(
|
|
340
|
+
document.querySelectorAll(".ui-dropdown.expanded").forEach((dropdown) => {
|
|
312
341
|
if (dropdown !== this.element) {
|
|
313
|
-
dropdown.classList.remove(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
489
|
+
const clearInvalid = () => this.element.classList.remove("invalid");
|
|
471
490
|
|
|
472
491
|
if (isInvalid) {
|
|
473
|
-
this.element
|
|
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
|
|
485
|
-
this.element
|
|
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
|
|
2
|
-
export * from
|
|
1
|
+
export { default } from "./dropdown";
|
|
2
|
+
export * from "./utils/utilities";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "*.less";
|
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
|
+
};
|