@weft-js/warp 0.0.0-stage → 0.1.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/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- package/tokens/color.mjs +66 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { KeyIcon, LeftIcon, MailIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { clockLabel, emailError, passwordRules, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, onCleanup, onMount, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Avatar } from '../parts'
|
|
5
|
+
import { Badge } from '../Badge'
|
|
6
|
+
import { Button } from '../Button'
|
|
7
|
+
import { Input } from '../Field'
|
|
8
|
+
import { FormError, Mark, Rail, StepFrame, Submit, TextLink, Title, useCooldown, useStepForm, type FlowForm, type Heading } from './kit'
|
|
9
|
+
import { PasswordMeter } from './SignUp'
|
|
10
|
+
import '../styles'
|
|
11
|
+
import '@weft-js/warp-core/styles/flows/auth.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
13
|
+
|
|
14
|
+
const Back = ({ onBack }: { onBack: () => void }) => <div><Button emphasis="ghost" tone="neutral" size="sm" icon={LeftIcon} onClick={onBack}>Back</Button></div>
|
|
15
|
+
|
|
16
|
+
/** "Check your inbox" (09 §2.1): waiting, so no solid; the address, Open mail app, resend on a countdown, change email. */
|
|
17
|
+
export function MagicLinkSent({ email, title, lede, heading, resendIn = 30, mailHref, onResend, onChangeEmail, onBack }: { email: string; title?: unknown; lede?: unknown; heading?: Heading; resendIn?: number; /** Shows Open email app (phones). */ mailHref?: string; onResend?: () => void; onChangeEmail?: () => void; onBack?: () => void }) {
|
|
18
|
+
const round = state(0)
|
|
19
|
+
const left = useCooldown(() => (round.value, resendIn))
|
|
20
|
+
return (
|
|
21
|
+
<div data-scope="stack">
|
|
22
|
+
{onBack && <Back onBack={onBack} />}
|
|
23
|
+
<Mark><MailIcon /></Mark>
|
|
24
|
+
<div><Title as={heading}>{title ?? 'Check your inbox'}</Title><p data-part="lede">{lede ?? <>We sent a sign-in link to <b>{email}</b>. It works once and expires in 15 minutes.</>}</p></div>
|
|
25
|
+
{mailHref && <Button emphasis="frame" full role="link" onClick={() => location.assign(mailHref)}>Open email app</Button>}
|
|
26
|
+
<div data-part="row">
|
|
27
|
+
{onResend && <TextLink disabled={left.value > 0} onClick={() => { onResend(); round.value++ }}>{left.value > 0 ? `Resend in ${clockLabel(left.value)}` : 'Resend link'}</TextLink>}
|
|
28
|
+
{onChangeEmail && <TextLink onClick={onChangeEmail}>Use a different email</TextLink>}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the reset form collects, across its two steps. */
|
|
35
|
+
export interface ResetValues { email?: string; password?: string; confirm?: string }
|
|
36
|
+
|
|
37
|
+
/** Props for ResetPassword. */
|
|
38
|
+
export interface ResetPasswordProps extends FlowForm<ResetValues> {
|
|
39
|
+
/** `request` asks for the email; `password` sets the new one (after the link or the code). */
|
|
40
|
+
stage?: 'request' | 'password' | undefined
|
|
41
|
+
onBack?: () => void
|
|
42
|
+
title?: unknown
|
|
43
|
+
lede?: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reset (09 §2.1): email → link or code → new password and confirm. Never says whether the email has an account (§2.3). */
|
|
47
|
+
export function ResetPassword(p: ResetPasswordProps) {
|
|
48
|
+
const id = uid('reset')
|
|
49
|
+
const stage = untrack(() => p.stage ?? 'request')
|
|
50
|
+
const password = state('')
|
|
51
|
+
const ctl = useStepForm<ResetValues>(untrack(() => p), v => (stage === 'request'
|
|
52
|
+
? { email: v.email ? emailError(v.email) : 'Enter the email you sign in with.' }
|
|
53
|
+
: {
|
|
54
|
+
password: passwordRules(v.password ?? '').every(r => r.met) ? undefined : 'Use at least 8 characters, with a letter and a number.',
|
|
55
|
+
confirm: v.confirm === v.password ? undefined : "Those passwords don't match.",
|
|
56
|
+
}))
|
|
57
|
+
return (
|
|
58
|
+
<StepFrame ctl={ctl} embedded={!!p.form} labelledby={`${id}-t`}>
|
|
59
|
+
{p.onBack && <Back onBack={p.onBack} />}
|
|
60
|
+
<Mark><KeyIcon /></Mark>
|
|
61
|
+
<div>
|
|
62
|
+
<Title as={p.heading} id={`${id}-t`}>{p.title ?? (stage === 'request' ? 'Reset your password' : 'Choose a new password')}</Title>
|
|
63
|
+
<p data-part="lede">{p.lede ?? (stage === 'request' ? "We'll email a link that lets you choose a new one. It expires in 30 minutes." : 'Then sign in with it everywhere.')}</p>
|
|
64
|
+
</div>
|
|
65
|
+
<FormError ctl={ctl} />
|
|
66
|
+
{stage === 'request'
|
|
67
|
+
? <Input label="Work email" name="email" type="email" inputmode="email" autocomplete="email" hint="" error={ctl.errors.value.email} onBlur={(e: FocusEvent) => ctl.flag('email', emailError((e.target as HTMLInputElement).value))} />
|
|
68
|
+
: (
|
|
69
|
+
<>
|
|
70
|
+
<div>
|
|
71
|
+
<Input label="New password" name="password" type="password" autocomplete="new-password" hint="" error={ctl.errors.value.password} onInput={e => (password.value = e.currentTarget.value)} />
|
|
72
|
+
<PasswordMeter value={password.value} />
|
|
73
|
+
</div>
|
|
74
|
+
<Input label="Confirm password" name="confirm" type="password" autocomplete="new-password" hint="" error={ctl.errors.value.confirm} />
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
<Submit ctl={ctl}>{stage === 'request' ? 'Send reset link' : 'Continue'}</Submit>
|
|
78
|
+
</StepFrame>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Invite accept (09 §2.1): who invited you, the org and role, "You'll join as…", Join and Decline; `children` holds an inline sign-in or sign-up. */
|
|
83
|
+
export function InviteAccept({ inviter, org, role, email, description, heading, pending, onAccept, onDecline, onOtherAccount, onBack, children }: { inviter: string; org: string; role?: string; email?: unknown; description?: unknown; heading?: Heading; pending?: boolean | undefined; onAccept?: () => void; onDecline?: () => void; onOtherAccount?: () => void; onBack?: () => void; children?: unknown }) {
|
|
84
|
+
const body = derived(() => children)
|
|
85
|
+
return (
|
|
86
|
+
<div data-scope="stack">
|
|
87
|
+
{onBack && <Back onBack={onBack} />}
|
|
88
|
+
<div data-scope="row" data-wrap="none">
|
|
89
|
+
<Avatar name={inviter} size="lg" />
|
|
90
|
+
<div>
|
|
91
|
+
<p data-part="lede">{inviter} invited you to</p>
|
|
92
|
+
<b>{org}</b> {role && <Badge tone="info">{role}</Badge>}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div><Title as={heading}>Join {org}</Title>{email !== undefined && <p data-part="lede">You'll join as <b>{email}</b>. {description}</p>}</div>
|
|
96
|
+
{body.value !== undefined ? body.value : <Button size="lg" full loading={pending} onClick={onAccept}>Join {org}</Button>}
|
|
97
|
+
<div data-part="row">
|
|
98
|
+
{onOtherAccount && <TextLink onClick={onOtherAccount}>Sign in with another account</TextLink>}
|
|
99
|
+
{onDecline && <Button emphasis="ghost" tone="neutral" size="sm" onClick={onDecline}>Decline</Button>}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Done (09 §2.1): the mark strokes in, then the flow continues after `delay` (at once under reduced motion). */
|
|
106
|
+
export function AuthDone({ title, lede, heading, delay = 1200, onContinue }: { title?: unknown; lede?: unknown; heading?: Heading; delay?: number; onContinue?: (() => void) | undefined }) {
|
|
107
|
+
onMount(() => {
|
|
108
|
+
const reduced = matchMedia?.('(prefers-reduced-motion: reduce)')?.matches || document.documentElement.dataset.motion === 'reduce'
|
|
109
|
+
const t = setTimeout(() => onContinue?.(), reduced ? 0 : untrack(() => delay))
|
|
110
|
+
onCleanup(() => clearTimeout(t))
|
|
111
|
+
})
|
|
112
|
+
return (
|
|
113
|
+
<div data-scope="stack">
|
|
114
|
+
<Mark tone="positive" draw />
|
|
115
|
+
<div><Title as={heading}>{title ?? "You're in"}</Title>{lede !== undefined && <p data-part="lede">{lede}</p>}</div>
|
|
116
|
+
<div data-scope="row"><Rail value={100} aria-hidden="true" /></div>
|
|
117
|
+
{onContinue && <Button full size="lg" onClick={onContinue}>Continue</Button>}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, watch } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { Checkbox } from '../Choice'
|
|
6
|
+
import { toast } from '../Toast'
|
|
7
|
+
import { Mark, Rail } from './kit'
|
|
8
|
+
import '@weft-js/warp-core/styles/flows/checklist.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
10
|
+
|
|
11
|
+
/** One onboarding task. */
|
|
12
|
+
export interface ChecklistItem {
|
|
13
|
+
id: string
|
|
14
|
+
title: unknown
|
|
15
|
+
description?: unknown
|
|
16
|
+
done?: boolean
|
|
17
|
+
/** The frame button on the row's end. */
|
|
18
|
+
action?: { label: string; onClick: () => void } | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Props for Checklist. */
|
|
22
|
+
export interface ChecklistProps {
|
|
23
|
+
title?: unknown
|
|
24
|
+
subtitle?: unknown
|
|
25
|
+
/** Derived from server state, never from clicks here: doing the task anywhere checks it. */
|
|
26
|
+
items: ChecklistItem[]
|
|
27
|
+
/** A user-checkable list instead; omit it and the boxes only report. */
|
|
28
|
+
onToggle?: (id: string, done: boolean) => void
|
|
29
|
+
doneTitle?: unknown
|
|
30
|
+
doneDescription?: unknown
|
|
31
|
+
/** Shows Hide; a Toast offers Undo for 10s. */
|
|
32
|
+
onHide?: () => void
|
|
33
|
+
onUndoHide?: () => void
|
|
34
|
+
/** A disclosure on the title (phones: "3 of 5"). */
|
|
35
|
+
collapsible?: boolean
|
|
36
|
+
/** A row under the list: "Take the tour". */
|
|
37
|
+
footer?: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The onboarding checklist (09 §3.1): a counter in the head, a rail that fills per item, rows, then "You're set up". */
|
|
41
|
+
export function Checklist({ title = 'Get set up', subtitle, items, onToggle, doneTitle = "You're set up", doneDescription, onHide, onUndoHide, collapsible, footer }: ChecklistProps) {
|
|
42
|
+
const id = uid('ck')
|
|
43
|
+
const list = derived(() => items)
|
|
44
|
+
const count = derived(() => list.value.filter(i => i.done).length)
|
|
45
|
+
const total = derived(() => list.value.length)
|
|
46
|
+
const hidden = state(false)
|
|
47
|
+
const closed = state(false)
|
|
48
|
+
watch(() => count.value, (n, was) => { if (was !== undefined && n > was) announce(document, n === total.value ? 'All set up.' : `${n} of ${total.value} done.`) })
|
|
49
|
+
const hide = () => {
|
|
50
|
+
hidden.value = true
|
|
51
|
+
onHide?.()
|
|
52
|
+
toast.show({ title: 'Checklist hidden', description: 'Find it again under Help.', duration: 10000, action: { label: 'Undo', onClick() { hidden.value = false; onUndoHide?.() } } })
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
<section data-scope="checklist" data-state={count.value === total.value && total.value > 0 ? 'complete' : undefined} aria-labelledby={id} hidden={hidden.value || undefined}>
|
|
56
|
+
<div data-part="head">
|
|
57
|
+
<div>
|
|
58
|
+
<h4 data-part="title" id={id} aria-level="3">{title} <span data-part="count">{count.value} of {total.value}</span></h4>
|
|
59
|
+
{subtitle !== undefined && <p>{subtitle}</p>}
|
|
60
|
+
</div>
|
|
61
|
+
<div data-scope="row">
|
|
62
|
+
{collapsible && <Button emphasis="ghost" tone="neutral" size="sm" aria-expanded={closed.value ? 'false' : 'true'} aria-controls={`${id}-l`} onClick={() => (closed.value = !closed.value)}>{closed.value ? 'Show' : 'Collapse'}</Button>}
|
|
63
|
+
{onHide && <Button emphasis="ghost" tone="neutral" size="sm" icon={XIcon} label="Hide checklist" onClick={hide} />}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div data-part="progress"><Rail value={total.value ? (count.value / total.value) * 100 : 0} role="progressbar" aria-label="Setup progress" aria-valuemin="0" aria-valuemax={total.value} aria-valuenow={count.value} /></div>
|
|
67
|
+
<ol id={`${id}-l`} hidden={closed.value || undefined}>
|
|
68
|
+
{list.value.map(it => (
|
|
69
|
+
<li key={it.id} data-part="item" data-state={it.done ? 'done' : undefined}>
|
|
70
|
+
<Checkbox checked={!!it.done} disabled={!onToggle} aria-labelledby={`${id}-${it.id}`} onChange={(e: Event) => onToggle?.(it.id, (e.currentTarget as HTMLInputElement).checked)} />
|
|
71
|
+
<b id={`${id}-${it.id}`}>{it.title}<span data-part="sr-only">{it.done ? ', done' : ', to do'}</span></b>
|
|
72
|
+
{it.action ? <Button data-part="action" emphasis="frame" size="sm" onClick={it.action.onClick}>{it.action.label}</Button> : <span></span>}
|
|
73
|
+
{it.description !== undefined && <p>{it.description}</p>}
|
|
74
|
+
</li>
|
|
75
|
+
))}
|
|
76
|
+
</ol>
|
|
77
|
+
<div data-part="done">
|
|
78
|
+
<Mark tone="positive" draw />
|
|
79
|
+
<div><b>{doneTitle}</b>{doneDescription !== undefined && <p>{doneDescription}</p>}</div>
|
|
80
|
+
</div>
|
|
81
|
+
{footer}
|
|
82
|
+
</section>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ClockIcon, RefreshIcon, SparkleIcon, WifiIcon, WifiOffIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, connView } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, onCleanup, onMount, state, untrack, watch } from '@weft-js/core'
|
|
4
|
+
import { Button, type ButtonProps } from '../Button'
|
|
5
|
+
import { useCooldown } from './kit'
|
|
6
|
+
import '../styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/flows/tokens.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/flows/base.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/flows/conn.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/components/spinner.css'
|
|
11
|
+
|
|
12
|
+
type Live<T> = T | { readonly value: T } | (() => { readonly value: T } | T)
|
|
13
|
+
|
|
14
|
+
const read = <T,>(v: Live<T>): T => {
|
|
15
|
+
const x = typeof v === 'function' ? (v as () => { readonly value: T } | T)() : v
|
|
16
|
+
return x !== null && typeof x === 'object' && 'value' in x ? (x).value : x
|
|
17
|
+
}
|
|
18
|
+
/** `online` from @weft-js/core/browser, its result, or a boolean: a factory is called once. */
|
|
19
|
+
function liveOf<T>(get: () => Live<T>) {
|
|
20
|
+
const src = untrack(get)
|
|
21
|
+
const once = typeof src === 'function' ? untrack(() => (src as () => Live<T>)()) : undefined
|
|
22
|
+
return derived(() => (once !== undefined ? read<T>(once) : read<T>(get())))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RECOVERED_S = 3
|
|
26
|
+
const LATER_MS = 30 * 60 * 1000
|
|
27
|
+
|
|
28
|
+
/** Props for ConnBanner. */
|
|
29
|
+
export interface ConnBannerProps {
|
|
30
|
+
/** `online` from `@weft-js/core/browser`, its derived, or a boolean. */
|
|
31
|
+
online?: Live<boolean>
|
|
32
|
+
/** The `.live` state (07 §4.4): `live`, `connecting`, `reconnecting`, `offline`. */
|
|
33
|
+
state?: Live<string | undefined>
|
|
34
|
+
/** Schema hash mismatch (§5.4). */
|
|
35
|
+
stale?: Live<boolean | undefined>
|
|
36
|
+
/** A mutation failed because the contract changed: the Banner turns caution. */
|
|
37
|
+
contractFailed?: boolean
|
|
38
|
+
/** Seconds to the next try: weft's backoff. */
|
|
39
|
+
retryIn?: number
|
|
40
|
+
/** Adds Retry now, to try before the backoff runs out. */
|
|
41
|
+
onRetry?: () => void
|
|
42
|
+
/** Saved changes waiting to sync. */
|
|
43
|
+
changes?: number
|
|
44
|
+
/** Save drafts, then reload. */
|
|
45
|
+
onReload?: () => void
|
|
46
|
+
/** Later hides the stale banner for 30 minutes. */
|
|
47
|
+
onLater?: () => void
|
|
48
|
+
/** "A new version of {name} is ready". */
|
|
49
|
+
name?: string | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const plural = (n: number) => (n === 1 ? '1 change' : `${n} changes`)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The connection and deploy banner under the shell header (09 §5.3, §5.4): offline with its retry countdown,
|
|
56
|
+
* reconnecting, "Back online" for 3s, a stale deploy with Reload and Later. Takes its state as props. Offline says so
|
|
57
|
+
* assertively once and back online politely once; the countdown itself is never announced. Needs a mounted <Toaster />.
|
|
58
|
+
*/
|
|
59
|
+
export function ConnBanner({ online = true, state: conn, stale, contractFailed, retryIn, onRetry, changes = 0, onReload, onLater, name = 'the app' }: ConnBannerProps) {
|
|
60
|
+
const isOn = liveOf<boolean>(() => online)
|
|
61
|
+
const st = liveOf<string | undefined>(() => conn)
|
|
62
|
+
const old = liveOf<boolean | undefined>(() => stale)
|
|
63
|
+
const later = state(false)
|
|
64
|
+
const recovered = state(false)
|
|
65
|
+
const view = derived(() => connView({ online: isOn.value, state: st.value, stale: old.value && !later.value, recovered: recovered.value }))
|
|
66
|
+
const backIn = useCooldown(() => (recovered.value ? RECOVERED_S : undefined), () => (recovered.value = false))
|
|
67
|
+
const retry = useCooldown(() => retryIn)
|
|
68
|
+
let hide: ReturnType<typeof setTimeout> | undefined
|
|
69
|
+
onCleanup(() => clearTimeout(hide))
|
|
70
|
+
onMount(() => {
|
|
71
|
+
watch(() => view.value, (v, was) => {
|
|
72
|
+
if (v === 'offline') announce(document, "You're offline. Changes are saved on this device.", true)
|
|
73
|
+
if (v === 'stale') announce(document, `A new version of ${untrack(() => name)} is ready.`)
|
|
74
|
+
if (was === 'offline' || was === 'reconnecting') {
|
|
75
|
+
if (v === undefined || v === 'stale') { recovered.value = true; announce(document, 'Back online.') }
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
const postpone = () => {
|
|
80
|
+
later.value = true
|
|
81
|
+
hide = setTimeout(() => (later.value = false), LATER_MS)
|
|
82
|
+
onLater?.()
|
|
83
|
+
}
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
{view.value && (
|
|
87
|
+
<div data-scope="conn" data-state={view.value === 'stale' && contractFailed ? 'limited' : view.value}>
|
|
88
|
+
{view.value === 'offline' && <>
|
|
89
|
+
<WifiOffIcon />
|
|
90
|
+
<span data-part="msg"><b>You're offline.</b> Changes are saved on this device and sync when you reconnect.</span>
|
|
91
|
+
{retryIn !== undefined && <span>Retrying in {retry.value}s</span>}
|
|
92
|
+
{onRetry && <Button emphasis="ghost" size="sm" tone="neutral" onClick={onRetry}>Retry now</Button>}
|
|
93
|
+
</>}
|
|
94
|
+
{view.value === 'reconnecting' && <>
|
|
95
|
+
<span data-scope="spinner" aria-hidden="true"></span>
|
|
96
|
+
<span data-part="msg"><b>Reconnecting…</b> {changes > 0 ? `Sending ${plural(changes)}.` : retryIn !== undefined ? `Trying again in ${retry.value}s.` : ''}</span>
|
|
97
|
+
</>}
|
|
98
|
+
{view.value === 'online' && <>
|
|
99
|
+
<WifiIcon />
|
|
100
|
+
<span data-part="msg"><b>Back online.</b> {changes > 0 ? `${plural(changes)} synced.` : ''}</span>
|
|
101
|
+
<span data-part="rail" data-on="" aria-hidden="true"><span data-part="rail-fill" style={`--p:${backIn.value / RECOVERED_S}`}></span></span>
|
|
102
|
+
</>}
|
|
103
|
+
{view.value === 'stale' && <>
|
|
104
|
+
<SparkleIcon />
|
|
105
|
+
<span data-part="msg"><b>A new version of {name} is ready.</b> {contractFailed ? 'Reload to finish saving.' : 'Reload to update; your draft is kept.'}</span>
|
|
106
|
+
<Button emphasis="ghost" size="sm" tone="neutral" onClick={postpone}>Later</Button>
|
|
107
|
+
<Button size="sm" tone="info" onClick={onReload}><RefreshIcon />Reload</Button>
|
|
108
|
+
</>}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Props for RateLimited. */
|
|
116
|
+
export interface RateLimitedProps {
|
|
117
|
+
/** Seconds, from the typed `RATE_LIMITED.retryAfter`: never a guess. */
|
|
118
|
+
retryAfter: number
|
|
119
|
+
title?: unknown
|
|
120
|
+
/** "You can run 5 exports a minute." */
|
|
121
|
+
description?: unknown
|
|
122
|
+
/** The action that hit the limit: disabled, labelled "Try again in 20s", and back by itself. */
|
|
123
|
+
trigger?: ButtonProps
|
|
124
|
+
/** Called when the wait is over and the action is back. */
|
|
125
|
+
onDone?: () => void
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Rate limited (09 §5.5): a caution notice in place with a draining rail. Polite at start and end only; the numbers keep counting under reduced motion. */
|
|
129
|
+
export function RateLimited({ retryAfter, title = 'Limit reached.', description, trigger, onDone }: RateLimitedProps) {
|
|
130
|
+
const total = derived(() => retryAfter)
|
|
131
|
+
const left = useCooldown(() => total.value, () => { announce(document, 'You can try again now.'); onDone?.() })
|
|
132
|
+
const hasTrigger = derived(() => !!trigger)
|
|
133
|
+
const on = derived(() => left.value > 0)
|
|
134
|
+
onMount(() => announce(document, `${untrack(() => (typeof title === 'string' ? title : 'Limit reached.'))} Try again in ${untrack(() => total.value)}s.`))
|
|
135
|
+
return (
|
|
136
|
+
<div data-scope="stack" style="--_gap:8px">
|
|
137
|
+
{on.value && (
|
|
138
|
+
<div data-scope="conn" data-state="limited">
|
|
139
|
+
<ClockIcon />
|
|
140
|
+
<span data-part="msg"><b>{title}</b> {description} Try again in {left.value}s.</span>
|
|
141
|
+
<span data-part="rail" data-on="" aria-hidden="true"><span data-part="rail-fill" style={`--p:${left.value / total.value}`}></span></span>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
{hasTrigger.value && <div><Button {...trigger} disabled={on.value}>{on.value ? `Try again in ${left.value}s` : trigger!.children}</Button></div>}
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { stepFlow } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, state, untrack, watch, type State } from '@weft-js/core'
|
|
3
|
+
import { bindable } from '../Picker'
|
|
4
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
5
|
+
import '../styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/flows/tokens.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/flows/base.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/flows/flow.css'
|
|
9
|
+
|
|
10
|
+
/** One step of a Flow. */
|
|
11
|
+
export interface FlowStep {
|
|
12
|
+
id: string
|
|
13
|
+
/** Announced on arrival ("Step 2 of 4, Shipping"), and the step's accessible name. */
|
|
14
|
+
label: string
|
|
15
|
+
/** The step's content: built when the step is first reached, then kept, so what was typed survives Back. */
|
|
16
|
+
render: () => unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Props for Flow. */
|
|
20
|
+
export interface FlowProps {
|
|
21
|
+
steps: FlowStep[]
|
|
22
|
+
/** The current step's id: a box, a value, or `defaultStep`. */
|
|
23
|
+
step?: State<string> | string
|
|
24
|
+
defaultStep?: string
|
|
25
|
+
onStepChange?: ((id: string) => void) | undefined
|
|
26
|
+
/** Mirror the step in the URL, one history entry a step: `true` for `?step=`, or the parameter's name. */
|
|
27
|
+
history?: boolean | string | undefined
|
|
28
|
+
/** The polite "Step 2 of 4, Shipping" (default true). */
|
|
29
|
+
announce?: boolean | undefined
|
|
30
|
+
[attr: string]: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Built once, on first arrival: `open` only ever turns true, so nothing is rebuilt as other steps are reached. */
|
|
34
|
+
function Body({ open, render }: { open: boolean; render: () => unknown }) {
|
|
35
|
+
const on = derived(() => open)
|
|
36
|
+
return <>{on.value && render()}</>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The step machine (09 §1.1): steps stacked in one grid cell, so height follows the content. On a change the old step
|
|
41
|
+
* exits, the new one enters from the side it lies on, focus goes to its `[data-autofocus]` or title, the polite region
|
|
42
|
+
* says where you are, and `history` gives each step a URL. A step is live as soon as it mounts: motion never gates it.
|
|
43
|
+
*/
|
|
44
|
+
export function Flow({ steps, step, defaultStep, onStepChange, history, announce: speak, ...rest }: FlowProps) {
|
|
45
|
+
const list = untrack(() => steps)
|
|
46
|
+
const ids = list.map(s => s.id)
|
|
47
|
+
const at = bindable<string>(() => step, () => defaultStep, ids[0]!, () => onStepChange && (v => onStepChange(v)))
|
|
48
|
+
const param = derived(() => (history === true ? 'step' : history || undefined))
|
|
49
|
+
const seen = state<ReadonlySet<string>>(new Set([untrack(() => at.value)]))
|
|
50
|
+
watch(() => at.value, id => { if (!seen.value.has(id)) seen.value = new Set([...seen.value, id]) })
|
|
51
|
+
let root: HTMLDivElement | undefined
|
|
52
|
+
use(stepFlow, () => root, () => ({ step: at.value, steps: ids, label: (id: string) => list.find(s => s.id === id)?.label, history: param.value, announce: speak, onStep: (id: string) => at.set(id) }))
|
|
53
|
+
return (
|
|
54
|
+
<div ref={n => (root = n)} data-scope="flow" data-dir="forward" {...rest}>
|
|
55
|
+
{list.map(s => (
|
|
56
|
+
<section key={s.id} data-part="step" data-step={s.id} data-state={at.value === s.id ? 'active' : undefined} aria-label={s.label}>
|
|
57
|
+
<Body open={seen.value.has(s.id)} render={s.render} />
|
|
58
|
+
</section>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { DownloadIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, copyText, mapFlowError } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, type State } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { Checkbox } from '../Choice'
|
|
6
|
+
import { Input } from '../Field'
|
|
7
|
+
import { Modal } from '../Modal'
|
|
8
|
+
import { toast } from '../Toast'
|
|
9
|
+
import { Flow } from './Flow'
|
|
10
|
+
import { TextLink } from './kit'
|
|
11
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
12
|
+
|
|
13
|
+
/** Props for MfaSetup. */
|
|
14
|
+
export interface MfaSetupProps {
|
|
15
|
+
open: State<boolean>
|
|
16
|
+
/** The QR code: the app's SVG or image, with its own label. */
|
|
17
|
+
/** Brings its own light plate, like QRCode: a code must stay dark on light in every theme. */
|
|
18
|
+
qr?: unknown
|
|
19
|
+
/** The key for "Can't scan?". */
|
|
20
|
+
secret: string
|
|
21
|
+
/** Checks the code: throw, or resolve false, when it's wrong. */
|
|
22
|
+
onVerify: (code: string) => unknown
|
|
23
|
+
/** Backup codes, shown once verified. */
|
|
24
|
+
codes: string[]
|
|
25
|
+
/** Default: saves `backup-codes.txt`. */
|
|
26
|
+
onDownload?: (codes: string[]) => void
|
|
27
|
+
onDone?: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STEPS = { scan: 'Step 1 of 3 · Scan the code with 1Password, Authy or Google Authenticator.', verify: 'Step 2 of 3 · Enter the code the app shows now.', codes: 'Step 3 of 3 · Save your backup codes.' }
|
|
31
|
+
|
|
32
|
+
function save(codes: string[]) {
|
|
33
|
+
const a = document.createElement('a')
|
|
34
|
+
a.href = URL.createObjectURL(new Blob([codes.join('\n')], { type: 'text/plain' }))
|
|
35
|
+
a.download = 'backup-codes.txt'
|
|
36
|
+
a.click()
|
|
37
|
+
URL.revokeObjectURL(a.href)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** MFA setup (09 §9, specimen `mfaSetup`): QR and key, confirm a code, backup codes; one dialog over a Flow. Finish waits for "I saved my backup codes". */
|
|
41
|
+
export function MfaSetup({ open, qr, secret, onVerify, codes, onDownload, onDone }: MfaSetupProps) {
|
|
42
|
+
const step = state<keyof typeof STEPS>('scan')
|
|
43
|
+
const code = state('')
|
|
44
|
+
const problem = state<string | undefined>(undefined)
|
|
45
|
+
const busy = state(false)
|
|
46
|
+
const saved = state(false)
|
|
47
|
+
const copy = async (text: string) => { await copyText(document, text); announce(document, 'Copied') }
|
|
48
|
+
const next = async () => {
|
|
49
|
+
if (step.value === 'scan') { step.value = 'verify'; return }
|
|
50
|
+
if (step.value === 'codes') { open.value = false; onDone?.(); return }
|
|
51
|
+
if (!/^\d{6}$/.test(code.value)) { problem.value = 'Enter the 6 digits from the app.'; return }
|
|
52
|
+
busy.value = true
|
|
53
|
+
try {
|
|
54
|
+
if ((await onVerify(code.value)) === false) problem.value = "That code didn't match. Use the newest one."
|
|
55
|
+
else { problem.value = undefined; step.value = 'codes' }
|
|
56
|
+
} catch (e) {
|
|
57
|
+
const m = mapFlowError(e)
|
|
58
|
+
if (m.surface === 'toast') toast.show({ title: m.title, description: m.description, tone: 'critical' })
|
|
59
|
+
else problem.value = m.fields.code ?? m.title
|
|
60
|
+
} finally { busy.value = false }
|
|
61
|
+
}
|
|
62
|
+
const label = derived(() => (step.value === 'codes' ? 'Finish' : 'Continue'))
|
|
63
|
+
return (
|
|
64
|
+
<Modal
|
|
65
|
+
open={open}
|
|
66
|
+
title="Set up an authenticator app"
|
|
67
|
+
description={STEPS[step.value]}
|
|
68
|
+
footer={<>
|
|
69
|
+
<Button emphasis="frame" data-close="">Cancel</Button>
|
|
70
|
+
<Button loading={busy.value} disabled={step.value === 'codes' && !saved.value} onClick={next}>{label.value}</Button>
|
|
71
|
+
</>}
|
|
72
|
+
>
|
|
73
|
+
<Flow
|
|
74
|
+
data-part="mfa"
|
|
75
|
+
step={step}
|
|
76
|
+
steps={[
|
|
77
|
+
{ id: 'scan', label: 'scan', render: () => (
|
|
78
|
+
<div data-scope="stack">
|
|
79
|
+
{qr}
|
|
80
|
+
<p>Can't scan? Enter this key: <code>{secret}</code> <TextLink onClick={() => copy(secret)}>Copy key</TextLink></p>
|
|
81
|
+
</div>
|
|
82
|
+
) },
|
|
83
|
+
{ id: 'verify', label: 'confirm', render: () => (
|
|
84
|
+
<form novalidate onSubmit={(e: SubmitEvent) => { e.preventDefault(); void next() }}>
|
|
85
|
+
<Input label="6-digit code from the app" name="totp" inputmode="numeric" autocomplete="one-time-code" maxlength="6" hint="" error={problem.value} data-autofocus="" onInput={e => (code.value = e.currentTarget.value)} />
|
|
86
|
+
</form>
|
|
87
|
+
) },
|
|
88
|
+
{ id: 'codes', label: 'backup codes', render: () => (
|
|
89
|
+
<div data-scope="stack">
|
|
90
|
+
<p>Each code works once if you lose your phone. Store them somewhere safe; you won't see them again.</p>
|
|
91
|
+
<ol data-part="codes" aria-label="Backup codes">{codes.map(c => <li key={c}><code>{c}</code></li>)}</ol>
|
|
92
|
+
<div data-scope="row">
|
|
93
|
+
<Button emphasis="frame" size="sm" onClick={() => (onDownload ?? save)(codes)}>{() => <><DownloadIcon />Download</>}</Button>
|
|
94
|
+
<Button emphasis="ghost" tone="neutral" size="sm" onClick={() => copy(codes.join('\n'))}>Copy all</Button>
|
|
95
|
+
</div>
|
|
96
|
+
<Checkbox onChange={(e: Event) => (saved.value = (e.currentTarget as HTMLInputElement).checked)}>I saved my backup codes</Checkbox>
|
|
97
|
+
</div>
|
|
98
|
+
) },
|
|
99
|
+
]}
|
|
100
|
+
/>
|
|
101
|
+
</Modal>
|
|
102
|
+
)
|
|
103
|
+
}
|
package/src/flow/Otp.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { LeftIcon, ShieldCheckIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { clockLabel, otpInput, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, untrack, watch } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import { Mark, FormError, StepFrame, Submit, TextLink, Title, useCooldown, useStepForm, type FlowForm } from './kit'
|
|
7
|
+
import '../fields'
|
|
8
|
+
import '@weft-js/warp-core/styles/inputs/field.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/inputs/text.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/flows/otp.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/flows/auth.css'
|
|
12
|
+
|
|
13
|
+
/** Props for OtpField. */
|
|
14
|
+
export interface OtpFieldProps {
|
|
15
|
+
label?: unknown
|
|
16
|
+
name?: string | undefined
|
|
17
|
+
length?: number
|
|
18
|
+
hint?: unknown
|
|
19
|
+
/** Pins every cell critical, clears them and refocuses; the message stays until the next key. */
|
|
20
|
+
error?: unknown
|
|
21
|
+
/** `checking` while the code is verified; `success` runs the cascade. */
|
|
22
|
+
status?: 'idle' | 'checking' | 'success' | undefined
|
|
23
|
+
/** Submit the form on the last digit (default true); `onComplete` runs either way. */
|
|
24
|
+
autoSubmit?: boolean
|
|
25
|
+
onComplete?: (code: string) => void
|
|
26
|
+
onValueChange?: (code: string) => void
|
|
27
|
+
/** Show dots (a PIN). */
|
|
28
|
+
masked?: boolean
|
|
29
|
+
id?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The code input (03 §3, 09 §2.3): one real input painted into cells, so one tab stop, native editing, SMS autofill and
|
|
34
|
+
* paste of the whole code all just work. The active cell holds its sweep.
|
|
35
|
+
*/
|
|
36
|
+
export function OtpField({ label = '6-digit code', name, length = 6, hint, error, status, autoSubmit = true, onComplete, onValueChange, masked, id: given }: OtpFieldProps) {
|
|
37
|
+
const id = untrack(() => given) ?? uid('otp')
|
|
38
|
+
const n = untrack(() => length)
|
|
39
|
+
const half = Math.ceil(n / 2)
|
|
40
|
+
const cells = Array.from({ length: n }, (_, i) => [{ k: `c${i}`, i }, ...(i === half - 1 ? [{ k: 'sep', i: -1 }] : [])]).flat()
|
|
41
|
+
// the error shows until the next key, whatever the prop still says
|
|
42
|
+
const shown = state(untrack(() => error))
|
|
43
|
+
watch(() => error, e => { shown.value = e })
|
|
44
|
+
let root: HTMLDivElement | undefined
|
|
45
|
+
use(otpInput, () => root, () => ({
|
|
46
|
+
length: n, invalid: !!shown.value, success: status === 'success',
|
|
47
|
+
onValue: (v: string) => { if (v) shown.value = undefined; onValueChange?.(v) },
|
|
48
|
+
onComplete(v: string) {
|
|
49
|
+
onComplete?.(v)
|
|
50
|
+
if (autoSubmit) root?.querySelector('input')?.form?.requestSubmit()
|
|
51
|
+
},
|
|
52
|
+
}))
|
|
53
|
+
return (
|
|
54
|
+
<div data-scope="stack" style="--_gap:8px">
|
|
55
|
+
<label for={id} data-part="code-label">{label}</label>
|
|
56
|
+
<div ref={el => (root = el)} data-scope="otp" data-invalid={shown.value ? '' : undefined} data-state={status === 'checking' ? 'pending' : status === 'success' ? 'success' : undefined} data-masked={masked ? '' : undefined}>
|
|
57
|
+
<input id={id} name={name} inputmode="numeric" autocomplete="one-time-code" maxlength={n} pattern="[0-9]*" enterkeyhint="done" data-autofocus="" aria-describedby={`${id}-m`} aria-invalid={shown.value ? 'true' : undefined} aria-busy={status === 'checking' ? 'true' : undefined} />
|
|
58
|
+
{cells.map(c => (c.i < 0
|
|
59
|
+
? <span key={c.k} data-part="sep" aria-hidden="true"></span>
|
|
60
|
+
: <span key={c.k} data-part="cell" data-sweep="" aria-hidden="true" style={`--n:${c.i}`}><span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span><b data-part="char"></b></span>))}
|
|
61
|
+
</div>
|
|
62
|
+
<p data-part="message" id={`${id}-m`} role={shown.value ? 'alert' : undefined}>{shown.value ?? hint}</p>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What the code step submits. */
|
|
68
|
+
export interface OtpValues { code: string }
|
|
69
|
+
|
|
70
|
+
/** Props for OtpStep. */
|
|
71
|
+
export interface OtpStepProps extends FlowForm<OtpValues> {
|
|
72
|
+
title?: unknown
|
|
73
|
+
/** "Code sent to •••• 42", or the authenticator line. */
|
|
74
|
+
lede?: unknown
|
|
75
|
+
length?: number
|
|
76
|
+
status?: OtpFieldProps['status'] | undefined
|
|
77
|
+
hint?: unknown
|
|
78
|
+
/** Seconds before Resend opens (default 30). */
|
|
79
|
+
resendIn?: number
|
|
80
|
+
/** Shows Resend; a press restarts the wait. */
|
|
81
|
+
onResend?: () => void
|
|
82
|
+
/** Shows "Use another method". */
|
|
83
|
+
onOtherMethod?: () => void
|
|
84
|
+
otherMethodLabel?: unknown
|
|
85
|
+
onBack?: () => void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The MFA step (09 §2.1, §2.3): the code, auto-submitted on the last digit, Verify, resend with its countdown, another method, Back. */
|
|
89
|
+
export function OtpStep(p: OtpStepProps) {
|
|
90
|
+
const id = uid('otpstep')
|
|
91
|
+
const ctl = useStepForm<OtpValues>(untrack(() => p))
|
|
92
|
+
const n = derived(() => p.length ?? 6)
|
|
93
|
+
const code = state('')
|
|
94
|
+
const round = state(0)
|
|
95
|
+
const left = useCooldown(() => (round.value, p.resendIn ?? 30))
|
|
96
|
+
const resend = () => { p.onResend?.(); round.value++ }
|
|
97
|
+
const status = derived(() => p.status ?? (ctl.pending.value ? 'checking' : 'idle'))
|
|
98
|
+
return (
|
|
99
|
+
<StepFrame ctl={ctl} embedded={!!p.form} labelledby={`${id}-t`}>
|
|
100
|
+
{p.onBack && <div><Button emphasis="ghost" tone="neutral" size="sm" icon={LeftIcon} onClick={p.onBack}>Back</Button></div>}
|
|
101
|
+
<Mark><ShieldCheckIcon /></Mark>
|
|
102
|
+
<div><Title as={p.heading} id={`${id}-t`}>{p.title ?? 'Enter your code'}</Title>{p.lede !== undefined && <p data-part="lede">{p.lede}</p>}</div>
|
|
103
|
+
<FormError ctl={ctl} />
|
|
104
|
+
<OtpField name="code" length={n.value} hint={p.hint ?? ''} status={status.value} error={ctl.errors.value.code} onValueChange={v => (code.value = v)} />
|
|
105
|
+
<Submit ctl={ctl} disabled={code.value.length !== n.value}>Verify</Submit>
|
|
106
|
+
{(p.onResend || p.onOtherMethod) && (
|
|
107
|
+
<div data-part="row">
|
|
108
|
+
{p.onResend && <TextLink disabled={left.value > 0} onClick={resend}>{left.value > 0 ? `Resend in ${clockLabel(left.value)}` : 'Resend code'}</TextLink>}
|
|
109
|
+
{p.onOtherMethod && <TextLink onClick={p.onOtherMethod}>{p.otherMethodLabel ?? 'Use another method'}</TextLink>}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</StepFrame>
|
|
113
|
+
)
|
|
114
|
+
}
|