@aquera/nile-elements 1.6.1 → 1.6.2

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.
Files changed (174) hide show
  1. package/README.md +6 -0
  2. package/dist/index.cjs.js +1 -1
  3. package/dist/index.esm.js +1 -1
  4. package/dist/index.js +953 -595
  5. package/dist/internal/enum.cjs.js +1 -1
  6. package/dist/internal/enum.cjs.js.map +1 -1
  7. package/dist/internal/enum.esm.js +1 -1
  8. package/dist/nile-badge/index.cjs.js +1 -1
  9. package/dist/nile-badge/index.esm.js +1 -1
  10. package/dist/nile-badge/nile-badge.cjs.js +1 -1
  11. package/dist/nile-badge/nile-badge.cjs.js.map +1 -1
  12. package/dist/nile-badge/nile-badge.esm.js +1 -1
  13. package/dist/nile-button/index.cjs.js +1 -1
  14. package/dist/nile-button/index.esm.js +1 -1
  15. package/dist/nile-button/nile-button.cjs.js +1 -1
  16. package/dist/nile-button/nile-button.cjs.js.map +1 -1
  17. package/dist/nile-button/nile-button.esm.js +1 -1
  18. package/dist/nile-carousel/index.cjs.js +1 -1
  19. package/dist/nile-carousel/index.esm.js +1 -1
  20. package/dist/nile-carousel/nile-carousel.cjs.js +1 -1
  21. package/dist/nile-carousel/nile-carousel.cjs.js.map +1 -1
  22. package/dist/nile-carousel/nile-carousel.esm.js +1 -1
  23. package/dist/nile-dialog/index.cjs.js +1 -1
  24. package/dist/nile-dialog/index.esm.js +1 -1
  25. package/dist/nile-dialog/nile-dialog.cjs.js +1 -1
  26. package/dist/nile-dialog/nile-dialog.cjs.js.map +1 -1
  27. package/dist/nile-dialog/nile-dialog.esm.js +1 -1
  28. package/dist/nile-drawer/index.cjs.js +1 -1
  29. package/dist/nile-drawer/index.esm.js +1 -1
  30. package/dist/nile-drawer/nile-drawer.cjs.js +1 -1
  31. package/dist/nile-drawer/nile-drawer.cjs.js.map +1 -1
  32. package/dist/nile-drawer/nile-drawer.esm.js +1 -1
  33. package/dist/nile-floating-panel/nile-floating-panel.cjs.js +1 -1
  34. package/dist/nile-floating-panel/nile-floating-panel.cjs.js.map +1 -1
  35. package/dist/nile-floating-panel/nile-floating-panel.esm.js +1 -1
  36. package/dist/nile-icon/icons/svg/folder_delete.cjs.js +2 -0
  37. package/dist/nile-icon/icons/svg/folder_delete.cjs.js.map +1 -0
  38. package/dist/nile-icon/icons/svg/folder_delete.esm.js +1 -0
  39. package/dist/nile-icon/icons/svg/index.cjs.js +1 -1
  40. package/dist/nile-icon/icons/svg/index.esm.js +1 -1
  41. package/dist/nile-icon/icons/svg/layers-three-02.cjs.js +1 -1
  42. package/dist/nile-icon/icons/svg/layers-three-02.cjs.js.map +1 -1
  43. package/dist/nile-icon/icons/svg/layers-three-02.esm.js +1 -1
  44. package/dist/nile-icon/index.cjs.js +1 -1
  45. package/dist/nile-icon/index.cjs.js.map +1 -1
  46. package/dist/nile-icon/index.esm.js +1 -1
  47. package/dist/nile-icon-button/index.cjs.js +1 -1
  48. package/dist/nile-icon-button/index.esm.js +1 -1
  49. package/dist/nile-icon-button/nile-icon-button.cjs.js +1 -1
  50. package/dist/nile-icon-button/nile-icon-button.cjs.js.map +1 -1
  51. package/dist/nile-icon-button/nile-icon-button.esm.js +1 -1
  52. package/dist/nile-input/index.cjs.js +1 -1
  53. package/dist/nile-input/index.esm.js +1 -1
  54. package/dist/nile-input/nile-input.cjs.js +1 -1
  55. package/dist/nile-input/nile-input.cjs.js.map +1 -1
  56. package/dist/nile-input/nile-input.esm.js +1 -1
  57. package/dist/nile-menu-item/index.cjs.js +1 -1
  58. package/dist/nile-menu-item/index.esm.js +1 -1
  59. package/dist/nile-menu-item/nile-menu-item.cjs.js +1 -1
  60. package/dist/nile-menu-item/nile-menu-item.cjs.js.map +1 -1
  61. package/dist/nile-menu-item/nile-menu-item.esm.js +1 -1
  62. package/dist/nile-option/index.cjs.js +1 -1
  63. package/dist/nile-option/index.esm.js +1 -1
  64. package/dist/nile-option/nile-option.cjs.js +1 -1
  65. package/dist/nile-option/nile-option.cjs.js.map +1 -1
  66. package/dist/nile-option/nile-option.esm.js +1 -1
  67. package/dist/nile-otp-input/index.cjs.js +2 -0
  68. package/dist/nile-otp-input/index.cjs.js.map +1 -0
  69. package/dist/nile-otp-input/index.esm.js +1 -0
  70. package/dist/nile-otp-input/nile-otp-input.cjs.js +2 -0
  71. package/dist/nile-otp-input/nile-otp-input.cjs.js.map +1 -0
  72. package/dist/nile-otp-input/nile-otp-input.css.cjs.js +2 -0
  73. package/dist/nile-otp-input/nile-otp-input.css.cjs.js.map +1 -0
  74. package/dist/nile-otp-input/nile-otp-input.css.esm.js +257 -0
  75. package/dist/nile-otp-input/nile-otp-input.enum.cjs.js +2 -0
  76. package/dist/nile-otp-input/nile-otp-input.enum.cjs.js.map +1 -0
  77. package/dist/nile-otp-input/nile-otp-input.enum.esm.js +1 -0
  78. package/dist/nile-otp-input/nile-otp-input.esm.js +103 -0
  79. package/dist/nile-select/index.cjs.js +1 -1
  80. package/dist/nile-select/index.esm.js +1 -1
  81. package/dist/nile-select/nile-select.cjs.js +1 -1
  82. package/dist/nile-select/nile-select.cjs.js.map +1 -1
  83. package/dist/nile-select/nile-select.esm.js +1 -1
  84. package/dist/nile-side-bar-action-menu-item/index.cjs.js +1 -1
  85. package/dist/nile-side-bar-action-menu-item/index.esm.js +1 -1
  86. package/dist/nile-side-bar-action-menu-item/nile-side-bar-action-menu-item.cjs.js +1 -1
  87. package/dist/nile-side-bar-action-menu-item/nile-side-bar-action-menu-item.cjs.js.map +1 -1
  88. package/dist/nile-side-bar-action-menu-item/nile-side-bar-action-menu-item.esm.js +1 -1
  89. package/dist/nile-tab/index.cjs.js +1 -1
  90. package/dist/nile-tab/index.esm.js +1 -1
  91. package/dist/nile-tab/nile-tab.cjs.js +1 -1
  92. package/dist/nile-tab/nile-tab.cjs.js.map +1 -1
  93. package/dist/nile-tab/nile-tab.esm.js +1 -1
  94. package/dist/nile-tab-group/index.cjs.js +1 -1
  95. package/dist/nile-tab-group/index.esm.js +1 -1
  96. package/dist/nile-tab-group/nile-tab-group.cjs.js +1 -1
  97. package/dist/nile-tab-group/nile-tab-group.cjs.js.map +1 -1
  98. package/dist/nile-tab-group/nile-tab-group.esm.js +1 -1
  99. package/dist/nile-tag/index.cjs.js +1 -1
  100. package/dist/nile-tag/index.esm.js +1 -1
  101. package/dist/nile-tag/nile-tag.cjs.js +1 -1
  102. package/dist/nile-tag/nile-tag.cjs.js.map +1 -1
  103. package/dist/nile-tag/nile-tag.esm.js +1 -1
  104. package/dist/nile-toast/index.cjs.js +1 -1
  105. package/dist/nile-toast/index.esm.js +1 -1
  106. package/dist/nile-toast/nile-toast.cjs.js +1 -1
  107. package/dist/nile-toast/nile-toast.cjs.js.map +1 -1
  108. package/dist/nile-toast/nile-toast.esm.js +1 -1
  109. package/dist/nile-tree/index.cjs.js +1 -1
  110. package/dist/nile-tree/index.esm.js +1 -1
  111. package/dist/nile-tree/nile-tree.cjs.js +1 -1
  112. package/dist/nile-tree/nile-tree.cjs.js.map +1 -1
  113. package/dist/nile-tree/nile-tree.esm.js +1 -1
  114. package/dist/nile-tree-item/index.cjs.js +1 -1
  115. package/dist/nile-tree-item/index.esm.js +1 -1
  116. package/dist/nile-tree-item/nile-tree-item.cjs.js +1 -1
  117. package/dist/nile-tree-item/nile-tree-item.cjs.js.map +1 -1
  118. package/dist/nile-tree-item/nile-tree-item.esm.js +1 -1
  119. package/dist/nile-virtual-select/index.cjs.js +1 -1
  120. package/dist/nile-virtual-select/index.esm.js +1 -1
  121. package/dist/nile-virtual-select/nile-virtual-select.cjs.js +2 -2
  122. package/dist/nile-virtual-select/nile-virtual-select.esm.js +1 -1
  123. package/dist/src/index.d.ts +1 -0
  124. package/dist/src/index.js +1 -0
  125. package/dist/src/index.js.map +1 -1
  126. package/dist/src/internal/enum.d.ts +21 -0
  127. package/dist/src/internal/enum.js +23 -1
  128. package/dist/src/internal/enum.js.map +1 -1
  129. package/dist/src/nile-floating-panel/nile-floating-panel.d.ts +2 -0
  130. package/dist/src/nile-floating-panel/nile-floating-panel.js +12 -1
  131. package/dist/src/nile-floating-panel/nile-floating-panel.js.map +1 -1
  132. package/dist/src/nile-icon/icons/svg/folder_delete.d.ts +5 -0
  133. package/dist/src/nile-icon/icons/svg/folder_delete.js +5 -0
  134. package/dist/src/nile-icon/icons/svg/folder_delete.js.map +1 -0
  135. package/dist/src/nile-icon/icons/svg/index.d.ts +1 -0
  136. package/dist/src/nile-icon/icons/svg/index.js +1 -0
  137. package/dist/src/nile-icon/icons/svg/index.js.map +1 -1
  138. package/dist/src/nile-icon/icons/svg/layers-three-02.d.ts +1 -1
  139. package/dist/src/nile-icon/icons/svg/layers-three-02.js +1 -1
  140. package/dist/src/nile-icon/icons/svg/layers-three-02.js.map +1 -1
  141. package/dist/src/nile-otp-input/index.d.ts +1 -0
  142. package/dist/src/nile-otp-input/index.js +2 -0
  143. package/dist/src/nile-otp-input/index.js.map +1 -0
  144. package/dist/src/nile-otp-input/nile-otp-input.css.d.ts +12 -0
  145. package/dist/src/nile-otp-input/nile-otp-input.css.js +269 -0
  146. package/dist/src/nile-otp-input/nile-otp-input.css.js.map +1 -0
  147. package/dist/src/nile-otp-input/nile-otp-input.d.ts +156 -0
  148. package/dist/src/nile-otp-input/nile-otp-input.enum.d.ts +26 -0
  149. package/dist/src/nile-otp-input/nile-otp-input.enum.js +32 -0
  150. package/dist/src/nile-otp-input/nile-otp-input.enum.js.map +1 -0
  151. package/dist/src/nile-otp-input/nile-otp-input.js +762 -0
  152. package/dist/src/nile-otp-input/nile-otp-input.js.map +1 -0
  153. package/dist/src/nile-otp-input/nile-otp-input.test.d.ts +1 -0
  154. package/dist/src/nile-otp-input/nile-otp-input.test.js +493 -0
  155. package/dist/src/nile-otp-input/nile-otp-input.test.js.map +1 -0
  156. package/dist/src/version.js +2 -2
  157. package/dist/src/version.js.map +1 -1
  158. package/dist/tsconfig.tsbuildinfo +1 -1
  159. package/package.json +2 -1
  160. package/plop-templates/lit/index.ts.hbs +1 -1
  161. package/plop-templates/lit/lit.css.ts.hbs +1 -1
  162. package/plop-templates/lit/lit.ts.hbs +1 -1
  163. package/src/index.ts +2 -1
  164. package/src/internal/enum.ts +23 -1
  165. package/src/nile-floating-panel/nile-floating-panel.ts +10 -1
  166. package/src/nile-icon/icons/svg/folder_delete.ts +5 -0
  167. package/src/nile-icon/icons/svg/index.ts +1 -0
  168. package/src/nile-icon/icons/svg/layers-three-02.ts +1 -1
  169. package/src/nile-otp-input/index.ts +1 -0
  170. package/src/nile-otp-input/nile-otp-input.css.ts +271 -0
  171. package/src/nile-otp-input/nile-otp-input.enum.ts +30 -0
  172. package/src/nile-otp-input/nile-otp-input.test.ts +732 -0
  173. package/src/nile-otp-input/nile-otp-input.ts +835 -0
  174. package/vscode-html-custom-data.json +171 -1
