@brandup/ui-textbox 1.0.48 → 1.0.49

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
@@ -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.49",
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.49",
34
+ "@brandup/ui-kit": "^1.0.49",
35
+ "@brandup/ui-richeditor": "^1.0.49"
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,13 @@
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
55
  margin-right: 5px;
51
- padding-right: calc(var(--input-padding-lr) - 5px);
56
+ padding-right: calc(var(--input-padding-lr, 15px) - 5px);
52
57
  overflow-y: auto; // полоса прокрутки — общая, от .ui-scrollable кита
53
58
  }
54
59
 
@@ -81,15 +86,15 @@
81
86
  display: flex;
82
87
  flex-flow: row nowrap;
83
88
  align-items: center;
84
- height: var(--input-height);
89
+ height: var(--input-height, 46px);
85
90
 
86
91
  & button {
87
92
  display: flex;
88
93
  flex-flow: row nowrap;
89
94
  justify-content: center;
90
95
  align-items: center;
91
- width: var(--input-height);
92
- height: var(--input-height);
96
+ width: var(--input-height, 46px);
97
+ height: var(--input-height, 46px);
93
98
  background: transparent;
94
99
  border: none;
95
100
  padding: 0;
@@ -100,7 +105,7 @@
100
105
  opacity: 0.7;
101
106
 
102
107
  &.success {
103
- --svg-fill: var(--color-success);
108
+ --svg-fill: var(--color-success, green);
104
109
  }
105
110
 
106
111
  &:hover,
@@ -143,30 +148,30 @@
143
148
  }
144
149
 
145
150
  &: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);
151
+ --input-border-color: var(--hover--input-border-color, #666);
152
+ --input-fill: var(--hover--input-fill, #fff);
153
+ --input-color: var(--hover--input-color, inherit);
154
+ --svg-fill: var(--hover--input-color, inherit);
150
155
  }
151
156
 
152
157
  &.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);
