@brandup/ui-textbox 1.0.48 → 1.0.50

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
@@ -56,7 +56,7 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
56
56
  | `data-format` | Включает форматирование текста (только для `type="text"`) |
57
57
  | `data-format-tools` | Состав инструментов форматирования через пробел (по умолчанию все): `bold italic strike underline spoiler code` |
58
58
  | `data-editor-actions` | Кнопки действий в панели через пробел (по умолчанию нет): `erase undo redo` |
59
- | `data-blocks` | Типы блоков многострочного поля через пробел (по умолчанию все): `quote code`; пустое значение оставляет только обычный текст |
59
+ | `data-blocks` | Типы блоков многострочного поля через пробел: `quote code`. Подключаются только объявлением без атрибута поле остаётся обычным текстом, и панель не показывается |
60
60
  | `data-format-storage` | Формат хранения значения: `html` (по умолчанию) или `markdown` |
61
61
  | `data-format-md-<tool>` | Markdown-маркер инструмента: `bold` — `**`, `italic` — `_`, `strike` — `~`, `underline` — `__`, `spoiler` — `\|\|`, `code` — `` ` `` |
62
62
 
@@ -64,8 +64,6 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
64
64
 
65
65
  Атрибут `data-format` включает панель форматирования с инструментами: жирный, курсив, зачёркивание, подчёркивание, спойлер, моноширинный. Панель всплывает над контролом, пока он в фокусе (класс состояния `focused`). Работает поверх существующего `contenteditable`-редактора, поэтому доступно только для текстового ввода (`type="text"` и `<textarea>`).
66
66
 
67
- Кнопка спойлера в панели временно скрыта (см. [`@brandup/ui-richeditor`](../brandup-ui-richeditor)): сам инструмент работает — значение разбирается, показывается и сохраняется.
68
-
69
67
  ```html
70
68
  <!-- все инструменты, хранение в HTML -->
71
69
  <textarea data-content-script="textbox" data-format></textarea>
@@ -92,12 +90,14 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
92
90
 
93
91
  ### Блоки — `data-blocks`
94
92
 
95
- В многострочном поле (`<textarea>`) верхний уровень состоит из блоков. Обычный текст доступен всегда, цитата (`quote`) и блок кода (`code`) тоже; набор ограничивают списком через пробел:
93
+ В многострочном поле (`<textarea>`) верхний уровень состоит из блоков. Обычный текст доступен всегда, а цитата (`quote`) и блок кода (`code`) подключаются явно списком через пробел:
96
94
 
97
95
  ```html
98
96
  <textarea data-content-script="textbox" data-format data-blocks="quote"></textarea>
99
97
  ```
100
98
 
99
+ Без атрибута поле остаётся обычным текстом, и панель форматирования сама по себе не появляется — как и действия, блоки включают объявлением. Пустое значение атрибута равносильно его отсутствию.
100
+
101
101
  **Enter** внутри блока заканчивает его, **Shift+Enter** переносит строку внутри, **Backspace** в начале блока возвращает обычный текст. Разбор, хранение и правку блоков описывает [`@brandup/ui-richeditor`](../brandup-ui-richeditor).
102
102
 
103
103
  ### Применение формата
@@ -150,7 +150,7 @@ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
150
150
  | `setValue(value: string): void` | Устанавливает значение программно |
151
151
  | `hasValue(): boolean` | `true`, если значение не пустое |
152
152
  | `validate(): boolean` | Валидирует значение, добавляет/снимает класс `invalid` |
153
- | `focus(): void` | Устанавливает фокус, прокручивает в видимую область |
153
+ | `focus(): void` | Ведёт фокус в редактор и прокручивает контрол в видимую область. В выключенном поле ничего не делает — как нативный `disabled` input; поле только для чтения фокусируется |
154
154
  | `destroy(): void` | Восстанавливает исходный DOM-элемент и освобождает ресурсы |
155
155
 
156
156
  ### Свойства
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.48",
27
+ "version": "1.0.50",
28
28
  "main": "source/index.ts",