@@ -0,0 +1,835 @@
1
+ /**
2
+ * Copyright Aquera Inc 2026
3
+ *
4
+ * This source code is licensed under the BSD-3-Clause license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import { html } from 'lit';
9
+ import {
10
+ customElement,
11
+ property,
12
+ query,
13
+ queryAll,
14
+ state,
15
+ } from 'lit/decorators.js';
16
+ import { classMap } from 'lit/directives/class-map.js';
17
+ import { ifDefined } from 'lit/directives/if-defined.js';
18
+ import { live } from 'lit/directives/live.js';
19
+ import { defaultValue } from '../internal/default-value';
20
+ import { FormControlController, validValidityState } from '../internal/form';
21
+ import { HasSlotController } from '../internal/slot';
22
+ import { watch } from '../internal/watch';
23
+ import NileElement from '../internal/nile-element';
24
+ import { KeyCode, Nile_Events } from '../internal/enum';
25
+ import type { CSSResultGroup } from 'lit';
26
+ import type { NileFormControl } from '../internal/nile-element';
27
+ import { styles } from './nile-otp-input.css';
28
+ import {
29
+ OtpInputMode,
30
+ OtpInputType,
31
+ OtpEnterKeyHint,
32
+ OtpAutoComplete,
33
+ OtpCellPattern,
34
+ } from './nile-otp-input.enum';
35
+ import '../nile-form-help-text';
36
+ import '../nile-form-error-message';
37
+
38
+ /**
39
+ * @summary OTP input renders a segmented set of cells but behaves like a single logical form control.
40
+ * @tag nile-otp-input
41
+ *
42
+ * @slot label - The input label. Alternatively, use the `label` attribute.
43
+ * @slot help-text - Helpful guidance text. Alternatively, use the `help-text` attribute.
44
+ *
45
+ * @event nile-input - Emitted whenever the OTP value changes from user input.
46
+ * @event nile-change - Emitted whenever the OTP value changes from user input.
47
+ * @event nile-complete - Emitted when all OTP cells are filled.
48
+ * @event nile-focus - Emitted when focus enters the component.
49
+ * @event nile-blur - Emitted when focus leaves the component.
50
+ * @event nile-paste - Emitted when OTP text is pasted.
51
+ * @event nile-invalid - Emitted when the control is invalid.
52
+ *
53
+ * @csspart form-control - Wrapper for label, input, and help/error content.
54
+ * @csspart form-control-label - Label wrapper.
55
+ * @csspart form-control-input - Input wrapper.
56
+ * @csspart form-control-help-text - Help text wrapper.
57
+ * @csspart form-control-error-message - Error message wrapper.
58
+ * @csspart base - OTP cell container.
59
+ * @csspart cell - Individual OTP cell.
60
+ * @csspart separator - Separator element between cell groups.
61
+ */
62
+ @customElement('nile-otp-input')
63
+ export class NileOtpInput extends NileElement implements NileFormControl {
64
+ static styles: CSSResultGroup = styles;
65
+
66
+ private readonly formControlController: FormControlController =
67
+ new FormControlController(this, {
68
+ assumeInteractionOn: [Nile_Events.NILE_BLUR, Nile_Events.NILE_INPUT],
69
+ });
70
+
71
+ private readonly hasSlotController = new HasSlotController(
72
+ this,
73
+ 'help-text',
74
+ 'label'
75
+ );
76
+
77
+ private customValidationMessage = '';
78
+ private wasComplete = false;
79
+
80
+ @query('.otp__value-input') valueInput: HTMLInputElement;
81
+ @queryAll('.otp__cell') cellInputs: NodeListOf<HTMLInputElement>;
82
+
83
+ @state() private hasFocus = false;
84
+ @state() private activeIndex = -1;
85
+ @state() private cells: string[] = this.createCells('');
86
+
87
+ /** The name of the input, submitted as a name/value pair with form data. */
88
+ @property({ reflect: true, type: String, attribute: true }) name = '';
89
+
90
+ /** The current value of the OTP control. */
91
+ @property({ reflect: true, type: String, attribute: true }) value = '';
92
+
93
+ /** The default value of the form control. Primarily used for resetting the form control. */
94
+ @defaultValue() defaultValue = '';
95
+
96
+ /** Number of OTP cells. Values below 4 are clamped to 4. */
97
+ @property({ type: Number, reflect: true, attribute: true }) length = 6;
98
+
99
+ /** Restricts input to numeric digits when true. Overridden by `alphanumeric`. */
100
+ @property({ type: Boolean, reflect: true, attribute: true }) numericOnly = true;
101
+
102
+ /** Allows both letters and digits. When present, overrides `numeric-only`. */
103
+ @property({ type: Boolean, reflect: true, attribute: true }) alphanumeric = false;
104
+
105
+ /** The input's label. */
106
+ @property({ reflect: true, attribute: true, type: String }) label = '';
107
+
108
+ @property({ attribute: true, reflect: true, type: String }) helpText = '';
109
+
110
+ @property({ attribute: true, reflect: true, type: String }) errorMessage = '';
111
+
112
+ /** Placeholder shown inside each OTP cell. */
113
+ @property({ reflect: true, attribute: true, type: String }) placeholder = '';
114
+
115
+ /** Optional separator text rendered between configured OTP groups (for example "-"). */
116
+ @property({ reflect: true, type: String }) separator = '';
117
+
118
+ /** Renders a separator after each N cells when `separator` is set. */
119
+ @property({ type: Number, attribute: true, reflect: true }) separatorEvery = 0;
120
+
121
+ /** Comma-separated zero-based cell indexes after which separators are rendered. */
122
+ @property({ attribute: 'separator-positions', type: String, reflect: true }) separatorPositions = '';
123
+
124
+ /** Masks filled cells with dots, showing each character briefly while typing. */
125
+ @property({ type: Boolean, reflect: true }) masked = false;
126
+
127
+ /** Sets the input to a warning state, changing its visual appearance. */
128
+ @property({ type: Boolean, attribute: true, reflect: true }) warning = false;
129
+
130
+ /** Sets the input to an error state, changing its visual appearance. */
131
+ @property({ type: Boolean, attribute: true, reflect: true }) error = false;
132
+
133
+ /** Sets the input to a success state, changing its visual appearance. */
134
+ @property({ type: Boolean, attribute: true, reflect: true }) success = false;
135
+
136
+ /** Disables the control. */
137
+ @property({ type: Boolean, reflect: true, attribute: true }) disabled = false;
138
+
139
+ /** Makes the control readonly. */
140
+ @property({ type: Boolean, attribute: true, reflect: true }) readonly = false;
141
+
142
+ /**
143
+ * By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
144
+ * to place the form control outside of a form and associate it with the form that has this `id`.
145
+ */
146
+ @property({ reflect: true, attribute: true, type: String }) form = '';
147
+
148
+ /** Makes this field required. */
149
+ @property({ type: Boolean, reflect: true, attribute: true }) required = false;
150
+
151
+ /** Optional regex pattern for full OTP validation. */
152
+ @property({ reflect: true, attribute: true, type: String }) pattern: string;
153
+
154
+ /** Indicates that the input should receive focus on page load. */
155
+ @property({ type: Boolean, reflect: true, attribute: true }) autofocus = false;
156
+
157
+ /** Controls keyboard type shown on supporting virtual keyboards. */
158
+ @property({ reflect: true, attribute: true, type: String }) inputmode:
159
+ | 'none'
160
+ | 'text'
161
+ | 'decimal'
162
+ | 'numeric'
163
+ | 'tel'
164
+ | 'search'
165
+ | 'email'
166
+ | 'url';
167
+
168
+ /** The autocomplete mode used on the first OTP cell. */
169
+ @property({ reflect: true, type: String }) autocomplete: string = OtpAutoComplete.ONE_TIME_CODE;
170
+
171
+ connectedCallback() {
172
+ super.connectedCallback();
173
+ this.emit(Nile_Events.NILE_INIT);
174
+ }
175
+
176
+ disconnectedCallback() {
177
+ super.disconnectedCallback();
178
+ this.emit(Nile_Events.NILE_DESTROY);
179
+ }
180
+
181
+ firstUpdated() {
182
+ const normalized = this.normalizeValue(this.value);
183
+ if (normalized !== this.value) {
184
+ this.value = normalized;
185
+ return;
186
+ }
187
+
188
+ this.syncCellsFromValue(normalized);
189
+ this.wasComplete = this.isComplete(normalized);
190
+ this.valueInput.setCustomValidity(this.customValidationMessage);
191
+ this.formControlController.updateValidity();
192
+
193
+ if (this.autofocus) {
194
+ this.focus();
195
+ }
196
+ }
197
+
198
+ /** Gets the validity state object. */
199
+ get validity() {
200
+ return this.valueInput?.validity ?? validValidityState;
201
+ }
202
+
203
+ /** Gets the validation message. */
204
+ get validationMessage() {
205
+ return this.valueInput?.validationMessage ?? '';
206
+ }
207
+
208
+ /** Returns true when all OTP cells have values. */
209
+ get complete() {
210
+ return this.isComplete(this.value);
211
+ }
212
+
213
+ private getNormalizedLength() {
214
+ const parsed = Number.isFinite(this.length) ? Math.trunc(this.length) : 6;
215
+ return Math.max(4, parsed);
216
+ }
217
+
218
+ private isNumericMode() {
219
+ return this.numericOnly && !this.alphanumeric;
220
+ }
221
+
222
+ private getResolvedInputMode() {
223
+ return this.inputmode ?? (this.isNumericMode() ? OtpInputMode.NUMERIC : OtpInputMode.TEXT);
224
+ }
225
+
226
+ private getValidationPattern() {
227
+ if (this.pattern) {
228
+ return this.pattern;
229
+ }
230
+
231
+ const normalizedLength = this.getNormalizedLength();
232
+ return this.isNumericMode()
233
+ ? `[0-9]{${normalizedLength}}`
234
+ : `[A-Za-z0-9]{${normalizedLength}}`;
235
+ }
236
+
237
+ private isAllowedCharacter(char: string) {
238
+ return this.isNumericMode() ? /^[0-9]$/.test(char) : /^[A-Za-z0-9]$/.test(char);
239
+ }
240
+
241
+ private toOtpCharacters(value: string) {
242
+ return Array.from(value ?? '').filter(char =>
243
+ this.isAllowedCharacter(char)
244
+ );
245
+ }
246
+
247
+ private normalizeValue(value: string) {
248
+ return this.toOtpCharacters(value ?? '')
249
+ .slice(0, this.getNormalizedLength())
250
+ .join('');
251
+ }
252
+
253
+ private createCells(value: string) {
254
+ const normalizedLength = this.getNormalizedLength();
255
+ const normalizedChars = this.toOtpCharacters(value).slice(
256
+ 0,
257
+ normalizedLength
258
+ );
259
+ return Array.from(
260
+ { length: normalizedLength },
261
+ (_, index) => normalizedChars[index] ?? ''
262
+ );
263
+ }
264
+
265
+ private syncCellsFromValue(value: string) {
266
+ this.cells = this.createCells(value);
267
+ }
268
+
269
+ private isComplete(value: string) {
270
+ return value.length === this.getNormalizedLength();
271
+ }
272
+
273
+ private getFirstEmptyIndex() {
274
+ const index = this.cells.findIndex(char => char === '');
275
+ return index === -1 ? this.getNormalizedLength() - 1 : index;
276
+ }
277
+
278
+ private getSeparatorIndices() {
279
+ const maxIndex = this.getNormalizedLength() - 2;
280
+ const indices = new Set<number>();
281
+
282
+ if (Number.isInteger(this.separatorEvery) && this.separatorEvery > 0) {
283
+ for (
284
+ let index = this.separatorEvery - 1;
285
+ index <= maxIndex;
286
+ index += this.separatorEvery
287
+ ) {
288
+ indices.add(index);
289
+ }
290
+ }
291
+
292
+ if (this.separatorPositions.trim().length > 0) {
293
+ this.separatorPositions
294
+ .split(',')
295
+ .map(value => Number.parseInt(value.trim(), 10))
296
+ .filter(
297
+ index => Number.isInteger(index) && index >= 0 && index <= maxIndex
298
+ )
299
+ .forEach(index => indices.add(index));
300
+ }
301
+
302
+ return indices;
303
+ }
304
+
305
+ private getCellPlaceholder(index: number) {
306
+ if (!this.placeholder || this.cells[index]) {
307
+ return undefined;
308
+ }
309
+
310
+ if (this.activeIndex === -1) {
311
+ return index === this.getFirstEmptyIndex() ? this.placeholder : undefined;
312
+ }
313
+
314
+ return index === this.activeIndex ? this.placeholder : undefined;
315
+ }
316
+
317
+ private focusCell(index: number, options?: FocusOptions) {
318
+ const input = this.cellInputs?.[index];
319
+ if (input) {
320
+ input.focus(options);
321
+ input.select();
322
+ }
323
+ }
324
+
325
+ private updateCell(index: number, value: string) {
326
+ const nextCells = [...this.cells];
327
+ nextCells[index] = value;
328
+ this.cells = nextCells;
329
+ }
330
+
331
+ private fillFromIndex(startIndex: number, chars: string[]) {
332
+ const nextCells = [...this.cells];
333
+ let cursor = startIndex;
334
+
335
+ for (const char of chars) {
336
+ if (cursor >= nextCells.length) {
337
+ break;
338
+ }
339
+
340
+ nextCells[cursor] = char;
341
+ cursor += 1;
342
+ }
343
+
344
+ this.cells = nextCells;
345
+ return cursor;
346
+ }
347
+
348
+ private commitUserValueUpdate() {
349
+ const previousValue = this.value;
350
+ const nextValue = this.cells.join('');
351
+ const isNowComplete = this.isComplete(nextValue);
352
+
353
+ this.value = nextValue;
354
+ this.valueInput.value = nextValue;
355
+ this.valueInput.setCustomValidity(this.customValidationMessage);
356
+ this.formControlController.updateValidity();
357
+
358
+ this.emit(Nile_Events.NILE_INPUT, { value: nextValue, complete: isNowComplete });
359
+ if (previousValue !== nextValue) {
360
+ this.emit(Nile_Events.NILE_CHANGE, { value: nextValue, complete: isNowComplete });
361
+ }
362
+
363
+ if (isNowComplete && !this.wasComplete) {
364
+ this.emit(Nile_Events.NILE_COMPLETE, { value: nextValue });
365
+ }
366
+
367
+ this.wasComplete = isNowComplete;
368
+ }
369
+
370
+ private handleInvalid(event: Event) {
371
+ this.formControlController.setValidity(false);
372
+ this.formControlController.emitInvalidEvent(event);
373
+ }
374
+
375
+ private handleCellFocus(event: Event) {
376
+ const target = event.target as HTMLInputElement;
377
+ const index = Number(target.dataset.index ?? -1);
378
+ const firstEmpty = this.getFirstEmptyIndex();
379
+
380
+ if (index > firstEmpty) {
381
+ this.focusCell(firstEmpty);
382
+ return;
383
+ }
384
+
385
+ if (index < firstEmpty && !this.cells[index]) {
386
+ this.focusCell(firstEmpty);
387
+ return;
388
+ }
389
+
390
+ this.activeIndex = index;
391
+ target.select();
392
+
393
+ if (!this.hasFocus) {
394
+ this.hasFocus = true;
395
+ this.emit(Nile_Events.NILE_FOCUS, { value: this.value });
396
+ }
397
+ }
398
+
399
+ private handleCellBlur() {
400
+ queueMicrotask(() => {
401
+ const active = this.shadowRoot?.activeElement;
402
+ const isInsideOtp =
403
+ active instanceof HTMLInputElement &&
404
+ active.classList.contains('otp__cell');
405
+
406
+ if (!isInsideOtp && this.hasFocus) {
407
+ this.hasFocus = false;
408
+ this.activeIndex = -1;
409
+ this.emit(Nile_Events.NILE_BLUR, { value: this.value });
410
+ }
411
+ });
412
+ }
413
+
414
+ private handleCellInput(event: Event) {
415
+ const target = event.target as HTMLInputElement;
416
+ const index = Number(target.dataset.index ?? 0);
417
+
418
+ if (this.disabled || this.readonly) {
419
+ target.value = this.cells[index] ?? '';
420
+ return;
421
+ }
422
+
423
+ const chars = this.toOtpCharacters(target.value);
424
+
425
+ if (chars.length === 0) {
426
+ this.updateCell(index, '');
427
+ this.commitUserValueUpdate();
428
+ return;
429
+ }
430
+
431
+ if (chars.length === 1) {
432
+ this.updateCell(index, chars[0]);
433
+ this.commitUserValueUpdate();
434
+
435
+ const nextEmpty = this.getFirstEmptyIndex();
436
+ this.focusCell(nextEmpty);
437
+ return;
438
+ }
439
+
440
+ const nextCursor = this.fillFromIndex(index, chars);
441
+ this.commitUserValueUpdate();
442
+ const nextEmpty = this.getFirstEmptyIndex();
443
+ this.focusCell(nextEmpty);
444
+ }
445
+
446
+ private handleCellPaste(event: ClipboardEvent) {
447
+ if (this.disabled || this.readonly) {
448
+ return;
449
+ }
450
+
451
+ const pasted = event.clipboardData?.getData('text') ?? '';
452
+ const chars = this.toOtpCharacters(pasted);
453
+
454
+ if (!chars.length) {
455
+ return;
456
+ }
457
+
458
+ event.preventDefault();
459
+
460
+ this.fillFromIndex(0, chars);
461
+ this.commitUserValueUpdate();
462
+ const nextEmpty = this.getFirstEmptyIndex();
463
+ this.focusCell(nextEmpty);
464
+ this.emit(Nile_Events.NILE_PASTE, { value: this.value });
465
+ }
466
+
467
+ private handleCellKeyDown(event: KeyboardEvent) {
468
+ const hasModifier = event.metaKey || event.ctrlKey || event.altKey;
469
+ const target = event.target as HTMLInputElement;
470
+ const index = Number(target.dataset.index ?? 0);
471
+
472
+ if (event.key === KeyCode.ENTER && !hasModifier && !event.shiftKey) {
473
+ setTimeout(() => {
474
+ if (!event.defaultPrevented && !event.isComposing) {
475
+ this.formControlController.submit();
476
+ }
477
+ });
478
+ return;
479
+ }
480
+
481
+ if (this.disabled || this.readonly) {
482
+ return;
483
+ }
484
+
485
+ const isHandledKey =
486
+ event.key === KeyCode.BACKSPACE ||
487
+ event.key === KeyCode.DELETE ||
488
+ event.key === KeyCode.ARROW_LEFT ||
489
+ event.key === KeyCode.ARROW_RIGHT ||
490
+ event.key === KeyCode.HOME ||
491
+ event.key === KeyCode.END ||
492
+ event.key === KeyCode.SPACE ||
493
+ (event.key.length === 1 && !hasModifier);
494
+
495
+ if (!isHandledKey) {
496
+ return;
497
+ }
498
+
499
+ event.preventDefault();
500
+
501
+ if (event.key === KeyCode.BACKSPACE) {
502
+ if (this.cells[index]) {
503
+ this.updateCell(index, '');
504
+ this.commitUserValueUpdate();
505
+ if (index > 0) {
506
+ this.focusCell(index - 1);
507
+ }
508
+ return;
509
+ }
510
+
511
+ if (index > 0) {
512
+ this.updateCell(index - 1, '');
513
+ this.commitUserValueUpdate();
514
+ this.focusCell(index - 1);
515
+ }
516
+
517
+ return;
518
+ }
519
+
520
+ if (event.key === KeyCode.DELETE) {
521
+ if (this.cells[index]) {
522
+ this.updateCell(index, '');
523
+ this.commitUserValueUpdate();
524
+ }
525
+ return;
526
+ }
527
+
528
+ if (event.key === KeyCode.ARROW_LEFT) {
529
+ if (index > 0) {
530
+ this.focusCell(index - 1);
531
+ }
532
+ return;
533
+ }
534
+
535
+ if (event.key === KeyCode.ARROW_RIGHT) {
536
+ const firstEmpty = this.getFirstEmptyIndex();
537
+ if (index < firstEmpty) {
538
+ this.focusCell(index + 1);
539
+ }
540
+ return;
541
+ }
542
+
543
+ if (event.key === KeyCode.HOME) {
544
+ this.focusCell(0);
545
+ return;
546
+ }
547
+
548
+ if (event.key === KeyCode.END) {
549
+ this.focusCell(this.getFirstEmptyIndex());
550
+ return;
551
+ }
552
+
553
+ if (event.key === KeyCode.SPACE) {
554
+ return;
555
+ }
556
+
557
+ if (event.key.length === 1 && !hasModifier && this.isAllowedCharacter(event.key)) {
558
+ this.updateCell(index, event.key);
559
+ this.commitUserValueUpdate();
560
+ const nextEmpty = this.getFirstEmptyIndex();
561
+ this.focusCell(nextEmpty);
562
+ }
563
+ }
564
+
565
+ @watch('length', { waitUntilFirstUpdate: true })
566
+ handleLengthChange() {
567
+ const normalizedLength = this.getNormalizedLength();
568
+ if (this.length !== normalizedLength) {
569
+ this.length = normalizedLength;
570
+ return;
571
+ }
572
+
573
+ const normalizedValue = this.normalizeValue(this.value);
574
+ this.syncCellsFromValue(normalizedValue);
575
+
576
+ if (normalizedValue !== this.value) {
577
+ this.value = normalizedValue;
578
+ return;
579
+ }
580
+
581
+ this.wasComplete = this.isComplete(normalizedValue);
582
+ this.valueInput.value = normalizedValue;
583
+ this.valueInput.setCustomValidity(this.customValidationMessage);
584
+ this.formControlController.updateValidity();
585
+ }
586
+
587
+ @watch('value', { waitUntilFirstUpdate: true })
588
+ handleValueChange() {
589
+ const normalizedValue = this.normalizeValue(this.value);
590
+ if (normalizedValue !== this.value) {
591
+ this.value = normalizedValue;
592
+ return;
593
+ }
594
+
595
+ this.syncCellsFromValue(normalizedValue);
596
+ this.wasComplete = this.isComplete(normalizedValue);
597
+ this.valueInput.value = normalizedValue;
598
+ this.valueInput.setCustomValidity(this.customValidationMessage);
599
+ this.formControlController.updateValidity();
600
+ }
601
+
602
+ @watch('disabled', { waitUntilFirstUpdate: true })
603
+ handleDisabledChange() {
604
+ if (this.disabled) {
605
+ this.hasFocus = false;
606
+ this.activeIndex = -1;
607
+ this.formControlController.setValidity(true);
608
+ } else {
609
+ this.formControlController.updateValidity();
610
+ }
611
+ }
612
+
613
+ @watch('numericOnly', { waitUntilFirstUpdate: true })
614
+ handleNumericOnlyChange() {
615
+ const normalizedValue = this.normalizeValue(this.value);
616
+ if (normalizedValue !== this.value) {
617
+ this.value = normalizedValue;
618
+ return;
619
+ }
620
+
621
+ this.syncCellsFromValue(normalizedValue);
622
+ this.wasComplete = this.isComplete(normalizedValue);
623
+ this.valueInput.value = normalizedValue;
624
+ this.valueInput.setCustomValidity(this.customValidationMessage);
625
+ this.formControlController.updateValidity();
626
+ }
627
+
628
+ @watch('pattern', { waitUntilFirstUpdate: true })
629
+ handlePatternChange() {
630
+ this.valueInput.setCustomValidity(this.customValidationMessage);
631
+ this.formControlController.updateValidity();
632
+ }
633
+
634
+ /** Checks validity without showing browser UI. */
635
+ checkValidity() {
636
+ return this.valueInput.checkValidity();
637
+ }
638
+
639
+ /** Returns associated form if one exists. */
640
+ getForm(): HTMLFormElement | null {
641
+ return this.formControlController.getForm();
642
+ }
643
+
644
+ /** Checks validity and shows browser UI when invalid. */
645
+ reportValidity() {
646
+ return this.valueInput.reportValidity();
647
+ }
648
+
649
+ /** Sets a custom validation message. Pass empty string to restore validity. */
650
+ setCustomValidity(message: string) {
651
+ this.customValidationMessage = message;
652
+ if (this.valueInput) {
653
+ this.valueInput.setCustomValidity(message);
654
+ }
655
+ this.formControlController.updateValidity();
656
+ }
657
+
658
+ /** Focuses the first empty cell, or the last one when complete. */
659
+ focus(options?: FocusOptions) {
660
+ this.focusCell(this.getFirstEmptyIndex(), options);
661
+ }
662
+
663
+ /** Removes focus from whichever OTP cell is currently focused. */
664
+ blur() {
665
+ const active = this.shadowRoot?.activeElement;
666
+ if (active instanceof HTMLElement) {
667
+ active.blur();
668
+ }
669
+ }
670
+
671
+ /** Clears all OTP cells. */
672
+ clear() {
673
+ if (this.disabled || this.readonly) {
674
+ return;
675
+ }
676
+
677
+ this.cells = Array.from({ length: this.getNormalizedLength() }, () => '');
678
+ this.commitUserValueUpdate();
679
+ this.focusCell(0);
680
+ }
681
+
682
+ render() {
683
+ const normalizedLength = this.getNormalizedLength();
684
+ const separatorIndices = this.getSeparatorIndices();
685
+ const hasLabelSlot = this.hasSlotController.test('label');
686
+ const hasHelpTextSlot = this.hasSlotController.test('help-text');
687
+ const hasLabel = Boolean(this.label || hasLabelSlot);
688
+ const hasHelpText = Boolean(this.helpText || hasHelpTextSlot);
689
+ const hasErrorMessage = Boolean(this.errorMessage);
690
+ const describedBy = [
691
+ hasHelpText ? 'help-text' : '',
692
+ hasErrorMessage ? 'error-message' : '',
693
+ ]
694
+ .filter(Boolean)
695
+ .join(' ');
696
+
697
+ return html`
698
+ <div
699
+ part="form-control"
700
+ class=${classMap({
701
+ 'form-control': true,
702
+ 'form-control--has-label': hasLabel,
703
+ })}
704
+ >
705
+ <label
706
+ id="label"
707
+ part="form-control-label"
708
+ class="form-control__label"
709
+ aria-hidden=${hasLabel ? 'false' : 'true'}
710
+ >
711
+ <slot name="label">${this.label}</slot>
712
+ </label>
713
+
714
+ <div part="form-control-input" class="form-control-input">
715
+ <div
716
+ part="base"
717
+ class=${classMap({
718
+ otp: true,
719
+ 'otp--warning': this.warning,
720
+ 'otp--error': this.error,
721
+ 'otp--success': this.success,
722
+ 'otp--disabled': this.disabled,
723
+ 'otp--readonly': this.readonly,
724
+ })}
725
+ role="group"
726
+ aria-labelledby=${ifDefined(hasLabel ? 'label' : undefined)}
727
+ aria-label=${ifDefined(hasLabel ? undefined : 'One-time password')}
728
+ aria-describedby=${ifDefined(describedBy || undefined)}
729
+ aria-disabled=${this.disabled ? 'true' : 'false'}
730
+ >
731
+ ${Array.from({ length: normalizedLength }, (_, index) => {
732
+ const value = this.cells[index] ?? '';
733
+ return html`
734
+ <input
735
+ part="cell"
736
+ class=${classMap({
737
+ otp__cell: true,
738
+ 'otp__cell--active': this.activeIndex === index,
739
+ })}
740
+ data-index=${index}
741
+ type=${this.masked && value && this.activeIndex !== index ? OtpInputType.PASSWORD : OtpInputType.TEXT}
742
+ maxlength="1"
743
+ .value=${live(value)}
744
+ ?disabled=${this.disabled}
745
+ ?readonly=${this.readonly}
746
+ placeholder=${ifDefined(this.getCellPlaceholder(index))}
747
+ inputmode=${ifDefined(this.getResolvedInputMode())}
748
+ pattern=${this.isNumericMode() ? OtpCellPattern.NUMERIC : OtpCellPattern.ALPHANUMERIC}
749
+ autocapitalize="none"
750
+ autocorrect="off"
751
+ spellcheck="false"
752
+ autocomplete=${index === 0 ? this.autocomplete : OtpAutoComplete.OFF}
753
+ enterkeyhint=${index === normalizedLength - 1
754
+ ? OtpEnterKeyHint.DONE
755
+ : OtpEnterKeyHint.NEXT}
756
+ aria-label=${`Digit ${index + 1} of ${normalizedLength}`}
757
+ aria-describedby=${ifDefined(describedBy || undefined)}
758
+ aria-invalid=${this.hasAttribute('data-user-invalid')
759
+ ? 'true'
760
+ : 'false'}
761
+ @focus=${this.handleCellFocus}
762
+ @blur=${this.handleCellBlur}
763
+ @keydown=${this.handleCellKeyDown}
764
+ @input=${this.handleCellInput}
765
+ @paste=${this.handleCellPaste}
766
+ />
767
+ ${this.separator && separatorIndices.has(index)
768
+ ? html`
769
+ <span
770
+ class="otp__separator"
771
+ part="separator"
772
+ aria-hidden="true"
773
+ >
774
+ ${this.separator}
775
+ </span>
776
+ `
777
+ : ''}
778
+ `;
779
+ })}
780
+
781
+ <input
782
+ class="otp__value-input"
783
+ type="text"
784
+ .value=${live(this.value)}
785
+ ?required=${this.required}
786
+ ?disabled=${this.disabled}
787
+ minlength=${normalizedLength}
788
+ maxlength=${normalizedLength}
789
+ pattern=${this.getValidationPattern()}
790
+ tabindex="-1"
791
+ aria-hidden="true"
792
+ @focus=${() => this.focus()}
793
+ @invalid=${this.handleInvalid}
794
+ />
795
+ </div>
796
+ </div>
797
+
798
+ ${hasHelpText
799
+ ? html`
800
+ <div
801
+ id="help-text"
802
+ part="form-control-help-text"
803
+ class="form-control__help-text"
804
+ >
805
+ <nile-form-help-text>
806
+ <slot name="help-text">${this.helpText}</slot>
807
+ </nile-form-help-text>
808
+ </div>
809
+ `
810
+ : ``}
811
+ ${hasErrorMessage
812
+ ? html`
813
+ <div
814
+ id="error-message"
815
+ part="form-control-error-message"
816
+ class="form-control__error-message"
817
+ >
818
+ <nile-form-error-message
819
+ >${this.errorMessage}</nile-form-error-message
820
+ >
821
+ </div>
822
+ `
823
+ : ``}
824
+ </div>
825
+ `;
826
+ }
827
+ }
828
+
829
+ export default NileOtpInput;
830
+
831
+ declare global {
832
+ interface HTMLElementTagNameMap {
833
+ 'nile-otp-input': NileOtpInput;
834
+ }
835
+ }