158
+ --input-border-color: var(--focus--input-border-color, #222);
159
+ --input-fill: var(--focus--input-fill, #fff);
160
+ --input-color: var(--focus--input-color, inherit);
161
+ --svg-fill: var(--focus--input-color, inherit);
157
162
  }
158
163
 
159
164
  &.readonly {
160
- --input-fill: var(--readonly--input-fill);
161
- --input-color: var(--readonly--input-color);
162
- --svg-fill: var(--readonly--input-color);
165
+ --input-fill: var(--readonly--input-fill, #f7f7f7);
166
+ --input-color: var(--readonly--input-color, #222);
167
+ --svg-fill: var(--readonly--input-color, #222);
163
168
  }
164
169
 
165
170
  &.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);
171
+ --input-border-color: var(--disabled--input-border-color, #aaa);
172
+ --input-fill: var(--disabled--input-fill, #eee);
173
+ --input-color: var(--disabled--input-color, #a2a2a2);
174
+ --svg-fill: var(--disabled--input-color, #a2a2a2);
170
175
 
171
176
  & .editor {
172
177
  user-select: none;
@@ -178,42 +183,38 @@
178
183
  cursor: default;
179
184
 
180
185
  & svg {
181
- fill: var(--disabled--input-color);
186
+ fill: var(--disabled--input-color, #a2a2a2);
182
187
  }
183
188
  }
184
189
  }
185
190
  }
186
191
 
187
192
  &.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);
193
+ --input-border-color: var(--invalid--input-border-color, red);
194
+ --input-fill: var(--invalid--input-fill, rgb(255, 220, 220));
195
+ --input-color: var(--invalid--input-color, inherit);
196
+ --placeholder-color: var(--invalid--input-color, inherit);
197
+ --svg-fill: var(--invalid--input-color, inherit);
193
198
 
194
199
  & .decorator {
195
- transition-delay: 0;
200
+ transition-delay: 0s;
196
201
  }
197
202
  }
198
203
 
199
204
  &.incorrect {
200
- --input-fill: var(--incorrect--input-fill);
201
- --input-color: var(--incorrect--input-color);
202
- --svg-fill: var(--incorrect--input-color);
205
+ --input-fill: var(--incorrect--input-fill, rgb(255, 220, 220));
206
+ --input-color: var(--incorrect--input-color, #222);
207
+ --svg-fill: var(--incorrect--input-color, #222);
203
208
 
204
209
  & .decorator {
205
- transition-delay: 0;
210
+ transition-delay: 0s;
206
211
  }
207
212
  }
208
213
  }
209
214
 
215
+ // поле уведено с экрана, но остаётся в форме — общий рецепт из @brandup/ui-input
210
216
  .textbox-input {
211
- opacity: 0;
212
- position: absolute;
213
- width: 1px;
214
- height: 1px;
215
- outline: none;
216
- visibility: collapse;
217
+ .ui-input-hidden-value();
217
218
  }
218
219
 
219
220
  textarea.textbox-input ~ .textbox-miniature {
@@ -221,9 +222,9 @@ textarea.textbox-input ~ .textbox-miniature {
221
222
  }
222
223
 
223
224
  .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);
225
+ border: var(--input-border-type, solid) var(--input-border-width, 1px) var(--input-border-color, #aaa);
226
+ border-radius: var(--input-border-radius, 0);
227
+ height: var(--input-height, 46px);
227
228
  background: white;
228
229
  box-sizing: border-box;
229
230
  }
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");
@@ -129,36 +129,39 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
129
129
  actionsElem,
130
130
  ]);
131
131
 
132
- TextBox.prepareValueElem(valueElem, container, INPUT_CLASS);
133
-
134
- inputElem.tabIndex = disabled ? -1 : valueElem.tabIndex;
135
- valueElem.tabIndex = -1;
136
-
137
132
  if (multyline) container.classList.add("multyline");
138
133
  if (symbolCounter) container.classList.add("counter");
139
134
  if (inputmode) inputElem.inputMode = inputmode;
140
135
 
141
136
  if (copyButton) {
142
- // команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui
137
+ // команда объявляется атрибутом data-command — по нему её ищет обработчик @brandup/ui;
138
+ // type обязателен: кнопка без него внутри формы — submit, и клик отправлял бы форму
143
139
  const buttonElem = DOM.tag(
144
140
  "button",
145
- { command: "copy-text", title: "Скопировать в буфер обмена" },
141
+ { type: "button", command: "copy-text", title: "Скопировать в буфер обмена" },
146
142
  copyIcon
147
143
  );
148
144
  if (disabled) buttonElem.disabled = true;
149
145
  actionsElem.insertAdjacentElement("beforeend", buttonElem);
150
146
  }
151
147
 
152
- // убираем висящую миниатюру, если есть, и вставляем container на место valueElem
148
+ // убираем висящую миниатюру, если есть, до обёртки поля: после неё соседом поля станет контейнер
153
149
  if (valueElem.nextElementSibling) {
154
150
  const nextElem = valueElem.nextElementSibling as HTMLElement;
155
151
  if (nextElem.classList.contains(MINIATURE_CLASS)) nextElem.remove();
156
152
  }
157
- valueElem.insertAdjacentElement("afterend", container);
158
- container.insertAdjacentElement("afterbegin", valueElem);
153
+
154
+ // скрыть поле, подменить tabindex и обернуть контейнером — общая механика базового класса
155
+ TextBox.wrapValueElem(valueElem, container, INPUT_CLASS, inputElem, disabled);
159
156
 
160
157
  // класс и подменённые атрибуты вернёт базовый класс при destroy
161
- super("BrandUp.TextBox", container, valueElem, { class: INPUT_CLASS, attrs: originalAttrs });
158
+ super(
159
+ "BrandUp.TextBox",
160
+ container,
161
+ valueElem,
162
+ { class: INPUT_CLASS, attrs: originalAttrs },
163
+ { changeEvent: CHANGE_EVENT }
164
+ );
162
165
 
163
166
  this.type = type;
164
167
  this.maxlength = maxlength;
@@ -189,6 +192,9 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
189
192
  multiline: multyline,
190
193
  blocks,
191
194
  readonly,
195
+ // disabled редактор знает сам: запрет правок как в readonly, плюс снятый contenteditable —
196
+ // без фокуса и выделения (визуал даёт класс .disabled от InputControl)
197
+ disabled,
192
198
  // тулбар позиционируется относительно контейнера TextBox (а не document.body)
193
199
  toolbarContainer: container,
194
200
  value: valueElem.value,
@@ -207,16 +213,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
207
213
 
208
214
  if (type === "number" || type === "email" || maxlength > 0) {
209
215
  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
- }
216
+ // достигнут лимит длины — отклоняем (выделение будет заменено, его длину учитывает helper)
217
+ if (maxlength > 0 && this.__lengthLeft() <= 0) return false;
215
218
  return typeAllowsChar(char);
216
219
  };
217
- }
218
220
 
219
- if (type === "number" || maxlength > 0) {
220
221
  options.filterPaste = (text) => {
221
222
  let pasted = text;
222
223
 
@@ -224,12 +225,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
224
225
  const numberData = /[\d\s]+/.exec(pasted);
225
226
  if (!numberData || !numberData.length) return null;
226
227
  pasted = numberData[0].replace(/\s/g, "");
228
+ } else if (type === "email") {
229
+ // вставка проходит тот же посимвольный фильтр, что и набор, — иначе запрещённые
230
+ // символы попадали бы в поле через буфер обмена
231
+ pasted = Array.from(pasted).filter(typeAllowsChar).join("");
232
+ if (!pasted) return null;
227
233
  }
228
234
 
229
235
  // обрезаем по количеству оставшихся символов (с учётом замены выделения)
230
236
  if (maxlength > 0) {
231
- const selectionLength = window.getSelection()?.toString().length ?? 0;
232
- const left = maxlength - this.__editor.getLength() + selectionLength;
237
+ const left = this.__lengthLeft();
233
238
  // не влезает ни одного символа — это отказ, а не пустая вставка: иначе
234
239
  // вставка молча не делала бы ничего, тогда как ввод символа на пределе мигает ошибкой
235
240
  if (left <= 0) return null;
@@ -240,17 +245,16 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
240
245
  };
241
246
  }
242
247
 
243
- this.__editor = new RichEditor(inputElem, options);
244
-
245
- // RichEditor не знает про disabled — отключаем редактирование на стороне TextBox
246
- // (визуал даёт класс .disabled от InputControl: затемнение, user-select: none)
247
- if (disabled) inputElem.contentEditable = "false";
248
+ this.__attachEditor(new RichEditor(inputElem, options));
248
249
 
249
250
  // синхронизируем скрытое поле с нормализованным содержимым редактора (без события)
250
251
  this.__valueElem.value = this.__editor.getValue();
251
252
 
252
253
  this.__initLogic();
253
254
  this.__refreshSymbolsCount();
255
+ // значение из разметки может уже нарушать лимит длины — объявляем это полю сразу,
256
+ // не дожидаясь первой синхронизации
257
+ this.__refreshValidity();
254
258
 
255
259
  if (this.autoFocus && !IS_TOUCH_DEVICE && !disabled && !readonly) this.__editor.focus();
256
260
  }
