From 8056a5d267cf4d8be990deeffaaa70c1ecddddd9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 14:03:38 -0400 Subject: [PATCH 1/3] feat(ui): add Confirmation block --- .changeset/mosaic-confirmation-block.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 12 ++ .../swingset/src/stories/confirmation.mdx | 87 ++++++++++++++ .../src/stories/confirmation.stories.tsx | 99 +++++++++++++++ .../blocks/confirmation/confirmation.test.tsx | 80 +++++++++++++ .../blocks/confirmation/confirmation.tsx | 113 ++++++++++++++++++ .../src/mosaic/blocks/confirmation/index.ts | 2 + 8 files changed, 396 insertions(+) create mode 100644 .changeset/mosaic-confirmation-block.md create mode 100644 packages/swingset/src/stories/confirmation.mdx create mode 100644 packages/swingset/src/stories/confirmation.stories.tsx create mode 100644 packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx create mode 100644 packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx create mode 100644 packages/ui/src/mosaic/blocks/confirmation/index.ts diff --git a/.changeset/mosaic-confirmation-block.md b/.changeset/mosaic-confirmation-block.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-confirmation-block.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 784e648e294..25d1249854b 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -37,6 +37,7 @@ const docModules: Record> = { 'user-profile-delete-section': dynamic(() => import('../stories/user-profile-delete-section.mdx')), }, blocks: { + confirmation: dynamic(() => import('../stories/confirmation.mdx')), destructive: dynamic(() => import('../stories/destructive.mdx')), reverification: dynamic(() => import('../stories/reverification.mdx')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ebe2f7b9fab..41aba8c101f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -31,6 +31,11 @@ import { meta as comboboxMeta, Scrolling as ComboboxScrolling, } from '../stories/combobox.stories'; +import { + Default as ConfirmationDefault, + meta as confirmationMeta, + WithError as ConfirmationWithError, +} from '../stories/confirmation.stories'; import { Default as DestructiveDefault, meta as destructiveMeta, @@ -526,6 +531,12 @@ const userProfileDeleteSectionModule: StoryModule = { WithError: UserProfileDeleteSectionWithError, }; +const confirmationModule: StoryModule = { + meta: confirmationMeta, + Default: ConfirmationDefault, + WithError: ConfirmationWithError, +}; + const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault, @@ -576,6 +587,7 @@ export const registry: StoryModule[] = [ userProfileWeb3WalletsSectionModule, userProfileDeleteSectionModule, // Blocks — flows assembled from components, wired by the caller's machine. + confirmationModule, destructiveModule, reverificationModule, // Components diff --git a/packages/swingset/src/stories/confirmation.mdx b/packages/swingset/src/stories/confirmation.mdx new file mode 100644 index 00000000000..417c8100ead --- /dev/null +++ b/packages/swingset/src/stories/confirmation.mdx @@ -0,0 +1,87 @@ +import * as Stories from './confirmation.stories'; + +# Confirmation + +## Example + + + +## Usage + +A confirmation for a destructive action that is worth a second look but not worth making the user type for. Removing a connected account, revoking a session, signing out everywhere. For the actions that do warrant typing, use [Destructive](/components/destructive). + +The block holds nothing of its own. Everything that decides what the dialog does next belongs to the caller. `open` closes it, `isConfirming` marks it busy, `errorMessage` explains a failure. + +```tsx +import { Confirmation } from '@clerk/ui/mosaic/blocks/confirmation'; +import { Button } from '@clerk/ui/mosaic/components/button'; + +const [open, setOpen] = useState(false); +const [isConfirming, setIsConfirming] = useState(false); + +const handleConfirm = async () => { + setIsConfirming(true); + await removeConnectedAccount(); + setIsConfirming(false); + setOpen(false); +}; + +Remove} + title='Remove connected account' + description='Google will be removed from this account. You will no longer be able to use this connected account and any dependent features will no longer work.' + actionLabel='Remove' + onConfirm={() => void handleConfirm()} + isConfirming={isConfirming} +/>; +``` + +## Failure + +A failed attempt leaves the dialog up. Pass the sentence the user should read as `errorMessage`, and clear it when the next attempt starts. The message renders as a banner between the description and the actions. + + + +## Props + +| Prop | Type | Description | +| -------------- | ------------------------- | ------------------------------------------------------------------------------ | +| `open` | `boolean` | Whether the confirmation is showing. Controlled, the way any dialog is. | +| `onOpenChange` | `(open: boolean) => void` | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | +| `trigger` | `ReactNode` | Optional. The button that asks to open the dialog. | +| `title` | `string` | Names what is about to happen. | +| `description` | `string` | Spells out what it means. | +| `actionLabel` | `string` | The destructive button's label. | +| `cancelLabel` | `string` | Optional. Defaults to `Cancel`. | +| `onConfirm` | `() => void` | Asks the caller to run the action. | +| `isConfirming` | `boolean` | Optional. Renders the action pending and ignores further presses. | +| `errorMessage` | `string` | Optional. Renders as a negative banner above the actions. | + +## Driving it from a machine + +A section that wires the block to a state machine maps the machine's state onto the same props: + +```tsx + send({ type: open ? 'OPEN' : 'CANCEL' })} + onConfirm={() => send({ type: 'CONFIRM' })} + isConfirming={snapshot.value === 'removing'} + errorMessage={snapshot.context.errorMessage} + {...copy} +/> +``` diff --git a/packages/swingset/src/stories/confirmation.stories.tsx b/packages/swingset/src/stories/confirmation.stories.tsx new file mode 100644 index 00000000000..264cc1114b8 --- /dev/null +++ b/packages/swingset/src/stories/confirmation.stories.tsx @@ -0,0 +1,99 @@ +import { Confirmation } from '@clerk/ui/mosaic/blocks/confirmation'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './confirmation.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + status: 'stable', + title: 'Confirmation', + source: 'packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx', +}; + +// A real removal is a network round trip. Without one the action never renders its pending +// state, so both stories wait before they settle. +const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const trigger = ( + +); + +/** + * The block holds nothing of its own. `open` closes it, `isConfirming` marks it busy, + * `errorMessage` explains a failure. + */ +export function Default() { + const [open, setOpen] = React.useState(false); + const [isConfirming, setIsConfirming] = React.useState(false); + + const handleConfirm = async () => { + setIsConfirming(true); + await settleAfter(2000); + setIsConfirming(false); + setOpen(false); + }; + + return ( + void handleConfirm()} + isConfirming={isConfirming} + /> + ); +} + +/** + * A failed attempt leaves the dialog up. Pass the sentence the user should read as + * `errorMessage`, and clear it when the next attempt starts. + */ +export function WithError() { + const [open, setOpen] = React.useState(false); + const [isConfirming, setIsConfirming] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(undefined); + + const handleConfirm = async () => { + setErrorMessage(undefined); + setIsConfirming(true); + await settleAfter(2000); + setIsConfirming(false); + setErrorMessage('Google is your only way to sign in. Add a password or another account first.'); + }; + + // The error belongs to the caller, so the caller drops it. Without this a reopened dialog + // still shows why the last attempt failed. + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) { + setErrorMessage(undefined); + } + }; + + return ( + void handleConfirm()} + isConfirming={isConfirming} + errorMessage={errorMessage} + /> + ); +} diff --git a/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx b/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx new file mode 100644 index 00000000000..dcae0e243a2 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { Button } from '../../components/button'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { ConfirmationProps } from './confirmation'; +import { Confirmation } from './confirmation'; + +function renderBlock(overrides: Partial = {}) { + return render( + + + , + ); +} + +const confirmButton = () => screen.getByRole('button', { name: 'Remove' }); + +describe('Confirmation', () => { + it('renders nothing until the caller opens it', () => { + renderBlock({ open: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('asks to open from the trigger', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderBlock({ open: false, onOpenChange, trigger: }); + + await user.click(confirmButton()); + + expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); + }); + + it('confirms from the action', async () => { + const onConfirm = vi.fn(); + const user = userEvent.setup(); + renderBlock({ onConfirm }); + + await user.click(confirmButton()); + + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it('asks to close from cancel', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderBlock({ onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('explains a failed attempt', () => { + renderBlock({ errorMessage: 'Google is your only way to sign in.' }); + + expect(screen.getByRole('alert')).toHaveTextContent('Google is your only way to sign in.'); + }); + + it('stays inert while the caller is confirming', async () => { + const onConfirm = vi.fn(); + const user = userEvent.setup(); + renderBlock({ isConfirming: true, onConfirm }); + + expect(confirmButton()).toHaveAttribute('aria-busy', 'true'); + await user.click(confirmButton()); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx b/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx new file mode 100644 index 00000000000..399cc09f70c --- /dev/null +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx @@ -0,0 +1,113 @@ +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; + +export interface ConfirmationProps { + /** Whether the dialog is open */ + open: boolean; + /** Callback when open state changes */ + onOpenChange: (open: boolean) => void; + /** Element that opens the dialog */ + trigger?: DialogTriggerProps['render']; + /** Dialog heading */ + title: string; + /** What the action does and why it warrants a second look */ + description: string; + /** Text of the confirming button */ + actionLabel: string; + /** Text of the cancel button (default: "Cancel") */ + cancelLabel?: string; + /** Callback when the action is confirmed */ + onConfirm: () => void; + /** Whether the confirmed action is in progress */ + isConfirming?: boolean; + /** Error message to display if the confirmed action fails */ + errorMessage?: string; +} + +/** + * Confirmation dialog for a destructive action that is worth a second look but not worth + * making the user type for. Use `Destructive` for the actions that are. + * + * Controlled: the caller owns `open`, `isConfirming`, and `errorMessage`. The block holds + * nothing of its own. + * + * @example + * send({ type: open ? 'OPEN' : 'CANCEL' })} + * trigger={} + * title='Remove connected account' + * description='Google will be removed from this account. You will no longer be able to use this connected account and any dependent features will no longer work.' + * actionLabel='Remove' + * onConfirm={() => send({ type: 'CONFIRM' })} + * isConfirming={snapshot.value === 'removing'} + * errorMessage={snapshot.context.errorMessage} + * /> + */ +export function Confirmation({ + open, + onOpenChange, + trigger, + title, + description, + actionLabel, + cancelLabel = 'Cancel', + onConfirm, + isConfirming = false, + errorMessage, +}: ConfirmationProps) { + return ( + + {trigger ? : null} + + + + {title} + {description} + + {errorMessage ? ( + + + {errorMessage} + + + ) : null} + + + {cancelLabel} + + } + /> + + {actionLabel} + + + + + + ); +} diff --git a/packages/ui/src/mosaic/blocks/confirmation/index.ts b/packages/ui/src/mosaic/blocks/confirmation/index.ts new file mode 100644 index 00000000000..5b00b65ea13 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/confirmation/index.ts @@ -0,0 +1,2 @@ +export { Confirmation } from './confirmation'; +export type { ConfirmationProps } from './confirmation'; From 2f773f21eaacebd256be8e59223430f836a730b1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 14:30:51 -0400 Subject: [PATCH 2/3] docs(ui): add defaults column and useState import to Confirmation page --- .../swingset/src/stories/confirmation.mdx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/swingset/src/stories/confirmation.mdx b/packages/swingset/src/stories/confirmation.mdx index 417c8100ead..8d14cd87f09 100644 --- a/packages/swingset/src/stories/confirmation.mdx +++ b/packages/swingset/src/stories/confirmation.mdx @@ -24,6 +24,7 @@ The block holds nothing of its own. Everything that decides what the dialog does ```tsx import { Confirmation } from '@clerk/ui/mosaic/blocks/confirmation'; import { Button } from '@clerk/ui/mosaic/components/button'; +import { useState } from 'react'; const [open, setOpen] = useState(false); const [isConfirming, setIsConfirming] = useState(false); @@ -58,18 +59,18 @@ A failed attempt leaves the dialog up. Pass the sentence the user should read as ## Props -| Prop | Type | Description | -| -------------- | ------------------------- | ------------------------------------------------------------------------------ | -| `open` | `boolean` | Whether the confirmation is showing. Controlled, the way any dialog is. | -| `onOpenChange` | `(open: boolean) => void` | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | -| `trigger` | `ReactNode` | Optional. The button that asks to open the dialog. | -| `title` | `string` | Names what is about to happen. | -| `description` | `string` | Spells out what it means. | -| `actionLabel` | `string` | The destructive button's label. | -| `cancelLabel` | `string` | Optional. Defaults to `Cancel`. | -| `onConfirm` | `() => void` | Asks the caller to run the action. | -| `isConfirming` | `boolean` | Optional. Renders the action pending and ignores further presses. | -| `errorMessage` | `string` | Optional. Renders as a negative banner above the actions. | +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ------------ | ------------------------------------------------------------------------------ | +| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | +| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | +| `title` | `string` | — (required) | Names what is about to happen. | +| `description` | `string` | — (required) | Spells out what it means. | +| `actionLabel` | `string` | — (required) | The destructive button's label. | +| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | +| `onConfirm` | `() => void` | — (required) | Asks the caller to run the action. | +| `isConfirming` | `boolean` | `false` | Renders the action pending and ignores further presses. | +| `errorMessage` | `string` | — | Renders as a negative banner above the actions. | ## Driving it from a machine From 167ef9f5f064e66fa45d8e9fe421ac488b4442c7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 16:10:58 -0400 Subject: [PATCH 3/3] feat(ui): accept markup in Confirmation description --- packages/swingset/src/stories/confirmation.mdx | 2 +- .../blocks/confirmation/confirmation.test.tsx | 15 +++++++++++++++ .../mosaic/blocks/confirmation/confirmation.tsx | 6 ++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/swingset/src/stories/confirmation.mdx b/packages/swingset/src/stories/confirmation.mdx index 8d14cd87f09..8a44b928bdd 100644 --- a/packages/swingset/src/stories/confirmation.mdx +++ b/packages/swingset/src/stories/confirmation.mdx @@ -65,7 +65,7 @@ A failed attempt leaves the dialog up. Pass the sentence the user should read as | `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | | `trigger` | `ReactNode` | — | The button that asks to open the dialog. | | `title` | `string` | — (required) | Names what is about to happen. | -| `description` | `string` | — (required) | Spells out what it means. | +| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | | `actionLabel` | `string` | — (required) | The destructive button's label. | | `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | | `onConfirm` | `() => void` | — (required) | Asks the caller to run the action. | diff --git a/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx b/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx index dcae0e243a2..b80b0fda194 100644 --- a/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx @@ -52,6 +52,21 @@ describe('Confirmation', () => { expect(onConfirm).toHaveBeenCalledOnce(); }); + it('renders markup in the description', () => { + renderBlock({ + description: ( + <> + preston@clerk.dev will be removed from this account. + + ), + }); + + expect(screen.getByRole('dialog')).toHaveAccessibleDescription( + 'preston@clerk.dev will be removed from this account.', + ); + expect(screen.getByText('preston@clerk.dev').tagName).toBe('STRONG'); + }); + it('asks to close from cancel', async () => { const onOpenChange = vi.fn(); const user = userEvent.setup(); diff --git a/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx b/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx index 399cc09f70c..cacca8455ab 100644 --- a/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + import { Banner } from '../../components/banner'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; @@ -13,8 +15,8 @@ export interface ConfirmationProps { trigger?: DialogTriggerProps['render']; /** Dialog heading */ title: string; - /** What the action does and why it warrants a second look */ - description: string; + /** What the action does and why it warrants a second look. Takes markup, for a name to emphasise */ + description: ReactNode; /** Text of the confirming button */ actionLabel: string; /** Text of the cancel button (default: "Cancel") */