@tonycodes-ui/mortar 0.0.0-stage → 0.11.0
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/AGENTS.md +10 -0
- package/CHANGELOG.md +276 -0
- package/CONTRIBUTING.md +48 -0
- package/DESIGN.md +51 -0
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/TESTING.md +83 -0
- package/baseline-policy.json +16 -0
- package/bin/mortar.mjs +258 -0
- package/catalog.json +9703 -0
- package/cli/audit.mjs +943 -0
- package/cli/data.mjs +176 -0
- package/cli/mcp.mjs +216 -0
- package/cli/migrations.mjs +128 -0
- package/cli/packet.mjs +267 -0
- package/cli/search.mjs +246 -0
- package/cli/tools.mjs +229 -0
- package/design-principles.json +30 -0
- package/dist/agent-data.json +1 -0
- package/dist/application.d.ts +340 -0
- package/dist/application.js +240 -0
- package/dist/auth.d.ts +121 -0
- package/dist/auth.js +97 -0
- package/dist/brand.d.ts +69 -0
- package/dist/brand.js +266 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.js +310 -0
- package/dist/choice.d.ts +36 -0
- package/dist/choice.js +36 -0
- package/dist/composer.d.ts +28 -0
- package/dist/composer.js +47 -0
- package/dist/display.d.ts +136 -0
- package/dist/display.js +75 -0
- package/dist/docs.d.ts +145 -0
- package/dist/docs.js +141 -0
- package/dist/ecommerce.d.ts +261 -0
- package/dist/ecommerce.js +177 -0
- package/dist/feedback.d.ts +80 -0
- package/dist/feedback.js +98 -0
- package/dist/foundation.d.ts +42 -0
- package/dist/foundation.js +121 -0
- package/dist/heading.d.ts +5 -0
- package/dist/heading.js +9 -0
- package/dist/icons.d.ts +50 -0
- package/dist/icons.js +70 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +17 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +14 -0
- package/dist/internal.d.ts +492 -0
- package/dist/internal.js +273 -0
- package/dist/layout.d.ts +107 -0
- package/dist/layout.js +66 -0
- package/dist/marketing-sections.d.ts +161 -0
- package/dist/marketing-sections.js +58 -0
- package/dist/marketing.d.ts +254 -0
- package/dist/marketing.js +150 -0
- package/dist/navigation.d.ts +35 -0
- package/dist/navigation.js +44 -0
- package/dist/primitives.d.ts +258 -0
- package/dist/primitives.js +244 -0
- package/dist/showcase.d.ts +29 -0
- package/dist/showcase.js +10 -0
- package/dist/workspace.d.ts +80 -0
- package/dist/workspace.js +48 -0
- package/docs/agents.md +127 -0
- package/docs/auth-flows.md +71 -0
- package/docs/migrating-to-0.6.md +94 -0
- package/docs/theming.md +170 -0
- package/package.json +94 -4
- package/skills/mortar/SKILL.md +36 -0
- package/src/application.css +1487 -0
- package/src/application.tsx +1413 -0
- package/src/auth.tsx +452 -0
- package/src/brand.ts +335 -0
- package/src/calendar.css +468 -0
- package/src/calendar.tsx +868 -0
- package/src/choice.tsx +240 -0
- package/src/composer.tsx +158 -0
- package/src/data.css +690 -0
- package/src/display.tsx +452 -0
- package/src/docs.css +704 -0
- package/src/docs.tsx +737 -0
- package/src/ecommerce.css +810 -0
- package/src/ecommerce.tsx +1033 -0
- package/src/feedback.tsx +388 -0
- package/src/foundation.css +1867 -0
- package/src/foundation.tsx +227 -0
- package/src/heading.ts +13 -0
- package/src/icons.tsx +237 -0
- package/src/index.ts +230 -0
- package/src/inspection.tsx +91 -0
- package/src/internal.tsx +614 -0
- package/src/layout.tsx +342 -0
- package/src/marketing-sections.tsx +621 -0
- package/src/marketing.css +2400 -0
- package/src/marketing.tsx +1156 -0
- package/src/navigation.tsx +118 -0
- package/src/overlays.css +350 -0
- package/src/primitives.tsx +1135 -0
- package/src/showcase.tsx +120 -0
- package/src/tokens.css +449 -0
- package/src/workspace.tsx +333 -0
- package/styles.css +56 -0
package/src/auth.tsx
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// Sign-in and registration building blocks: the auth page shell, provider
|
|
2
|
+
// buttons and the password and one-time-code fields. See docs/auth-flows.md.
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useId,
|
|
6
|
+
useState,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type InputHTMLAttributes,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { Button, FieldLabel, type FieldDescription } from "./primitives.js";
|
|
12
|
+
import { BrandMark } from "./layout.js";
|
|
13
|
+
import { Check, Circle, Eye, EyeOff } from "./icons.js";
|
|
14
|
+
import { headingTag, type HeadingLevel } from "./heading.js";
|
|
15
|
+
import { FieldFeedback, componentRoot, cx, useField } from "./internal.js";
|
|
16
|
+
|
|
17
|
+
/** A sign-in provider such as GitHub or Google; the auth SDK handles `onSelect`. */
|
|
18
|
+
export interface SignInProvider {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
/** Decorative logo; the label stays the accessible name. */
|
|
22
|
+
icon?: ReactNode;
|
|
23
|
+
onSelect: () => void;
|
|
24
|
+
}
|
|
25
|
+
export interface ProviderButtonsProps {
|
|
26
|
+
providers: SignInProvider[];
|
|
27
|
+
/** `grid` sets the buttons side by side where they fit, e.g. two short labels. */
|
|
28
|
+
layout?: "list" | "grid";
|
|
29
|
+
/** Names the group of buttons, e.g. "Sign in with". */
|
|
30
|
+
label?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One full-width secondary button per sign-in provider. Pair it with a
|
|
35
|
+
* labelled Divider ("or continue with") above or below the email form.
|
|
36
|
+
*/
|
|
37
|
+
export function ProviderButtons({
|
|
38
|
+
providers,
|
|
39
|
+
layout = "list",
|
|
40
|
+
label,
|
|
41
|
+
className,
|
|
42
|
+
}: ProviderButtonsProps) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
{...componentRoot("ProviderButtons")}
|
|
46
|
+
className={cx("m-provider-buttons", className)}
|
|
47
|
+
role={label ? "group" : undefined}
|
|
48
|
+
aria-label={label}
|
|
49
|
+
data-layout={layout === "list" ? undefined : layout}
|
|
50
|
+
>
|
|
51
|
+
{providers.map((provider) => (
|
|
52
|
+
<Button
|
|
53
|
+
key={provider.id}
|
|
54
|
+
variant="secondary"
|
|
55
|
+
icon={provider.icon}
|
|
56
|
+
onClick={provider.onSelect}
|
|
57
|
+
>
|
|
58
|
+
{provider.label}
|
|
59
|
+
</Button>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The logo, heading and description that open an auth panel (internal). */
|
|
66
|
+
export function AuthHeader({
|
|
67
|
+
logo,
|
|
68
|
+
title,
|
|
69
|
+
description,
|
|
70
|
+
headingLevel,
|
|
71
|
+
headingId,
|
|
72
|
+
className,
|
|
73
|
+
titleClassName,
|
|
74
|
+
}: {
|
|
75
|
+
logo?: ReactNode;
|
|
76
|
+
title: string;
|
|
77
|
+
description?: ReactNode;
|
|
78
|
+
headingLevel: HeadingLevel;
|
|
79
|
+
headingId: string;
|
|
80
|
+
className?: string;
|
|
81
|
+
titleClassName: string;
|
|
82
|
+
}) {
|
|
83
|
+
const Heading = headingTag(headingLevel);
|
|
84
|
+
return (
|
|
85
|
+
<header className={className}>
|
|
86
|
+
{logo && <BrandMark logo={logo} />}
|
|
87
|
+
<Heading id={headingId} className={titleClassName}>
|
|
88
|
+
{title}
|
|
89
|
+
</Heading>
|
|
90
|
+
{description && <p>{description}</p>}
|
|
91
|
+
</header>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AuthLayoutProps {
|
|
96
|
+
/** The screen's heading, e.g. "Sign in to your account". */
|
|
97
|
+
title: string;
|
|
98
|
+
description?: ReactNode;
|
|
99
|
+
/** Logo node above the heading. */
|
|
100
|
+
logo?: ReactNode;
|
|
101
|
+
/**
|
|
102
|
+
* `center` sets the form on the page, `cards` on a raised card, and `split`
|
|
103
|
+
* beside the `aside` on wide screens.
|
|
104
|
+
*/
|
|
105
|
+
layout?: "center" | "cards" | "split";
|
|
106
|
+
/** The page's h1 by default; lower it only when another h1 precedes the layout. */
|
|
107
|
+
headingLevel?: HeadingLevel;
|
|
108
|
+
/** The form. A `<form>` placed directly here stacks its fields, and its direct Button children fill the width. */
|
|
109
|
+
children: ReactNode;
|
|
110
|
+
/** Text under the form, e.g. "Don't have an account? Sign up". */
|
|
111
|
+
footer?: ReactNode;
|
|
112
|
+
/**
|
|
113
|
+
* Image, quote or product art beside the form; shown only with `layout="split"`.
|
|
114
|
+
* On narrow screens text moves below the form and a lone image or video is hidden.
|
|
115
|
+
*/
|
|
116
|
+
aside?: ReactNode;
|
|
117
|
+
id?: string;
|
|
118
|
+
className?: string;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The page shell for sign-in, sign-up, password reset and verification
|
|
122
|
+
* screens. It renders the page's `main` landmark, named by the heading.
|
|
123
|
+
*/
|
|
124
|
+
export function AuthLayout({
|
|
125
|
+
title,
|
|
126
|
+
description,
|
|
127
|
+
logo,
|
|
128
|
+
layout = "center",
|
|
129
|
+
headingLevel = 1,
|
|
130
|
+
children,
|
|
131
|
+
footer,
|
|
132
|
+
aside,
|
|
133
|
+
id,
|
|
134
|
+
className,
|
|
135
|
+
}: AuthLayoutProps) {
|
|
136
|
+
const headingId = useId();
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
id={id}
|
|
140
|
+
{...componentRoot("AuthLayout")}
|
|
141
|
+
className={cx("m-auth", className)}
|
|
142
|
+
data-layout={layout === "center" ? undefined : layout}
|
|
143
|
+
>
|
|
144
|
+
<main className="m-auth-main" aria-labelledby={headingId}>
|
|
145
|
+
<div className="m-auth-panel">
|
|
146
|
+
<AuthHeader
|
|
147
|
+
className="m-auth-header"
|
|
148
|
+
titleClassName="m-auth-title"
|
|
149
|
+
logo={logo}
|
|
150
|
+
title={title}
|
|
151
|
+
description={description}
|
|
152
|
+
headingLevel={headingLevel}
|
|
153
|
+
headingId={headingId}
|
|
154
|
+
/>
|
|
155
|
+
<div className="m-auth-body">{children}</div>
|
|
156
|
+
{footer && <footer className="m-auth-footer">{footer}</footer>}
|
|
157
|
+
</div>
|
|
158
|
+
</main>
|
|
159
|
+
{layout === "split" && aside && (
|
|
160
|
+
<aside className="m-auth-aside">{aside}</aside>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface PasswordRequirement {
|
|
167
|
+
/** Shown before the password is typed, e.g. "At least 12 characters". */
|
|
168
|
+
label: string;
|
|
169
|
+
met: boolean;
|
|
170
|
+
}
|
|
171
|
+
export interface PasswordStrength {
|
|
172
|
+
/** 0 (very weak) to 4 (very strong), e.g. from zxcvbn's score. */
|
|
173
|
+
value: 0 | 1 | 2 | 3 | 4;
|
|
174
|
+
/** Visible and announced value, e.g. "Strong". */
|
|
175
|
+
label: string;
|
|
176
|
+
}
|
|
177
|
+
export interface PasswordInputProps
|
|
178
|
+
extends
|
|
179
|
+
Omit<InputHTMLAttributes<HTMLInputElement>, "type">,
|
|
180
|
+
FieldDescription {
|
|
181
|
+
/**
|
|
182
|
+
* `current` (sign-in) sets autocomplete="current-password"; `new` (sign-up,
|
|
183
|
+
* reset) sets "new-password" so password managers offer to generate one.
|
|
184
|
+
*/
|
|
185
|
+
purpose?: "current" | "new";
|
|
186
|
+
/** Rules shown under the field from the start, each marked met or not met as the person types. */
|
|
187
|
+
requirements?: PasswordRequirement[];
|
|
188
|
+
/** Strength meter under the field. */
|
|
189
|
+
strength?: PasswordStrength;
|
|
190
|
+
/** Accessible name of the show/hide toggle; its pressed state says whether the password is visible. */
|
|
191
|
+
toggleLabel?: string;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Password field with a show/hide toggle, optional requirement checklist and
|
|
195
|
+
* strength meter. Paste is never blocked.
|
|
196
|
+
*/
|
|
197
|
+
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
198
|
+
function PasswordInput(
|
|
199
|
+
{
|
|
200
|
+
label,
|
|
201
|
+
hint,
|
|
202
|
+
error,
|
|
203
|
+
id,
|
|
204
|
+
className,
|
|
205
|
+
purpose = "current",
|
|
206
|
+
requirements,
|
|
207
|
+
strength,
|
|
208
|
+
toggleLabel = "Show password",
|
|
209
|
+
autoComplete,
|
|
210
|
+
...props
|
|
211
|
+
},
|
|
212
|
+
ref,
|
|
213
|
+
) {
|
|
214
|
+
const [visible, setVisible] = useState(false);
|
|
215
|
+
const requirementsId = useId();
|
|
216
|
+
const { fieldId, messageId, describedBy } = useField({
|
|
217
|
+
id,
|
|
218
|
+
hint,
|
|
219
|
+
error,
|
|
220
|
+
describedBy: props["aria-describedby"],
|
|
221
|
+
});
|
|
222
|
+
const allMet = !!requirements?.length && requirements.every((r) => r.met);
|
|
223
|
+
// The status region speaks only when the overall state flips, not on
|
|
224
|
+
// every keystroke: silent until every rule has been met once.
|
|
225
|
+
const [everMet, setEverMet] = useState(false);
|
|
226
|
+
if (allMet && !everMet) setEverMet(true);
|
|
227
|
+
const status = allMet
|
|
228
|
+
? "All password requirements met."
|
|
229
|
+
: everMet
|
|
230
|
+
? "Password no longer meets every requirement."
|
|
231
|
+
: "";
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
{...componentRoot("PasswordInput")}
|
|
235
|
+
className={cx("m-field", "m-password", className)}
|
|
236
|
+
>
|
|
237
|
+
<FieldLabel htmlFor={fieldId} label={label} required={props.required} />
|
|
238
|
+
<div className="m-input-group" data-invalid={!!error || undefined}>
|
|
239
|
+
<input
|
|
240
|
+
{...props}
|
|
241
|
+
ref={ref}
|
|
242
|
+
id={fieldId}
|
|
243
|
+
type={visible ? "text" : "password"}
|
|
244
|
+
autoComplete={
|
|
245
|
+
autoComplete ??
|
|
246
|
+
(purpose === "new" ? "new-password" : "current-password")
|
|
247
|
+
}
|
|
248
|
+
autoCapitalize="off"
|
|
249
|
+
spellCheck={false}
|
|
250
|
+
aria-invalid={!!error || props["aria-invalid"]}
|
|
251
|
+
aria-describedby={
|
|
252
|
+
[describedBy, requirements?.length && requirementsId]
|
|
253
|
+
.filter(Boolean)
|
|
254
|
+
.join(" ") || undefined
|
|
255
|
+
}
|
|
256
|
+
/>
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
className="m-password-toggle"
|
|
260
|
+
aria-pressed={visible}
|
|
261
|
+
aria-controls={fieldId}
|
|
262
|
+
disabled={props.disabled}
|
|
263
|
+
// A pointer press keeps focus where it was (usually the field).
|
|
264
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
265
|
+
onClick={() => setVisible((v) => !v)}
|
|
266
|
+
>
|
|
267
|
+
{visible ? <EyeOff /> : <Eye />}
|
|
268
|
+
<span className="m-sr-only">{toggleLabel}</span>
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
<FieldFeedback id={messageId} hint={hint} error={error} />
|
|
272
|
+
{strength && <StrengthMeter strength={strength} />}
|
|
273
|
+
{requirements && requirements.length > 0 && (
|
|
274
|
+
<>
|
|
275
|
+
<ul
|
|
276
|
+
id={requirementsId}
|
|
277
|
+
className="m-password-requirements"
|
|
278
|
+
aria-label="Password requirements"
|
|
279
|
+
>
|
|
280
|
+
{requirements.map((rule) => (
|
|
281
|
+
<li key={rule.label} data-met={rule.met || undefined}>
|
|
282
|
+
{rule.met ? <Check /> : <Circle />}
|
|
283
|
+
<span>{rule.label}</span>
|
|
284
|
+
<span className="m-sr-only">
|
|
285
|
+
{rule.met ? ", met" : ", not met"}
|
|
286
|
+
</span>
|
|
287
|
+
</li>
|
|
288
|
+
))}
|
|
289
|
+
</ul>
|
|
290
|
+
<p role="status" className="m-sr-only">
|
|
291
|
+
{status}
|
|
292
|
+
</p>
|
|
293
|
+
</>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
function StrengthMeter({ strength }: { strength: PasswordStrength }) {
|
|
301
|
+
const labelId = useId();
|
|
302
|
+
const value = Math.max(0, Math.min(4, Math.round(strength.value)));
|
|
303
|
+
return (
|
|
304
|
+
<div className="m-password-strength" data-value={value}>
|
|
305
|
+
<span id={labelId} className="m-sr-only">
|
|
306
|
+
Password strength
|
|
307
|
+
</span>
|
|
308
|
+
<div
|
|
309
|
+
className="m-password-meter"
|
|
310
|
+
role="meter"
|
|
311
|
+
aria-labelledby={labelId}
|
|
312
|
+
aria-valuemin={0}
|
|
313
|
+
aria-valuemax={4}
|
|
314
|
+
aria-valuenow={value}
|
|
315
|
+
aria-valuetext={strength.label}
|
|
316
|
+
>
|
|
317
|
+
{[1, 2, 3, 4].map((step) => (
|
|
318
|
+
<span key={step} data-on={value >= step || undefined} />
|
|
319
|
+
))}
|
|
320
|
+
</div>
|
|
321
|
+
<span aria-hidden="true">{strength.label}</span>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface CodeInputProps extends FieldDescription {
|
|
327
|
+
/** Number of characters; one visual cell each. */
|
|
328
|
+
length?: number;
|
|
329
|
+
/** Controlled value; pair with `onChange`. */
|
|
330
|
+
value?: string;
|
|
331
|
+
defaultValue?: string;
|
|
332
|
+
/** The cleaned code after each edit: allowed characters only, at most `length`. */
|
|
333
|
+
onChange?: (value: string) => void;
|
|
334
|
+
/**
|
|
335
|
+
* Called once when the code reaches `length` characters, by typing, paste or
|
|
336
|
+
* autofill. Submitting here is opt-in; without it the person submits the form.
|
|
337
|
+
*/
|
|
338
|
+
onComplete?: (code: string) => void;
|
|
339
|
+
/** Accept letters as well as digits (upper-cased); digits only by default. */
|
|
340
|
+
alphanumeric?: boolean;
|
|
341
|
+
/** Id of the input, e.g. the target of an error summary link. */
|
|
342
|
+
id?: string;
|
|
343
|
+
name?: string;
|
|
344
|
+
required?: boolean;
|
|
345
|
+
disabled?: boolean;
|
|
346
|
+
autoFocus?: boolean;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* One-time code field: a single input shown as one cell per character, so
|
|
350
|
+
* screen readers, paste and SMS or password-manager autofill all see one
|
|
351
|
+
* field (autocomplete="one-time-code").
|
|
352
|
+
*/
|
|
353
|
+
export const CodeInput = forwardRef<HTMLInputElement, CodeInputProps>(
|
|
354
|
+
function CodeInput(
|
|
355
|
+
{
|
|
356
|
+
label,
|
|
357
|
+
hint,
|
|
358
|
+
error,
|
|
359
|
+
className,
|
|
360
|
+
length = 6,
|
|
361
|
+
value: controlled,
|
|
362
|
+
defaultValue = "",
|
|
363
|
+
onChange,
|
|
364
|
+
onComplete,
|
|
365
|
+
alphanumeric = false,
|
|
366
|
+
id,
|
|
367
|
+
name,
|
|
368
|
+
required,
|
|
369
|
+
disabled,
|
|
370
|
+
autoFocus,
|
|
371
|
+
},
|
|
372
|
+
ref,
|
|
373
|
+
) {
|
|
374
|
+
const clean = (text: string) =>
|
|
375
|
+
(alphanumeric
|
|
376
|
+
? text.replace(/[^a-z0-9]/gi, "").toUpperCase()
|
|
377
|
+
: text.replace(/\D/g, "")
|
|
378
|
+
).slice(0, length);
|
|
379
|
+
const [local, setLocal] = useState(() => clean(defaultValue));
|
|
380
|
+
const value = controlled === undefined ? local : clean(controlled);
|
|
381
|
+
const [focused, setFocused] = useState(false);
|
|
382
|
+
const { fieldId, messageId, describedBy } = useField({ id, hint, error });
|
|
383
|
+
const active = focused ? Math.min(value.length, length - 1) : -1;
|
|
384
|
+
return (
|
|
385
|
+
<div
|
|
386
|
+
{...componentRoot("CodeInput")}
|
|
387
|
+
className={cx("m-field", "m-code-input", className)}
|
|
388
|
+
data-disabled={disabled || undefined}
|
|
389
|
+
>
|
|
390
|
+
<FieldLabel htmlFor={fieldId} label={label} required={required} />
|
|
391
|
+
<div
|
|
392
|
+
className="m-code-control"
|
|
393
|
+
data-invalid={!!error || undefined}
|
|
394
|
+
style={{ "--m-code-length": length } as CSSProperties}
|
|
395
|
+
>
|
|
396
|
+
<input
|
|
397
|
+
ref={ref}
|
|
398
|
+
id={fieldId}
|
|
399
|
+
name={name}
|
|
400
|
+
type="text"
|
|
401
|
+
inputMode={alphanumeric ? "text" : "numeric"}
|
|
402
|
+
autoComplete="one-time-code"
|
|
403
|
+
autoCapitalize={alphanumeric ? "characters" : "off"}
|
|
404
|
+
autoCorrect="off"
|
|
405
|
+
spellCheck={false}
|
|
406
|
+
pattern={
|
|
407
|
+
alphanumeric ? `[A-Za-z0-9]{${length}}` : `[0-9]{${length}}`
|
|
408
|
+
}
|
|
409
|
+
required={required}
|
|
410
|
+
disabled={disabled}
|
|
411
|
+
autoFocus={autoFocus}
|
|
412
|
+
value={value}
|
|
413
|
+
aria-invalid={!!error || undefined}
|
|
414
|
+
aria-describedby={describedBy}
|
|
415
|
+
onFocus={() => setFocused(true)}
|
|
416
|
+
onBlur={() => setFocused(false)}
|
|
417
|
+
// Keep the caret after the last character, so typing fills the
|
|
418
|
+
// next cell and Backspace clears the previous one.
|
|
419
|
+
onSelect={(event) => {
|
|
420
|
+
const input = event.currentTarget;
|
|
421
|
+
const end = input.value.length;
|
|
422
|
+
if (
|
|
423
|
+
input.selectionStart === input.selectionEnd &&
|
|
424
|
+
input.selectionStart !== end
|
|
425
|
+
)
|
|
426
|
+
input.setSelectionRange(end, end);
|
|
427
|
+
}}
|
|
428
|
+
onChange={(event) => {
|
|
429
|
+
const next = clean(event.target.value);
|
|
430
|
+
if (controlled === undefined) setLocal(next);
|
|
431
|
+
onChange?.(next);
|
|
432
|
+
if (next !== value && next.length === length) onComplete?.(next);
|
|
433
|
+
}}
|
|
434
|
+
/>
|
|
435
|
+
<div className="m-code-cells" aria-hidden="true">
|
|
436
|
+
{Array.from({ length }, (_, index) => (
|
|
437
|
+
<span
|
|
438
|
+
key={index}
|
|
439
|
+
className="m-code-cell"
|
|
440
|
+
data-filled={index < value.length || undefined}
|
|
441
|
+
data-active={index === active || undefined}
|
|
442
|
+
>
|
|
443
|
+
{value[index]}
|
|
444
|
+
</span>
|
|
445
|
+
))}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
<FieldFeedback id={messageId} hint={hint} error={error} />
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
},
|
|
452
|
+
);
|