@@ -274,24 +278,19 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
274
278
 
275
279
  // Счётчик — на каждый ввод, а не по change: длина считается по textContent и стоит копейки,
276
280
  // тогда как значение поля синхронизируется реже (см. 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
- );
281
+ // Выключенный счётчик скрыт пересчитывать его на каждое нажатие незачем.
282
+ if (this.symbolCounter) editable.addEventListener("input", () => this.__refreshSymbolsCount(), { signal });
283
+
284
+ // Клик мимо текста тоже клик по полю: контрол выглядит одним полем ввода, а его
285
+ // внутренние отступы, место справа от текста и полоса действий в редактируемый
286
+ // элемент не входят.
287
+ this.element.addEventListener("mousedown", (e) => this.__focusFromBox(e), { signal });
288
+
289
+ // таймер вспышки "incorrect" не должен переживать компонент — гасим его вместе со слушателями
290
+ signal.addEventListener("abort", () => window.clearTimeout(this.__incorrectTimer));
292
291
 
293
- // двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
294
292
  if (this.copyButton) {
293
+ // двойной клик по readonly-полю с кнопкой копирования — выделить всё для копирования
295
294
  editable.addEventListener(
296
295
  "dblclick",
297
296
  () => {
@@ -301,38 +300,56 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
301
300
  },
302
301
  { signal }
303
302
  );
304
- }
305
303
 
306
- this.registerCommand("copy-text", async (context) => {
307
- // повторный клик, пока показана галочка, запомнил бы её как исходную иконку —
308
- // после возврата кнопка так и осталась бы с галочкой
309
- if (!window.navigator.clipboard || this.disabled || context.target.classList.contains("success")) return;
304
+ this.registerCommand("copy-text", async (context) => {
305
+ // повторный клик, пока показана галочка, запомнил бы её как исходную иконку —
306
+ // после возврата кнопка так и осталась бы с галочкой
307
+ if (!window.navigator.clipboard || this.disabled || context.target.classList.contains("success"))
308
+ return;
309
+
310
+ // копия значения в поле отстаёт на окно троттлинга — доводим её перед чтением
311
+ this.__syncValue();
312
+ await window.navigator.clipboard.writeText(this.__valueElem.value);
313
+
314
+ const prevHtml = context.target.innerHTML;
315
+ context.target.innerHTML = doneIcon;
316
+ context.target.classList.add("success");
317
+
318
+ // возврат иконки отменяем вместе с компонентом: иначе таймер переживает destroy
319
+ // и дописывает в уже отсоединённую кнопку
320
+ try {
321
+ await FuncHelper.delay(2000, signal);
322
+ } catch {
323
+ return;
324
+ }
310
325
 
311
- await window.navigator.clipboard.writeText(this.__valueElem.value);
326
+ context.target.innerHTML = prevHtml;
327
+ context.target.classList.remove("success");
328
+ });
329
+ }
330
+ }
312
331
 
