@lumiose/ui 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// OTP Field's client script (decision-62, decision-63): it defines `<lu-otp-field>`, the light-DOM
|
|
2
|
+
// element OTPField.Root renders around one native `<input>` and the visual slots that show its
|
|
3
|
+
// characters. The input is the field: SMS autofill (`autocomplete="one-time-code"`), paste,
|
|
4
|
+
// Backspace, the arrow keys, `maxlength`, `pattern`, `required` and the form's value are the
|
|
5
|
+
// platform's, and assistive technology meets one text field, not one per character. Without
|
|
6
|
+
// JavaScript the input shows as a plain text field and the slots stay hidden (otp-field.css).
|
|
7
|
+
//
|
|
8
|
+
// The element:
|
|
9
|
+
//
|
|
10
|
+
// - keeps the value to the allowed characters (`validation-type`: numeric, the default; alpha;
|
|
11
|
+
// alphanumeric; none) and to `length`, dropping spaces, as typing, a paste or autofill puts them
|
|
12
|
+
// in (a paste is cleaned before `maxlength` could cut it); rejected characters fire
|
|
13
|
+
// `valueinvalid` (`detail.value`, what was entered);
|
|
14
|
+
// - shows the value in the `[data-slot]` elements (as dots with `mask`), with `data-filled`, and the
|
|
15
|
+
// caret's slot with `data-active` while the input has focus; `data-complete` on the element;
|
|
16
|
+
// - types over the character at the caret when the field is full, and moves the caret to the slot a
|
|
17
|
+
// press lands on;
|
|
18
|
+
// - fires `valuecomplete` (`detail.value`) when the last slot fills, and with `auto-submit` submits
|
|
19
|
+
// the form;
|
|
20
|
+
// - with a `normalizeValue` function (a property: OTPField.Root sets it in an island, TASK-413),
|
|
21
|
+
// normalizes what is entered (`toUpperCase`, say) before it keeps it to the allowed characters.
|
|
22
|
+
//
|
|
23
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
24
|
+
|
|
25
|
+
/** Submits `form` as its submit button would. Through the prototype: a control named
|
|
26
|
+
* `requestSubmit` shadows the form's own (DOM clobbering, TASK-473 L9). */
|
|
27
|
+
const submit = (form: HTMLFormElement) => HTMLFormElement.prototype.requestSubmit.call(form);
|
|
28
|
+
|
|
29
|
+
export interface OTPFieldValueDetail {
|
|
30
|
+
value: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DIGIT = /\p{Nd}/u;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `text` with full-width letters as ASCII (NFKC) and every decimal digit of any script as its
|
|
37
|
+
* ASCII digit: Unicode encodes each script's digits as runs of ten, 0 to 9 (TASK-440, M34). A copy
|
|
38
|
+
* of ../locale/digits.ts: importing it would make a chunk of its own, shared with Number Field's
|
|
39
|
+
* script, and one more request on a page with both.
|
|
40
|
+
*/
|
|
41
|
+
function asciiDigits(text: string): string {
|
|
42
|
+
let out = "";
|
|
43
|
+
for (const char of text.normalize("NFKC")) {
|
|
44
|
+
if (!DIGIT.test(char)) {
|
|
45
|
+
out += char;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
let code = char.codePointAt(0)!;
|
|
49
|
+
let index = 0;
|
|
50
|
+
while (DIGIT.test(String.fromCodePoint(code - 1))) {
|
|
51
|
+
code--;
|
|
52
|
+
index++;
|
|
53
|
+
}
|
|
54
|
+
out += String(index % 10);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The characters each validation type keeps, as Base UI's: ASCII digits and letters. Digits of
|
|
61
|
+
* other scripts and full-width letters are typed as their ASCII forms first (TASK-440, M34).
|
|
62
|
+
*/
|
|
63
|
+
const ALLOWED: Record<string, RegExp> = {
|
|
64
|
+
numeric: /[0-9]/,
|
|
65
|
+
alpha: /[A-Za-z]/,
|
|
66
|
+
alphanumeric: /[A-Za-z0-9]/,
|
|
67
|
+
none: /\S/u,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export class OTPFieldElement extends HTMLElement {
|
|
71
|
+
/** The value when the last `valuecomplete` fired, so it fires once per completion. */
|
|
72
|
+
#completed = "";
|
|
73
|
+
/** The character of the last key pressed, for a `beforeinput` without `data` (WebKit). */
|
|
74
|
+
#key = "";
|
|
75
|
+
|
|
76
|
+
/** Normalizes what is entered before the allowed characters are kept: an island sets it. */
|
|
77
|
+
normalizeValue: ((value: string) => string) | undefined;
|
|
78
|
+
|
|
79
|
+
/** The input that holds the value. */
|
|
80
|
+
get input(): HTMLInputElement | null {
|
|
81
|
+
return this.querySelector<HTMLInputElement>("input");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The visual slots, in order. */
|
|
85
|
+
get slots(): HTMLElement[] {
|
|
86
|
+
return [...this.querySelectorAll<HTMLElement>("[data-slot]")].filter(
|
|
87
|
+
(slot) => slot.closest("lu-otp-field") === this,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The number of characters; the `length` attribute. Not a property Lumiose would set. */
|
|
92
|
+
get #length(): number {
|
|
93
|
+
const length = Number(this.getAttribute("length"));
|
|
94
|
+
return Number.isInteger(length) && length > 0 ? length : this.slots.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The code. Setting it shows it, kept to the allowed characters, without events. */
|
|
98
|
+
get value(): string {
|
|
99
|
+
return this.input?.value ?? "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
set value(next: string | null | undefined) {
|
|
103
|
+
const input = this.input;
|
|
104
|
+
if (!input) return;
|
|
105
|
+
input.value = this.#clean(next ?? "");
|
|
106
|
+
this.#completed = input.value.length === this.#length ? input.value : "";
|
|
107
|
+
this.#sync();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
connectedCallback(): void {
|
|
111
|
+
// Capturing: the value is cleaned before any other listener reads it.
|
|
112
|
+
this.addEventListener("input", this.#onInput, true);
|
|
113
|
+
this.addEventListener("keydown", this.#onKey);
|
|
114
|
+
this.addEventListener("beforeinput", this.#onBeforeInput);
|
|
115
|
+
this.addEventListener("compositionend", this.#onCompositionEnd);
|
|
116
|
+
this.addEventListener("paste", this.#onPaste);
|
|
117
|
+
this.addEventListener("pointerdown", this.#onPress);
|
|
118
|
+
this.addEventListener("focusin", this.#onFocus);
|
|
119
|
+
this.addEventListener("focusout", this.#onFocus);
|
|
120
|
+
document.addEventListener("selectionchange", this.#onSelection);
|
|
121
|
+
document.addEventListener("reset", this.#onReset, true);
|
|
122
|
+
const settle = () => {
|
|
123
|
+
this.#completed = this.value.length === this.#length ? this.value : "";
|
|
124
|
+
this.#sync();
|
|
125
|
+
};
|
|
126
|
+
if (document.readyState === "loading") {
|
|
127
|
+
document.addEventListener("DOMContentLoaded", settle, { once: true });
|
|
128
|
+
} else queueMicrotask(settle);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
disconnectedCallback(): void {
|
|
132
|
+
this.removeEventListener("input", this.#onInput, true);
|
|
133
|
+
this.removeEventListener("keydown", this.#onKey);
|
|
134
|
+
this.removeEventListener("beforeinput", this.#onBeforeInput);
|
|
135
|
+
this.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
136
|
+
this.removeEventListener("paste", this.#onPaste);
|
|
137
|
+
this.removeEventListener("pointerdown", this.#onPress);
|
|
138
|
+
this.removeEventListener("focusin", this.#onFocus);
|
|
139
|
+
this.removeEventListener("focusout", this.#onFocus);
|
|
140
|
+
document.removeEventListener("selectionchange", this.#onSelection);
|
|
141
|
+
document.removeEventListener("reset", this.#onReset, true);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The characters `validation-type` allows; an unknown type allows digits. An own key only:
|
|
146
|
+
* `constructor` or `__proto__` are not types (TASK-473, L11).
|
|
147
|
+
*/
|
|
148
|
+
#allowed(): RegExp {
|
|
149
|
+
const type = this.getAttribute("validation-type") ?? "numeric";
|
|
150
|
+
return Object.hasOwn(ALLOWED, type) ? ALLOWED[type]! : ALLOWED.numeric!;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `text` kept to the allowed characters and the length. */
|
|
154
|
+
#clean(text: string): string {
|
|
155
|
+
const allowed = this.#allowed();
|
|
156
|
+
return [...this.#normalize(text)]
|
|
157
|
+
.filter((char) => allowed.test(char))
|
|
158
|
+
.slice(0, this.#length)
|
|
159
|
+
.join("");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#normalize(text: string): string {
|
|
163
|
+
const ascii = this.getAttribute("validation-type") === "none" ? text : asciiDigits(text);
|
|
164
|
+
return this.normalizeValue ? this.normalizeValue(ascii) : ascii;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether `text` holds characters the field does not allow (spaces aside). */
|
|
168
|
+
#rejects(text: string): boolean {
|
|
169
|
+
const allowed = this.#allowed();
|
|
170
|
+
return [...this.#normalize(text)].some((char) => /\S/u.test(char) && !allowed.test(char));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Writes the value to the slots, and the caret's slot while the input has focus. */
|
|
174
|
+
#sync(): void {
|
|
175
|
+
const input = this.input;
|
|
176
|
+
if (!input) return;
|
|
177
|
+
const value = input.value;
|
|
178
|
+
const length = this.#length;
|
|
179
|
+
const mask = this.hasAttribute("mask");
|
|
180
|
+
const focused = document.activeElement === input;
|
|
181
|
+
const caret = Math.min(input.selectionStart ?? value.length, length - 1);
|
|
182
|
+
this.slots.forEach((slot, i) => {
|
|
183
|
+
const char = value[i] ?? "";
|
|
184
|
+
const text = char && mask ? "•" : char;
|
|
185
|
+
if (slot.textContent !== text) slot.textContent = text;
|
|
186
|
+
slot.toggleAttribute("data-filled", char !== "");
|
|
187
|
+
slot.toggleAttribute("data-active", focused && i === caret);
|
|
188
|
+
});
|
|
189
|
+
this.toggleAttribute("data-complete", value.length === length);
|
|
190
|
+
this.toggleAttribute("data-filled", value.length > 0);
|
|
191
|
+
this.toggleAttribute("data-focused", focused);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#onInput = (event: Event) => {
|
|
195
|
+
const input = this.input;
|
|
196
|
+
if (!input || event.target !== input) return;
|
|
197
|
+
// An IME composition is not text yet: cleaning it would lose the commit, and completing it
|
|
198
|
+
// would submit what the user has not chosen. The slots show it; `compositionend` goes on
|
|
199
|
+
// (TASK-432).
|
|
200
|
+
if ((event as InputEvent).isComposing) {
|
|
201
|
+
this.#sync();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const raw = input.value;
|
|
205
|
+
const clean = this.#clean(raw);
|
|
206
|
+
if (this.#rejects(raw)) {
|
|
207
|
+
this.dispatchEvent(
|
|
208
|
+
new CustomEvent<OTPFieldValueDetail>("valueinvalid", { detail: { value: raw } }),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (clean !== raw) {
|
|
212
|
+
// The caret stays after the same characters it was after, those that remain.
|
|
213
|
+
const caret = input.selectionStart ?? raw.length;
|
|
214
|
+
const at = Math.min(this.#clean(raw.slice(0, caret)).length, clean.length);
|
|
215
|
+
input.value = clean;
|
|
216
|
+
input.setSelectionRange(at, at);
|
|
217
|
+
}
|
|
218
|
+
this.#sync();
|
|
219
|
+
if (clean.length === this.#length && clean !== this.#completed) {
|
|
220
|
+
this.#completed = clean;
|
|
221
|
+
// After the other listeners have seen the value.
|
|
222
|
+
queueMicrotask(() => {
|
|
223
|
+
this.dispatchEvent(
|
|
224
|
+
new CustomEvent<OTPFieldValueDetail>("valuecomplete", { detail: { value: clean } }),
|
|
225
|
+
);
|
|
226
|
+
if (this.hasAttribute("auto-submit") && input.form) submit(input.form);
|
|
227
|
+
});
|
|
228
|
+
} else if (clean.length < this.#length) this.#completed = "";
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The committed text: cleaned, completed and submitted as typing would. Chromium fires no
|
|
233
|
+
* `input` after `compositionend`, so this one tells every listener; where the browser fires one
|
|
234
|
+
* too, the second finds the value clean and the completion done.
|
|
235
|
+
*/
|
|
236
|
+
#onCompositionEnd = (event: CompositionEvent) => {
|
|
237
|
+
const input = this.input;
|
|
238
|
+
if (!input || event.target !== input) return;
|
|
239
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
#onKey = (event: KeyboardEvent) => {
|
|
243
|
+
const plain = event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.isComposing;
|
|
244
|
+
this.#key = plain ? event.key : "";
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Typing replaces the character at the caret (the ringed slot), as Base UI's, instead of
|
|
249
|
+
* inserting before it (TASK-448, L20), and in a full field instead of being refused.
|
|
250
|
+
*/
|
|
251
|
+
#onBeforeInput = (event: InputEvent) => {
|
|
252
|
+
const input = this.input;
|
|
253
|
+
// WebKit cuts `data` to what `maxlength` lets in: nothing in a full field. The key tells.
|
|
254
|
+
const typed = event.data || this.#key;
|
|
255
|
+
this.#key = "";
|
|
256
|
+
if (!input || event.target !== input || event.inputType !== "insertText" || !typed) return;
|
|
257
|
+
const { selectionStart: start, selectionEnd: end, value } = input;
|
|
258
|
+
if (start === null || start !== end || start >= value.length) return;
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
const char = this.#clean(typed).slice(0, 1);
|
|
261
|
+
if (!char) {
|
|
262
|
+
this.dispatchEvent(
|
|
263
|
+
new CustomEvent<OTPFieldValueDetail>("valueinvalid", { detail: { value: typed } }),
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
input.value = value.slice(0, start) + char + value.slice(start + 1);
|
|
268
|
+
const next = Math.min(start + 1, value.length);
|
|
269
|
+
input.setSelectionRange(next, next);
|
|
270
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* A paste: cleaned first, then put in place of the selection, so `maxlength` does not cut a
|
|
275
|
+
* code pasted with spaces or dashes.
|
|
276
|
+
*/
|
|
277
|
+
#onPaste = (event: ClipboardEvent) => {
|
|
278
|
+
const input = this.input;
|
|
279
|
+
const text = event.clipboardData?.getData("text") ?? "";
|
|
280
|
+
if (!input || event.target !== input || input.readOnly || !text) return;
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
if (this.#rejects(text)) {
|
|
283
|
+
this.dispatchEvent(
|
|
284
|
+
new CustomEvent<OTPFieldValueDetail>("valueinvalid", { detail: { value: text } }),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const { value, selectionStart: start, selectionEnd: end } = input;
|
|
288
|
+
const before = value.slice(0, start ?? value.length);
|
|
289
|
+
const after = value.slice(end ?? value.length);
|
|
290
|
+
const next = this.#clean(before + this.#clean(text) + after);
|
|
291
|
+
input.value = next;
|
|
292
|
+
const at = Math.min((before + this.#clean(text)).length, next.length);
|
|
293
|
+
input.setSelectionRange(at, at);
|
|
294
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/** A press on a slot puts the caret there (or after the last character typed). */
|
|
298
|
+
#onPress = (event: PointerEvent) => {
|
|
299
|
+
const input = this.input;
|
|
300
|
+
if (!input || input.disabled || event.button !== 0) return;
|
|
301
|
+
const slots = this.slots;
|
|
302
|
+
const index = slots.findIndex((slot) => {
|
|
303
|
+
const box = slot.getBoundingClientRect();
|
|
304
|
+
return event.clientX >= box.left && event.clientX <= box.right;
|
|
305
|
+
});
|
|
306
|
+
if (index === -1) return;
|
|
307
|
+
event.preventDefault();
|
|
308
|
+
input.focus();
|
|
309
|
+
const at = Math.min(index, input.value.length);
|
|
310
|
+
input.setSelectionRange(at, at);
|
|
311
|
+
this.#sync();
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
#onFocus = () => this.#sync();
|
|
315
|
+
|
|
316
|
+
#onSelection = () => {
|
|
317
|
+
if (document.activeElement === this.input) this.#sync();
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/** A form reset puts the input back without an `input` event: the slots follow it. */
|
|
321
|
+
#onReset = (event: Event) => {
|
|
322
|
+
if (this.input?.form !== event.target) return;
|
|
323
|
+
setTimeout(() => {
|
|
324
|
+
this.#completed = this.value.length === this.#length ? this.value : "";
|
|
325
|
+
this.#sync();
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!customElements.get("lu-otp-field")) customElements.define("lu-otp-field", OTPFieldElement);
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// OTP Field (decision-62, TASK-376): a one-time code input, after Base UI's OTPField. Unlike Base UI
|
|
2
|
+
// (one input per character), it is one native `<input>` under visual slots: SMS autofill
|
|
3
|
+
// (`autocomplete="one-time-code"`) puts the whole code in one field, paste, Backspace and the arrow
|
|
4
|
+
// keys work as in any text field, `maxlength`, `pattern` and `required` validate it and the form
|
|
5
|
+
// submits it, without JavaScript too; and assistive technology meets one labelled text field
|
|
6
|
+
// rather than a field per character. Root renders `<lu-otp-field>`, whose script
|
|
7
|
+
// (otp-field.script.ts) keeps the value to the allowed characters and shows it in the slots. The
|
|
8
|
+
// `value` prop drives it when it changes; the input's `input` events and the element's
|
|
9
|
+
// `valuecomplete` and `valueinvalid` report back.
|
|
10
|
+
import { createContext, effect, ref, untrack, useContext, useId } from "lumiose";
|
|
11
|
+
import { controlId, describedBy, useField } from "../field/context.ts";
|
|
12
|
+
import { onFormReset } from "../form/reset.ts";
|
|
13
|
+
import type { OTPFieldElement, OTPFieldValueDetail } from "./otp-field.script.ts";
|
|
14
|
+
import otpFieldScript from "./otp-field.script.ts?client";
|
|
15
|
+
|
|
16
|
+
declare module "lumiose/jsx-runtime" {
|
|
17
|
+
namespace JSX {
|
|
18
|
+
interface IntrinsicElements {
|
|
19
|
+
/**
|
|
20
|
+
* OTP Field's element. Attributes `length`, `validation-type`, `mask`, `auto-submit`;
|
|
21
|
+
* property `value`; events `valuecomplete` and `valueinvalid` (`detail.value`).
|
|
22
|
+
*/
|
|
23
|
+
"lu-otp-field": JSX.HTMLProps<OTPFieldElement> & {
|
|
24
|
+
length?: number;
|
|
25
|
+
"validation-type"?: OTPFieldValidationType;
|
|
26
|
+
mask?: boolean;
|
|
27
|
+
"auto-submit"?: boolean;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The characters a code may hold. */
|
|
34
|
+
export type OTPFieldValidationType = "numeric" | "alpha" | "alphanumeric" | "none";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The element props Root passes to `<lu-otp-field>`, without the brand of intrinsic elements'
|
|
38
|
+
* props, so that `bind:value` applies to Root.
|
|
39
|
+
*/
|
|
40
|
+
type ElementProps = {
|
|
41
|
+
[
|
|
42
|
+
K in keyof JSX.HTMLProps<OTPFieldElement> as K extends string ? K : never
|
|
43
|
+
]: JSX.HTMLProps<OTPFieldElement>[K];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `OTPField.Root` renders `<lu-otp-field>` around the input and the slots. `bind:value={signal}`
|
|
48
|
+
* stands in for `value` and `onValueChange`.
|
|
49
|
+
*/
|
|
50
|
+
export interface OTPFieldRootProps extends Omit<
|
|
51
|
+
ElementProps,
|
|
52
|
+
"children" | "onChange" | "onInput" | "id"
|
|
53
|
+
> {
|
|
54
|
+
/** How many characters the code has. */
|
|
55
|
+
length: number;
|
|
56
|
+
/** Shows this code when it changes. Pair it with `onValueChange`. */
|
|
57
|
+
value?: string;
|
|
58
|
+
/** The code at first, when `value` is left out; a form reset goes back to it. */
|
|
59
|
+
defaultValue?: string;
|
|
60
|
+
/** Called as the code changes: typing, a paste, autofill, Backspace. */
|
|
61
|
+
onValueChange?: (value: string) => void;
|
|
62
|
+
/** Called when the last character is entered, with the whole code. */
|
|
63
|
+
onValueComplete?: (value: string) => void;
|
|
64
|
+
/** Called with what was entered when it held characters the field does not allow. */
|
|
65
|
+
onValueInvalid?: (value: string) => void;
|
|
66
|
+
/** The characters the code may hold. Default `"numeric"` (digits). */
|
|
67
|
+
validationType?: OTPFieldValidationType;
|
|
68
|
+
/**
|
|
69
|
+
* Normalizes what is entered before the allowed characters are kept: `(text) =>
|
|
70
|
+
* text.toUpperCase()`, say. In an island: a server page's script cannot receive a function.
|
|
71
|
+
*/
|
|
72
|
+
normalizeValue?: (value: string) => string;
|
|
73
|
+
/** The virtual keyboard: default `"numeric"` for digits, `"text"` otherwise. */
|
|
74
|
+
inputMode?: "numeric" | "text" | "tel" | "none";
|
|
75
|
+
/** The slots show dots instead of the characters. */
|
|
76
|
+
mask?: boolean;
|
|
77
|
+
/** The input's `autocomplete`. Default `"one-time-code"`: the browser offers a code it received. */
|
|
78
|
+
autoComplete?: string;
|
|
79
|
+
/** Submits the form when the last character is entered. */
|
|
80
|
+
autoSubmit?: boolean;
|
|
81
|
+
/** The name the code is submitted under. */
|
|
82
|
+
name?: string;
|
|
83
|
+
/** The id of a form the field belongs to, when it is outside it. */
|
|
84
|
+
form?: string;
|
|
85
|
+
/** The input's id, for a `<label for>`. Default: generated, or the Field's. */
|
|
86
|
+
id?: string;
|
|
87
|
+
/** The form needs a whole code. */
|
|
88
|
+
required?: boolean;
|
|
89
|
+
/** The field cannot be used. */
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
/** The code can be read and submitted, not changed. */
|
|
92
|
+
readOnly?: boolean;
|
|
93
|
+
/** The `Input` and the `Slot`s (with `Separator`s between them). */
|
|
94
|
+
children?: JSX.Element;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `<input>` that holds the code: over the slots, its text transparent, once the script runs; a
|
|
99
|
+
* plain text field without it. Name it with a label or `aria-label`.
|
|
100
|
+
*/
|
|
101
|
+
export type OTPFieldInputProps = Omit<
|
|
102
|
+
JSX.IntrinsicElements["input"],
|
|
103
|
+
| "type"
|
|
104
|
+
| "value"
|
|
105
|
+
| "name"
|
|
106
|
+
| "form"
|
|
107
|
+
| "required"
|
|
108
|
+
| "readonly"
|
|
109
|
+
| "maxlength"
|
|
110
|
+
| "minlength"
|
|
111
|
+
| "pattern"
|
|
112
|
+
| "inputmode"
|
|
113
|
+
| "autocomplete"
|
|
114
|
+
| "id"
|
|
115
|
+
>;
|
|
116
|
+
|
|
117
|
+
/** A `<div aria-hidden="true">` that shows one character of the code, the next in order. */
|
|
118
|
+
export type OTPFieldSlotProps = Omit<JSX.IntrinsicElements["div"], "children">;
|
|
119
|
+
|
|
120
|
+
/** A `<div aria-hidden="true">` between slots: a dash, a gap. */
|
|
121
|
+
export type OTPFieldSeparatorProps = JSX.IntrinsicElements["div"] & {
|
|
122
|
+
/** Which way it runs. Default `"horizontal"`. */
|
|
123
|
+
orientation?: "horizontal" | "vertical";
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
interface OTPFieldContextValue {
|
|
127
|
+
inputId: string;
|
|
128
|
+
length: () => number;
|
|
129
|
+
initial: string;
|
|
130
|
+
validationType: () => OTPFieldValidationType;
|
|
131
|
+
inputMode: () => string;
|
|
132
|
+
mask: () => boolean;
|
|
133
|
+
autoComplete: () => string;
|
|
134
|
+
name: () => string | undefined;
|
|
135
|
+
form: () => string | undefined;
|
|
136
|
+
required: () => boolean | undefined;
|
|
137
|
+
disabled: () => boolean;
|
|
138
|
+
readOnly: () => boolean | undefined;
|
|
139
|
+
/** How many slots have rendered: the next slot's index. */
|
|
140
|
+
slots: { count: number };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const OTPFieldContext = createContext<OTPFieldContextValue>();
|
|
144
|
+
|
|
145
|
+
function useOTPField(part: string): OTPFieldContextValue {
|
|
146
|
+
const context = useContext(OTPFieldContext);
|
|
147
|
+
if (!context) throw new Error(`OTPField.${part} must be inside <OTPField.Root>.`);
|
|
148
|
+
return context;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The `pattern` that matches a whole code of `length` characters of `type`. */
|
|
152
|
+
function pattern(type: OTPFieldValidationType, length: number): string {
|
|
153
|
+
// ASCII, as the script keeps (TASK-440, M34).
|
|
154
|
+
const char = {
|
|
155
|
+
numeric: "[0-9]",
|
|
156
|
+
alpha: "[A-Za-z]",
|
|
157
|
+
alphanumeric: "[A-Za-z0-9]",
|
|
158
|
+
none: "\\S",
|
|
159
|
+
}[type];
|
|
160
|
+
return `${char}{${length}}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function Root({
|
|
164
|
+
length,
|
|
165
|
+
value,
|
|
166
|
+
defaultValue,
|
|
167
|
+
onValueChange,
|
|
168
|
+
onValueComplete,
|
|
169
|
+
onValueInvalid,
|
|
170
|
+
validationType,
|
|
171
|
+
normalizeValue,
|
|
172
|
+
inputMode,
|
|
173
|
+
mask,
|
|
174
|
+
autoComplete,
|
|
175
|
+
autoSubmit,
|
|
176
|
+
name,
|
|
177
|
+
form,
|
|
178
|
+
id,
|
|
179
|
+
required,
|
|
180
|
+
disabled,
|
|
181
|
+
readOnly,
|
|
182
|
+
children,
|
|
183
|
+
...rest
|
|
184
|
+
}: OTPFieldRootProps) {
|
|
185
|
+
// Cleans the value and shows it in the slots, on server pages and in islands (decision-63).
|
|
186
|
+
otpFieldScript();
|
|
187
|
+
const generated = useId();
|
|
188
|
+
const field = useField();
|
|
189
|
+
const element = ref<OTPFieldElement>();
|
|
190
|
+
const type = () => validationType ?? "numeric";
|
|
191
|
+
// The `value` prop drives the code when it changes.
|
|
192
|
+
effect(() => {
|
|
193
|
+
const next = value;
|
|
194
|
+
const host = element();
|
|
195
|
+
if (next !== undefined && host?.isConnected && host.value !== next) host.value = next;
|
|
196
|
+
});
|
|
197
|
+
effect(() => {
|
|
198
|
+
const host = element();
|
|
199
|
+
if (host) host.normalizeValue = normalizeValue;
|
|
200
|
+
});
|
|
201
|
+
// A form reset puts the code back without an `input` event: it goes to `onValueChange`
|
|
202
|
+
// (TASK-435).
|
|
203
|
+
if (onValueChange) {
|
|
204
|
+
onFormReset(
|
|
205
|
+
() => element()?.input ?? undefined,
|
|
206
|
+
() => element()!.value,
|
|
207
|
+
onValueChange,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const context: OTPFieldContextValue = {
|
|
211
|
+
inputId: untrack(() => id ?? (field ? controlId(field) : `${generated}-input`)),
|
|
212
|
+
length: () => length,
|
|
213
|
+
initial: untrack(() => value ?? defaultValue ?? ""),
|
|
214
|
+
validationType: type,
|
|
215
|
+
inputMode: () => inputMode ?? (type() === "numeric" ? "numeric" : "text"),
|
|
216
|
+
mask: () => mask === true,
|
|
217
|
+
autoComplete: () => autoComplete ?? "one-time-code",
|
|
218
|
+
name: () => name ?? field?.name(),
|
|
219
|
+
form: () => form,
|
|
220
|
+
required: () => required,
|
|
221
|
+
disabled: () => disabled === true || field?.disabled() === true,
|
|
222
|
+
readOnly: () => readOnly,
|
|
223
|
+
slots: { count: 0 },
|
|
224
|
+
};
|
|
225
|
+
return (
|
|
226
|
+
<OTPFieldContext value={context}>
|
|
227
|
+
<lu-otp-field
|
|
228
|
+
{...rest}
|
|
229
|
+
ref={element}
|
|
230
|
+
// Digits read left to right in right-to-left pages too: a numeric code's slots run that
|
|
231
|
+
// way unless `dir` says otherwise (TASK-385). Letters follow the page.
|
|
232
|
+
dir={rest.dir ?? (type() === "numeric" ? "ltr" : undefined)}
|
|
233
|
+
length={length}
|
|
234
|
+
validation-type={type()}
|
|
235
|
+
mask={mask}
|
|
236
|
+
auto-submit={autoSubmit}
|
|
237
|
+
on:input={(event: Event) => {
|
|
238
|
+
const input = element()?.input;
|
|
239
|
+
// A composition reports once committed (the element fires `input` then, TASK-432).
|
|
240
|
+
if (input && event.target === input && !(event as InputEvent).isComposing) {
|
|
241
|
+
onValueChange?.(input.value);
|
|
242
|
+
}
|
|
243
|
+
}}
|
|
244
|
+
on:valuecomplete={(event: CustomEvent<OTPFieldValueDetail>) =>
|
|
245
|
+
onValueComplete?.(event.detail.value)
|
|
246
|
+
}
|
|
247
|
+
on:valueinvalid={(event: CustomEvent<OTPFieldValueDetail>) =>
|
|
248
|
+
onValueInvalid?.(event.detail.value)
|
|
249
|
+
}
|
|
250
|
+
>
|
|
251
|
+
{children}
|
|
252
|
+
</lu-otp-field>
|
|
253
|
+
</OTPFieldContext>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function Input(props: OTPFieldInputProps) {
|
|
258
|
+
const otp = useOTPField("Input");
|
|
259
|
+
const field = useField();
|
|
260
|
+
return (
|
|
261
|
+
<input
|
|
262
|
+
spellcheck={false}
|
|
263
|
+
autocapitalize="off"
|
|
264
|
+
aria-describedby={field ? describedBy(field) : undefined}
|
|
265
|
+
aria-invalid={field?.invalid() ? "true" : undefined}
|
|
266
|
+
{...props}
|
|
267
|
+
// Masked, a password: assistive technology does not speak the code (TASK-440, M33).
|
|
268
|
+
type={otp.mask() ? "password" : "text"}
|
|
269
|
+
id={otp.inputId}
|
|
270
|
+
name={otp.name()}
|
|
271
|
+
form={otp.form()}
|
|
272
|
+
value={otp.initial || undefined}
|
|
273
|
+
inputmode={otp.inputMode() as JSX.IntrinsicElements["input"]["inputmode"]}
|
|
274
|
+
autocomplete={otp.autoComplete() as JSX.IntrinsicElements["input"]["autocomplete"]}
|
|
275
|
+
maxlength={otp.length()}
|
|
276
|
+
minlength={otp.length()}
|
|
277
|
+
pattern={pattern(otp.validationType(), otp.length())}
|
|
278
|
+
required={otp.required()}
|
|
279
|
+
readonly={otp.readOnly()}
|
|
280
|
+
disabled={props.disabled || otp.disabled() || undefined}
|
|
281
|
+
/>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function Slot(props: OTPFieldSlotProps) {
|
|
286
|
+
const otp = useOTPField("Slot");
|
|
287
|
+
const index = otp.slots.count++;
|
|
288
|
+
// The server HTML's character; the script keeps it from then on.
|
|
289
|
+
const char = untrack(() => otp.initial[index] ?? "");
|
|
290
|
+
return (
|
|
291
|
+
<div {...props} aria-hidden="true" data-slot="" data-filled={char ? "" : undefined}>
|
|
292
|
+
{char && otp.mask() ? "•" : char}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function Separator({ orientation, ...rest }: OTPFieldSeparatorProps) {
|
|
298
|
+
useOTPField("Separator");
|
|
299
|
+
return <div {...rest} aria-hidden="true" data-orientation={orientation ?? "horizontal"} />;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A one-time code field: one native input, shown as one slot per character, with SMS autofill,
|
|
304
|
+
* paste, validation of the allowed characters and the form's value. Unstyled: style the parts
|
|
305
|
+
* through `class`, the slots' `[data-filled]` and `[data-active]`, and the root's
|
|
306
|
+
* `[data-complete]` and `[data-focused]`.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* <OTPField.Root length={6} name="code">
|
|
310
|
+
* <OTPField.Input aria-label="Verification code" />
|
|
311
|
+
* <OTPField.Slot />
|
|
312
|
+
* <OTPField.Slot />
|
|
313
|
+
* <OTPField.Slot />
|
|
314
|
+
* <OTPField.Separator />
|
|
315
|
+
* <OTPField.Slot />
|
|
316
|
+
* <OTPField.Slot />
|
|
317
|
+
* <OTPField.Slot />
|
|
318
|
+
* </OTPField.Root>
|
|
319
|
+
*/
|
|
320
|
+
export const OTPField = { Root, Input, Slot, Separator };
|