29
29
  "types": "source/index.ts",
30
30
  "dependencies": {
31
31
  "@brandup/ui": "^2.0.9",
32
32
  "@brandup/ui-helpers": "^2.0.9",
33
- "@brandup/ui-input": "^1.0.48",
34
- "@brandup/ui-kit": "^1.0.48",
35
- "@brandup/ui-richeditor": "^1.0.48"
33
+ "@brandup/ui-input": "^1.0.50",
34
+ "@brandup/ui-kit": "^1.0.50",
35
+ "@brandup/ui-richeditor": "^1.0.50"
36
36
  },
37
37
  "files": [
38
38
  "source",
@@ -1,26 +1,31 @@
1
+ @import (reference) "@brandup/ui-input/source/input.less";
2
+
3
+ // Значения по умолчанию у переменных кита — их же канонические значения (см. vars.less кита):
4
+ // без темы кита контрол выглядит так же, как с темой по умолчанию, а не разваливается.
5
+
1
6
  :root {
2
7
  --textbox-maxheight: 200px;
3
8
  --textbox-multyline-height: 100px;
4
9
  }
5
10
 
6
11
  .ui-textbox {
7
- min-height: var(--input-height);
12
+ min-height: var(--input-height, 46px);
8
13
  max-height: var(--textbox-maxheight);
9
14
  display: flex;
10
15
  flex-flow: row nowrap;
11
16
  align-items: stretch;
12
17
  position: relative;
13
- background-color: var(--input-fill);
14
- border-radius: var(--input-border-radius); // чтобы заливка при состояниях не выходила за пределы импута
18
+ background-color: var(--input-fill, #fff);
19
+ border-radius: var(--input-border-radius, 0); // чтобы заливка при состояниях не выходила за пределы импута
15
20
  box-sizing: border-box;
16
21
  outline: none;
17
22
  transition: background-color ease 60ms;
18
- line-height: var(--input-line-height);
19
- font-size: var(--input-font-size);
20
- font-weight: var(--input-font-weight);
21
- color: var(--input-color);
23
+ line-height: var(--input-line-height, 1.2rem);
24
+ font-size: var(--input-font-size, 14px);
25
+ font-weight: var(--input-font-weight, inherit);
26
+ color: var(--input-color, #222);
22
27
 
23
- --svg-fill: var(--input-color);
28
+ --svg-fill: var(--input-color, #222);
24
29
  --svg-size: 18px;
25
30
 
26
31
  & .decorator {
@@ -30,8 +35,8 @@
30
35
  top: 0;
31
36
  width: 100%;
32
37
  height: 100%;
33
- border: var(--input-border-type) var(--input-border-width) var(--input-border-color);
34
- border-radius: var(--input-border-radius);
38
+ border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
39
+ border-radius: var(--input-border-radius, 0);
35
40
  z-index: 1;
36
41
  box-sizing: border-box;
37
42
  transition: border-color ease 120ms;
@@ -42,13 +47,11 @@
42
47
  display: flex;
43
48
  flex-flow: row nowrap;
44
49
  gap: 15px;
45
- padding: calc((var(--input-height) - var(--input-line-height)) / 2) var(--input-padding-lr);
50
+ padding: calc((var(--input-height, 46px) - var(--input-line-height, 1.2rem)) / 2) var(--input-padding-lr, 15px);
46
51
  box-sizing: border-box;
47
52
  cursor: text;
48
53
  position: relative;
49
54
  z-index: 2;
50
- margin-right: 5px;
51
- padding-right: calc(var(--input-padding-lr) - 5px);
52
55
  overflow-y: auto; // полоса прокрутки — общая, от .ui-scrollable кита
53
56
  }
54
57
 
@@ -81,15 +84,15 @@
81
84
  display: flex;
82
85
  flex-flow: row nowrap;
83
86
  align-items: center;
84
- height: var(--input-height);
87
+ height: var(--input-height, 46px);
85
88
 
86
89
  & button {
87
90
  display: flex;
88
91
  flex-flow: row nowrap;
89
92
  justify-content: center;
90
93
  align-items: center;
91
- width: var(--input-height);
92
- height: var(--input-height);
94
+ width: var(--input-height, 46px);
95
+ height: var(--input-height, 46px);
93
96
  background: transparent;
94
97
  border: none;
95
98
  padding: 0;
@@ -100,7 +103,7 @@
100
103
  opacity: 0.7;
101
104
 
102
105
  &.success {
103
- --svg-fill: var(--color-success);
106
+ --svg-fill: var(--color-success, green);
104
107
  }
105
108
 
106
109
  &:hover,
@@ -143,30 +146,30 @@
143
146
  }
144
147
 
145
148
  &:hover {
146
- --input-border-color: var(--hover--input-border-color);
147
- --input-fill: var(--hover--input-fill);
148
- --input-color: var(--hover--input-color);
149
- --svg-fill: var(--hover--input-color);
149
+ --input-border-color: var(--hover--input-border-color, #666);
150
+ --input-fill: var(--hover--input-fill, #fff);
151
+ --input-color: var(--hover--input-color, inherit);
152
+ --svg-fill: var(--hover--input-color, inherit);
150
153
  }
151
154
 
152
155
  &.focused {
153
- --input-border-color: var(--focus--input-border-color);
154
- --input-fill: var(--focus--input-fill);
155
- --input-color: var(--focus--input-color);
156
- --svg-fill: var(--focus--input-color);
156
+ --input-border-color: var(--focus--input-border-color, #222);
157
+ --input-fill: var(--focus--input-fill, #fff);
158
+ --input-color: var(--focus--input-color, inherit);
159
+ --svg-fill: var(--focus--input-color, inherit);
157
160
  }
158
161
 
159
162
  &.readonly {
160
- --input-fill: var(--readonly--input-fill);
161
- --input-color: var(--readonly--input-color);
162
- --svg-fill: var(--readonly--input-color);
163
+ --input-fill: var(--readonly--input-fill, #f7f7f7);
164
+ --input-color: var(--readonly--input-color, #222);
165
+ --svg-fill: var(--readonly--input-color, #222);
163
166
  }
164
167
 
165
168
  &.disabled {
166
- --input-border-color: var(--disabled--input-border-color);
167
- --input-fill: var(--disabled--input-fill);
168
- --input-color: var(--disabled--input-color);
169
- --svg-fill: var(--disabled--input-color);
169
+ --input-border-color: var(--disabled--input-border-color, #aaa);
170
+ --input-fill: var(--disabled--input-fill, #eee);
171
+ --input-color: var(--disabled--input-color, #a2a2a2);
172
+ --svg-fill: var(--disabled--input-color, #a2a2a2);
170
173
 
171
174
  & .editor {
172
175
  user-select: none;
@@ -178,42 +181,38 @@
178
181
  cursor: default;
179
182
 
180
183
  & svg {
181
- fill: var(--disabled--input-color);
184
+ fill: var(--disabled--input-color, #a2a2a2);
182
185
  }
183
186
  }
184
187
  }
185
188
  }
186
189
 
187
190
  &.invalid {
188
- --input-border-color: var(--invalid--input-border-color);
189
- --input-fill: var(--invalid--input-fill);
190
- --input-color: var(--invalid--input-color);
191
- --placeholder-color: var(--invalid--input-color);
192
- --svg-fill: var(--invalid--input-color);
191
+ --input-border-color: var(--invalid--input-border-color, red);
192
+ --input-fill: var(--invalid--input-fill, rgb(255, 220, 220));
193
+ --input-color: var(--invalid--input-color, inherit);
194
+ --placeholder-color: var(--invalid--input-color, inherit);
195
+ --svg-fill: var(--invalid--input-color, inherit);
193
196
 
194
197
  & .decorator {
195
- transition-delay: 0;
198
+ transition-delay: 0s;
196
199
  }
197
200
  }
198
201
 
199
202
  &.incorrect {
200
- --input-fill: var(--incorrect--input-fill);
201
- --input-color: var(--incorrect--input-color);
202
- --svg-fill: var(--incorrect--input-color);
203
+ --input-fill: var(--incorrect--input-fill, rgb(255, 220, 220));
204
+ --input-color: var(--incorrect--input-color, #222);
205
+ --svg-fill: var(--incorrect--input-color, #222);
203
206
 
204
207
  & .decorator {
205
- transition-delay: 0;
208
+ transition-delay: 0s;
206
209
  }
207
210
  }
208
211
  }
209
212
 
213
+ // поле уведено с экрана, но остаётся в форме — общий рецепт из @brandup/ui-input
210
214
  .textbox-input {
211
- opacity: 0;
212
- position: absolute;
213
- width: 1px;
214
- height: 1px;
215
- outline: none;
216
- visibility: collapse;
215
+ .ui-input-hidden-value();
217
216
  }
218
217
 
219
218
  textarea.textbox-input ~ .textbox-miniature {
@@ -221,9 +220,9 @@ textarea.textbox-input ~ .textbox-miniature {
221
220
  }
222
221
 
223
222
  .textbox-miniature {
224
- border: var(--input-border-type) var(--input-border-width) var(--input-border-color);
225
- border-radius: var(--input-border-radius);
226
- height: var(--input-height);
223
+ border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
224
+ border-radius: var(--input-border-radius, 0);
225
+ height: var(--input-height, 46px);
227
226
  background: white;
228
227
  box-sizing: border-box;
229
228
  }
package/source/textbox.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import "./textbox.less"; // стили компонента
2
2
 
3
- import { InputControl } from "@brandup/ui-input";
4
- import { IS_TOUCH_DEVICE, SCROLLABLE_CLASS } from "@brandup/ui-kit";
3
+ import { EditorInputControl } from "@brandup/ui-input";
4
+ import { IS_TOUCH_DEVICE, POPUP_CLASS, SCROLLABLE_CLASS } from "@brandup/ui-kit";
5
5
  import { DOM } from "@brandup/ui";
6
6
  import { FuncHelper } from "@brandup/ui-helpers";
7
7
  import RichEditor, {
8
+ TOOLBAR_CLASS,
8
9
  defaultFormatMarkers,
9
10
  parseBlockTypes,
10
11
  parseEditorActions,
@@ -34,11 +35,10 @@ type TextBoxEvents = {
34
35
  [CHANGE_EVENT]: (data: ChangeEventData) => void;
35
36
  };
36
37
 
37
- export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAreaElement, TextBoxEvents> {
38
- private __editor: RichEditor;
38
+ export default class TextBox extends EditorInputControl<RichEditor, ChangeEventData, TextBoxEvents> {
39
39
  private __inputElem: HTMLElement; // редактируемый элемент (им владеет RichEditor)
40
40
  private __symbolsCountElem: HTMLElement;
41
- private __listenerAbort = new AbortController();
41
+ private __incorrectTimer?: number;
42
42
 
43
43
  readonly type: TextBoxType;
44
44
  readonly allowEmptyStrings: boolean;
@@ -98,7 +98,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
98
98
  const multyline = valueElem instanceof HTMLTextAreaElement;
99
99
  const copyButton = valueElem.hasAttribute("data-copy-button") || valueElem.hasAttribute("data-copybutton");
100
100
  const disabled = valueElem.disabled;
101
- const readonly = valueElem.hasAttribute("readonly") || valueElem.hasAttribute("data-readonly");
101
+ const readonly = TextBox.isReadonly(valueElem);
102
102
 
103
103
  // форматирование доступно только для обычного текстового ввода
104
104
  const format = type === "text" && valueElem.hasAttribute("data-format");
@@ -106,8 +106,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
106
106
  const formatTools = format ? parseFormatTools(valueElem.dataset.formatTools ?? null) : [];
107
107
  // кнопки действий панели (очистка формата, отмена, повтор) — подключаются явно
108
108
  const editorActions = format ? parseEditorActions(valueElem.dataset.editorActions ?? null) : [];
109
- // типы блоков (цитата, блок кода) — тоже явно и только в многострочном поле
110
- const blocks = parseBlockTypes(multyline ? (valueElem.dataset.blocks ?? null) : null);
109
+ // Типы блоков (цитата, блок кода) — тоже явно и только в многострочном поле. Без атрибута
110
+ // список пуст, а не «не задан»: незаданный редактор понимает как «все», и в поле без единого
111
+ // объявления всплывала бы панель с кнопками цитаты и кода.
112
+ const blocks =
113
+ multyline && valueElem.dataset.blocks !== undefined ? parseBlockTypes(valueElem.dataset.blocks) : [];
111
114
 
112
115
  // markdown-маркеры с дефолтами, переопределяются атрибутами data-format-md-<tool>
113
116
  const formatMarkers = defaultFormatMarkers();
@@ -129,36 +132,39 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
129
132
  actionsElem,
130
133
  ]);
131
134
 
132
- TextBox.prepareValueElem(valueElem, container, INPUT_CLASS);
133
-
134
- inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
135
- valueElem.tabIndex = -1;
136
-
137
135
  if (multyline) container.classList.add("multyline");
138
136
  if (symbolCounter) container.classList.add("counter");
139
137
  if (inputmode) inputElem.inputMode = inputmode;
140
138
 
141
139
  if (copyButton) {
142
- // команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui
140
+ // команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui;
141
+ // type обязателен: кнопка без него внутри формы — submit, и клик отправлял бы форму
143
142
  const buttonElem = DOM.tag(
144
143
  "button",
145
- { command: "copy-text", title: "Скопировать в буфер обмена" },
144
+ { type: "button", command: "copy-text", title: "Скопировать в буфер обмена" },
146
145
  copyIcon
147
146
  );
148
147
  if (disabled) buttonElem.disabled = true;
149
148
  actionsElem.insertAdjacentElement("beforeend", buttonElem);
150
149
  }
151
150
 
152
- // убираем висящую миниатюру, если есть, и вставляем container на место valueElem
151
+ // убираем висящую миниатюру, если есть, до обёртки поля: после неё соседом поля станет контейнер
153
152
  if (valueElem.nextElementSibling) {
154
153
  const nextElem = valueElem.nextElementSibling as HTMLElement;
155
154
  if (nextElem.classList.contains(MINIATURE_CLASS)) nextElem.remove();
156
155
  }
157
- valueElem.insertAdjacentElement("afterend", container);
158
- container.insertAdjacentElement("afterbegin", valueElem);
156
+
157
+ // скрыть поле, подменить tabindex и обернуть контейнером — общая механика базового класса
158
+ TextBox.wrapValueElem(valueElem, container, INPUT_CLASS, inputElem, disabled);
159
159
 
160
160
  // класс и подменённые атрибуты вернёт базовый класс при destroy
161
- super("BrandUp.TextBox", container, valueElem, { class: INPUT_CLASS, attrs: originalAttrs });
161
+ super(
162
+ "BrandUp.TextBox",
163
+ container,
164
+ valueElem,
165
+ { class: INPUT_CLASS, attrs: originalAttrs },
166
+ { changeEvent: CHANGE_EVENT }
167
+ );
162
168
 
163
169
  this.type = type;
164
170
  this.maxlength = maxlength;
@@ -189,6 +195,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
189
195
  multiline: multyline,
190
196
  blocks,
191
197
  readonly,
198
+ // disabled редактор знает сам: запрет правок как в readonly, плюс снятый contenteditable —
199
+ // без фокуса и выделения (визуал даёт класс .disabled от InputControl)
200
+ disabled,
192
201
  // тулбар позиционируется относительно контейнера TextBox (а не document.body)
193
202
  toolbarContainer: container,
194
203
  value: valueElem.value,
@@ -207,16 +216,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
207
216
 
208
217
  if (type === "number" || type === "email" || maxlength > 0) {
209
218
  options.filterChar = (char) => {
210
- // достигнут лимит длины — отклоняем (выделение будет заменено, поэтому вычитаем его длину)
211
- if (maxlength > 0) {
212
- const selectionLength = window.getSelection()?.toString().length ?? 0;
213
- if (this.__editor.getLength() - selectionLength >= maxlength) return false;
214
- }
219
+ // достигнут лимит длины — отклоняем (выделение будет заменено, его длину учитывает helper)
220
+ if (maxlength > 0 && this.__lengthLeft() <= 0) return false;
215
221
  return typeAllowsChar(char);
216
222
  };
217
- }
218
223
 
219
- if (type === "number" || maxlength > 0) {
220
224
  options.filterPaste = (text) => {
221
225
  let pasted = text;
222
226
 
@@ -224,12 +228,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
224
228
  const numberData = /[\d\s]+/.exec(pasted);
225
229
  if (!numberData || !numberData.length) return null;
226
230
  pasted = numberData[0].replace(/\s/g, "");
231
+ } else if (type === "email") {
232
+ // вставка проходит тот же посимвольный фильтр, что и набор, — иначе запрещённые
233
+ // символы попадали бы в поле через буфер обмена
234
+ pasted = Array.from(pasted).filter(typeAllowsChar).join("");
235
+ if (!pasted) return null;
227
236
  }
228
237
 
229
238
  // обрезаем по количеству оставшихся символов (с учётом замены выделения)
230
239
  if (maxlength > 0) {
231
- const selectionLength = window.getSelection()?.toString().length ?? 0;
232
- const left = maxlength - this.__editor.getLength() + selectionLength;
240
+ const left = this.__lengthLeft();
233
241
  // не влезает ни одного символа — это отказ, а не пустая вставка: иначе
234
242
  // вставка молча не делала бы ничего, тогда как ввод символа на пределе мигает ошибкой
235
243
  if (left <= 0) return null;
@@ -240,17 +248,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
240
248
  };
241
249
  }
242
250
 
243
- this.__editor = new RichEditor(inputElem, options);
244
-
245
- // RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
246
- // (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
247
- if (disabled) inputElem.contentEditable = "false";
251
+ this.__attachEditor(new RichEditor(inputElem, options));
248
252
 
249
253
  // синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
250
254
  this.__valueElem.value = this.__editor.getValue();
251
255
 
252
256
  this.__initLogic();
253
257
  this.__refreshSymbolsCount();
258
+ // значение из разметки может уже нарушать лимит длины — объявляем это полю сразу,
259
+ // не дожидаясь первой синхронизации
260
+ this.__refreshValidity();
254
261
 
255
262
  if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
256
263
  }
@@ -274,24 +281,19 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
274
281
 
275
282
  // Счётчик — на каждый ввод, а не по change: длина считается по textContent и стоит копейки,
276
283
  // тогда как значение поля синхронизируется реже (см. RichEditor.flushChange).
277
- editable.addEventListener("input", () => this.__refreshSymbolsCount(), { signal });
278
-
279
- // состояние фокуса контрола (рамка/заливка) — на корневом элементе
280
- editable.addEventListener("focus", () => !this.disabled && this.element.classList.add("focused"), { signal });
281
- editable.addEventListener("blur", () => !this.disabled && this.element.classList.remove("focused"), { signal });
282
-
283
- // гасим нативный change скрытого поля
284
- this.__valueElem.addEventListener(
285
- "change",
286
- (e: Event) => {
287
- e.preventDefault();
288
- e.stopImmediatePropagation();
289
- },
290
- { signal }
291
- );
284
+ // Выключенный счётчик скрыт пересчитывать его на каждое нажатие незачем.
285
+ if (this.symbolCounter) editable.addEventListener("input", () => this.__refreshSymbolsCount(), { signal });
286
+
287
+ // Клик мимо текста тоже клик по полю: контрол выглядит одним полем ввода, а его
288
+ // внутренние отступы, место справа от текста и полоса действий в редактируемый
289
+ // элемент не входят.
290
+ this.element.addEventListener("mousedown", (e) => this.__focusFromBox(e), { signal });
291
+
292
+ // таймер вспышки "incorrect" не должен переживать компонент — гасим его вместе со слушателями
293
+ signal.addEventListener("abort", () => window.clearTimeout(this.__incorrectTimer));
292
294
 
293
- // двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
294
295
  if (this.copyButton) {
296
+ // двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
295
297
  editable.addEventListener(
296
298
  "dblclick",
297
299
  () => {
@@ -301,38 +303,56 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
301
303
  },
302
304
  { signal }
303
305
  );
304
- }
305
306
 
306
- this.registerCommand("copy-text", async (context) => {
307
- // повторный клик, пока показана галочка, запомнил бы её как исходную иконку —
308
- // после возврата кнопка так и осталась бы с галочкой
309
- if (!window.navigator.clipboard || this.disabled || context.target.classList.contains("success")) return;
307
+ this.registerCommand("copy-text", async (context) => {
308
+ // повторный клик, пока показана галочка, запомнил бы её как исходную иконку —
309
+ // после возврата кнопка так и осталась бы с галочкой
310
+ if (!window.navigator.clipboard || this.disabled || context.target.classList.contains("success"))
311
+ return;
312
+
313
+ // копия значения в поле отстаёт на окно троттлинга — доводим её перед чтением
314
+ this.__syncValue();
315
+ await window.navigator.clipboard.writeText(this.__valueElem.value);
316
+
317
+ const prevHtml = context.target.innerHTML;
318
+ context.target.innerHTML = doneIcon;
319
+ context.target.classList.add("success");
320
+
321
+ // возврат иконки отменяем вместе с компонентом: иначе таймер переживает destroy
322
+ // и дописывает в уже отсоединённую кнопку
323
+ try {
324
+ await FuncHelper.delay(2000, signal);
325
+ } catch {
326
+ return;
327
+ }
310
328
 
311
- await window.navigator.clipboard.writeText(this.__valueElem.value);
329
+ context.target.innerHTML = prevHtml;
330
+ context.target.classList.remove("success");
331
+ });
332
+ }
333
+ }
312
334
 
313
- const prevHtml = context.target.innerHTML;
314
- context.target.innerHTML = doneIcon;
315
- context.target.classList.add("success");
335
+ /**
336
+ * Фокус по клику в контрол мимо текста: внутренние отступы, место справа от последней
337
+ * строки и полоса действий в редактируемый элемент не входят, а выглядит всё это одним
338
+ * полем ввода.
339
+ *
340
+ * Гасим нажатие: браузер иначе снял бы выделение и увёл фокус на корневой элемент, где
341
+ * каретке места нет. Каретку редактор вернёт на прежнее место, а если её ещё не было —
342
+ * в конец текста: клик мимо текста это клик за ним.
343
+ */
344
+ private __focusFromBox(e: MouseEvent) {
345
+ // в выключенном поле редактируемого элемента нет вовсе — фокусировать нечего
346
+ if (this.disabled) return;
316
347
 
317
- // возврат иконки отменяем вместе с компонентом: иначе таймер переживает destroy
318
- // и дописывает в уже отсоединённую кнопку
319
- try {
320
- await FuncHelper.delay(2000, signal);
321
- } catch {
322
- return;
323
- }
348
+ const target = e.target as HTMLElement | null;
349
+ if (!target || this.__inputElem.contains(target)) return; // в сам текст браузер попадёт и сам
324
350
 
325
- context.target.innerHTML = prevHtml;
326
- context.target.classList.remove("success");
327
- });
328
- }
351
+ // кнопка копирования, панель форматирования и её попапы живут внутри контрола и работают сами
352
+ if (target.closest(`button, a, input, textarea, select, .${TOOLBAR_CLASS}, .${POPUP_CLASS}`)) return;
329
353
 
330
- // Редактор откладывает событие изменения при печати, поэтому копия значения в поле формы
331
- // отстаёт. Базовый класс зовёт этот хук перед каждым чтением значения снаружи —
332
- // валидация, отправка формы, сбор FormData.
333
- protected override __syncValue(): void {
334
- this.__editor.flushChange();
335
- this.__refreshValidity();
354
+ e.preventDefault();
355
+ this.__editor.focus(true);
336
356
  }
337
357
 
338
358
  /**
@@ -344,20 +364,39 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
344
364
  * разделители абзацев. Нативный maxLength здесь не помощник: он ограничивает набор, но не
345
365
  * помечает значение, выставленное из кода.
346
366
  */
347
- private __refreshValidity(): void {
367
+ protected override __refreshValidity(): void {
348
368
  if (this.maxlength <= 0) return;
349
369
 
350
370
  const tooLong = this.maxlength < this.__editor.getLength();
351
371
  this.__valueElem.setCustomValidity(tooLong ? `Не больше ${this.maxlength} символов.` : "");
352
372
  }
353
373
 
374
+ /**
375
+ * Сколько символов ещё поместится с учётом того, что выделение будет заменено вводом.
376
+ *
377
+ * Выделение берём у самого редактора: чужое выделение страницы (например, при вставке
378
+ * с отсоединённой кареткой) к содержимому отношения не имеет и ёмкость не освобождает.
379
+ * Длину выделения меряем так же, как getLength(), — по textContent, без переносов на границах
380
+ * блоков, которые toString() у Selection добавляет, — иначе многострочное выделение
381
+ * считалось бы длиннее содержимого.
382
+ */
383
+ private __lengthLeft(): number {
384
+ const selection = this.__editor.selection;
385
+ const selectionLength = selection ? (selection.getRangeAt(0).cloneContents().textContent?.length ?? 0) : 0;
386
+
387
+ return this.maxlength - this.__editor.getLength() + selectionLength;
388
+ }
389
+
354
390
  private __toIncorrect() {
355
391
  this.element.classList.add("incorrect");
356
- window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
392
+
393
+ // каждый отказ перезапускает таймер: иначе таймер предыдущего отказа гасил бы вспышку раньше
394
+ window.clearTimeout(this.__incorrectTimer);
395
+ this.__incorrectTimer = window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
357
396
  }
358
397
 
359
398
  private __refreshSymbolsCount() {
360
- if (!this.__symbolsCountElem) return;
399
+ if (!this.symbolCounter) return;
361
400
 
362
401
  const textLength = this.__editor.getLength();
363
402
  let counterValue: string;
@@ -388,24 +427,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
388
427
  return this.__editor;
389
428
  }
390
429
 
391
- onChange(handler: (e: ChangeEventData) => void) {
392
- this.on(CHANGE_EVENT, handler);
393
- }
394
-
395
- hasValue(): boolean {
396
- return !!this.getValue();
397
- }
398
-
399
- getValue(): string {
400
- this.__syncValue(); // значение читают снаружи — отложенное изменение сюда обязано попасть
401
- return this.__valueElem.value.trim();
402
- }
403
-
404
- setValue(value: string): void {
405
- // RichEditor нормализует и сгенерирует change — он синхронизирует скрытое поле и вызовет textbox-change
406
- this.__editor.setValue(value?.trim() ?? "");
407
- }
408
-
409
430
  override validate(): boolean {
410
431
  let isValid = super.validate(); // super синхронизирует значение сам, через __syncValue
411
432
  if (isValid) {
@@ -423,13 +444,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
423
444
 
424
445
  return isValid;
425
446
  }
426
-
427
- override destroy(): void {
428
- this.__listenerAbort.abort();
429
- this.__editor.destroy();
430
-
431
- super.destroy(); // снимет слушатели формы и вернёт поле-носитель в исходный вид
432
- }
433
447
  }
434
448
 
435
449
  export interface ChangeEventData {