313
- const prevHtml = context.target.innerHTML;
314
- context.target.innerHTML = doneIcon;
315
- context.target.classList.add("success");
332
+ /**
333
+ * Фокус по клику в контрол мимо текста: внутренние отступы, место справа от последней
334
+ * строки и полоса действий в редактируемый элемент не входят, а выглядит всё это одним
335
+ * полем ввода.
336
+ *
337
+ * Гасим нажатие: браузер иначе снял бы выделение и увёл фокус на корневой элемент, где
338
+ * каретке места нет. Каретку редактор вернёт на прежнее место, а если её ещё не было —
339
+ * в конец текста: клик мимо текста это клик за ним.
340
+ */
341
+ private __focusFromBox(e: MouseEvent) {
342
+ // в выключенном поле редактируемого элемента нет вовсе — фокусировать нечего
343
+ if (this.disabled) return;
316
344
 
317
- // возврат иконки отменяем вместе с компонентом: иначе таймер переживает destroy
318
- // и дописывает в уже отсоединённую кнопку
319
- try {
320
- await FuncHelper.delay(2000, signal);
321
- } catch {
322
- return;
323
- }
345
+ const target = e.target as HTMLElement | null;
346
+ if (!target || this.__inputElem.contains(target)) return; // в сам текст браузер попадёт и сам
324
347
 
325
- context.target.innerHTML = prevHtml;
326
- context.target.classList.remove("success");
327
- });
328
- }
348
+ // кнопка копирования, панель форматирования и её попапы живут внутри контрола и работают сами
349
+ if (target.closest(`button, a, input, textarea, select, .${TOOLBAR_CLASS}, .${POPUP_CLASS}`)) return;
329
350
 
330
- // Редактор откладывает событие изменения при печати, поэтому копия значения в поле формы
331
- // отстаёт. Базовый класс зовёт этот хук перед каждым чтением значения снаружи —
332
- // валидация, отправка формы, сбор FormData.
333
- protected override __syncValue(): void {
334
- this.__editor.flushChange();
335
- this.__refreshValidity();
351
+ e.preventDefault();
352
+ this.__editor.focus(true);
336
353
  }
337
354
 
338
355
  /**
@@ -344,20 +361,39 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
344
361
  * разделители абзацев. Нативный maxLength здесь не помощник: он ограничивает набор, но не
345
362
  * помечает значение, выставленное из кода.
346
363
  */
347
- private __refreshValidity(): void {
364
+ protected override __refreshValidity(): void {
348
365
  if (this.maxlength <= 0) return;
349
366
 
350
367
  const tooLong = this.maxlength < this.__editor.getLength();
351
368
  this.__valueElem.setCustomValidity(tooLong ? `Не больше ${this.maxlength} символов.` : "");
352
369
  }
353
370
 
371
+ /**
372
+ * Сколько символов ещё поместится с учётом того, что выделение будет заменено вводом.
373
+ *
374
+ * Выделение берём у самого редактора: чужое выделение страницы (например, при вставке
375
+ * с отсоединённой кареткой) к содержимому отношения не имеет и ёмкость не освобождает.
376
+ * Длину выделения меряем так же, как getLength(), — по textContent, без переносов на границах
377
+ * блоков, которые toString() у Selection добавляет, — иначе многострочное выделение
378
+ * считалось бы длиннее содержимого.
379
+ */
380
+ private __lengthLeft(): number {
381
+ const selection = this.__editor.selection;
382
+ const selectionLength = selection ? (selection.getRangeAt(0).cloneContents().textContent?.length ?? 0) : 0;
383
+
384
+ return this.maxlength - this.__editor.getLength() + selectionLength;
385
+ }
386
+
354
387
  private __toIncorrect() {
355
388
  this.element.classList.add("incorrect");
356
- window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
389
+
390
+ // каждый отказ перезапускает таймер: иначе таймер предыдущего отказа гасил бы вспышку раньше
391
+ window.clearTimeout(this.__incorrectTimer);
392
+ this.__incorrectTimer = window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
357
393
  }
358
394
 
359
395
  private __refreshSymbolsCount() {
360
- if (!this.__symbolsCountElem) return;
396
+ if (!this.symbolCounter) return;
361
397
 
362
398
  const textLength = this.__editor.getLength();
363
399
  let counterValue: string;
@@ -388,24 +424,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
388
424
  return this.__editor;
389
425
  }
390
426
 
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
427
  override validate(): boolean {
410
428
  let isValid = super.validate(); // super синхронизирует значение сам, через __syncValue
411
429
  if (isValid) {
@@ -423,13 +441,6 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
423
441
 
424
442
  return isValid;
425
443
  }
426
-
427
- override destroy(): void {
428
- this.__listenerAbort.abort();
429
- this.__editor.destroy();
430
-
431
- super.destroy(); // снимет слушатели формы и вернёт поле-носитель в исходный вид
432
- }
433
444
  }
434
445
 
435
446
  export interface ChangeEventData {