@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 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` | Фильтрует недопустимые символы, ограничивает длину до 256 символов (RFC 5321) |
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.36",
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.5",
32
- "@brandup/ui-helpers": "^2.0.5",
33
- "@brandup/ui-input": "^1.0.36",
34
- "@brandup/ui-kit": "^1.0.36",
35
- "@brandup/ui-richeditor": "^1.0.36"
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
- if (!valueElem.maxLength || valueElem.maxLength > MAX_EMAIL_LENGTH)
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: [ROOT_CLASS].concat(Array.from(valueElem.classList)) }, [
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
- container.classList.remove(INPUT_CLASS);
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
- super("BrandUp.TextBox", container, valueElem);
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.\-_@]/.test(char);
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
- if (pasted.length > left) pasted = pasted.substring(0, Math.max(0, left));
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
- if (!window.navigator.clipboard || this.disabled) return;
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
- const abort = new AbortController();
287
- await FuncHelper.delay(2000, abort.signal);
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
- this.__valueElem.tabIndex = this.__inputElem.tabIndex;
371
- this.element.insertAdjacentElement("afterend", this.__valueElem);
372
- this.element.remove();
373
-
374
- super.destroy();
410
+ super.destroy(); // снимет слушатели формы и вернёт поле-носитель в исходный вид
375
411
  }
376
412
  }
377
413