@brandup/ui-textbox 1.0.26 → 1.0.28

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
@@ -1,27 +1,104 @@
1
- # brandup-ui-textbox
1
+ # @brandup/ui-textbox
2
2
 
3
- [![Build Status](https://dev.azure.com/brandup/BrandUp%20Core/_apis/build/status%2FBrandUp%2Fbrandup-ui-kit?branchName=master)]()
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
- ## Installation
5
+ Компонент текстового поля на базе `contenteditable`. Заменяет стандартные `<input>` и `<textarea>`, добавляя расширенное поведение: счётчик символов, кнопку копирования, фильтрацию ввода по типу и валидацию.
6
6
 
7
- Install NPM package [@brandup/ui-textbox](https://www.npmjs.com/package/@brandup/ui-textbox).
7
+ ## Установка
8
8
 
9
9
  ```
10
- npm i @brandup/ui-textbox@latest
10
+ npm i @brandup/ui-textbox
11
11
  ```
12
12
 
13
- ## TextBox
14
-
15
- `Textbox` - это класс компонента для ввода текста, который расширяет возможности стандартных элементов `input` и `textarea`.
16
- Наследуется от `InputControl`.
13
+ ## Использование
17
14
 
18
15
  ```html
19
- <input id="name" type="text" />
16
+ <input id="name" type="text" placeholder="Введите имя" />
17
+ ```
18
+
19
+ ```typescript
20
+ import TextBox from "@brandup/ui-textbox";
21
+ import { CHANGE_EVENT, ChangeEventData } from "@brandup/ui-textbox";
22
+
23
+ const inputElem = document.getElementById("name") as HTMLInputElement;
24
+ const textbox = new TextBox(inputElem);
25
+
26
+ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
27
+ console.log(data.value);
28
+ });
29
+ ```
30
+
31
+ ## Поддерживаемые типы
32
+
33
+ `TextBox` принимает `<input>` со следующими значениями атрибута `type`:
34
+
35
+ | Тип | Поведение |
36
+ |---|---|
37
+ | `text` | Обычный текст |
38
+ | `email` | Фильтрует недопустимые символы, ограничивает длину до 256 символов (RFC 5321) |
39
+ | `url` | URL-адрес |
40
+ | `tel` | Телефон |
41
+ | `number` | Только цифры, вставка фильтруется |
42
+
43
+ Для многострочного ввода передайте `<textarea>`.
44
+
45
+ ## Data-атрибуты
46
+
47
+ | Атрибут | Описание |
48
+ |---|---|
49
+ | `data-symbolcounter` | Показывает счётчик введённых символов (и максимума, если задан `maxlength`) |
50
+ | `data-autofocus` | Автофокус при инициализации (игнорируется на touch-устройствах) |
51
+ | `data-copy-button` | Добавляет кнопку копирования значения в буфер обмена |
52
+ | `data-allow-empty-strings` | Разрешает значение из одних пробелов |
53
+ | `data-readonly` | Альтернативный способ задать режим только для чтения |
54
+
55
+ ## API
56
+
57
+ ### Методы
58
+
59
+ | Метод | Описание |
60
+ |---|---|
61
+ | `getValue(): string` | Возвращает текущее значение (обрезает пробелы по краям) |
62
+ | `setValue(value: string): void` | Устанавливает значение программно |
63
+ | `hasValue(): boolean` | `true`, если значение не пустое |
64
+ | `validate(): boolean` | Валидирует значение, добавляет/снимает класс `invalid` |
65
+ | `focus(): void` | Устанавливает фокус, прокручивает в видимую область |
66
+ | `destroy(): void` | Восстанавливает исходный DOM-элемент и освобождает ресурсы |
67
+
68
+ ### Свойства
69
+
70
+ | Свойство | Тип | Описание |
71
+ |---|---|---|
72
+ | `type` | `TextBoxType` | Тип ввода: `"text"` \| `"email"` \| `"url"` \| `"tel"` \| `"number"` |
73
+ | `multyline` | `boolean` | `true` для `<textarea>` |
74
+ | `maxlength` | `number` | Максимальная длина (из атрибута `maxlength`) |
75
+ | `copyButton` | `boolean` | Наличие кнопки копирования |
76
+ | `symbolCounter` | `boolean` | Наличие счётчика символов |
77
+ | `placeholder` | `string \| null` | Текст-заглушка |
78
+
79
+ ### Событие textbox-change
80
+
81
+ Генерируется при каждом изменении значения.
82
+
83
+ ```typescript
84
+ import { CHANGE_EVENT, ChangeEventData } from "@brandup/ui-textbox";
85
+
86
+ textbox.on(CHANGE_EVENT, (data: ChangeEventData) => {
87
+ console.log(data.value); // текущее значение
88
+ console.log(data.textbox); // ссылка на экземпляр TextBox
89
+ });
90
+
91
+ // Или через helper-метод
92
+ textbox.onChange((data) => { ... });
20
93
  ```
21
94
 
22
- ```TypeScript
23
- const inputElem = document.getElementById("name");
24
- const textbox = new Textbox(inputElem);
95
+ ## CSS-классы состояний
25
96
 
26
- textbox.on(CHANGE_EVENT, (e: ChangeEventData) => { });
27
- ```
97
+ | Класс | Условие |
98
+ |---|---|
99
+ | `focused` | Поле в фокусе |
100
+ | `invalid` | Значение не прошло валидацию |
101
+ | `incorrect` | Введён недопустимый символ (мигает, затем снимается) |
102
+ | `required` | Атрибут `required` задан |
103
+ | `readonly` | Режим только для чтения |
104
+ | `disabled` | Элемент отключён |
package/package.json CHANGED
@@ -21,15 +21,14 @@
21
21
  "email": "it@brandup.online"
22
22
  },
23
23
  "license": "Apache-2.0",
24
- "version": "1.0.26",
24
+ "version": "1.0.28",
25
25
  "main": "source/index.ts",
26
26
  "types": "source/index.ts",
27
27
  "dependencies": {
28
- "@brandup/ui": "^1.0.44",
29
- "@brandup/ui-dom": "^1.0.44",
30
- "@brandup/ui-helpers": "^1.0.44",
31
- "@brandup/ui-input": "^1.0.26",
32
- "@brandup/ui-kit": "^1.0.26"
28
+ "@brandup/ui": "^2.0.2",
29
+ "@brandup/ui-helpers": "^2.0.2",
30
+ "@brandup/ui-input": "^1.0.28",
31
+ "@brandup/ui-kit": "^1.0.28"
33
32
  },
34
33
  "files": [
35
34
  "source",
package/source/index.ts CHANGED
@@ -1 +1 @@
1
- export { default } from './textbox'
1
+ export { default } from "./textbox";
package/source/textbox.ts CHANGED
@@ -2,7 +2,7 @@ import "./textbox.less"; // стили компонента
2
2
 
3
3
  import { InputControl } from "@brandup/ui-input";
4
4
  import { IS_TOUCH_DEVICE } from "@brandup/ui-kit";
5
- import { DOM } from "@brandup/ui-dom";
5
+ import { DOM } from "@brandup/ui";
6
6
  import { FuncHelper } from "@brandup/ui-helpers";
7
7
  import copyIcon from "../svg/copy.svg";
8
8
  import doneIcon from "../svg/tick.svg";
@@ -15,10 +15,14 @@ export const MAX_EMAIL_LENGTH = 256; // https://www.rfc-editor.org/rfc/rfc5321#s
15
15
 
16
16
  export type TextBoxType = "text" | "email" | "url" | "tel" | "number";
17
17
 
18
- export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAreaElement> {
18
+ type TextBoxEvents = {
19
+ [CHANGE_EVENT]: (data: ChangeEventData) => void;
20
+ };
21
+
22
+ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAreaElement, TextBoxEvents> {
19
23
  private __inputElem: HTMLElement;
20
- private __actionsElem: HTMLElement;
21
- private __symbolsCountElem?: HTMLElement;
24
+ private __symbolsCountElem: HTMLElement;
25
+ private __listenerAbort = new AbortController();
22
26
 
23
27
  readonly type: TextBoxType;
24
28
  readonly allowEmptyStrings: boolean;
@@ -30,309 +34,316 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
30
34
  readonly symbolCounter: boolean;
31
35
  readonly autoFocus: boolean;
32
36
 
33
- get typeName(): string { return "BrandUp.TextBox"; }
34
-
35
37
  constructor(valueElem: HTMLInputElement | HTMLTextAreaElement) {
36
- super(valueElem);
37
-
38
- if (this.__valueElem instanceof HTMLInputElement) {
39
- switch (this.__valueElem.type) {
38
+ // определяем тип ввода и нормализуем валидационные атрибуты до super()
39
+ let type: TextBoxType = "text";
40
+ if (valueElem instanceof HTMLInputElement) {
41
+ switch (valueElem.type) {
40
42
  case "text":
41
- this.type = "text";
43
+ type = "text";
42
44
  break;
43
45
  case "email":
44
- this.type = "email";
45
-
46
- if (!this.__valueElem.maxLength || this.__valueElem.maxLength > MAX_EMAIL_LENGTH)
47
- this.__valueElem.maxLength = MAX_EMAIL_LENGTH;
48
-
46
+ type = "email";
47
+ if (!valueElem.maxLength || valueElem.maxLength > MAX_EMAIL_LENGTH)
48
+ valueElem.maxLength = MAX_EMAIL_LENGTH;
49
49
  break;
50
50
  case "url":
51
- this.type = "url";
51
+ type = "url";
52
52
  break;
53
53
  case "tel":
54
- this.type = "tel";
54
+ type = "tel";
55
55
  break;
56
56
  case "number":
57
- this.type = "number";
58
- this.__valueElem.step = "1"; // Поддерживаем пока что только целые числа
57
+ type = "number";
58
+ valueElem.step = "1"; // Поддерживаем пока что только целые числа
59
59
  break;
60
60
  default:
61
- throw new Error(`Тип ввода ${this.__valueElem.type} не поддерживается.`);
61
+ throw new Error(`Тип ввода ${valueElem.type} не поддерживается.`);
62
62
  }
63
63
  }
64
- else
65
- this.type = "text";
66
64
 
67
- this.__valueElem.classList.add(INPUT_CLASS);
65
+ valueElem.classList.add(INPUT_CLASS);
68
66
 
69
- // Инициализация свойств
70
- this.maxlength = this.__valueElem.maxLength;
71
- this.symbolCounter = this.__valueElem.hasAttribute("data-symbolcounter");
72
- this.autoFocus = this.__valueElem.hasAttribute("data-autofocus");
73
- this.allowEmptyStrings = this.__valueElem.hasAttribute("data-allow-empty-strings");
74
- this.placeholder = this.__valueElem.getAttribute("placeholder");
75
- this.inputmode = this.__valueElem.inputMode;
76
- this.multyline = valueElem instanceof HTMLTextAreaElement;
77
- this.copyButton = this.__valueElem.hasAttribute("data-copy-button") || this.__valueElem.hasAttribute("data-copybutton");
67
+ const maxlength = valueElem.maxLength;
68
+ const symbolCounter = valueElem.hasAttribute("data-symbolcounter");
69
+ const autoFocus = valueElem.hasAttribute("data-autofocus");
70
+ const allowEmptyStrings = valueElem.hasAttribute("data-allow-empty-strings");
71
+ const placeholder = valueElem.getAttribute("placeholder");
72
+ const inputmode = valueElem.inputMode;
73
+ const multyline = valueElem instanceof HTMLTextAreaElement;
74
+ const copyButton = valueElem.hasAttribute("data-copy-button") || valueElem.hasAttribute("data-copybutton");
75
+ const disabled = valueElem.disabled;
78
76
 
79
- this.__inputElem = DOM.tag("div", { class: "input" });
80
- this.__actionsElem = DOM.tag("div", { class: "actions" });
81
-
82
- this.__renderUI();
83
- this.__initLogic();
84
- this.__initText();
85
- }
77
+ const inputElem = DOM.tag("div", { class: "input" });
78
+ const actionsElem = DOM.tag("div", { class: "actions" });
79
+ const symbolsCountElem = DOM.tag("div", { class: "symbols" });
86
80
 
87
- private __renderUI() {
88
- const container = DOM.tag("div", { class: [ROOT_CLASS].concat(Array.from(this.__valueElem.classList)) }, [
81
+ const container = DOM.tag("div", { class: [ROOT_CLASS].concat(Array.from(valueElem.classList)) }, [
89
82
  DOM.tag("div", { class: "decorator" }),
90
- DOM.tag("div", "editor", [
91
- this.__inputElem,
92
- this.__symbolsCountElem = DOM.tag("div", { class: "symbols" })
93
- ]),
94
- this.__actionsElem
83
+ DOM.tag("div", { class: "editor" }, [inputElem, symbolsCountElem]),
84
+ actionsElem,
95
85
  ]);
96
86
 
97
87
  container.classList.remove(INPUT_CLASS);
98
88
 
99
- this.__inputElem.tabIndex = this.__valueElem.tabIndex;
100
- this.__valueElem.tabIndex = -1;
89
+ inputElem.tabIndex = valueElem.tabIndex;
90
+ valueElem.tabIndex = -1;
101
91
 
102
- if (this.multyline) container.classList.add("multyline");
92
+ if (multyline) container.classList.add("multyline");
93
+ if (symbolCounter) container.classList.add("counter");
103
94
 
104
- if (this.symbolCounter) container.classList.add("counter");
95
+ if (disabled) inputElem.tabIndex = -1;
96
+ else inputElem.contentEditable = "true";
105
97
 
106
- if (this.disabled) {
107
- this.__inputElem.tabIndex = -1;
98
+ if (inputmode) inputElem.inputMode = inputmode;
99
+
100
+ if (copyButton) {
101
+ const buttonElem = DOM.tag(
102
+ "button",
103
+ { command: "copy-text", title: "Скопировать в буфер обмена" },
104
+ copyIcon
105
+ );
106
+ if (disabled) buttonElem.disabled = true;
107
+ actionsElem.insertAdjacentElement("beforeend", buttonElem);
108
108
  }
109
- else
110
- this.__inputElem.contentEditable = "true";
111
109
 
112
- if (this.inputmode) this.__inputElem.inputMode = this.inputmode;
110
+ inputElem.setAttribute("data-placeholder", placeholder ?? "");
113
111
 
114
- if (this.copyButton) {
115
- const buttonElem = <HTMLButtonElement>DOM.tag("button", { command: "copy-text", title: "Скопировать в буфер обмена" }, copyIcon);
116
- if (this.disabled)
117
- buttonElem.disabled = true;
118
- this.__actionsElem.insertAdjacentElement("beforeend", buttonElem);
112
+ // убираем висящую миниатюру, если есть, и вставляем container на место valueElem
113
+ if (valueElem.nextElementSibling) {
114
+ const nextElem = valueElem.nextElementSibling as HTMLElement;
115
+ if (nextElem.classList.contains(MINIATURE_CLASS)) nextElem.remove();
119
116
  }
117
+ valueElem.insertAdjacentElement("afterend", container);
118
+ container.insertAdjacentElement("afterbegin", valueElem);
120
119
 
121
- this.__inputElem.setAttribute("data-placeholder", this.placeholder ?? "");
120
+ super("BrandUp.TextBox", container, valueElem);
122
121
 
123
- this.setElement(container);
122
+ this.type = type;
123
+ this.maxlength = maxlength;
124
+ this.symbolCounter = symbolCounter;
125
+ this.autoFocus = autoFocus;
126
+ this.allowEmptyStrings = allowEmptyStrings;
127
+ this.placeholder = placeholder;
128
+ this.inputmode = inputmode;
129
+ this.multyline = multyline;
130
+ this.copyButton = copyButton;
124
131
 
125
- if (this.__valueElem.nextElementSibling) {
126
- // Если следующий элемент это миниатюра пока textbox не отрисован
127
- const nextElem = <HTMLElement>this.__valueElem.nextElementSibling;
128
- if (nextElem.classList.contains(MINIATURE_CLASS)) nextElem.remove();
129
- }
132
+ this.__inputElem = inputElem;
133
+ this.__symbolsCountElem = symbolsCountElem;
130
134
 
131
- this.__valueElem.insertAdjacentElement("afterend", container);
132
- container.insertAdjacentElement("afterbegin", this.__valueElem);
135
+ this.__initLogic();
136
+ this.__initText();
133
137
  }
134
138
 
135
139
  private __initLogic() {
136
- if (!this.element)
137
- return;
140
+ const { signal } = this.__listenerAbort; // один AbortController отписывает все listener'ы в destroy
138
141
 
139
- this.element.addEventListener("drop", (e) => e.preventDefault());
140
- this.element.addEventListener("dragenter", (e) => e.preventDefault());
142
+ this.element.addEventListener("drop", (e) => e.preventDefault(), { signal });
143
+ this.element.addEventListener("dragenter", (e) => e.preventDefault(), { signal });
141
144
 
142
- this.__valueElem.addEventListener("change", (e: Event) => {
143
- e.preventDefault();
144
- e.stopImmediatePropagation();
145
- });
145
+ this.__valueElem.addEventListener(
146
+ "change",
147
+ (e: Event) => {
148
+ e.preventDefault();
149
+ e.stopImmediatePropagation();
150
+ },
151
+ { signal }
152
+ );
146
153
 
147
154
  let hasInputClick = false;
148
- this.__inputElem.addEventListener("mousedown", () => {
149
- if (this.disabled)
150
- return;
151
-
152
- hasInputClick = true;
153
- });
154
-
155
- this.__inputElem.addEventListener("focus", () => {
156
- if (this.disabled)
157
- return;
158
-
159
- this.element?.classList.add("focused");
160
-
161
- if (this.readonly)
162
- this.__selectAll();
163
- else if (!hasInputClick)
164
- this.__carretToEnd(); // пыремещаем курсов в конец, если клик не по строке
165
- });
166
-
167
- this.__inputElem.addEventListener("blur", () => {
168
- hasInputClick = false;
169
-
170
- if (this.disabled)
171
- return;
172
-
173
- this.element?.classList.remove("focused");
174
-
175
- // когда удаляем весь текст, то браузер оставляет один BR, что означает что текста нет
176
- // удалить BR нужно, чтобы появился placeholder
177
- if (this.__inputElem.firstChild?.nodeName === "BR")
178
- DOM.empty(this.__inputElem);
179
- });
180
-
181
- this.__inputElem.addEventListener("dblclick", () => {
182
- if (this.disabled)
183
- return;
184
-
185
- if (this.copyButton && this.readonly)
186
- this.__selectAll();
187
- });
188
-
189
- this.element.addEventListener("paste", (e: ClipboardEvent) => {
190
- e.preventDefault();
191
- e.stopPropagation();
155
+ this.__inputElem.addEventListener(
156
+ "mousedown",
157
+ () => {
158
+ if (this.disabled) return;
159
+
160
+ hasInputClick = true;
161
+ },
162
+ { signal }
163
+ );
164
+
165
+ this.__inputElem.addEventListener(
166
+ "focus",
167
+ () => {
168
+ if (this.disabled) return;
169
+
170
+ this.element.classList.add("focused");
171
+
172
+ if (this.readonly) this.__selectAll();
173
+ else if (!hasInputClick) this.__carretToEnd(); // пыремещаем курсов в конец, если клик не по строке
174
+ },
175
+ { signal }
176
+ );
177
+
178
+ this.__inputElem.addEventListener(
179
+ "blur",
180
+ () => {
181
+ hasInputClick = false;
182
+
183
+ if (this.disabled) return;
184
+
185
+ this.element.classList.remove("focused");
186
+
187
+ // когда удаляем весь текст, то браузер оставляет один BR, что означает что текста нет
188
+ // удалить BR нужно, чтобы появился placeholder
189
+ if (this.__inputElem.firstChild?.nodeName === "BR") DOM.empty(this.__inputElem);
190
+ },
191
+ { signal }
192
+ );
193
+
194
+ this.__inputElem.addEventListener(
195
+ "dblclick",
196
+ () => {
197
+ if (this.disabled) return;
198
+
199
+ if (this.copyButton && this.readonly) this.__selectAll();
200
+ },
201
+ { signal }
202
+ );
203
+
204
+ this.element.addEventListener(
205
+ "paste",
206
+ (e: ClipboardEvent) => {
207
+ e.preventDefault();
208
+ e.stopPropagation();
192
209
 
193
- if (this.readonly || this.disabled || !this.element)
194
- return false;
210
+ if (this.readonly || this.disabled) return false;
195
211
 
196
- let pastedData = e.clipboardData?.getData("text/plain");
197
- if (!pastedData)
198
- return false;
212
+ let pastedData = e.clipboardData?.getData("text/plain");
213
+ if (!pastedData) return false;
199
214
 
200
- if (this.type == "number") {
201
- const numberData = /[\d\s]+/.exec(pastedData);
202
- if (numberData && numberData.length)
203
- pastedData = numberData[0].replace(/\s/g, '');
204
- else {
205
- this.element.classList.add("incorrect");
206
- window.setTimeout(() => this.element?.classList.remove("incorrect"), 300);
207
- return false;
215
+ if (this.type == "number") {
216
+ const numberData = /[\d\s]+/.exec(pastedData);
217
+ if (numberData && numberData.length) pastedData = numberData[0].replace(/\s/g, "");
218
+ else {
219
+ this.element.classList.add("incorrect");
220
+ window.setTimeout(() => this.element.classList.remove("incorrect"), 300);
221
+ return false;
222
+ }
208
223
  }
209
- }
210
224
 
211
- const selection = window.getSelection();
212
- if (!selection)
213
- return false; // TODO
225
+ const selection = window.getSelection();
226
+ if (!selection) return false; // TODO
214
227
 
215
- if (this.maxlength > 0) {
216
- // обрезаем вставляемый текст по кол-ву оставшихся символов для ввода
228
+ if (this.maxlength > 0) {
229
+ // обрезаем вставляемый текст по кол-ву оставшихся символов для ввода
217
230
 
218
- const selectionLength = selection.toString().length;
219
- const currentTextLength = this.__getTextLenght();
220
- const leftSymbols = this.maxlength - currentTextLength + selectionLength; // осталось символов для ввода
221
-
222
- if (pastedData.length > leftSymbols)
223
- pastedData = pastedData.substring(0, leftSymbols);
224
- }
231
+ const selectionLength = selection.toString().length;
232
+ const currentTextLength = this.__getTextLength();
233
+ const leftSymbols = this.maxlength - currentTextLength + selectionLength; // осталось символов для ввода
225
234
 
226
- const lines = pastedData.split(/\n/);
227
- // тримим все строки, кроме начала первой строки, вдруг так нужно
228
- const output = lines.map((line, index) => index === 0 ? line.trimEnd() : line.trim());
235
+ if (pastedData.length > leftSymbols) pastedData = pastedData.substring(0, leftSymbols);
236
+ }
229
237
 
230
- var fragment = document.createDocumentFragment();
231
- if (!this.multyline) {
232
- fragment.appendChild(document.createTextNode(output.join(" ")));
233
- } else {
234
- output.forEach((line, index) => {
235
- if (index > 0) fragment.appendChild(document.createElement("br"));
238
+ const lines = pastedData.split(/\n/);
239
+ // тримим все строки, кроме начала первой строки, вдруг так нужно
240
+ const output = lines.map((line, index) => (index === 0 ? line.trimEnd() : line.trim()));
236
241
 
237
- fragment.appendChild(document.createTextNode(line));
238
- });
239
- }
242
+ const fragment = document.createDocumentFragment();
243
+ if (!this.multyline) {
244
+ fragment.appendChild(document.createTextNode(output.join(" ")));
245
+ } else {
246
+ output.forEach((line, index) => {
247
+ if (index > 0) fragment.appendChild(document.createElement("br"));
240
248
 
241
- // Удаляем выделенную область
242
- selection.getRangeAt(0).deleteContents();
249
+ fragment.appendChild(document.createTextNode(line));
250
+ });
251
+ }
243
252
 
244
- // Вставляем текст
245
- selection.getRangeAt(0).insertNode(fragment);
253
+ // Удаляем выделенную область
254
+ selection.getRangeAt(0).deleteContents();
246
255
 
247
- // Перемещаем курсор в конец вставленной области
248
- selection.setPosition(selection.focusNode, selection.focusOffset);
256
+ // Вставляем текст
257
+ selection.getRangeAt(0).insertNode(fragment);
249
258
 
250
- this.__applyValue();
259
+ // Перемещаем курсор в конец вставленной области
260
+ selection.setPosition(selection.focusNode, selection.focusOffset);
251
261
 
252
- return true;
253
- });
262
+ this.__applyValue();
254
263
 
255
- this.__inputElem.addEventListener("keydown", (e: KeyboardEvent) => {
256
- if (!this.element)
257
- return false;
264
+ return true;
265
+ },
266
+ { signal }
267
+ );
258
268
 
259
- const isChar = e.key.length === 1;
269
+ this.__inputElem.addEventListener(
270
+ "keydown",
271
+ (e: KeyboardEvent) => {
272
+ const isChar = e.key.length === 1;
260
273
 
261
- if ((this.readonly || this.disabled) && isChar && !e.ctrlKey) {
262
- e.preventDefault();
263
- e.stopPropagation();
264
- return false;
265
- }
266
-
267
- if (this.maxlength > 0 && isChar && !e.ctrlKey) {
268
- const currentTextLength = this.__getTextLenght();
269
- if (currentTextLength >= this.maxlength) {
274
+ if ((this.readonly || this.disabled) && isChar && !e.ctrlKey) {
270
275
  e.preventDefault();
271
276
  e.stopPropagation();
272
-
273
- this.__toIncorrect();
274
277
  return false;
275
278
  }
276
- }
277
279
 
278
- if (isChar && !e.ctrlKey) {
279
- let isIncorrect = false;
280
-
281
- switch (this.type) {
282
- case "number":
283
- if (!/\d/.test(e.key))
284
- isIncorrect = true;
285
- break;
286
- case "email":
287
- if (!/[a-zA-Z\d\.\-\_\@]/.test(e.key))
288
- isIncorrect = true;
289
- break;
280
+ if (this.maxlength > 0 && isChar && !e.ctrlKey) {
281
+ const currentTextLength = this.__getTextLength();
282
+ if (currentTextLength >= this.maxlength) {
283
+ e.preventDefault();
284
+ e.stopPropagation();
285
+
286
+ this.__toIncorrect();
287
+ return false;
288
+ }
290
289
  }
291
290
 
292
- if (isIncorrect) {
293
- e.preventDefault();
294
- e.stopPropagation();
291
+ if (isChar && !e.ctrlKey) {
292
+ let isIncorrect = false;
293
+
294
+ switch (this.type) {
295
+ case "number":
296
+ if (!/\d/.test(e.key)) isIncorrect = true;
297
+ break;
298
+ case "email":
299
+ if (!/[a-zA-Z\d.\-_@]/.test(e.key)) isIncorrect = true;
300
+ break;
301
+ }
302
+
303
+ if (isIncorrect) {
304
+ e.preventDefault();
305
+ e.stopPropagation();
306
+
307
+ this.__toIncorrect();
308
+ return false;
309
+ }
310
+ }
295
311
 
296
- this.__toIncorrect();
312
+ if (!this.multyline && (e.key == "U+000A" || e.key == "Enter")) {
313
+ // Если однострочный режим и нажат enter, то отправляем submit формы
314
+ e.preventDefault();
315
+ this.__submitForm();
297
316
  return false;
298
317
  }
299
- }
300
-
301
- if (!this.multyline && (e.key == "U+000A" || e.key == "Enter")) {
302
- // Если однострочный режим и нажат enter, то отправляем submit формы
303
- e.preventDefault();
304
- this.__submitForm();
305
- return false;
306
- }
307
-
308
- return true;
309
- });
310
318
 
311
- this.__inputElem.addEventListener("input", () => {
312
- if (!this.element)
313
- return;
314
-
315
- if (this.multyline && this.__inputElem.children.length === 1) {
316
- const child = this.__inputElem.children.item(0);
317
- if (child && child.tagName === "BR")
318
- this.__inputElem.innerHTML = '';
319
- }
319
+ return true;
320
+ },
321
+ { signal }
322
+ );
323
+
324
+ this.__inputElem.addEventListener(
325
+ "input",
326
+ () => {
327
+ if (this.multyline && this.__inputElem.children.length === 1) {
328
+ const child = this.__inputElem.children.item(0);
329
+ if (child && child.tagName === "BR") this.__inputElem.innerHTML = "";
330
+ }
320
331
 
321
- this.__applyValue();
332
+ this.__applyValue();
322
333
 
323
- let clearInvalidState = true;
334
+ let clearInvalidState = true;
324
335
 
325
- if (this.element.classList.contains("invalid"))
326
- clearInvalidState = this.validate(); // Если уже не валидно, то перепроверяем
336
+ if (this.element.classList.contains("invalid")) clearInvalidState = this.validate(); // Если уже не валидно, то перепроверяем
327
337
 
328
- if (clearInvalidState)
329
- this.element.classList.remove("invalid");
330
- });
338
+ if (clearInvalidState) this.element.classList.remove("invalid");
339
+ },
340
+ { signal }
341
+ );
331
342
 
332
- this.registerCommand("copy-text", async context => {
343
+ this.registerCommand("copy-text", async (context) => {
333
344
  if (!window.navigator.clipboard || this.disabled) return;
334
345
 
335
- await window.navigator.clipboard.writeText(this.__valueElem.value)
346
+ await window.navigator.clipboard.writeText(this.__valueElem.value);
336
347
 
337
348
  const prevHtml = context.target.innerHTML;
338
349
  context.target.innerHTML = doneIcon;
@@ -355,8 +366,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
355
366
  lines.forEach((line, index) => {
356
367
  line = line.trim();
357
368
 
358
- if (index === 0)
359
- this.__inputElem.append(document.createTextNode(line));
369
+ if (index === 0) this.__inputElem.append(document.createTextNode(line));
360
370
  else {
361
371
  const lineElem = document.createElement("div");
362
372
  lineElem.textContent = line;
@@ -367,12 +377,11 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
367
377
 
368
378
  this.__refreshSymbolsCount();
369
379
 
370
- if (this.autoFocus && !IS_TOUCH_DEVICE && !this.disabled && !this.readonly)
371
- this.__inputElem.focus();
380
+ if (this.autoFocus && !IS_TOUCH_DEVICE && !this.disabled && !this.readonly) this.__inputElem.focus();
372
381
  }
373
382
 
374
383
  private __applyValue() {
375
- let newValue = this.__inputElem.innerText.trim();
384
+ const newValue = this.__inputElem.innerText.trim();
376
385
  this.__valueElem.value = newValue;
377
386
 
378
387
  this.__refreshSymbolsCount();
@@ -380,29 +389,21 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
380
389
  }
381
390
 
382
391
  private __toIncorrect() {
383
- if (!this.element)
384
- return;
385
-
386
392
  this.element.classList.add("incorrect");
387
- window.setTimeout(() => this.element?.classList.remove("incorrect"), 200);
393
+ window.setTimeout(() => this.element.classList.remove("incorrect"), 200);
388
394
  }
389
395
 
390
396
  private __refreshSymbolsCount() {
391
- if (!this.__symbolsCountElem)
392
- return;
397
+ if (!this.__symbolsCountElem) return;
393
398
 
394
- const textLength = this.__getTextLenght();
399
+ const textLength = this.__getTextLength();
395
400
  let counterValue: string;
396
401
 
397
402
  if (this.maxlength > 0) {
398
403
  counterValue = `${textLength}/${this.maxlength}`;
399
- if (this.maxlength < textLength)
400
- this.__symbolsCountElem.classList.add("invalid");
401
- else
402
- this.__symbolsCountElem.classList.remove("invalid");
403
- }
404
- else
405
- counterValue = textLength.toString();
404
+ if (this.maxlength < textLength) this.__symbolsCountElem.classList.add("invalid");
405
+ else this.__symbolsCountElem.classList.remove("invalid");
406
+ } else counterValue = textLength.toString();
406
407
 
407
408
  this.__symbolsCountElem.textContent = counterValue;
408
409
  }
@@ -410,28 +411,30 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
410
411
  private __selectAll() {
411
412
  this.__inputElem.focus();
412
413
 
413
- window.getSelection()?.selectAllChildren(this.__inputElem)
414
+ window.getSelection()?.selectAllChildren(this.__inputElem);
414
415
  }
415
416
 
416
417
  private __carretToEnd() {
417
- var range = document.createRange();
418
+ const range = document.createRange();
418
419
  range.selectNodeContents(this.__inputElem);
419
420
  range.collapse(false);
420
- var sel = window.getSelection();
421
+ const sel = window.getSelection();
421
422
  if (sel) {
422
423
  sel.removeAllRanges();
423
424
  sel.addRange(range);
424
425
  }
425
426
  }
426
427
 
427
- private __getTextLenght() {
428
- return this.__inputElem.innerText.length;
428
+ private __getTextLength() {
429
+ // textContent не вставляет \n между блочными детьми (в отличие от innerText), так что multiline-контент считается корректно;
430
+ // заодно работает в jsdom, где innerText не реализован.
431
+ return this.__inputElem.textContent?.length ?? 0;
429
432
  }
430
433
 
431
434
  private __onChange() {
432
435
  this.trigger(CHANGE_EVENT, <ChangeEventData>{
433
436
  textbox: this,
434
- value: this.getValue()
437
+ value: this.getValue(),
435
438
  });
436
439
  }
437
440
 
@@ -444,7 +447,7 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
444
447
  }
445
448
 
446
449
  getValue(): string {
447
- return this.__valueElem.value?.trim() ?? null;
450
+ return this.__valueElem.value.trim();
448
451
  }
449
452
 
450
453
  setValue(value: string): void {
@@ -455,33 +458,27 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
455
458
  }
456
459
 
457
460
  override validate(): boolean {
458
- if (!this.element)
459
- return false;
460
-
461
461
  let isValid = super.validate();
462
462
  if (isValid) {
463
- let value = this.getValue();
463
+ const value = this.getValue();
464
464
 
465
- if (this.required && !value)
466
- isValid = false;
465
+ if (this.required && !value) isValid = false;
467
466
 
468
- if (this.maxlength > 0 && this.maxlength < value.length)
469
- isValid = false;
467
+ if (this.maxlength > 0 && this.maxlength < value.length) isValid = false;
470
468
  }
471
469
 
472
- if (!isValid)
473
- this.element.classList.add("invalid");
474
- else
475
- this.element.classList.remove("invalid");
470
+ if (!isValid) this.element.classList.add("invalid");
471
+ else this.element.classList.remove("invalid");
476
472
 
477
473
  return isValid;
478
474
  }
479
475
 
480
476
  override destroy(): void {
477
+ this.__listenerAbort.abort();
481
478
  this.__valueElem.tabIndex = this.__inputElem.tabIndex;
482
479
 
483
- this.element?.insertAdjacentElement("afterend", this.__valueElem);
484
- this.element?.remove();
480
+ this.element.insertAdjacentElement("afterend", this.__valueElem);
481
+ this.element.remove();
485
482
 
486
483
  super.destroy();
487
484
  }
@@ -490,4 +487,4 @@ export default class TextBox extends InputControl<HTMLInputElement | HTMLTextAre
490
487
  export interface ChangeEventData {
491
488
  textbox: TextBox;
492
489
  value: string;
493
- }
490
+ }
@@ -1,4 +1,4 @@
1
1
  declare module "*.svg" {
2
- const content: any;
3
- export default content;
4
- }
2
+ const content: any;
3
+ export default content;
4
+ }