@brandup/ui-textbox 1.0.36 → 1.0.39
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 +16 -9
- package/package.json +6 -6
- package/source/index.ts +4 -0
- package/source/textbox.ts +55 -19
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
## Установка
|
|
10
10
|
|
|
11
|
-
```
|
|
11
|
+
```bash
|
|
12
12
|
npm i @brandup/ui-textbox
|
|
13
13
|
```
|
|
14
14
|
|
|
@@ -35,19 +35,19 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
35
35
|
`TextBox` принимает `<input>` со следующими значениями атрибута `type`:
|
|
36
36
|
|
|
37
37
|
| Тип | Поведение |
|
|
38
|
-
|
|
38
|
+
| --- | --- |
|
|
39
39
|
| `text` | Обычный текст |
|
|
40
|
-
| `email` |
|
|
40
|
+
| `email` | Оставляет буквы, цифры и `. - _ + @`; ограничивает длину до 256 символов (RFC 5321) — свой `maxlength` применяется, если он строже |
|
|
41
41
|
| `url` | URL-адрес |
|
|
42
42
|
| `tel` | Телефон |
|
|
43
|
-
| `number` | Только
|
|
43
|
+
| `number` | Только цифры (целые неотрицательные), вставка фильтруется |
|
|
44
44
|
|
|
45
45
|
Для многострочного ввода передайте `<textarea>`.
|
|
46
46
|
|
|
47
47
|
## Data-атрибуты
|
|
48
48
|
|
|
49
49
|
| Атрибут | Описание |
|
|
50
|
-
|
|
50
|
+
| --- | --- |
|
|
51
51
|
| `data-symbolcounter` | Показывает счётчик введённых символов (и максимума, если задан `maxlength`) |
|
|
52
52
|
| `data-autofocus` | Автофокус при инициализации (игнорируется на touch-устройствах) |
|
|
53
53
|
| `data-copy-button` | Добавляет кнопку копирования значения в буфер обмена |
|
|
@@ -55,6 +55,7 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
55
55
|
| `data-readonly` | Альтернативный способ задать режим только для чтения |
|
|
56
56
|
| `data-format` | Включает форматирование текста (только для `type="text"`) |
|
|
57
57
|
| `data-format-tools` | Состав инструментов форматирования через пробел (по умолчанию все): `bold italic strike underline` |
|
|
58
|
+
| `data-editor-actions` | Кнопки действий в панели через пробел (по умолчанию нет): `erase undo redo` |
|
|
58
59
|
| `data-format-storage` | Формат хранения значения: `html` (по умолчанию) или `markdown` |
|
|
59
60
|
| `data-format-md-bold` | Markdown-маркер для жирного (по умолчанию `**`) |
|
|
60
61
|
| `data-format-md-italic` | Markdown-маркер для курсива (по умолчанию `*`) |
|
|
@@ -83,6 +84,12 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
83
84
|
|
|
84
85
|
Список через пробел из значений `bold`, `italic`, `strike`, `underline`. Неизвестные значения игнорируются. Если атрибут не задан — включаются все инструменты.
|
|
85
86
|
|
|
87
|
+
### Кнопки действий — `data-editor-actions`
|
|
88
|
+
|
|
89
|
+
Список через пробел из значений `erase` (очистить форматирование), `undo` (отменить), `redo` (повторить). В отличие от инструментов, действия подключаются явно: без атрибута кнопок действий нет. Они добавляются в панель после инструментов форматирования, отделяются разделителем и блокируются, когда действие недоступно (нечего отменять или очищать).
|
|
90
|
+
|
|
91
|
+
`erase` снимает все форматы с выделения, а без выделения — со слова под кареткой. Чтобы очистить всё поле, выделите текст целиком (`Ctrl+A`) и нажмите кнопку.
|
|
92
|
+
|
|
86
93
|
### Применение формата
|
|
87
94
|
|
|
88
95
|
- Формат — **переключатель** (toggle): повторное применение к уже отформатированному тексту снимает его.
|
|
@@ -100,7 +107,7 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
100
107
|
Значение синхронизируется в скрытое поле в выбранном формате:
|
|
101
108
|
|
|
102
109
|
| Значение | Хранение | Поддерживаемые теги/маркеры |
|
|
103
|
-
|
|
110
|
+
| --- | --- | --- |
|
|
104
111
|
| `html` (по умолчанию) | Санитизированный HTML | `<b>`, `<i>`, `<s>`, `<u>`, переводы строк через `<br>` |
|
|
105
112
|
| `markdown` | Лёгкая разметка | `**жирный**`, `*курсив*`, `~~зачёркнутый~~`, `++подчёркнутый++` |
|
|
106
113
|
|
|
@@ -125,7 +132,7 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
125
132
|
### Методы
|
|
126
133
|
|
|
127
134
|
| Метод | Описание |
|
|
128
|
-
|
|
135
|
+
| --- | --- |
|
|
129
136
|
| `getValue(): string` | Возвращает текущее значение (обрезает пробелы по краям) |
|
|
130
137
|
| `setValue(value: string): void` | Устанавливает значение программно |
|
|
131
138
|
| `hasValue(): boolean` | `true`, если значение не пустое |
|
|
@@ -136,7 +143,7 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
|
|
|
136
143
|
### Свойства
|
|
137
144
|
|
|
138
145
|
| Свойство | Тип | Описание |
|
|
139
|
-
|
|
146
|
+
| --- | --- | --- |
|
|
140
147
|
| `type` | `TextBoxType` | Тип ввода: `"text"` \| `"email"` \| `"url"` \| `"tel"` \| `"number"` |
|
|
141
148
|
| `multyline` | `boolean` | `true` для `<textarea>` |
|
|
142
149
|
| `maxlength` | `number` | Максимальная длина (из атрибута `maxlength`) |
|
|
@@ -176,7 +183,7 @@ textbox.onChange((data) => { ... });
|
|
|
176
183
|
## CSS-классы состояний
|
|
177
184
|
|
|
178
185
|
| Класс | Условие |
|
|
179
|
-
|
|
186
|
+
| --- | --- |
|
|
180
187
|
| `focused` | Поле в фокусе |
|
|
181
188
|
| `invalid` | Значение не прошло валидацию |
|
|
182
189
|
| `incorrect` | Введён недопустимый символ (мигает, затем снимается) |
|
package/package.json
CHANGED
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"email": "it@brandup.online"
|
|
25
25
|
},
|
|
26
26
|
"license": "Apache-2.0",
|
|
27
|
-
"version": "1.0.
|
|
27
|
+
"version": "1.0.39",
|
|
28
28
|
"main": "source/index.ts",
|
|
29
29
|
"types": "source/index.ts",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@brandup/ui": "^2.0.
|
|
32
|
-
"@brandup/ui-helpers": "^2.0.
|
|
33
|
-
"@brandup/ui-input": "^1.0.
|
|
34
|
-
"@brandup/ui-kit": "^1.0.
|
|
35
|
-
"@brandup/ui-richeditor": "^1.0.
|
|
31
|
+
"@brandup/ui": "^2.0.7",
|
|
32
|
+
"@brandup/ui-helpers": "^2.0.7",
|
|
33
|
+
"@brandup/ui-input": "^1.0.39",
|
|
34
|
+
"@brandup/ui-kit": "^1.0.39",
|
|
35
|
+
"@brandup/ui-richeditor": "^1.0.39"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"source",
|
package/source/index.ts
CHANGED
|
@@ -3,11 +3,15 @@ export * from "./textbox";
|
|
|
3
3
|
|
|
4
4
|
// реэкспорт типов/утилит форматирования из @brandup/ui-richeditor для обратной совместимости
|
|
5
5
|
export {
|
|
6
|
+
ALL_EDITOR_ACTIONS,
|
|
6
7
|
ALL_FORMAT_TOOLS,
|
|
8
|
+
EDITOR_ACTIONS,
|
|
7
9
|
FORMAT_TOOLS,
|
|
10
|
+
parseEditorActions,
|
|
8
11
|
parseFormatTools,
|
|
9
12
|
defaultFormatMarkers,
|
|
10
13
|
normalizeWhitespace,
|
|
14
|
+
type EditorAction,
|
|
11
15
|
type FormatTool,
|
|
12
16
|
type FormatStorage,
|
|
13
17
|
type FormatMarkers,
|
package/source/textbox.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { DOM } from "@brandup/ui";
|
|
|
6
6
|
import { FuncHelper } from "@brandup/ui-helpers";
|
|
7
7
|
import RichEditor, {
|
|
8
8
|
defaultFormatMarkers,
|
|
9
|
+
parseEditorActions,
|
|
9
10
|
parseFormatTools,
|
|
10
11
|
type FormatMarkers,
|
|
11
12
|
type FormatStorage,
|
|
@@ -23,6 +24,11 @@ export const MAX_EMAIL_LENGTH = 256; // https://www.rfc-editor.org/rfc/rfc5321#s
|
|
|
23
24
|
|
|
24
25
|
export type TextBoxType = "text" | "email" | "url" | "tel" | "number";
|
|
25
26
|
|
|
27
|
+
// Атрибуты поля, которые компонент подменяет под себя (фокус уходит на редактируемый элемент,
|
|
28
|
+
// ограничения типа нормализуются). При destroy возвращаем их ровно в исходное состояние —
|
|
29
|
+
// иначе после снятия компонента поле остаётся с чужими ограничениями.
|
|
30
|
+
const ATTRS_TO_RESTORE = ["tabindex", "maxlength", "step"];
|
|
31
|
+
|
|
26
32
|
type TextBoxEvents = {
|
|
27
33
|
[CHANGE_EVENT]: (data: ChangeEventData) => void;
|
|
28
34
|
};
|
|
@@ -48,6 +54,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
48
54
|
readonly formatMarkers: FormatMarkers;
|
|
49
55
|
|
|
50
56
|
constructor(valueElem: HTMLInputElement | HTMLTextAreaElement) {
|
|
57
|
+
// исходные атрибуты запоминаем до любых правок — компонент их подменяет, а destroy возвращает
|
|
58
|
+
const originalAttrs = ATTRS_TO_RESTORE.map(
|
|
59
|
+
(name) => [name, valueElem.getAttribute(name)] as [string, string | null]
|
|
60
|
+
);
|
|
61
|
+
|
|
51
62
|
// определяем тип ввода и нормализуем валидационные атрибуты до super()
|
|
52
63
|
let type: TextBoxType = "text";
|
|
53
64
|
if (valueElem instanceof HTMLInputElement) {
|
|
@@ -57,7 +68,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
57
68
|
break;
|
|
58
69
|
case "email":
|
|
59
70
|
type = "email";
|
|
60
|
-
|
|
71
|
+
// у поля без атрибута maxlength свойство равно -1, а не 0, поэтому проверять
|
|
72
|
+
// нужно именно «не задан положительный предел», иначе ограничение RFC не применялось бы
|
|
73
|
+
if (valueElem.maxLength <= 0 || valueElem.maxLength > MAX_EMAIL_LENGTH)
|
|
61
74
|
valueElem.maxLength = MAX_EMAIL_LENGTH;
|
|
62
75
|
break;
|
|
63
76
|
case "url":
|
|
@@ -75,8 +88,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
90
|
|
|
78
|
-
valueElem.classList.add(INPUT_CLASS);
|
|
79
|
-
|
|
80
91
|
const maxlength = valueElem.maxLength;
|
|
81
92
|
const symbolCounter = valueElem.hasAttribute("data-symbolcounter");
|
|
82
93
|
const autoFocus = valueElem.hasAttribute("data-autofocus");
|
|
@@ -93,6 +104,8 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
93
104
|
const formatStorage: FormatStorage =
|
|
94
105
|
valueElem.getAttribute("data-format-storage") === "markdown" ? "markdown" : "html";
|
|
95
106
|
const formatTools = format ? parseFormatTools(valueElem.getAttribute("data-format-tools")) : [];
|
|
107
|
+
// кнопки действий панели (очистка формата, отмена, повтор) — подключаются явно
|
|
108
|
+
const editorActions = format ? parseEditorActions(valueElem.getAttribute("data-editor-actions")) : [];
|
|
96
109
|
|
|
97
110
|
// markdown-маркеры с дефолтами, переопределяются атрибутами data-format-md-<tool>
|
|
98
111
|
const formatMarkers = defaultFormatMarkers();
|
|
@@ -107,13 +120,13 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
107
120
|
const actionsElem = DOM.tag("div", { class: "actions" });
|
|
108
121
|
const symbolsCountElem = DOM.tag("div", { class: "symbols" });
|
|
109
122
|
|
|
110
|
-
const container = DOM.tag("div", { class:
|
|
123
|
+
const container = DOM.tag("div", { class: ROOT_CLASS }, [
|
|
111
124
|
DOM.tag("div", { class: "decorator" }),
|
|
112
125
|
DOM.tag("div", { class: "editor" }, [inputElem, symbolsCountElem]),
|
|
113
126
|
actionsElem,
|
|
114
127
|
]);
|
|
115
128
|
|
|
116
|
-
|
|
129
|
+
TextBox.prepareValueElem(valueElem, container, INPUT_CLASS);
|
|
117
130
|
|
|
118
131
|
inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
|
|
119
132
|
valueElem.tabIndex = -1;
|
|
@@ -123,9 +136,10 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
123
136
|
if (inputmode) inputElem.inputMode = inputmode;
|
|
124
137
|
|
|
125
138
|
if (copyButton) {
|
|
139
|
+
// команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui
|
|
126
140
|
const buttonElem = DOM.tag(
|
|
127
141
|
"button",
|
|
128
|
-
{ command: "copy-text", title: "Скопировать в буфер обмена" },
|
|
142
|
+
{ "data-command": "copy-text", title: "Скопировать в буфер обмена" },
|
|
129
143
|
copyIcon
|
|
130
144
|
);
|
|
131
145
|
if (disabled) buttonElem.disabled = true;
|
|
@@ -140,7 +154,8 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
140
154
|
valueElem.insertAdjacentElement("afterend", container);
|
|
141
155
|
container.insertAdjacentElement("afterbegin", valueElem);
|
|
142
156
|
|
|
143
|
-
|
|
157
|
+
// класс и подменённые атрибуты вернёт базовый класс при destroy
|
|
158
|
+
super("BrandUp.TextBox", container, valueElem, { class: INPUT_CLASS, attrs: originalAttrs });
|
|
144
159
|
|
|
145
160
|
this.type = type;
|
|
146
161
|
this.maxlength = maxlength;
|
|
@@ -164,6 +179,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
164
179
|
const options: RichEditorOptions = {
|
|
165
180
|
format,
|
|
166
181
|
tools: formatTools,
|
|
182
|
+
actions: editorActions,
|
|
167
183
|
storage: formatStorage,
|
|
168
184
|
markers: formatMarkers,
|
|
169
185
|
placeholder,
|
|
@@ -176,10 +192,12 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
176
192
|
onEnter: () => this.__submitForm(),
|
|
177
193
|
};
|
|
178
194
|
|
|
179
|
-
// допустим ли вводимый символ по
|
|
195
|
+
// допустим ли вводимый символ по типу.
|
|
196
|
+
// `+` в адресе — обычное дело (подадреса вида user+tag@example.com), без него такие
|
|
197
|
+
// адреса нельзя было бы набрать; остальные разрешённые в local-part символы редки
|
|
180
198
|
const typeAllowsChar = (char: string) => {
|
|
181
199
|
if (type === "number") return /\d/.test(char);
|
|
182
|
-
if (type === "email") return /[a-zA-Z\d.\-_
|
|
200
|
+
if (type === "email") return /[a-zA-Z\d.\-_+@]/.test(char);
|
|
183
201
|
return true;
|
|
184
202
|
};
|
|
185
203
|
|
|
@@ -208,7 +226,10 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
208
226
|
if (maxlength > 0) {
|
|
209
227
|
const selectionLength = window.getSelection()?.toString().length ?? 0;
|
|
210
228
|
const left = maxlength - this.__editor.getLength() + selectionLength;
|
|
211
|
-
|
|
229
|
+
// не влезает ни одного символа — это отказ, а не пустая вставка: иначе
|
|
230
|
+
// вставка молча не делала бы ничего, тогда как ввод символа на пределе мигает ошибкой
|
|
231
|
+
if (left <= 0) return null;
|
|
232
|
+
if (pasted.length > left) pasted = pasted.substring(0, left);
|
|
212
233
|
}
|
|
213
234
|
|
|
214
235
|
return pasted;
|
|
@@ -247,6 +268,10 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
247
268
|
this.__onChange();
|
|
248
269
|
});
|
|
249
270
|
|
|
271
|
+
// Счётчик — на каждый ввод, а не по change: длина считается по textContent и стоит копейки,
|
|
272
|
+
// тогда как значение поля синхронизируется реже (см. RichEditor.flushChange).
|
|
273
|
+
editable.addEventListener("input", () => this.__refreshSymbolsCount(), { signal });
|
|
274
|
+
|
|
250
275
|
// состояние фокуса контрола (рамка/заливка) — на корневом элементе
|
|
251
276
|
editable.addEventListener("focus", () => !this.disabled && this.element.classList.add("focused"), { signal });
|
|
252
277
|
editable.addEventListener("blur", () => !this.disabled && this.element.classList.remove("focused"), { signal });
|
|
@@ -275,7 +300,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
275
300
|
}
|
|
276
301
|
|
|
277
302
|
this.registerCommand("copy-text", async (context) => {
|
|
278
|
-
|
|
303
|
+
// повторный клик, пока показана галочка, запомнил бы её как исходную иконку —
|
|
304
|
+
// после возврата кнопка так и осталась бы с галочкой
|
|
305
|
+
if (!window.navigator.clipboard || this.disabled || context.target.classList.contains("success")) return;
|
|
279
306
|
|
|
280
307
|
await window.navigator.clipboard.writeText(this.__valueElem.value);
|
|
281
308
|
|
|
@@ -283,14 +310,26 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
283
310
|
context.target.innerHTML = doneIcon;
|
|
284
311
|
context.target.classList.add("success");
|
|
285
312
|
|
|
286
|
-
|
|
287
|
-
|
|
313
|
+
// возврат иконки отменяем вместе с компонентом: иначе таймер переживает destroy
|
|
314
|
+
// и дописывает в уже отсоединённую кнопку
|
|
315
|
+
try {
|
|
316
|
+
await FuncHelper.delay(2000, signal);
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
288
320
|
|
|
289
321
|
context.target.innerHTML = prevHtml;
|
|
290
322
|
context.target.classList.remove("success");
|
|
291
323
|
});
|
|
292
324
|
}
|
|
293
325
|
|
|
326
|
+
// Редактор откладывает событие изменения при печати, поэтому копия значения в поле формы
|
|
327
|
+
// отстаёт. Базовый класс зовёт этот хук перед каждым чтением значения снаружи —
|
|
328
|
+
// валидация, отправка формы, сбор FormData.
|
|
329
|
+
protected override __syncValue(): void {
|
|
330
|
+
this.__editor.flushChange();
|
|
331
|
+
}
|
|
332
|
+
|
|
294
333
|
private __toIncorrect() {
|
|
295
334
|
this.element.classList.add("incorrect");
|
|
296
335
|
window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
|
|
@@ -337,6 +376,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
337
376
|
}
|
|
338
377
|
|
|
339
378
|
getValue(): string {
|
|
379
|
+
this.__syncValue(); // значение читают снаружи — отложенное изменение сюда обязано попасть
|
|
340
380
|
return this.__valueElem.value.trim();
|
|
341
381
|
}
|
|
342
382
|
|
|
@@ -346,7 +386,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
346
386
|
}
|
|
347
387
|
|
|
348
388
|
override validate(): boolean {
|
|
349
|
-
let isValid = super.validate();
|
|
389
|
+
let isValid = super.validate(); // super синхронизирует значение сам, через __syncValue
|
|
350
390
|
if (isValid) {
|
|
351
391
|
const value = this.getValue();
|
|
352
392
|
|
|
@@ -367,11 +407,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
|
|
|
367
407
|
this.__listenerAbort.abort();
|
|
368
408
|
this.__editor.destroy();
|
|
369
409
|
|
|
370
|
-
|
|
371
|
-
this.element.insertAdjacentElement("afterend", this.__valueElem);
|
|
372
|
-
this.element.remove();
|
|
373
|
-
|
|
374
|
-
super.destroy();
|
|
410
|
+
super.destroy(); // снимет слушатели формы и вернёт поле-носитель в исходный вид
|
|
375
411
|
}
|
|
376
412
|
}
|
|
377
413
|
|