@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/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
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
|
+
}
|