@uniflowed/ui 0.0.0-alpha.10

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/input-otp.js ADDED
@@ -0,0 +1,218 @@
1
+ // @flow
2
+ //
3
+ // A one-time-code field: six boxes that are one `<input>`.
4
+ //
5
+ // # What it gives that one plain `<input>` does not
6
+ //
7
+ // Less, if it is written the obvious way — and that is the whole point of this
8
+ // module's shape. `<input autocomplete="one-time-code" inputmode="numeric">` is
9
+ // already an excellent one-time-code field: the operating system offers the
10
+ // code it just received by SMS, the phone shows a number pad, a password
11
+ // manager fills it, and a screen reader announces one field with one name. Six
12
+ // `<input maxlength="1">`es lose every one of those, and they lose them
13
+ // silently: the autofill simply never appears.
14
+ //
15
+ // So the decision this component *is*: there is **one real `<input>`**, and the
16
+ // boxes are a picture of its value. The slots are `aria-hidden` `<div>`s with a
17
+ // character in them. Everything the platform does keeps working because the
18
+ // platform is still looking at the field it expects:
19
+ //
20
+ // * `autocomplete="one-time-code"`, which is the single most valuable thing
21
+ // about the component and the first thing a hand-written version loses.
22
+ // * `inputmode` from `kind`, so a phone shows a number pad for a numeric code
23
+ // and a keyboard for an alphanumeric one.
24
+ // * **Pasting fills every box**, because pasting into one input is just an
25
+ // `input` event carrying the whole string. There is nothing to distribute.
26
+ // * **`Backspace`, the arrow keys, `Home` and `End` are the browser's.** In
27
+ // one input they are text editing, which the browser already does
28
+ // correctly. A six-input version has to reimplement all four — and the
29
+ // selection, and what a paste into the fourth box means — and gets one of
30
+ // them wrong.
31
+ // * **One accessible name**, and one value a `<form>` submits under one
32
+ // `name` — the joined code rather than six of them. No hidden input is
33
+ // needed here, which is why `internal/form-value.js` is not imported: the
34
+ // field the reader types into is the field the form reads.
35
+ //
36
+ // # Which box is lit
37
+ //
38
+ // The one the next character goes in: `data-active` follows the *length of the
39
+ // code*, not the caret. That is a deliberately small claim. Chasing
40
+ // `selectionStart` would need a `selectionchange` listener, would still have no
41
+ // answer for a reader who has selected all six characters, and would be
42
+ // describing something a reader filling in a code from a text message never
43
+ // does. Editing still works exactly as the browser does it — the arrow keys
44
+ // move the caret, `Backspace` deletes — and the highlight goes back a box when
45
+ // the code gets shorter, which is the part somebody looking at the screen sees.
46
+ //
47
+ // # What it costs
48
+ //
49
+ // The input has to be drawn over the slots by the styling layer — transparent
50
+ // text, a caret positioned by the design, or the whole input made invisible and
51
+ // the caret drawn by `data-active`. That is a real cost and it is the reason
52
+ // this component takes its DOM props on the *input* rather than on a wrapper:
53
+ // the input is the field, so `Field.Control`'s `id`, `aria-describedby` and
54
+ // `aria-invalid` land where they mean something. `InputOtp.Root` renders no
55
+ // wrapper at all, so the layout around the slots is the caller's, whole.
56
+
57
+ "use client";
58
+
59
+ import * as React from "@uniflowed/react";
60
+ import { createContext, useContext, useMemo, useState } from "@uniflowed/react";
61
+
62
+ import type { Rest } from "./internal/merge-props.js";
63
+ import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
64
+ import { useControlled } from "./internal/controlled-state.js";
65
+
66
+ /**
67
+ * What a code is made of.
68
+ *
69
+ * A union rather than a `pattern` string, because the answer decides three
70
+ * things at once — which characters survive typing and pasting, which keyboard
71
+ * a phone shows, and what the field's `pattern` says — and a caller who writes
72
+ * `kind="numberic"` should be told at the call rather than discover that their
73
+ * numeric field takes letters.
74
+ */
75
+ export type InputOtpKind = "numeric" | "alphanumeric";
76
+
77
+ type InputOtpState = {|
78
+ readonly value: string,
79
+ readonly length: number,
80
+ readonly focused: boolean,
81
+ |};
82
+
83
+ const InputOtpContext: React.Context<InputOtpState | null> = createContext(null);
84
+
85
+ /**
86
+ * The field a part belongs to.
87
+ *
88
+ * Raising rather than returning null, for the reason `useDialog` gives: an
89
+ * `InputOtp.Slot` outside a root would render an empty box that never fills,
90
+ * and it would look correct until somebody typed.
91
+ */
92
+ hook useInputOtp(part: string): InputOtpState {
93
+ const state = useContext(InputOtpContext);
94
+ if (state == null) {
95
+ throw new Error(`${part} must be rendered inside an InputOtp.Root`);
96
+ }
97
+ return state;
98
+ }
99
+
100
+ /** Everything but the code, removed as it arrives — typed or pasted. */
101
+ function clean(raw: string, kind: InputOtpKind, length: number): string {
102
+ const kept = match (kind) {
103
+ "numeric" => raw.replace(/[^0-9]/g, ""),
104
+ "alphanumeric" => raw.replace(/[^0-9A-Za-z]/g, ""),
105
+ };
106
+ return kept.slice(0, length);
107
+ }
108
+
109
+ /**
110
+ * The field: one input, and the slots that draw it.
111
+ *
112
+ * Renders no element of its own beyond the input — see the module header. The
113
+ * caller's props go on the input, because the input is the field.
114
+ */
115
+ export component InputOtpRoot(
116
+ children: React.Node,
117
+ defaultValue?: string = "",
118
+ disabled?: boolean = false,
119
+ kind?: InputOtpKind = "numeric",
120
+ label: string,
121
+ length: number,
122
+ name?: string,
123
+ onComplete?: (code: string) => void,
124
+ onValueChange?: (value: string) => void,
125
+ value?: string,
126
+ ...rest: Rest
127
+ ) {
128
+ const [code, setCode] = useControlled(value, defaultValue, onValueChange);
129
+ const [focused, setFocused] = useState(false);
130
+ const passed = withoutComposed(rest, ["onBlur", "onChange", "onFocus"]);
131
+ // `aria-labelledby` wins over `aria-label`, so a field wired through
132
+ // `Field.Control` keeps the label the caller actually rendered.
133
+ const named = rest["aria-labelledby"] != null;
134
+
135
+ const state = useMemo(() => ({ focused, length, value: code }), [focused, length, code]);
136
+
137
+ return (
138
+ <InputOtpContext.Provider value={state}>
139
+ <input
140
+ {...passed}
141
+ aria-label={named ? undefined : label}
142
+ // The reason the component exists. Without it the operating system has
143
+ // no field to offer the code it just received by SMS to.
144
+ autoComplete="one-time-code"
145
+ disabled={disabled}
146
+ inputMode={kind === "numeric" ? "numeric" : "text"}
147
+ maxLength={length}
148
+ name={name}
149
+ onBlur={composeHandlers(rest.onBlur, () => setFocused(false))}
150
+ onChange={composeHandlers(rest.onChange, (event: $FlowFixMe) => {
151
+ // One event whether a character was typed or six were pasted, which
152
+ // is why "pasting fills every box" needs no code of its own.
153
+ const next = clean(String(event.currentTarget?.value ?? ""), kind, length);
154
+ setCode(next);
155
+ if (next.length === length) {
156
+ onComplete?.(next);
157
+ }
158
+ })}
159
+ onFocus={composeHandlers(rest.onFocus, () => setFocused(true))}
160
+ pattern={kind === "numeric" ? "[0-9]*" : "[0-9A-Za-z]*"}
161
+ type="text"
162
+ value={code}
163
+ />
164
+ {children}
165
+ </InputOtpContext.Provider>
166
+ );
167
+ }
168
+
169
+ /**
170
+ * A run of slots, drawn together.
171
+ *
172
+ * `aria-hidden`, like everything else that draws the value: the field has
173
+ * already been announced, and a reader told "group, 3, group, 4" is being read
174
+ * a picture.
175
+ */
176
+ export component InputOtpGroup(children: React.Node, ...rest: Rest) {
177
+ return (
178
+ <div {...rest} aria-hidden="true">
179
+ {children}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ /**
185
+ * One box.
186
+ *
187
+ * `data-active` is the box the next character goes in and `data-filled` is
188
+ * whether there is one in it already, so a stylesheet can draw a cursor and a
189
+ * border without this module having an opinion about either.
190
+ */
191
+ export component InputOtpSlot(children?: React.Node, index: number, ...rest: Rest) {
192
+ const otp = useInputOtp("InputOtp.Slot");
193
+ const character = otp.value[index] ?? "";
194
+ // The box the next character goes in, clamped so a full code lights its last
195
+ // box rather than nothing at all.
196
+ const active = otp.focused && index === Math.min(otp.value.length, otp.length - 1);
197
+
198
+ return (
199
+ <div
200
+ {...rest}
201
+ aria-hidden="true"
202
+ data-active={active ? "true" : undefined}
203
+ data-filled={character === "" ? undefined : "true"}
204
+ data-index={String(index)}
205
+ >
206
+ {children ?? character}
207
+ </div>
208
+ );
209
+ }
210
+
211
+ /** The dash between two groups. Decoration, and it says so. */
212
+ export component InputOtpSeparator(children?: React.Node, ...rest: Rest) {
213
+ return (
214
+ <div {...rest} aria-hidden="true">
215
+ {children}
216
+ </div>
217
+ );
218
+ }