From 47173ce69009b46d24bbb5dd4682674a94033293 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 27 Aug 2026 15:24:04 -0600 Subject: [PATCH 1/4] chore(repo): add reverification changeset --- .changeset/calm-pandas-verify.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/calm-pandas-verify.md diff --git a/.changeset/calm-pandas-verify.md b/.changeset/calm-pandas-verify.md new file mode 100644 index 00000000000..253a85c8415 --- /dev/null +++ b/.changeset/calm-pandas-verify.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Adds Mosaic reverification views for password, passkey, one-time password, backup code, method selection, and recovery help. From ca17aaf50595cb79bfd736b76158aa5bf4b5fafc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 27 Aug 2026 15:35:26 -0600 Subject: [PATCH 2/4] feat(ui): remove Otp size variants --- .changeset/calm-pandas-verify.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-pandas-verify.md b/.changeset/calm-pandas-verify.md index 253a85c8415..62b02e7a9ed 100644 --- a/.changeset/calm-pandas-verify.md +++ b/.changeset/calm-pandas-verify.md @@ -2,4 +2,4 @@ '@clerk/ui': minor --- -Adds Mosaic reverification views for password, passkey, one-time password, backup code, method selection, and recovery help. +Adds Mosaic reverification views for password, passkey, one-time password, backup code, method selection, and recovery help. Updates the Mosaic OTP input to use a consistent, full-width layout. From d4d43df9c680cb6b320ce65cb8115a97dcd4e321 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 27 Aug 2026 15:36:41 -0600 Subject: [PATCH 3/4] chore(repo): make reverification changeset empty --- .changeset/calm-pandas-verify.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/calm-pandas-verify.md b/.changeset/calm-pandas-verify.md index 62b02e7a9ed..a845151cc84 100644 --- a/.changeset/calm-pandas-verify.md +++ b/.changeset/calm-pandas-verify.md @@ -1,5 +1,2 @@ --- -'@clerk/ui': minor --- - -Adds Mosaic reverification views for password, passkey, one-time password, backup code, method selection, and recovery help. Updates the Mosaic OTP input to use a consistent, full-width layout. From b67bad2f2ccf2d4b008645044b30d0b142a42cf2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 28 Aug 2026 14:37:45 -0600 Subject: [PATCH 4/4] Initial commit --- .../swingset/src/components/Composition.tsx | 2 +- .../swingset/src/components/DocsViewer.tsx | 8 +- packages/swingset/src/lib/registry.ts | 102 ++-- .../swingset/src/stories/flow.component.mdx | 38 ++ .../src/stories/flow.component.stories.tsx | 69 +++ .../stories/reverification-backup-code.mdx | 7 - .../reverification-backup-code.stories.tsx | 39 -- .../src/stories/reverification-help.mdx | 7 - .../stories/reverification-help.stories.tsx | 33 -- .../stories/reverification-method-picker.mdx | 7 - .../reverification-method-picker.stories.tsx | 44 -- .../src/stories/reverification-otp.mdx | 9 - .../stories/reverification-otp.stories.tsx | 66 --- .../src/stories/reverification-passkey.mdx | 7 - .../reverification-passkey.stories.tsx | 34 -- .../src/stories/reverification-password.mdx | 7 - .../reverification-password.stories.tsx | 40 -- .../src/stories/reverification-story-card.tsx | 6 - .../swingset/src/stories/reverification.mdx | 95 +++ .../src/stories/reverification.stories.tsx | 539 ++++++++++++++++++ .../src/mosaic/blocks/reverification/index.ts | 2 + .../reverification-backup-code.tsx | 5 +- .../reverification-method-picker.tsx | 19 +- .../reverification/reverification-otp.tsx | 5 +- .../reverification/reverification-passkey.tsx | 8 +- .../reverification-password.tsx | 5 +- .../reverification/reverification.test.tsx | 125 ++++ .../blocks/reverification/reverification.tsx | 71 +++ .../src/mosaic/components/flow/flow.styles.ts | 10 + .../src/mosaic/components/flow/flow.test.tsx | 71 +++ .../ui/src/mosaic/components/flow/flow.tsx | 74 +++ .../ui/src/mosaic/components/flow/index.ts | 2 + packages/ui/src/mosaic/styles/index.ts | 2 + 33 files changed, 1168 insertions(+), 390 deletions(-) create mode 100644 packages/swingset/src/stories/flow.component.mdx create mode 100644 packages/swingset/src/stories/flow.component.stories.tsx delete mode 100644 packages/swingset/src/stories/reverification-backup-code.mdx delete mode 100644 packages/swingset/src/stories/reverification-backup-code.stories.tsx delete mode 100644 packages/swingset/src/stories/reverification-help.mdx delete mode 100644 packages/swingset/src/stories/reverification-help.stories.tsx delete mode 100644 packages/swingset/src/stories/reverification-method-picker.mdx delete mode 100644 packages/swingset/src/stories/reverification-method-picker.stories.tsx delete mode 100644 packages/swingset/src/stories/reverification-otp.mdx delete mode 100644 packages/swingset/src/stories/reverification-otp.stories.tsx delete mode 100644 packages/swingset/src/stories/reverification-passkey.mdx delete mode 100644 packages/swingset/src/stories/reverification-passkey.stories.tsx delete mode 100644 packages/swingset/src/stories/reverification-password.mdx delete mode 100644 packages/swingset/src/stories/reverification-password.stories.tsx delete mode 100644 packages/swingset/src/stories/reverification-story-card.tsx create mode 100644 packages/swingset/src/stories/reverification.mdx create mode 100644 packages/swingset/src/stories/reverification.stories.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx create mode 100644 packages/ui/src/mosaic/blocks/reverification/reverification.tsx create mode 100644 packages/ui/src/mosaic/components/flow/flow.styles.ts create mode 100644 packages/ui/src/mosaic/components/flow/flow.test.tsx create mode 100644 packages/ui/src/mosaic/components/flow/flow.tsx create mode 100644 packages/ui/src/mosaic/components/flow/index.ts diff --git a/packages/swingset/src/components/Composition.tsx b/packages/swingset/src/components/Composition.tsx index 27dc0fd44bb..37d0d781440 100644 --- a/packages/swingset/src/components/Composition.tsx +++ b/packages/swingset/src/components/Composition.tsx @@ -13,7 +13,7 @@ export interface CompositionPiece { // Mosaic layers, high → low. Drives the order the composition groups render in. // Matches the sidebar group names. -const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives']; +const LAYER_ORDER = ['User Button', 'User Profile', 'Blocks', 'Components', 'Styles', 'Primitives']; function layerRank(layer: string): number { const i = LAYER_ORDER.indexOf(layer); diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 0ab2d13a613..3d3136adf30 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -39,12 +39,7 @@ const docModules: Record> = { }, blocks: { destructive: dynamic(() => import('../stories/destructive.mdx')), - 'reverification-password': dynamic(() => import('../stories/reverification-password.mdx')), - 'reverification-passkey': dynamic(() => import('../stories/reverification-passkey.mdx')), - 'reverification-otp': dynamic(() => import('../stories/reverification-otp.mdx')), - 'reverification-backup-code': dynamic(() => import('../stories/reverification-backup-code.mdx')), - 'reverification-method-picker': dynamic(() => import('../stories/reverification-method-picker.mdx')), - 'reverification-help': dynamic(() => import('../stories/reverification-help.mdx')), + reverification: dynamic(() => import('../stories/reverification.mdx')), }, components: { avatar: dynamic(() => import('../stories/avatar.mdx')), @@ -65,6 +60,7 @@ const docModules: Record> = { section: dynamic(() => import('../stories/section.mdx')), text: dynamic(() => import('../stories/text.mdx')), field: dynamic(() => import('../stories/field.component.mdx')), + flow: dynamic(() => import('../stories/flow.component.mdx')), }, primitives: { // Headless primitives — alphabetical. diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index d41f4e358f2..71b0a1e552e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -40,6 +40,7 @@ import { meta as dialogMeta } from '../stories/dialog.stories'; import { meta as drawerMeta } from '../stories/drawer.stories'; import { Default as FieldDefault, meta as fieldMeta } from '../stories/field.component.stories'; import { meta as fileUploadMeta } from '../stories/file-upload.stories'; +import { Default as FlowDefault, meta as flowComponentMeta } from '../stories/flow.component.stories'; import { meta as flowMeta } from '../stories/flow.stories'; import { Colors as HeadingColors, @@ -94,30 +95,26 @@ import { } from '../stories/popover.component.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; import { - Default as ReverificationBackupCodeDefault, - meta as reverificationBackupCodeMeta, -} from '../stories/reverification-backup-code.stories'; -import { - Default as ReverificationHelpDefault, - meta as reverificationHelpMeta, -} from '../stories/reverification-help.stories'; -import { - Default as ReverificationMethodPickerDefault, - meta as reverificationMethodPickerMeta, -} from '../stories/reverification-method-picker.stories'; -import { - Authenticator as ReverificationOTPAuthenticator, - Default as ReverificationOTPDefault, - meta as reverificationOTPMeta, -} from '../stories/reverification-otp.stories'; -import { - Default as ReverificationPasskeyDefault, - meta as reverificationPasskeyMeta, -} from '../stories/reverification-passkey.stories'; -import { - Default as ReverificationPasswordDefault, - meta as reverificationPasswordMeta, -} from '../stories/reverification-password.stories'; + AuthenticatorOTP as ReverificationAuthenticatorOTP, + BackupCode as ReverificationBackupCode, + BackupCodeError as ReverificationBackupCodeError, + BackupCodePending as ReverificationBackupCodePending, + Default as ReverificationDefault, + Help as ReverificationHelp, + meta as reverificationMeta, + MethodPicker as ReverificationMethodPicker, + MethodPickerPending as ReverificationMethodPickerPending, + OTP as ReverificationOTP, + OTPError as ReverificationOTPError, + OTPPending as ReverificationOTPPending, + OTPResending as ReverificationOTPResending, + Passkey as ReverificationPasskey, + PasskeyError as ReverificationPasskeyError, + PasskeyPending as ReverificationPasskeyPending, + Password as ReverificationPassword, + PasswordError as ReverificationPasswordError, + PasswordPending as ReverificationPasswordPending, +} from '../stories/reverification.stories'; import { Default as ScrollAreaDefault, Gutter as ScrollAreaGutter, @@ -314,6 +311,11 @@ const fieldModule: StoryModule = { Default: FieldDefault, }; +const flowComponentModule: StoryModule = { + meta: flowComponentMeta, + Default: FlowDefault, +}; + const iconModule: StoryModule = { meta: iconMeta, Default: IconDefault, @@ -439,30 +441,26 @@ const destructiveModule: StoryModule = { WithError: DestructiveWithError, }; -const reverificationPasswordModule: StoryModule = { - meta: reverificationPasswordMeta, - Default: ReverificationPasswordDefault, -}; -const reverificationPasskeyModule: StoryModule = { - meta: reverificationPasskeyMeta, - Default: ReverificationPasskeyDefault, -}; -const reverificationOTPModule: StoryModule = { - meta: reverificationOTPMeta, - Default: ReverificationOTPDefault, - Authenticator: ReverificationOTPAuthenticator, -}; -const reverificationBackupCodeModule: StoryModule = { - meta: reverificationBackupCodeMeta, - Default: ReverificationBackupCodeDefault, -}; -const reverificationMethodPickerModule: StoryModule = { - meta: reverificationMethodPickerMeta, - Default: ReverificationMethodPickerDefault, -}; -const reverificationHelpModule: StoryModule = { - meta: reverificationHelpMeta, - Default: ReverificationHelpDefault, +const reverificationModule: StoryModule = { + meta: reverificationMeta, + Default: ReverificationDefault, + Password: ReverificationPassword, + PasswordPending: ReverificationPasswordPending, + PasswordError: ReverificationPasswordError, + Passkey: ReverificationPasskey, + PasskeyPending: ReverificationPasskeyPending, + PasskeyError: ReverificationPasskeyError, + OTP: ReverificationOTP, + AuthenticatorOTP: ReverificationAuthenticatorOTP, + OTPPending: ReverificationOTPPending, + OTPError: ReverificationOTPError, + OTPResending: ReverificationOTPResending, + BackupCode: ReverificationBackupCode, + BackupCodePending: ReverificationBackupCodePending, + BackupCodeError: ReverificationBackupCodeError, + MethodPicker: ReverificationMethodPicker, + MethodPickerPending: ReverificationMethodPickerPending, + Help: ReverificationHelp, }; export const registry: StoryModule[] = [ // User Button @@ -488,18 +486,14 @@ export const registry: StoryModule[] = [ userProfileDeleteSectionModule, // Blocks — flows assembled from components, wired by the caller's machine. destructiveModule, - reverificationPasswordModule, - reverificationPasskeyModule, - reverificationOTPModule, - reverificationBackupCodeModule, - reverificationMethodPickerModule, - reverificationHelpModule, + reverificationModule, // Components avatarModule, badgeModule, bannerModule, buttonModule, cardComponentModule, + flowComponentModule, inputModule, itemModule, dialogComponentModule, diff --git a/packages/swingset/src/stories/flow.component.mdx b/packages/swingset/src/stories/flow.component.mdx new file mode 100644 index 00000000000..8cde30703c6 --- /dev/null +++ b/packages/swingset/src/stories/flow.component.mdx @@ -0,0 +1,38 @@ +import * as FlowStories from './flow.component.stories'; + +# Flow + +`Flow` is a controlled Mosaic screen compositor. It treats `state` as opaque and renders the `Flow.Step` whose `ids` contain its `value`, establishing the seam where screen transitions can later retain outgoing and incoming state. + +## Example + + + +## Usage + +```tsx +import { Flow } from '@clerk/ui/mosaic/components/flow'; + +} value={model.status} state={model}> + {state => ( + <> + {state.details} + {state.confirmation} + + )} + +``` + +## Parts + +| Part | Class | Description | +| ----------- | --------------- | ------------------------------------------------------------- | +| `Flow.Root` | `.cl-flow-root` | Receives the controlled value and opaque state. | +| `Flow.Step` | `.cl-flow-step` | Renders when its `ids` include the root's controlled `value`. | + +## Styling + +`Flow.Root` reflects its controlled value through `data-value`. The active `Flow.Step` carries `data-active` and reflects the first grouped id through `data-step`. Both parts accept `className`, `style`, and the Mosaic `render` prop. diff --git a/packages/swingset/src/stories/flow.component.stories.tsx b/packages/swingset/src/stories/flow.component.stories.tsx new file mode 100644 index 00000000000..9c751a06852 --- /dev/null +++ b/packages/swingset/src/stories/flow.component.stories.tsx @@ -0,0 +1,69 @@ +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Card } from '@clerk/ui/mosaic/components/card'; +import { Flow, type FlowDirection } from '@clerk/ui/mosaic/components/flow'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './flow.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Flow', + source: 'packages/ui/src/mosaic/components/flow/flow.tsx', +}; + +export function Default(): JSX.Element { + const [step, setStep] = useState('details'); + const [direction, setDirection] = useState(1); + + const navigate = (nextStep: string, nextDirection: FlowDirection) => { + setDirection(nextDirection); + setStep(nextStep); + }; + + return ( + } + value={step} + direction={direction} + state={{ step }} + > + {state => ( + <> + + + Account details + Current controller state: {state.step} + + + + + + + + Confirm changes + Review the final step before submitting. + + + + + + + + )} + + ); +} diff --git a/packages/swingset/src/stories/reverification-backup-code.mdx b/packages/swingset/src/stories/reverification-backup-code.mdx deleted file mode 100644 index c029106ce75..00000000000 --- a/packages/swingset/src/stories/reverification-backup-code.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import * as Stories from './reverification-backup-code.stories'; - -# ReverificationBackupCode - -The backup-code card content used by the reverification flow. - - diff --git a/packages/swingset/src/stories/reverification-backup-code.stories.tsx b/packages/swingset/src/stories/reverification-backup-code.stories.tsx deleted file mode 100644 index e13338c7a83..00000000000 --- a/packages/swingset/src/stories/reverification-backup-code.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ReverificationBackupCode } from '@clerk/ui/mosaic/blocks/reverification'; -import React from 'react'; - -import type { StoryMeta } from '@/lib/types'; - -import { ReverificationStoryCard } from './reverification-story-card'; - -export { default as __source } from './reverification-backup-code.stories?raw'; - -export const meta: StoryMeta = { - group: 'Blocks', - title: 'ReverificationBackupCode', - label: 'Backup code', - navigation: { category: 'Reverification' }, - source: 'packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx', -}; - -export function Default() { - const [value, setValue] = React.useState(''); - - return ( - - undefined} - onCancel={() => setValue('')} - /> - - ); -} diff --git a/packages/swingset/src/stories/reverification-help.mdx b/packages/swingset/src/stories/reverification-help.mdx deleted file mode 100644 index acf705cc09f..00000000000 --- a/packages/swingset/src/stories/reverification-help.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import * as Stories from './reverification-help.stories'; - -# ReverificationHelp - -The help card content used by the reverification flow. - - diff --git a/packages/swingset/src/stories/reverification-help.stories.tsx b/packages/swingset/src/stories/reverification-help.stories.tsx deleted file mode 100644 index d61b6a922be..00000000000 --- a/packages/swingset/src/stories/reverification-help.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { ReverificationHelp } from '@clerk/ui/mosaic/blocks/reverification'; - -import type { StoryMeta } from '@/lib/types'; - -import { ReverificationStoryCard } from './reverification-story-card'; - -export { default as __source } from './reverification-help.stories?raw'; - -export const meta: StoryMeta = { - group: 'Blocks', - title: 'ReverificationHelp', - label: 'Help', - navigation: { category: 'Reverification' }, - source: 'packages/ui/src/mosaic/blocks/reverification/reverification-help.tsx', -}; - -export function Default() { - return ( - - undefined} - onBack={() => undefined} - /> - - ); -} diff --git a/packages/swingset/src/stories/reverification-method-picker.mdx b/packages/swingset/src/stories/reverification-method-picker.mdx deleted file mode 100644 index 8e61126b4a0..00000000000 --- a/packages/swingset/src/stories/reverification-method-picker.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import * as Stories from './reverification-method-picker.stories'; - -# ReverificationMethodPicker - -The alternative-method picker content used by the reverification flow. - - diff --git a/packages/swingset/src/stories/reverification-method-picker.stories.tsx b/packages/swingset/src/stories/reverification-method-picker.stories.tsx deleted file mode 100644 index 4ecceab9d56..00000000000 --- a/packages/swingset/src/stories/reverification-method-picker.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { ReverificationMethodPicker } from '@clerk/ui/mosaic/blocks/reverification'; -import React from 'react'; - -import type { StoryMeta } from '@/lib/types'; - -import { ReverificationStoryCard } from './reverification-story-card'; - -export { default as __source } from './reverification-method-picker.stories?raw'; - -export const meta: StoryMeta = { - group: 'Blocks', - title: 'ReverificationMethodPicker', - label: 'Method picker', - navigation: { category: 'Reverification' }, - source: 'packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx', -}; - -export function Default() { - const [pendingMethodId, setPendingMethodId] = React.useState(); - - return ( - - undefined} - onBack={() => setPendingMethodId(undefined)} - /> - - ); -} diff --git a/packages/swingset/src/stories/reverification-otp.mdx b/packages/swingset/src/stories/reverification-otp.mdx deleted file mode 100644 index 39e83f34b61..00000000000 --- a/packages/swingset/src/stories/reverification-otp.mdx +++ /dev/null @@ -1,9 +0,0 @@ -import * as Stories from './reverification-otp.stories'; - -# ReverificationOTP - -The one-time-code card content used by the reverification flow. - - - - diff --git a/packages/swingset/src/stories/reverification-otp.stories.tsx b/packages/swingset/src/stories/reverification-otp.stories.tsx deleted file mode 100644 index fb1bd8aef16..00000000000 --- a/packages/swingset/src/stories/reverification-otp.stories.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ReverificationOTP } from '@clerk/ui/mosaic/blocks/reverification'; -import React from 'react'; - -import type { StoryMeta } from '@/lib/types'; - -import { ReverificationStoryCard } from './reverification-story-card'; - -export { default as __source } from './reverification-otp.stories?raw'; - -export const meta: StoryMeta = { - group: 'Blocks', - title: 'ReverificationOTP', - label: 'OTP', - navigation: { category: 'Reverification' }, - source: 'packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx', -}; - -export function Default() { - const [value, setValue] = React.useState(''); - - return ( - - setValue(''), - }} - onValueChange={setValue} - onSubmit={() => undefined} - onCancel={() => setValue('')} - /> - - ); -} - -export function Authenticator() { - const [value, setValue] = React.useState(''); - - return ( - - undefined} - onCancel={() => setValue('')} - /> - - ); -} diff --git a/packages/swingset/src/stories/reverification-passkey.mdx b/packages/swingset/src/stories/reverification-passkey.mdx deleted file mode 100644 index 74d9133211b..00000000000 --- a/packages/swingset/src/stories/reverification-passkey.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import * as Stories from './reverification-passkey.stories'; - -# ReverificationPasskey - -The passkey card content used by the reverification flow. - - diff --git a/packages/swingset/src/stories/reverification-passkey.stories.tsx b/packages/swingset/src/stories/reverification-passkey.stories.tsx deleted file mode 100644 index a0e5f48a475..00000000000 --- a/packages/swingset/src/stories/reverification-passkey.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ReverificationPasskey } from '@clerk/ui/mosaic/blocks/reverification'; - -import type { StoryMeta } from '@/lib/types'; - -import { ReverificationStoryCard } from './reverification-story-card'; - -export { default as __source } from './reverification-passkey.stories?raw'; - -export const meta: StoryMeta = { - group: 'Blocks', - title: 'ReverificationPasskey', - label: 'Passkey', - navigation: { category: 'Reverification' }, - source: 'packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx', -}; - -export function Default() { - return ( - - undefined} - onCancel={() => undefined} - /> - - ); -} diff --git a/packages/swingset/src/stories/reverification-password.mdx b/packages/swingset/src/stories/reverification-password.mdx deleted file mode 100644 index f2d8705d7a7..00000000000 --- a/packages/swingset/src/stories/reverification-password.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import * as Stories from './reverification-password.stories'; - -# ReverificationPassword - -The password card content used by the reverification flow. - - diff --git a/packages/swingset/src/stories/reverification-password.stories.tsx b/packages/swingset/src/stories/reverification-password.stories.tsx deleted file mode 100644 index b7835a4e384..00000000000 --- a/packages/swingset/src/stories/reverification-password.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ReverificationPassword } from '@clerk/ui/mosaic/blocks/reverification'; -import React from 'react'; - -import type { StoryMeta } from '@/lib/types'; - -import { ReverificationStoryCard } from './reverification-story-card'; - -export { default as __source } from './reverification-password.stories?raw'; - -export const meta: StoryMeta = { - group: 'Blocks', - title: 'ReverificationPassword', - label: 'Password', - navigation: { category: 'Reverification' }, - source: 'packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx', -}; - -export function Default() { - const [value, setValue] = React.useState(''); - - return ( - - undefined} - onCancel={() => setValue('')} - /> - - ); -} diff --git a/packages/swingset/src/stories/reverification-story-card.tsx b/packages/swingset/src/stories/reverification-story-card.tsx deleted file mode 100644 index 66c2049cb54..00000000000 --- a/packages/swingset/src/stories/reverification-story-card.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Card } from '@clerk/ui/mosaic/components/card'; -import type { ReactNode } from 'react'; - -export function ReverificationStoryCard({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/packages/swingset/src/stories/reverification.mdx b/packages/swingset/src/stories/reverification.mdx new file mode 100644 index 00000000000..85f2a82b737 --- /dev/null +++ b/packages/swingset/src/stories/reverification.mdx @@ -0,0 +1,95 @@ +import * as Stories from './reverification.stories'; + +# Reverification + +The working example shows the complete reverification card. The individual panels are available below for isolated development. + +The example pauses briefly in each pending state. Enter `error` in password or backup code fields, or `000000` for OTP, to render an attempt error. + +## Working example + + + +## Panels + +### Password + + + +#### Pending + + + +#### Error + + + +### Passkey + + + +#### Pending + + + +#### Error + + + +### OTP + + + +### Authenticator OTP + + + +#### Pending + + + +#### Error + + + +#### Resending + + + +### Backup code + + + +#### Pending + + + +#### Error + + + +### Method picker + + + +#### Pending + + + +### Help + + diff --git a/packages/swingset/src/stories/reverification.stories.tsx b/packages/swingset/src/stories/reverification.stories.tsx new file mode 100644 index 00000000000..77952417ce2 --- /dev/null +++ b/packages/swingset/src/stories/reverification.stories.tsx @@ -0,0 +1,539 @@ +import type { ReverificationState } from '@clerk/ui/mosaic/blocks/reverification'; +import { + Reverification, + ReverificationBackupCode, + ReverificationHelp, + ReverificationMethodPicker, + ReverificationOTP, + ReverificationPasskey, + ReverificationPassword, +} from '@clerk/ui/mosaic/blocks/reverification'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Card } from '@clerk/ui/mosaic/components/card'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './reverification.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'Reverification', + source: 'packages/ui/src/mosaic/blocks/reverification/reverification.tsx', +}; + +const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const actions = { + secondaryActionLabel: 'Use another method', + primaryActionLabel: 'Continue', + pendingLabel: 'Verifying', +}; + +const methodPickerMessages = { + title: 'Use another method', + description: 'Facing issues? You can use any of these methods for verification.', + backButton: 'Back', + helpText: 'Don’t have any of these?', + helpButton: 'Get help', +}; + +const helpMessages = { + title: 'Get help', + description: + 'If you have trouble verifying your account, email us and we will work with you to restore access as soon as possible.', + backButton: 'Back', + supportButton: 'Email support', +}; + +function ReverificationStoryCard({ children }: { children: ReactNode }): JSX.Element { + return {children}; +} + +const methodPresentation = { + password: { label: 'Continue with your password', icon: 'security-lock-square' }, + otp: { label: 'Use your authenticator app', icon: 'security-authenticator' }, + 'backup-code': { label: 'Use a backup code', icon: 'security-phone' }, + passkey: { label: 'Use your passkey', icon: 'security-passkey' }, +} satisfies Record>; + +type MethodId = keyof typeof methodPresentation; + +function WorkingExample({ onComplete }: { onComplete: () => void }): JSX.Element { + const [status, setStatus] = useState('password'); + const [direction, setDirection] = useState<-1 | 1>(1); + const [methodId, setMethodId] = useState('password'); + const [value, setValue] = useState(''); + const [fieldError, setFieldError] = useState(); + const [pendingMethod, setPendingMethod] = useState(); + const [pendingMethodId, setPendingMethodId] = useState(); + const [isResending, setIsResending] = useState(false); + const [supportRequested, setSupportRequested] = useState(false); + + const navigate = (nextStatus: ReverificationState['status'], nextDirection: -1 | 1) => { + setDirection(nextDirection); + setStatus(nextStatus); + setValue(''); + setFieldError(undefined); + }; + + const onValueChange = (nextValue: string) => { + setValue(nextValue); + setFieldError(undefined); + }; + + const submitPassword = async () => { + if (!value.trim()) { + setFieldError('This field is required.'); + return; + } + setPendingMethod('password'); + await settleAfter(700); + setPendingMethod(undefined); + if (value.toLowerCase() === 'error') { + setFieldError('That password is incorrect. Try again.'); + return; + } + setMethodId('otp'); + navigate('otp', 1); + }; + + const submitOtp = async (code: string) => { + if (code.length !== 6) { + setFieldError('Enter the complete verification code.'); + return; + } + setPendingMethod('otp'); + await settleAfter(700); + setPendingMethod(undefined); + if (code === '000000') { + setFieldError('That verification code is incorrect. Try again.'); + return; + } + onComplete(); + }; + + const submitBackupCode = async () => { + if (!value.trim()) { + setFieldError('This field is required.'); + return; + } + setPendingMethod('backup-code'); + await settleAfter(700); + setPendingMethod(undefined); + if (value.toLowerCase() === 'error') { + setFieldError('That backup code is incorrect. Try again.'); + return; + } + onComplete(); + }; + + const submitPasskey = async () => { + setPendingMethod('passkey'); + await settleAfter(700); + setPendingMethod(undefined); + onComplete(); + }; + + const selectMethod = async (id: string) => { + const nextMethodId = id as MethodId; + setPendingMethodId(id); + await settleAfter(700); + setPendingMethodId(undefined); + setMethodId(nextMethodId); + navigate(nextMethodId, 1); + }; + + const resend = async () => { + setIsResending(true); + await settleAfter(700); + setIsResending(false); + onValueChange(''); + }; + + const showMethods = () => navigate('method-picker', 1); + const viewState: ReverificationState = { + status, + password: { + messages: { + title: 'Verification required', + description: 'Enter your current password to continue', + fieldLabel: 'Password', + fieldPlaceholder: 'Enter your password', + ...actions, + }, + value, + errorMessage: fieldError, + isPending: pendingMethod === 'password', + onValueChange, + onSubmit: () => void submitPassword(), + onCancel: showMethods, + }, + passkey: { + messages: { + title: 'Use your passkey', + description: + 'Using your passkey confirms your identity. Your device may ask for your fingerprint, face, or screen lock.', + ...actions, + }, + isPending: pendingMethod === 'passkey', + onVerify: () => void submitPasskey(), + onCancel: showMethods, + }, + otp: { + messages: { + title: 'Verification required', + description: 'Enter the code generated by your authenticator app to continue', + fieldLabel: 'Verification code', + ...actions, + }, + value, + errorMessage: fieldError, + isPending: pendingMethod === 'otp', + resend: { + label: isResending ? 'Sending a new code…' : 'Didn’t receive a code? Resend', + disabled: isResending || pendingMethod === 'otp', + onClick: () => void resend(), + }, + onValueChange, + onComplete: code => void submitOtp(code), + onSubmit: () => void submitOtp(value), + onCancel: showMethods, + }, + backupCode: { + messages: { + title: 'Enter a backup code', + description: 'Enter the backup code you received when setting up two-step authentication', + fieldLabel: 'Backup code', + ...actions, + }, + value, + errorMessage: fieldError, + isPending: pendingMethod === 'backup-code', + onValueChange, + onSubmit: () => void submitBackupCode(), + onCancel: showMethods, + }, + methodPicker: { + messages: methodPickerMessages, + methods: (Object.keys(methodPresentation) as MethodId[]) + .filter(id => id !== methodId) + .map(id => ({ id, ...methodPresentation[id] })), + pendingMethodId, + onSelect: id => void selectMethod(id), + onHelp: () => navigate('help', 1), + onBack: () => navigate(methodId, -1), + }, + help: { + messages: helpMessages, + onEmailSupport: () => setSupportRequested(true), + onBack: () => navigate('method-picker', -1), + }, + }; + + return ( + <> + + {supportRequested ?

Email support requested.

: null} + + ); +} + +export function Default(): JSX.Element { + const [runId, setRunId] = useState(0); + const [complete, setComplete] = useState(false); + + if (complete) { + return ( +
+

Reverification complete.

+ +
+ ); + } + + return ( + setComplete(true)} + /> + ); +} + +function PasswordPanel({ + isPending = false, + errorMessage, +}: { + isPending?: boolean; + errorMessage?: string; +}): JSX.Element { + const [value, setValue] = useState(errorMessage ? 'incorrect-password' : ''); + + return ( + + undefined} + onCancel={() => setValue('')} + /> + + ); +} + +export function Password(): JSX.Element { + return ; +} + +export function PasswordPending(): JSX.Element { + return ; +} + +export function PasswordError(): JSX.Element { + return ; +} + +function PasskeyPanel({ + isPending = false, + errorMessage, +}: { + isPending?: boolean; + errorMessage?: string; +}): JSX.Element { + return ( + + undefined} + onCancel={() => undefined} + /> + + ); +} + +export function Passkey(): JSX.Element { + return ; +} + +export function PasskeyPending(): JSX.Element { + return ; +} + +export function PasskeyError(): JSX.Element { + return ; +} + +function OTPPanel({ + description, + isPending = false, + errorMessage, + isResending = false, + renderResend = true, +}: { + description: string; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + renderResend?: boolean; +}): JSX.Element { + const [value, setValue] = useState(isPending || errorMessage ? '123456' : ''); + + return ( + + setValue(''), + } + : undefined + } + onValueChange={setValue} + onSubmit={() => undefined} + onCancel={() => setValue('')} + /> + + ); +} + +export function OTP(): JSX.Element { + return ; +} + +export function AuthenticatorOTP(): JSX.Element { + return ( + + ); +} + +export function OTPPending(): JSX.Element { + return ( + + ); +} + +export function OTPError(): JSX.Element { + return ( + + ); +} + +export function OTPResending(): JSX.Element { + return ( + + ); +} + +function BackupCodePanel({ + isPending = false, + errorMessage, +}: { + isPending?: boolean; + errorMessage?: string; +}): JSX.Element { + const [value, setValue] = useState(errorMessage ? 'invalid-code' : ''); + + return ( + + undefined} + onCancel={() => setValue('')} + /> + + ); +} + +export function BackupCode(): JSX.Element { + return ; +} + +export function BackupCodePending(): JSX.Element { + return ; +} + +export function BackupCodeError(): JSX.Element { + return ; +} + +export function MethodPicker(): JSX.Element { + const [pendingMethodId, setPendingMethodId] = useState(); + + return ( + + undefined} + onBack={() => setPendingMethodId(undefined)} + /> + + ); +} + +export function MethodPickerPending(): JSX.Element { + return ( + + undefined} + onHelp={() => undefined} + onBack={() => undefined} + /> + + ); +} + +export function Help(): JSX.Element { + return ( + + undefined} + onBack={() => undefined} + /> + + ); +} diff --git a/packages/ui/src/mosaic/blocks/reverification/index.ts b/packages/ui/src/mosaic/blocks/reverification/index.ts index 88fb8763027..486d1b759d6 100644 --- a/packages/ui/src/mosaic/blocks/reverification/index.ts +++ b/packages/ui/src/mosaic/blocks/reverification/index.ts @@ -1,3 +1,5 @@ +export { Reverification } from './reverification'; +export type { ReverificationProps, ReverificationState, ReverificationStatus } from './reverification'; export { ReverificationBackupCode } from './reverification-backup-code'; export type { ReverificationBackupCodeMessages, ReverificationBackupCodeProps } from './reverification-backup-code'; export { ReverificationHelp } from './reverification-help'; diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx index a38a7820596..d18e8d51ee3 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-backup-code.tsx @@ -35,11 +35,10 @@ export function ReverificationBackupCode({ onCancel, }: ReverificationBackupCodeProps) { const formId = useId(); - const canSubmit = value.trim().length > 0; const handleSubmit = (event: FormEvent) => { event.preventDefault(); - if (canSubmit && !isPending) { + if (!isPending) { onSubmit(); } }; @@ -90,8 +89,6 @@ export function ReverificationBackupCode({ diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx index ccb5a3c87a5..0567b7b7880 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-method-picker.tsx @@ -1,16 +1,17 @@ import { Button } from '../../components/button'; import { Card } from '../../components/card'; import type { IconProps } from '../../components/icon'; -import { Icon } from '../../components/icon'; +import { Icon, IconFrame } from '../../components/icon'; import { Item } from '../../components/item'; import { Spinner } from '../../components/spinner'; import { Text } from '../../components/text'; +import { space } from '../../tokens.stylex'; export interface ReverificationMethod { id: string; label: string; description?: string; - icon?: IconProps['name']; + icon: IconProps['name']; } export interface ReverificationMethodPickerMessages { @@ -45,12 +46,13 @@ export function ReverificationMethodPicker({ {messages.description} - + {methods.map(method => { const isPending = pendingMethodId === method.id; return ( } > - {method.icon ? ( - + + - - ) : null} + + {method.label} {method.description ? {method.description} : null} @@ -79,6 +81,9 @@ export function ReverificationMethodPicker({ variant='ghost' fullWidth onClick={onBack} + style={{ + marginBlockStart: space['2'], + }} > {messages.backButton} diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx index 1d66bdf80b6..685e38e02f2 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-otp.tsx @@ -47,11 +47,10 @@ export function ReverificationOTP({ onCancel, }: ReverificationOTPProps) { const formId = useId(); - const canSubmit = value.length === length; const handleSubmit = (event: FormEvent) => { event.preventDefault(); - if (canSubmit && !isPending) { + if (!isPending) { onSubmit(); } }; @@ -115,8 +114,6 @@ export function ReverificationOTP({ diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx index e34b49635e8..7609b921742 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-passkey.tsx @@ -1,6 +1,6 @@ +import { Banner } from '../../components/banner'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; -import { Text } from '../../components/text'; export interface ReverificationPasskeyMessages { title: string; @@ -33,12 +33,12 @@ export function ReverificationPasskey({ {errorMessage ? ( - - {errorMessage} - + {errorMessage} + ) : null} diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx index b1dba085738..f229fe2e44d 100644 --- a/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx +++ b/packages/ui/src/mosaic/blocks/reverification/reverification-password.tsx @@ -36,11 +36,10 @@ export function ReverificationPassword({ onCancel, }: ReverificationPasswordProps) { const formId = useId(); - const canSubmit = value.length > 0; const handleSubmit = (event: FormEvent) => { event.preventDefault(); - if (canSubmit && !isPending) { + if (!isPending) { onSubmit(); } }; @@ -90,8 +89,6 @@ export function ReverificationPassword({ diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx new file mode 100644 index 00000000000..a74be6132d5 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ReverificationState } from './reverification'; +import { Reverification } from './reverification'; + +const actions = { + secondaryActionLabel: 'Use another method', + primaryActionLabel: 'Continue', + pendingLabel: 'Verifying', +}; + +function state(status: ReverificationState['status']): ReverificationState { + const onValueChange = vi.fn(); + const onSubmit = vi.fn(); + + return { + status, + password: { + messages: { + title: 'Verification required', + description: 'Enter your password.', + fieldLabel: 'Password', + fieldPlaceholder: 'Enter your password', + ...actions, + }, + value: '', + onValueChange, + onSubmit, + }, + passkey: { + messages: { + title: 'Use your passkey', + description: 'Verify with your passkey.', + ...actions, + }, + onVerify: onSubmit, + }, + otp: { + messages: { + title: 'Verification required', + description: 'Enter the code from your authenticator.', + fieldLabel: 'Verification code', + ...actions, + }, + value: '', + onValueChange, + onSubmit, + }, + backupCode: { + messages: { + title: 'Enter a backup code', + description: 'Enter one of your backup codes.', + fieldLabel: 'Backup code', + ...actions, + }, + value: '', + onValueChange, + onSubmit, + }, + methodPicker: { + messages: { + title: 'Use another method', + description: 'Choose another way to verify.', + backButton: 'Back', + helpText: 'Need help?', + helpButton: 'Get help', + }, + methods: [], + onSelect: vi.fn(), + onHelp: vi.fn(), + }, + help: { + messages: { + title: 'Get help', + description: 'Contact support for help.', + backButton: 'Back', + supportButton: 'Email support', + }, + onEmailSupport: vi.fn(), + onBack: vi.fn(), + }, + }; +} + +describe('Reverification', () => { + it('keeps one Card and Flow surface while view props and panels change', () => { + const { container, rerender } = render(); + + const card = container.querySelector('.cl-card-root'); + const flow = container.querySelector('.cl-flow-root'); + const passwordStep = screen.getByLabelText('Password').closest('.cl-flow-step'); + + expect(card).not.toBeNull(); + expect(flow).toBe(card); + expect(flow).toHaveAttribute('data-value', 'password'); + + const pendingPassword = state('password'); + pendingPassword.password.isPending = true; + rerender(); + + expect(container.querySelector('.cl-card-root')).toBe(card); + expect(container.querySelector('.cl-flow-root')).toBe(flow); + expect(screen.getByLabelText('Password').closest('.cl-flow-step')).toBe(passwordStep); + + rerender(); + + expect(container.querySelector('.cl-card-root')).toBe(card); + expect(container.querySelector('.cl-flow-root')).toBe(flow); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Verification code')).toBeInTheDocument(); + }); + + it('renders a passkey attempt error in a negative Banner', () => { + const errorState = state('passkey'); + errorState.passkey.errorMessage = 'We couldn’t verify that passkey. Try again.'; + + render(); + + const banner = screen.getByRole('alert'); + expect(banner).toHaveClass('cl-banner-root'); + expect(banner).toHaveAttribute('data-color', 'negative'); + expect(banner).toHaveTextContent('We couldn’t verify that passkey. Try again.'); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/reverification/reverification.tsx b/packages/ui/src/mosaic/blocks/reverification/reverification.tsx new file mode 100644 index 00000000000..6fb5f9800e4 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/reverification/reverification.tsx @@ -0,0 +1,71 @@ +import { Card } from '../../components/card'; +import type { FlowDirection } from '../../components/flow'; +import { Flow } from '../../components/flow'; +import type { ReverificationBackupCodeProps } from './reverification-backup-code'; +import { ReverificationBackupCode } from './reverification-backup-code'; +import type { ReverificationHelpProps } from './reverification-help'; +import { ReverificationHelp } from './reverification-help'; +import type { ReverificationMethodPickerProps } from './reverification-method-picker'; +import { ReverificationMethodPicker } from './reverification-method-picker'; +import type { ReverificationOTPProps } from './reverification-otp'; +import { ReverificationOTP } from './reverification-otp'; +import type { ReverificationPasskeyProps } from './reverification-passkey'; +import { ReverificationPasskey } from './reverification-passkey'; +import type { ReverificationPasswordProps } from './reverification-password'; +import { ReverificationPassword } from './reverification-password'; + +export type ReverificationStatus = 'password' | 'passkey' | 'otp' | 'backup-code' | 'method-picker' | 'help'; + +export interface ReverificationState { + status: ReverificationStatus; + password: ReverificationPasswordProps; + passkey: ReverificationPasskeyProps; + otp: ReverificationOTPProps; + backupCode: ReverificationBackupCodeProps; + methodPicker: ReverificationMethodPickerProps; + help: ReverificationHelpProps; +} + +export interface ReverificationProps { + state: ReverificationState; + direction?: FlowDirection; +} + +export function Reverification({ state, direction }: ReverificationProps): JSX.Element { + return ( + } + value={state.status} + direction={direction} + state={state} + > + {current => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +} diff --git a/packages/ui/src/mosaic/components/flow/flow.styles.ts b/packages/ui/src/mosaic/components/flow/flow.styles.ts new file mode 100644 index 00000000000..f64c9f7daf0 --- /dev/null +++ b/packages/ui/src/mosaic/components/flow/flow.styles.ts @@ -0,0 +1,10 @@ +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + step: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/components/flow/flow.test.tsx b/packages/ui/src/mosaic/components/flow/flow.test.tsx new file mode 100644 index 00000000000..c198368d494 --- /dev/null +++ b/packages/ui/src/mosaic/components/flow/flow.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Flow } from './flow'; + +describe('Mosaic Flow', () => { + it('renders only the controlled step', () => { + const { rerender } = render( + + {state => ( + <> + {state.label} + OTP + + )} + , + ); + + const root = screen.getByTestId('root'); + expect(root).toHaveClass('cl-flow-root'); + expect(root).toHaveAttribute('data-value', 'password'); + expect(screen.getByText('Password')).toHaveClass('cl-flow-step'); + expect(screen.getByText('Password')).toHaveAttribute('data-open'); + expect(screen.queryByText('OTP')).not.toBeInTheDocument(); + + rerender( + + {state => ( + <> + {state.label} + OTP + + )} + , + ); + + expect(screen.getByTestId('root')).toBe(root); + expect(root).toHaveAttribute('data-value', 'password-pending'); + expect(screen.getByText('Verifying password')).toHaveAttribute('data-step', 'password'); + expect(screen.getByText('Verifying password')).toHaveAttribute('data-open'); + + rerender( + + {state => ( + <> + {state.label} + OTP + + )} + , + ); + + expect(screen.queryByText('Verifying password')).not.toBeInTheDocument(); + expect(screen.getByText('OTP')).toHaveAttribute('data-step', 'otp'); + expect(screen.getByText('OTP')).toHaveAttribute('data-open'); + expect(screen.getByText('OTP').style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1'); + }); +}); diff --git a/packages/ui/src/mosaic/components/flow/flow.tsx b/packages/ui/src/mosaic/components/flow/flow.tsx new file mode 100644 index 00000000000..d256a9dc5d7 --- /dev/null +++ b/packages/ui/src/mosaic/components/flow/flow.tsx @@ -0,0 +1,74 @@ +import { Flow as HeadlessFlow, type FlowDirection } from '@clerk/headless/flow'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { styles } from './flow.styles'; + +export interface FlowRootProps extends Omit, 'children'> { + /** Controlled value used to select the active step. */ + value: string; + /** Direction of travel between steps. */ + direction?: FlowDirection; + /** Opaque state supplied to the active step. */ + state: State; + children: (state: State) => React.ReactNode; +} + +export interface FlowStepProps extends Omit, 'id'> { + /** Controlled values rendered by this step. */ + ids: readonly string[]; +} + +function FlowRoot( + { value, direction, state, render, className, style, children, ...rest }: FlowRootProps, + ref: React.ForwardedRef, +): JSX.Element { + return ( + + {children(state)} + + ); +} + +type FlowRootComponent = ( + props: FlowRootProps & React.RefAttributes, +) => React.ReactElement; + +const Root = React.forwardRef(FlowRoot) as FlowRootComponent; + +const Step = React.forwardRef(function FlowStep( + { ids, render, className, style, children, ...rest }, + ref, +) { + return ( + + {children} + + ); +}); + +/** A controlled set of screens driven by opaque caller state. */ +export const Flow = { Root, Step }; + +export type { FlowDirection }; diff --git a/packages/ui/src/mosaic/components/flow/index.ts b/packages/ui/src/mosaic/components/flow/index.ts new file mode 100644 index 00000000000..299af5dcfe4 --- /dev/null +++ b/packages/ui/src/mosaic/components/flow/index.ts @@ -0,0 +1,2 @@ +export { Flow } from './flow'; +export type { FlowDirection, FlowRootProps, FlowStepProps } from './flow'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index c8919d9aeb7..22b3807881c 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -57,6 +57,8 @@ export type { } from '../components/dialog'; export { Field } from '../components/field'; export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldRootProps } from '../components/field'; +export { Flow } from '../components/flow'; +export type { FlowRootProps, FlowStepProps } from '../components/flow'; export { Heading, HeadingContext } from '../components/heading'; export type { HeadingProps } from '../components/heading'; export { Icon, IconFrame } from '../components/icon';