@brandup/ui-input 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Build Status](https://dev.azure.com/brandup/BrandUp%20Core/_apis/build/status%2FBrandUp%2Fbrandup-ui-kit?branchName=main)](https://dev.azure.com/brandup/BrandUp%20Core/_build/latest?definitionId=81&branchName=main)
4
4
 
5
- Абстрактный базовый класс для компонентов ввода в форме. Обеспечивает единый интерфейс для `HTMLInputElement`, `HTMLTextAreaElement` и `HTMLSelectElement`.
5
+ Общая база компонентов ввода в форме: абстрактные классы `InputControl` и `EditorInputControl`, а также LESS-миксин для поля-носителя значения. Пакет ничего не рендерит сам — им пользуются `@brandup/ui-textbox`, `@brandup/ui-messageeditor` и `@brandup/ui-dropdown`.
6
6
 
7
7
  ## Установка
8
8
 
@@ -10,38 +10,40 @@
10
10
  npm i @brandup/ui-input
11
11
  ```
12
12
 
13
+ > **Версии.** `@brandup/ui-textbox`, `@brandup/ui-messageeditor` и `@brandup/ui-dropdown` подключают из этого пакета не только TypeScript, но и LESS (см. [LESS](#less)). Пакеты кита версионируются одной сборкой CI и рассчитаны друг на друга: обновляя любой из них, обновляйте и `@brandup/ui-input`. Иначе сборка стилей падает ещё до TypeScript — на `@import` отсутствующего `input.less` либо на неизвестном миксине `.ui-input-hidden-value`.
14
+
13
15
  ## InputControl
14
16
 
15
- `InputControl<T, TEvents>` — абстрактный класс, от которого наследуются все компоненты ввода (`TextBox`, `MessageEditor`, `DropDown`). Расширяет `UIElementBound` из `@brandup/ui`.
17
+ `InputControl<T, TEvents>` — абстрактный класс, от которого наследуются все компоненты ввода. Расширяет `UIElementBound` из `@brandup/ui`. Параметр `T` — тип поля-носителя: `HTMLInputElement`, `HTMLTextAreaElement` или `HTMLSelectElement`.
16
18
 
17
19
  ### Свойства
18
20
 
19
21
  | Свойство | Тип | Описание |
20
22
  | --- | --- | --- |
21
- | `form` | `HTMLFormElement \| null` | Форма, к которой привязан элемент |
22
- | `disabled` | `boolean` | Элемент отключён |
23
- | `required` | `boolean` | Элемент обязателен для заполнения |
24
- | `readonly` | `boolean` | Элемент только для чтения (`readonly` или `data-readonly`) |
23
+ | `form` | `HTMLFormElement \| null` | Форма, к которой привязано поле-носитель |
24
+ | `disabled` | `boolean` | Поле отключено |
25
+ | `required` | `boolean` | Поле обязательно для заполнения |
26
+ | `readonly` | `boolean` | Поле только для чтения (`readonly` или `data-readonly`) |
25
27
 
26
28
  ### Методы
27
29
 
28
30
  | Метод | Описание |
29
31
  | --- | --- |
30
- | `validate(): boolean` | Проверяет значение через нативный `checkValidity()`. Возвращает `true`, если значение валидно |
31
- | `focus(): void` | Устанавливает фокус и прокручивает элемент в видимую область |
32
- | `destroy(): void` | Снимает все обработчики и освобождает ресурсы |
32
+ | `validate(): boolean` | Синхронизирует значение и проверяет его нативным `checkValidity()` |
33
+ | `focus(): void` | Ведёт фокус в контрол и прокручивает его в видимую область |
34
+ | `destroy(): void` | Снимает обработчики, возвращает поле-носитель в исходное состояние и удаляет контейнер контрола |
33
35
 
34
36
  ### Поведение
35
37
 
36
- - Добавляет CSS-класс `ui-input` на корневой элемент.
37
- - При инициализации добавляет классы `required`, `readonly`, `disabled` в зависимости от состояния элемента.
38
- - Перехватывает событие `invalid` нативного поля и делегирует валидацию через `validate()`.
39
- - Перехватывает `submit` формы: если элемент невалиденостанавливает отправку и вызывает `focus()`.
38
+ - Добавляет CSS-класс `ui-input` на корневой элемент, а также `required`, `readonly`, `disabled` — по состоянию поля.
39
+ - Гасит показ нативной подсказки валидации (`invalid`): поле уведено с экрана, привязать подсказку не к чему. Само решение о валидности остаётся за браузером, а состояние видно по классу `invalid`.
40
+ - Переносит значение в поле-носитель до отправки формы — и в фазе перехвата на документе, чтобы обработчик `submit`, повешенный приложением раньше контрола, тоже увидел актуальное значение.
41
+ - `focus()` ничего не делает у выключенного контрола как нативный `disabled` input, который игнорирует `focus()` сам. Поле только для чтения фокусируется: это его нативное поведение, текст читают, выделяют и копируют.
40
42
 
41
43
  ### Интерфейс IInputControl
42
44
 
43
45
  ```typescript
44
- import { IInputControl } from "@brandup/ui-input";
46
+ import type { IInputControl } from "@brandup/ui-input";
45
47
 
46
48
  interface IInputControl {
47
49
  get form(): HTMLFormElement | null;
@@ -55,6 +57,29 @@ interface IInputControl {
55
57
  }
56
58
  ```
57
59
 
60
+ ### Защищённые члены для наследников
61
+
62
+ | Член | Описание |
63
+ | --- | --- |
64
+ | `__valueElem` | Поле-носитель значения |
65
+ | `__syncValue(): void` | Хук: довести значение до поля-носителя, если контрол держит его отдельно. Зовётся перед каждым чтением значения снаружи |
66
+ | `__focusValue(): void` | Хук: куда именно ведёт фокус контрола. По умолчанию — поле-носитель; общие проверки и прокрутку делает `focus()` |
67
+ | `__requestSubmit(): void` | Неявная отправка формы (Enter), как у обычного `input`: через `form.requestSubmit()` с первой кнопкой отправки |
68
+ | `__submitForm(): void` | Досылает форме синтетический `submit` — движкам без `requestSubmit()` |
69
+ | `static isReadonly(valueElem: HTMLElement): boolean` | Признано ли поле только для чтения: атрибут `readonly` либо `data-readonly` (последний нужен полям без нативного атрибута, например `select`). Статический, потому что режим нужен и до `super(...)` — он влияет на сборку разметки контрола |
70
+ | `static prepareValueElem(valueElem, container, inputClass)` | Переносит собственные классы поля на контейнер, а класс-скрыватель — на поле |
71
+
72
+ `ValueElemOverrides` описывает, что контрол навязал полю и что вернуть при `destroy`:
73
+
74
+ ```typescript
75
+ export interface ValueElemOverrides {
76
+ /** Класс, добавленный полю контролом (обычно уводит его с экрана). */
77
+ class?: string;
78
+ /** Подменённые атрибуты: имя и исходное значение (`null` — атрибута не было). */
79
+ attrs?: [name: string, value: string | null][];
80
+ }
81
+ ```
82
+
58
83
  ### Создание собственного компонента
59
84
 
60
85
  ```typescript
@@ -74,3 +99,100 @@ class MyInput extends InputControl<HTMLInputElement> {
74
99
  }
75
100
  }
76
101
  ```
102
+
103
+ ## EditorInputControl
104
+
105
+ `EditorInputControl<TEditor, TChangeData, TEvents>` — база контролов, где ввод идёт не в само поле, а в редактор рядом: поле-носитель уводится с экрана, но остаётся в форме (отправка, валидация, `FormData`). На нём построены `TextBox` и `MessageEditor`.
106
+
107
+ Класс берёт на себя общую механику: синхронизацию отложенного изменения редактора с полем, зеркало фокуса классом `focused` на корневом элементе, гашение нативного `change` скрытого поля, выравнивание редактора после `form.reset()` и снятие всего этого при `destroy()`. Доменное — фильтры ввода, подсветка, кнопки — остаётся в наследниках.
108
+
109
+ ### Публичные методы
110
+
111
+ | Метод | Описание |
112
+ | --- | --- |
113
+ | `getValue(): string` | Значение поля-носителя; сначала доводит отложенное изменение редактора |
114
+ | `setValue(value: string): void` | Передаёт значение редактору — тот нормализует его и поднимет своё изменение |
115
+ | `hasValue(): boolean` | Есть ли непустое значение |
116
+ | `onChange(handler): void` | Подписка на событие изменения контрола (имя события задаёт `EditorControlInit.changeEvent`) |
117
+
118
+ ### Защищённые члены
119
+
120
+ | Член | Описание |
121
+ | --- | --- |
122
+ | `__editor` | Редактор контрола. Появляется только после `__attachEditor` |
123
+ | `__listenerAbort` | `AbortController`, одним сигналом снимающий слушатели контрола и таймеры наследников |
124
+ | `__attachEditor(editor): void` | Передать базовому классу созданный редактор — с этого момента им владеет база |
125
+ | `__refreshValidity(): void` | Хук: освежить собственное ограничение контрола (`setCustomValidity`) на поле-носителе. Зовётся при каждой синхронизации значения |
126
+ | `static wrapValueElem(valueElem, container, inputClass, editable, disabled)` | Скрыть поле-носитель, подменить `tabindex` (в фокус попадает редактируемый элемент) и обернуть поле контейнером |
127
+
128
+ ### ValueEditor
129
+
130
+ Структурный контракт редактора — ровно то, что зовёт база. Не тип из `@brandup/ui-richeditor`: этот пакет — общая база всех контролов ввода, и потребители без редактора (например, dropdown) не должны тянуть его за собой. `RichEditor` подходит под контракт как есть, но подойдёт и любая другая реализация.
131
+
132
+ ```typescript
133
+ export interface ValueEditor {
134
+ /** Редактируемый элемент — он принимает фокус вместо уведённого с экрана поля-носителя. */
135
+ readonly editable: HTMLElement;
136
+ /** Заменяет содержимое редактора; редактор нормализует значение и поднимает своё изменение. */
137
+ setValue(value: string): void;
138
+ /** Доставляет отложенное изменение немедленно — перед чтением значения извне. */
139
+ flushChange(): void;
140
+ /** Фокус в редактор; `atEnd` — ставить ли каретку в конец текста, если её ещё не было. */
141
+ focus(atEnd?: boolean): void;
142
+ destroy(): void;
143
+ }
144
+ ```
145
+
146
+ ### EditorControlInit
147
+
148
+ Что базовому классу нужно знать о конкретном контроле — передаётся последним аргументом `super(...)`.
149
+
150
+ ```typescript
151
+ export interface EditorControlInit {
152
+ /** Имя события изменения контрола — на него подписывает onChange. */
153
+ changeEvent: string;
154
+ /** Ставить ли при focus() каретку в конец текста, если её ещё не было. */
155
+ focusAtEnd?: boolean;
156
+ }
157
+ ```
158
+
159
+ ### Порядок конструирования
160
+
161
+ Редактор создаёт наследник: его опции замыкаются на `this` и собираются только после `super(...)`. Сразу после создания редактор обязан уйти в `__attachEditor` — до этого момента базовый класс уже привязал элемент, повесил слушатели формы и включил авторазрушение по удалению из DOM, но редактора у него ещё нет. Методы базы этот промежуток терпят (падение конструктора наследника не оставляет на странице обработчиков, которые ломали бы отправку любой формы), но контрол без редактора не работает.
162
+
163
+ ```typescript
164
+ import { EditorInputControl, type ValueEditor } from "@brandup/ui-input";
165
+
166
+ const CHANGE_EVENT = "myeditor-change";
167
+
168
+ class MyEditorControl extends EditorInputControl<MyEditor, ChangeData, MyEditorEvents> {
169
+ constructor(valueElem: HTMLInputElement) {
170
+ const editable = document.createElement("div");
171
+ const container = document.createElement("div");
172
+ container.appendChild(editable);
173
+
174
+ // скрыть поле, подменить tabindex и обернуть контейнером
175
+ MyEditorControl.wrapValueElem(valueElem, container, "myeditor-input", editable, valueElem.disabled);
176
+
177
+ super("My.EditorControl", container, valueElem, { class: "myeditor-input", attrs: [] }, {
178
+ changeEvent: CHANGE_EVENT,
179
+ });
180
+
181
+ this.__attachEditor(new MyEditor(editable, { value: valueElem.value }));
182
+ }
183
+ }
184
+ ```
185
+
186
+ ## LESS
187
+
188
+ `source/input.less` содержит рецепт поля-носителя, уведённого с экрана: поле остаётся в форме (отправка, валидация, `FormData`), но не показывается — вводом управляет UI контрола. Общего класса у поля нет: каждый контрол вешает свой (`textbox-input`, `messageeditor-input`, `ui-dropdown-input`), поэтому рецепт оформлен миксином.
189
+
190
+ ```less
191
+ @import (reference) "@brandup/ui-input/source/input.less";
192
+
193
+ .my-control-input {
194
+ .ui-input-hidden-value();
195
+ }
196
+ ```
197
+
198
+ Так его подключают `@brandup/ui-textbox`, `@brandup/ui-messageeditor` и `@brandup/ui-dropdown` — см. предупреждение о версиях в начале файла.
package/package.json CHANGED
@@ -24,12 +24,12 @@
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
- "@brandup/ui-kit": "^1.0.48"
32
+ "@brandup/ui-kit": "^1.0.50"
33
33
  },
34
34
  "files": [
35
35
  "source",
@@ -0,0 +1,186 @@
1
+ import { InputControl, type ValueElemOverrides } from "./input";
2
+
3
+ /** Поле формы, которое контрол на редакторе оставляет носителем значения. */
4
+ type EditorValueElem = HTMLInputElement | HTMLTextAreaElement;
5
+
6
+ /**
7
+ * Редактор, которому контрол доверяет ввод значения. Структурный интерфейс, а не тип из
8
+ * `@brandup/ui-richeditor`: этот пакет — общая база всех контролов ввода, и потребители без
9
+ * редактора (например, dropdown) не должны тянуть его за собой. `RichEditor` подходит под
10
+ * интерфейс как есть; членов здесь ровно столько, сколько зовёт общая механика базового класса.
11
+ */
12
+ export interface ValueEditor {
13
+ /** Редактируемый элемент — он принимает фокус вместо уведённого с экрана поля-носителя. */
14
+ readonly editable: HTMLElement;
15
+ /** Заменяет содержимое редактора; редактор нормализует значение и поднимает своё изменение. */
16
+ setValue(value: string): void;
17
+ /** Доставляет отложенное изменение немедленно — перед чтением значения извне. */
18
+ flushChange(): void;
19
+ /** Фокус в редактор; `atEnd` — ставить ли каретку в конец текста, если её ещё не было. */
20
+ focus(atEnd?: boolean): void;
21
+ destroy(): void;
22
+ }
23
+
24
+ /** Что базовому классу нужно знать о конкретном контроле. */
25
+ export interface EditorControlInit {
26
+ /** Имя события изменения контрола — на него подписывает {@link EditorInputControl.onChange}. */
27
+ changeEvent: string;
28
+ /** Ставить ли при {@link EditorInputControl.focus} каретку в конец текста, если её ещё не было. */
29
+ focusAtEnd?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Контрол ввода, где значением управляет редактор в соседнем редактируемом элементе, а исходное
34
+ * поле остаётся носителем значения и участвует в форме. Общая механика таких контролов
35
+ * (textbox, messageeditor): синхронизация отложенного изменения редактора с полем, зеркало
36
+ * фокуса, гашение нативного change скрытого поля, обёртка поля контейнером и снятие всего
37
+ * этого при destroy. Всё доменное — фильтры ввода, подсветка, кнопки — остаётся в наследниках.
38
+ *
39
+ * Редактор создаёт наследник: опции редактора замыкаются на `this` и собираются только после
40
+ * `super(...)`. Сразу после создания наследник обязан передать редактор в {@link __attachEditor}.
41
+ */
42
+ export abstract class EditorInputControl<TEditor extends ValueEditor, TChangeData, TEvents = {}> extends InputControl<
43
+ EditorValueElem,
44
+ TEvents
45
+ > {
46
+ /**
47
+ * Редактор контрола; назначается в {@link __attachEditor} сразу после `super(...)`.
48
+ *
49
+ * До этого момента его нет, и промежуток не пустой: базовый конструктор уже привязал
50
+ * элемент, повесил слушатели формы и включил авторазрушение по удалению из DOM. Если
51
+ * конструктор наследника упадёт после `super(...)`, эти слушатели останутся жить на
52
+ * недостроенном контроле — поэтому каждый метод базы, трогающий редактор, обязан
53
+ * терпеть его отсутствие. Наружу тип объявлен непустым: наследники работают с редактором
54
+ * уже после привязки, и разбирать `undefined` в каждом их обращении было бы шумом.
55
+ */
56
+ protected __editor!: TEditor;
57
+ /** Слушатели контрола снимаются одним сигналом — он же гасит и таймеры наследников. */
58
+ protected __listenerAbort = new AbortController();
59
+ private __init: EditorControlInit;
60
+
61
+ constructor(
62
+ typeName: string,
63
+ elem: HTMLElement,
64
+ valueElem: EditorValueElem,
65
+ overrides: ValueElemOverrides | undefined,
66
+ init: EditorControlInit
67
+ ) {
68
+ super(typeName, elem, valueElem, overrides);
69
+
70
+ this.__init = init;
71
+
72
+ // гасим нативный change скрытого поля
73
+ this.__valueElem.addEventListener(
74
+ "change",
75
+ (e: Event) => {
76
+ e.preventDefault();
77
+ e.stopImmediatePropagation();
78
+ },
79
+ { signal: this.__listenerAbort.signal }
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Скрывает поле-носитель и оборачивает его контейнером контрола: {@link prepareValueElem}
85
+ * плюс подмена tabindex — в фокус попадает редактируемый элемент, а не уведённое с экрана
86
+ * поле. Статический, потому что вызывается до `super(...)`; подменённый tabindex наследник
87
+ * возвращает через {@link ValueElemOverrides}.
88
+ */
89
+ protected static wrapValueElem(
90
+ valueElem: EditorValueElem,
91
+ container: HTMLElement,
92
+ inputClass: string,
93
+ editable: HTMLElement,
94
+ disabled: boolean
95
+ ) {
96
+ InputControl.prepareValueElem(valueElem, container, inputClass);
97
+
98
+ editable.tabIndex = disabled ? -1 : valueElem.tabIndex;
99
+ valueElem.tabIndex = -1;
100
+
101
+ valueElem.insertAdjacentElement("afterend", container);
102
+ container.insertAdjacentElement("afterbegin", valueElem);
103
+ }
104
+
105
+ /**
106
+ * Принимает созданный наследником редактор: с этого момента им владеет базовый класс —
107
+ * он его синхронизирует, фокусирует и разрушает. Заодно зеркалит фокус редактируемого
108
+ * элемента классом на корневом элементе контрола — состояние поля показывает весь контрол.
109
+ */
110
+ protected __attachEditor(editor: TEditor): void {
111
+ this.__editor = editor;
112
+
113
+ const { signal } = this.__listenerAbort;
114
+ const editable = editor.editable;
115
+
116
+ // состояние фокуса контрола — на корневом элементе
117
+ editable.addEventListener("focus", () => !this.disabled && this.element.classList.add("focused"), { signal });
118
+ editable.addEventListener("blur", () => !this.disabled && this.element.classList.remove("focused"), { signal });
119
+
120
+ // form.reset() возвращает поле-носитель к defaultValue, а редактор об этом сам не узнал бы —
121
+ // следующая синхронизация перезаписала бы сброс обратно. Сброс применяется после события,
122
+ // поэтому выравниваем редактор отложенно и только если сброс не отменили; разрушенный
123
+ // к этому моменту контрол трогать нечего — его выдаёт снятый сигнал.
124
+ this.form?.addEventListener(
125
+ "reset",
126
+ (e) => {
127
+ window.setTimeout(() => {
128
+ if (!signal.aborted && !e.defaultPrevented) this.__editor?.setValue(this.__valueElem.defaultValue);
129
+ });
130
+ },
131
+ { signal }
132
+ );
133
+ }
134
+
135
+ // Редактор откладывает событие изменения при печати, поэтому копия значения в поле формы
136
+ // отстаёт. Базовый класс зовёт этот хук перед каждым чтением значения снаружи —
137
+ // валидация, отправка формы, сбор FormData.
138
+ protected override __syncValue(): void {
139
+ if (!this.__editor) return; // редактора ещё (или уже) нет — синхронизировать нечего
140
+
141
+ this.__editor.flushChange();
142
+ this.__refreshValidity();
143
+ }
144
+
145
+ /**
146
+ * Освежает собственное ограничение контрола на поле-носителе (setCustomValidity) — зовётся
147
+ * при каждой синхронизации значения. По умолчанию собственных ограничений нет.
148
+ */
149
+ protected __refreshValidity(): void {}
150
+
151
+ onChange(handler: (e: TChangeData) => void) {
152
+ // имя события у каждого контрола своё — типизацию даёт TEvents наследника
153
+ this.on(this.__init.changeEvent as keyof TEvents & string, handler as never);
154
+ }
155
+
156
+ hasValue(): boolean {
157
+ return !!this.getValue();
158
+ }
159
+
160
+ getValue(): string {
161
+ this.__syncValue(); // значение читают снаружи — отложенное изменение сюда обязано попасть
162
+ return this.__valueElem.value.trim();
163
+ }
164
+
165
+ setValue(value: string): void {
166
+ // редактор нормализует значение и поднимет своё изменение — оно и синхронизирует
167
+ // поле-носитель, и вызовет событие изменения контрола
168
+ this.__editor?.setValue(value?.trim() ?? "");
169
+ }
170
+
171
+ /**
172
+ * Поле-носитель уведено с экрана (visibility: collapse) и в браузере фокус не принимает —
173
+ * ведём фокус в редактор. Проверки состояния и прокрутку к контролу делает базовый
174
+ * {@link InputControl.focus}.
175
+ */
176
+ protected override __focusValue(): void {
177
+ this.__editor?.focus(this.__init.focusAtEnd);
178
+ }
179
+
180
+ override destroy(): void {
181
+ this.__listenerAbort.abort();
182
+ this.__editor?.destroy();
183
+
184
+ super.destroy(); // снимет слушатели формы и вернёт поле-носитель в исходный вид
185
+ }
186
+ }
package/source/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from "./input";
2
+ export * from "./editor-control";
package/source/input.less CHANGED
@@ -1 +1,15 @@
1
- // Тут могут быть общие стили для класса ui-input.
1
+ // Общие стили контролов ввода (ui-input).
2
+
3
+ // Поле-носитель значения, уведённое с экрана: остаётся в форме (отправка, валидация, FormData),
4
+ // но не показывается — вводом управляет UI контрола. Общего класса у поля нет: каждый контрол
5
+ // вешает свой (textbox-input, messageeditor-input, ui-dropdown-input), поэтому рецепт оформлен
6
+ // миксином — потребители подключают этот файл через `@import (reference)` и вызывают миксин
7
+ // в своём правиле.
8
+ .ui-input-hidden-value() {
9
+ opacity: 0;
10
+ position: absolute;
11
+ width: 1px;
12
+ height: 1px;
13
+ outline: none;
14
+ visibility: collapse;
15
+ }
package/source/input.ts CHANGED
@@ -32,6 +32,17 @@ export abstract class InputControl<T extends InputType, TEvents = {}>
32
32
  this.__initForm();
33
33
  }
34
34
 
35
+ /**
36
+ * Признан ли элемент полем только для чтения: нативный атрибут `readonly` либо `data-readonly` —
37
+ * последний нужен полям, у которых нативного атрибута нет (например, `select`).
38
+ *
39
+ * Статический, потому что контролам это нужно и до `super(...)` — режим влияет на сборку
40
+ * их разметки; после конструирования то же самое отдаёт getter {@link readonly}.
41
+ */
42
+ protected static isReadonly(valueElem: HTMLElement): boolean {
43
+ return valueElem.hasAttribute("readonly") || valueElem.hasAttribute("data-readonly");
44
+ }
45
+
35
46
  /**
36
47
  * Готовит поле-носитель к обёртке контейнером контрола: класс-скрыватель переезжает на поле,
37
48
  * а собственные классы поля — на контейнер, чтобы оформление из разметки применялось к тому,
@@ -56,7 +67,7 @@ export abstract class InputControl<T extends InputType, TEvents = {}>
56
67
  return this.__valueElem.required;
57
68
  }
58
69
  get readonly(): boolean {
59
- return this.__valueElem.hasAttribute("readonly") || this.__valueElem.hasAttribute("data-readonly");
70
+ return InputControl.isReadonly(this.__valueElem);
60
71
  }
61
72
 
62
73
  /**
@@ -162,11 +173,31 @@ export abstract class InputControl<T extends InputType, TEvents = {}>
162
173
  return result;
163
174
  }
164
175
 
176
+ /**
177
+ * Фокус в контрол. Выключенное поле фокус не принимает — как нативный `disabled` input:
178
+ * раньше это выходило само собой (браузер игнорирует `focus()` на выключенном поле),
179
+ * но контрол на редакторе уводит фокус в свой элемент, а тот выключение не запрещает,
180
+ * — поэтому запрет объявлен здесь, рядом с таким же в {@link __requestSubmit}.
181
+ *
182
+ * Поле только для чтения фокусируется: это его нативное поведение — текст читают,
183
+ * выделяют и копируют, и уводить от него клавиатуру нельзя.
184
+ */
165
185
  focus(): void {
166
- this.__valueElem.focus();
186
+ if (this.disabled) return;
187
+
188
+ this.__focusValue();
167
189
  this.element.scrollIntoView({ block: "center", inline: "center" });
168
190
  }
169
191
 
192
+ /**
193
+ * Куда именно ведёт фокус контрола. По умолчанию — поле-носитель; контролы, у которых
194
+ * ввод идёт в другом элементе, подменяют его здесь, а общие проверки и прокрутку
195
+ * оставляют базовому {@link focus}.
196
+ */
197
+ protected __focusValue(): void {
198
+ this.__valueElem.focus();
199
+ }
200
+
170
201
  /**
171
202
  * Возвращает поле-носитель в исходное состояние: снимает класс, которым контрол увёл его
172
203
  * с экрана, возвращает подменённые атрибуты и вынимает поле из контейнера, а сам контейнер