From 33bce327850f0b7163220a0cc9a155fa3c21894c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Aug 2026 20:25:25 -0400 Subject: [PATCH 1/6] feat(ui): Add Destructive block and wire the delete account section Type-to-confirm dialog extracted into a reusable block. The section drives it from a state machine. Mosaic is not exported from @clerk/ui yet, so no user-facing change. --- .changeset/wild-mangos-clap.md | 2 + .claude/skills/mosaic/references/views.md | 35 +++- .../swingset/src/components/DocsViewer.tsx | 3 + .../swingset/src/components/app-sidebar.tsx | 2 +- packages/swingset/src/lib/registry.ts | 15 ++ packages/swingset/src/stories/destructive.mdx | 93 +++++++++ .../src/stories/destructive.stories.tsx | 103 ++++++++++ .../src/stories/user-page.stories.tsx | 4 +- .../stories/user-profile-delete-section.mdx | 7 + .../user-profile-delete-section.stories.tsx | 21 ++- .../user-profile-profile-panel.stories.tsx | 2 +- .../user-profile-security-panel.stories.tsx | 2 +- .../blocks/destructive/destructive.test.tsx | 144 ++++++++++++++ .../mosaic/blocks/destructive/destructive.tsx | 177 ++++++++++++++++++ .../ui/src/mosaic/blocks/destructive/index.ts | 2 + .../mosaic/components/button/button.styles.ts | 2 +- .../components/button/submit-button.test.tsx | 15 ++ .../components/button/submit-button.tsx | 7 +- .../mosaic/components/field/field.styles.ts | 5 + .../ui/src/mosaic/components/field/field.tsx | 2 +- .../user-profile-delete-section.view.test.tsx | 74 ++++++++ .../user-profile-profile-panel.view.test.tsx | 5 +- .../user-profile-security-panel.view.test.tsx | 11 +- .../user-profile-delete-section.view.tsx | 36 ---- ...ser-profile-delete-section.machine.test.ts | 50 +++++ .../user-profile-delete-section.machine.ts | 51 +++++ .../user-profile-delete-section.messages.ts | 32 ++++ .../user-profile-delete-section.view.tsx | 60 ++++++ .../user-profile-profile-panel.view.tsx | 7 +- .../user-profile-security-panel.view.tsx | 5 +- 30 files changed, 911 insertions(+), 63 deletions(-) create mode 100644 .changeset/wild-mangos-clap.md create mode 100644 packages/swingset/src/stories/destructive.mdx create mode 100644 packages/swingset/src/stories/destructive.stories.tsx create mode 100644 packages/ui/src/mosaic/blocks/destructive/destructive.test.tsx create mode 100644 packages/ui/src/mosaic/blocks/destructive/destructive.tsx create mode 100644 packages/ui/src/mosaic/blocks/destructive/index.ts create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-delete-section.view.test.tsx delete mode 100644 packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.test.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx diff --git a/.changeset/wild-mangos-clap.md b/.changeset/wild-mangos-clap.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wild-mangos-clap.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md index 6cde265d985..e930daa2b79 100644 --- a/.claude/skills/mosaic/references/views.md +++ b/.claude/skills/mosaic/references/views.md @@ -9,16 +9,41 @@ The view renders a snapshot and emits events. Nothing else. - **Take derived booleans from the controller.** `actor.can(...)` results (e.g. `canSubmit`) are passed in — the view never re-implements a machine guard. +```tsx +
send({ type: 'SUBMIT' })}> + send({ type: 'TYPE_NAME', value: event.target.value })} + /> + + Save + +
+``` + +A **block** takes the flow's state as props. It owns only what nothing outside +it can use. `Destructive` is the example: it holds the half-typed confirmation +phrase and compares it, while `open`, `isDeleting`, and `errorMessage` come from +the machine, because those are what decide whether the dialog closes or explains +itself. + ```tsx send({ type: 'TYPE_CONFIRMATION', value })} + onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })} + trigger={} + title='Delete organization?' + description="All of this organization's data will be permanently deleted." + fieldLabel='Type the organization name below to continue' + confirmationValue={organizationName} + actionLabel='Delete organization' onDelete={() => send({ type: 'CONFIRM' })} - canSubmit={canSubmit} isDeleting={snapshot.value === 'deleting'} - error={snapshot.context.error} + errorMessage={snapshot.context.errorMessage} /> ``` diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3a52fbae376..c144dfd18db 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -37,6 +37,9 @@ const docModules: Record> = { 'user-profile-web3wallets-section': dynamic(() => import('../stories/user-profile-web3-wallets-section.mdx')), 'user-profile-delete-section': dynamic(() => import('../stories/user-profile-delete-section.mdx')), }, + blocks: { + destructive: dynamic(() => import('../stories/destructive.mdx')), + }, components: { avatar: dynamic(() => import('../stories/avatar.mdx')), badge: dynamic(() => import('../stories/badge.mdx')), diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 00cc791f6c2..2f18c0d7c2b 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -167,7 +167,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {groups.map(({ group, groupSlug, components }) => ( - {group === 'Components' && } + {group === 'Blocks' && } + +## Usage + +The block holds one thing: the phrase the user types. Nothing outside the dialog can use a half-typed string, so keeping it inside removes the keystroke plumbing a caller would otherwise write. + +Everything that decides what the dialog does next belongs to the caller. `open` closes it, `isDeleting` marks it busy, `errorMessage` explains a failure. + +```tsx +import { Destructive } from '@clerk/ui/mosaic/blocks/destructive'; +import { Button } from '@clerk/ui/mosaic/components/button'; + +const [open, setOpen] = useState(false); +const [isDeleting, setIsDeleting] = useState(false); + +const handleDelete = async () => { + setIsDeleting(true); + await deleteAccount(); + setIsDeleting(false); + setOpen(false); +}; + +Delete account} + title='Delete account?' + description='Are you sure you want to delete your account? All of your data will be permanently deleted.' + fieldLabel='Type “Delete account” below to continue' + confirmationValue='Delete account' + actionLabel='Delete account' + onDelete={() => void handleDelete()} + isDeleting={isDeleting} +/>; +``` + +## 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 field is marked invalid for as long as a message is set. + + + +## 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` | `ReactNode` | Names what is about to be destroyed. | +| `description` | `ReactNode` | Spells out what is lost. Sits above the confirmation field. | +| `fieldLabel` | `ReactNode` | Labels the confirmation field. | +| `confirmationValue` | `string` | The phrase the user has to type back. Also the field's placeholder. | +| `actionLabel` | `ReactNode` | The destructive button's label. | +| `cancelLabel` | `ReactNode` | Optional. Defaults to `Cancel`. | +| `onDelete` | `() => void` | Asks the caller to run the action. Only reachable once the typed phrase matches. | +| `isDeleting` | `boolean` | Optional. Disables the field and renders the action pending. | +| `errorMessage` | `string` | Optional. Marks the field invalid and renders under it. | + +## Driving it from a machine + +`UserProfileDeleteSection` wires the same block to a state machine rather than to `useState`. The machine's state maps onto the same props: + +```tsx + send({ type: open ? 'OPEN' : 'CANCEL' })} + onDelete={() => send({ type: 'CONFIRM' })} + isDeleting={snapshot.value === 'deleting'} + errorMessage={snapshot.context.errorMessage} + {...copy} +/> +``` diff --git a/packages/swingset/src/stories/destructive.stories.tsx b/packages/swingset/src/stories/destructive.stories.tsx new file mode 100644 index 00000000000..27f9811e8cb --- /dev/null +++ b/packages/swingset/src/stories/destructive.stories.tsx @@ -0,0 +1,103 @@ +import { Destructive } from '@clerk/ui/mosaic/blocks/destructive'; +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 './destructive.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'Destructive', + source: 'packages/ui/src/mosaic/blocks/destructive/destructive.tsx', +}; + +// A real delete 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 the typed phrase and compares it to `confirmationValue`. Everything that + * decides what the dialog does next stays with the caller: `open` closes it, `isDeleting` + * marks it busy, `errorMessage` explains a failure. + */ +export function Default() { + const [open, setOpen] = React.useState(false); + const [isDeleting, setIsDeleting] = React.useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + await settleAfter(2000); + setIsDeleting(false); + setOpen(false); + }; + + return ( + void handleDelete()} + isDeleting={isDeleting} + /> + ); +} + +/** + * 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 [isDeleting, setIsDeleting] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(undefined); + + const handleDelete = async () => { + setErrorMessage(undefined); + setIsDeleting(true); + await settleAfter(2000); + setIsDeleting(false); + setErrorMessage('Your subscription is still active. Cancel it before you delete your account.'); + }; + + // 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 handleDelete()} + isDeleting={isDeleting} + errorMessage={errorMessage} + /> + ); +} diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx index bcb7f35e382..42da0e3aa7e 100644 --- a/packages/swingset/src/stories/user-page.stories.tsx +++ b/packages/swingset/src/stories/user-page.stories.tsx @@ -121,7 +121,7 @@ export function Default() { isVerified: true, }, ]), - onDeleteAccount: () => undefined, + onDeleteAccount: () => Promise.resolve(), onEditProfilePicture: () => undefined, onManageEmail: () => undefined, onManagePhone: () => undefined, @@ -162,7 +162,7 @@ export function Default() { { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, ]), onChangePassword: () => undefined, - onDeleteAccount: () => undefined, + onDeleteAccount: () => Promise.resolve(), onManageDevice: () => undefined, onManagePasskey: () => undefined, onRegenerateBackupCodes: () => diff --git a/packages/swingset/src/stories/user-profile-delete-section.mdx b/packages/swingset/src/stories/user-profile-delete-section.mdx index e6ca07a140b..6e29e05699d 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.mdx +++ b/packages/swingset/src/stories/user-profile-delete-section.mdx @@ -12,3 +12,10 @@ The terminal destructive action for deleting the current user account. { name: 'Button', href: '/components/button', layer: 'Components' }, ]} /> + +A failed delete keeps the dialog up and renders the reason under the confirmation field. + + diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx index cc18ac403eb..b4f1c8b377c 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -1,4 +1,4 @@ -import { UserProfileDeleteSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-delete-section.view'; +import { UserProfileDeleteSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -9,9 +9,24 @@ export const meta: StoryMeta = { title: 'UserProfileDeleteSection', label: 'Danger zone', navigation: { category: 'Sections' }, - source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx', + source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx', }; +// A real delete is a network round trip. Without one the button never renders its pending +// state, so both stories wait before they settle. +const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + export function Default() { - return undefined} />; + return settleAfter(2000)} />; +} + +export function WithError() { + return ( + { + await settleAfter(2000); + throw new Error('Your subscription is still active. Cancel it before you delete your account.'); + }} + /> + ); } diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 3a1b0164cc3..0371e85037d 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -77,7 +77,7 @@ export function Default(_args: Record) { ]) } onConnectAccount={() => undefined} - onDeleteAccount={() => undefined} + onDeleteAccount={() => Promise.resolve()} onEditProfilePicture={() => undefined} onManageEmail={() => undefined} onManagePhone={() => undefined} diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx index e02a43d8489..8c1614a3138 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -82,7 +82,7 @@ export function Default() { ]) } onChangePassword={() => undefined} - onDeleteAccount={() => undefined} + onDeleteAccount={() => Promise.resolve()} onManageDevice={() => undefined} onManagePasskey={() => undefined} onRegenerateBackupCodes={() => diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.test.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.test.tsx new file mode 100644 index 00000000000..057b39fa78d --- /dev/null +++ b/packages/ui/src/mosaic/blocks/destructive/destructive.test.tsx @@ -0,0 +1,144 @@ +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 { DestructiveProps } from './destructive'; +import { Destructive } from './destructive'; + +function renderBlock(overrides: Partial = {}) { + return render( + + + , + ); +} + +const confirmButton = () => screen.getByRole('button', { name: 'Delete account' }); + +describe('Destructive', () => { + 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('holds the action until the typed phrase matches', async () => { + const onDelete = vi.fn(); + const user = userEvent.setup(); + renderBlock({ onDelete }); + + expect(confirmButton()).toHaveAttribute('aria-disabled', 'true'); + + await user.type(screen.getByRole('textbox'), 'Delete accoun'); + expect(confirmButton()).toHaveAttribute('aria-disabled', 'true'); + + await user.type(screen.getByRole('textbox'), 't'); + expect(confirmButton()).not.toHaveAttribute('aria-disabled'); + + await user.click(confirmButton()); + expect(onDelete).toHaveBeenCalledOnce(); + }); + + it('submits on enter in the confirmation field, once the typed phrase matches', async () => { + const onDelete = vi.fn(); + const user = userEvent.setup(); + renderBlock({ onDelete }); + + await user.type(screen.getByRole('textbox'), 'Delete accoun{Enter}'); + expect(onDelete).not.toHaveBeenCalled(); + + await user.type(screen.getByRole('textbox'), 't{Enter}'); + expect(onDelete).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('clears the typed phrase once the caller closes it', async () => { + const user = userEvent.setup(); + const view = renderBlock(); + + await user.type(screen.getByRole('textbox'), 'Delete account'); + view.rerender( + + + , + ); + view.rerender( + + + , + ); + + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('marks the field invalid and explains a failed attempt', () => { + renderBlock({ errorMessage: 'Your subscription is still active.' }); + + expect(screen.getByText('Your subscription is still active.')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('stays inert while the caller is deleting', async () => { + const onDelete = vi.fn(); + const user = userEvent.setup(); + renderBlock({ isDeleting: true, onDelete }); + + await user.type(screen.getByRole('textbox'), 'Delete account'); + + expect(screen.getByRole('textbox')).toBeDisabled(); + // Busy, not unavailable: the block leaves the pending affordance to `isPending` rather than + // disabling the action a second time. + expect(confirmButton()).toHaveAttribute('aria-busy', 'true'); + await user.click(confirmButton()); + expect(onDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx new file mode 100644 index 00000000000..a5e294b64e7 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx @@ -0,0 +1,177 @@ +import type { FormEvent, ReactNode } from 'react'; +import { useEffect, useId, useState } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Heading } from '../../components/heading'; +import { Input } from '../../components/input'; +import { Text } from '../../components/text'; + +export interface DestructiveProps { + /** Whether the confirmation is showing. Controlled, the way any dialog is. */ + open: boolean; + onOpenChange: (open: boolean) => void; + /** Renders the button that asks to open the dialog. Omit to open it some other way. */ + trigger?: DialogProps['trigger']; + /** Names what is about to be destroyed, e.g. `Delete account?`. */ + title: ReactNode; + /** Spells out what is lost. Sits above the confirmation field. */ + description: ReactNode; + /** Labels the confirmation field, e.g. `Type “Delete account” below to continue`. */ + fieldLabel: ReactNode; + /** + * The phrase the user has to type back. Also the field's placeholder. The block holds + * what is typed and compares it, so no flow has to carry a keystroke through a machine + * to find out whether two strings match. + */ + confirmationValue: string; + actionLabel: ReactNode; + /** @default 'Cancel' */ + cancelLabel?: ReactNode; + /** + * Asks the caller to run the action. Reached by pressing the action or by Enter in the + * confirmation field, and only once the typed phrase matches. + */ + onDelete: () => void; + /** Keeps the dialog inert and the action pending while the caller works. */ + isDeleting?: boolean; + /** Why the last attempt failed. Marks the field invalid and renders under it. */ + errorMessage?: string; +} + +/** + * Type-to-confirm dialog for an action that cannot be undone: the destructive button stays + * inert until the typed phrase matches `confirmationValue`. + * + * The block owns one thing, the typed phrase, because nothing outside it can use a + * half-typed string. Everything with a consequence stays with the caller: `open` closes the + * dialog, `isDeleting` marks it busy, `error` explains a failure. + * + * @example + * send({ type: open ? 'OPEN' : 'CANCEL' })} + * trigger={} + * title='Delete account?' + * description='All of your data will be permanently deleted.' + * fieldLabel='Type “Delete account” below to continue' + * confirmationValue='Delete account' + * actionLabel='Delete account' + * onDelete={() => send({ type: 'CONFIRM' })} + * isDeleting={snapshot.value === 'deleting'} + * errorMessage={snapshot.context.errorMessage} + * /> + */ +export function Destructive({ + open, + onOpenChange, + trigger, + title, + description, + fieldLabel, + confirmationValue, + actionLabel, + cancelLabel = 'Cancel', + onDelete, + isDeleting = false, + errorMessage, +}: DestructiveProps) { + const formId = useId(); + const [typedValue, setTypedValue] = useState(''); + + // The caller may close the dialog without going through the trigger or Cancel, so the + // field is cleared on close rather than in a handler. + useEffect(() => { + if (!open) { + setTypedValue(''); + } + }, [open]); + + const isConfirmed = typedValue === confirmationValue; + + // The action sits in the footer, outside the form, so `form={formId}` associates the two. + // That is what makes Enter in the field submit. Both guards are re-checked here because + // neither spelling stops a native submit: `focusableWhenDisabled` only marks the button + // `aria-disabled`, and `isPending` only cancels the press. + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (isConfirmed && !isDeleting) { + onDelete(); + } + }; + + return ( + + {trigger ? : null} + + + + + } + > + + + }>{title} + }>{description} + + +
+ + {fieldLabel} + setTypedValue(event.target.value)} + /> + {errorMessage ? {errorMessage} : null} + +
+
+ + + {cancelLabel} + + } + /> + + {actionLabel} + + +
+
+
+
+ ); +} diff --git a/packages/ui/src/mosaic/blocks/destructive/index.ts b/packages/ui/src/mosaic/blocks/destructive/index.ts new file mode 100644 index 00000000000..39b75d99817 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/destructive/index.ts @@ -0,0 +1,2 @@ +export { Destructive } from './destructive'; +export type { DestructiveProps } from './destructive'; diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 08aaaae2625..8495c4ec902 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -172,7 +172,7 @@ export const styles = stylex.create({ }, // state / modifiers - fullWidth: { width: '100%' }, + fullWidth: { flex: '1', width: '100%' }, disabled: { cursor: 'not-allowed', opacity: 0.5 }, }); diff --git a/packages/ui/src/mosaic/components/button/submit-button.test.tsx b/packages/ui/src/mosaic/components/button/submit-button.test.tsx index b8398f07523..a7fbca36ba8 100644 --- a/packages/ui/src/mosaic/components/button/submit-button.test.tsx +++ b/packages/ui/src/mosaic/components/button/submit-button.test.tsx @@ -67,6 +67,21 @@ describe('Mosaic SubmitButton', () => { expect(button).not.toHaveAttribute('data-pending'); }); + it('keeps the focusable-disabled marking when the button is disabled but not pending', () => { + render( + + Save + , + ); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).not.toHaveAttribute('disabled'); + expect(button).not.toHaveAttribute('aria-busy'); + }); + it('renders the spinner and reflects the pending state', () => { render(Save); const button = screen.getByRole('button'); diff --git a/packages/ui/src/mosaic/components/button/submit-button.tsx b/packages/ui/src/mosaic/components/button/submit-button.tsx index 711af575e38..0a117779c73 100644 --- a/packages/ui/src/mosaic/components/button/submit-button.tsx +++ b/packages/ui/src/mosaic/components/button/submit-button.tsx @@ -88,9 +88,10 @@ export const SubmitButton = React.forwardRef(function MosaicFie render, ref, props: { - ...mergeStyleProps(themeProps('field-root'), stylex.props(reset.base), className, style), + ...mergeStyleProps(themeProps('field-root'), stylex.props(reset.base, styles.root), className, style), ...rest, }, }); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-delete-section.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-delete-section.view.test.tsx new file mode 100644 index 00000000000..911d97432fc --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-delete-section.view.test.tsx @@ -0,0 +1,74 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view'; + +function renderView(onDelete: () => Promise = vi.fn(() => Promise.resolve())) { + return render( + + + , + ); +} + +const openDialog = async (user: ReturnType) => { + await user.click(screen.getByRole('button', { name: 'Delete account' })); + return screen.getByRole('dialog'); +}; + +describe('UserProfileDeleteSectionView', () => { + it('renders the danger zone with the dialog closed', () => { + renderView(); + + expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); + expect( + screen.getByText('Permanently delete this account and all its data. This cannot be undone.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('deletes only after the phrase is typed back, then closes', async () => { + const onDelete = vi.fn(() => Promise.resolve()); + const user = userEvent.setup(); + renderView(onDelete); + + const dialog = await openDialog(user); + const confirm = within(dialog).getByRole('button', { name: 'Delete account' }); + + expect(within(dialog).getByText('Type “Delete account” below to continue')).toBeInTheDocument(); + expect(confirm).toHaveAttribute('aria-disabled', 'true'); + + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(confirm); + + expect(onDelete).toHaveBeenCalledOnce(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('keeps the dialog up and explains a failed delete', async () => { + const user = userEvent.setup(); + renderView(() => Promise.reject(new Error('Your subscription is still active.'))); + + const dialog = await openDialog(user); + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Delete account' })); + + expect(await screen.findByText('Your subscription is still active.')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('clears the phrase when the dialog is cancelled', async () => { + const user = userEvent.setup(); + renderView(); + + const dialog = await openDialog(user); + await user.type(within(dialog).getByRole('textbox'), 'Delete account'); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await openDialog(user); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 5d3e26e4c52..e6227392e30 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -139,7 +139,7 @@ describe('UserProfileProfilePanelView', () => { it('renders connected accounts and the danger zone when provided', async () => { const onConnectAccount = vi.fn(); const onManageConnectedAccount = vi.fn(); - const onDeleteAccount = vi.fn(); + const onDeleteAccount = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ connectedAccounts: [ @@ -165,6 +165,9 @@ describe('UserProfileProfilePanelView', () => { await user.click(screen.getByRole('menuitem', { name: 'Manage' })); await user.click(screen.getByRole('button', { name: 'Connect' })); await user.click(screen.getByRole('button', { name: 'Delete account' })); + const deleteDialog = screen.getByRole('dialog'); + await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); + await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); expect(onManageConnectedAccount).toHaveBeenCalledWith('google'); expect(onConnectAccount).toHaveBeenCalledWith('apple'); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 5892b8c8cd8..0d5038bf3d9 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -57,7 +57,7 @@ function renderView(overrides: Partial = {}) describe('UserProfileSecurityPanelView', () => { it('composes authentication, active devices, and the danger zone', () => { - renderView({ onDeleteAccount: vi.fn() }); + renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) }); expect(screen.getByRole('heading', { level: 3, name: 'Security' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); @@ -83,7 +83,7 @@ describe('UserProfileSecurityPanelView', () => { const onAddMfaMethod = vi.fn(); const onSignOutDevice = vi.fn(); const onSignOutAllOtherDevices = vi.fn(); - const onDeleteAccount = vi.fn(); + const onDeleteAccount = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ @@ -107,7 +107,6 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('menuitem', { name: 'SMS verification' })).not.toBeInTheDocument(); await user.click(screen.getByRole('menuitem', { name: 'Authenticator app' })); await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(screen.getByRole('button', { name: 'Delete account' })); await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); await user.click(screen.getByRole('menuitem', { name: 'Rename' })); @@ -118,6 +117,12 @@ describe('UserProfileSecurityPanelView', () => { await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' })); await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + // The danger zone confirms in a modal, so it goes last: nothing else is clickable while it is open. + await user.click(screen.getByRole('button', { name: 'Delete account' })); + const deleteDialog = screen.getByRole('dialog'); + await user.type(within(deleteDialog).getByRole('textbox'), 'Delete account'); + await user.click(within(deleteDialog).getByRole('button', { name: 'Delete account' })); + expect(onChangePassword).toHaveBeenCalledOnce(); expect(onAddPasskey).toHaveBeenCalledOnce(); expect(onManagePasskey).toHaveBeenCalledWith('passkey_1'); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx deleted file mode 100644 index 8ace3e6e9c6..00000000000 --- a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Button } from '../components/button'; -import { Section } from '../components/section'; - -export interface UserProfileDeleteSectionViewProps { - onDelete: () => void; -} - -export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { - return ( - - Danger zone - - - - - Delete account - - Permanently delete this account and all its data. This cannot be undone. - - - - - - - - - - ); -} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.test.ts new file mode 100644 index 00000000000..dec69b04073 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { userProfileDeleteSectionMachine } from './user-profile-delete-section.machine'; + +function start(deleteAccount: () => Promise) { + const actor = createActor(userProfileDeleteSectionMachine, { context: { deleteAccount } }).start(); + actor.send({ type: 'OPEN' }); + return actor; +} + +describe('userProfileDeleteSectionMachine', () => { + it('finishes in deleted when the account goes', async () => { + const actor = start(() => Promise.resolve()); + actor.send({ type: 'CONFIRM' }); + expect(actor.getSnapshot().value).toBe('deleting'); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('deleted')); + expect(actor.getSnapshot().status).toBe('done'); + }); + + it('returns to confirming with the reason when the delete fails', async () => { + const actor = start(() => Promise.reject(new Error('Your subscription is still active.'))); + actor.send({ type: 'CONFIRM' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('confirming')); + expect(actor.getSnapshot().context.errorMessage).toBe('Your subscription is still active.'); + }); + + it('falls back to generic copy when the rejection is not an Error', async () => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test + const actor = start(() => Promise.reject('nope')); + actor.send({ type: 'CONFIRM' }); + + await vi.waitFor(() => + expect(actor.getSnapshot().context.errorMessage).toBe('Something went wrong. Please try again.'), + ); + }); + + it('drops the error when the dialog is cancelled', async () => { + const actor = start(() => Promise.reject(new Error('nope'))); + actor.send({ type: 'CONFIRM' }); + await vi.waitFor(() => expect(actor.getSnapshot().context.errorMessage).toBe('nope')); + + actor.send({ type: 'CANCEL' }); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.errorMessage).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.ts b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.ts new file mode 100644 index 00000000000..0e792a1e968 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.ts @@ -0,0 +1,51 @@ +import { setup } from '../../machine/setup'; + +export interface UserProfileDeleteSectionContext { + /** Deletes the account. Injected by the view from its `onDelete` prop. */ + deleteAccount: () => Promise; + /** Why the last attempt failed. */ + errorMessage: string | undefined; +} + +export type UserProfileDeleteSectionEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' }; + +const { createMachine, assign, fromPromise } = setup(); + +/** + * The delete-account flow. `deleting` is the state that decides the dialog's fate: the + * account is gone on success, so the machine finishes in `deleted` and never reopens, + * while a failure drops back to `confirming` with the reason to render. + * + * The typed confirmation phrase is not here. It is a half-formed string that only the + * `Destructive` block can use, so the block keeps it. + */ +export const userProfileDeleteSectionMachine = createMachine({ + id: 'deleteAccount', + initial: 'idle', + context: { + deleteAccount: async () => {}, + errorMessage: undefined, + }, + states: { + idle: { on: { OPEN: 'confirming' } }, + confirming: { + on: { + CONFIRM: 'deleting', + CANCEL: { target: 'idle', actions: assign(() => ({ errorMessage: undefined })) }, + }, + }, + deleting: { + invoke: fromPromise(context => context.deleteAccount(), { + onDone: 'deleted', + onError: { + target: 'confirming', + actions: assign((_, event) => ({ + errorMessage: + event.error instanceof Error ? event.error.message : 'Something went wrong. Please try again.', + })), + }, + }), + }, + deleted: { type: 'final' }, + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts new file mode 100644 index 00000000000..bbf8edc34eb --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts @@ -0,0 +1,32 @@ +/** + * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so + * localizing this component is a matter of registering the namespace and swapping the reads for + * `useMessages('userButton', userButtonBase)`, not of hunting the literals down first. + * + * A plural message is its forms, the way `count()` takes them; a parameterized one is its template, + * the way `params()` takes it. `plural` and `fill` below resolve them until that layer lands. + */ +export const userProfileDeleteSectionBase = { + sectionTitle: 'Danger zone', + sectionLabel: 'Delete account', + sectionDescription: 'Permanently delete this account and all its data. This cannot be undone.', + dialogTitle: 'Delete account?', + dialogDescription: 'Are you sure you want to delete your account? All of your data will be permanently deleted.', + fieldLabel: 'Type “{phrase}” below to continue', + fieldPlaceholder: 'Delete account', + actionLabel: 'Delete account', + cancelLabel: 'Cancel', +}; + +/** Substitutes `{name}`-style placeholders. Replaced by the localization layer's own formatter. */ +export function fill(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)); +} + +/** + * Picks a plural form and fills `{count}`. English has the two forms below; the localization layer + * selects across all six categories with `Intl.PluralRules`. + */ +export function plural(forms: { one: string; other: string }, count: number): string { + return fill(count === 1 ? forms.one : forms.other, { count }); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx new file mode 100644 index 00000000000..154b424c421 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -0,0 +1,60 @@ +import { Destructive } from '../../blocks/destructive'; +import { Button } from '../../components/button'; +import { Section } from '../../components/section'; +import { useMachine } from '../../machine/useMachine'; +import { userProfileDeleteSectionMachine } from './user-profile-delete-section.machine'; +import { fill, userProfileDeleteSectionBase as m } from './user-profile-delete-section.messages'; + +export interface UserProfileDeleteSectionViewProps { + /** + * Deletes the account. Resolve and the confirmation dialog closes; reject with an `Error` + * and it stays open with that message under the confirmation field. + */ + onDelete: () => Promise; +} + +export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { + const [snapshot, send] = useMachine(userProfileDeleteSectionMachine, { + context: { deleteAccount: onDelete }, + }); + + return ( + + {m.sectionTitle} + + + + + {m.sectionLabel} + {m.sectionDescription} + + + send({ type: open ? 'OPEN' : 'CANCEL' })} + trigger={ + + } + title={m.dialogTitle} + description={m.dialogDescription} + fieldLabel={fill(m.fieldLabel, { phrase: m.fieldPlaceholder })} + confirmationValue={m.fieldPlaceholder} + actionLabel={m.actionLabel} + cancelLabel={m.cancelLabel} + onDelete={() => send({ type: 'CONFIRM' })} + isDeleting={snapshot.value === 'deleting'} + errorMessage={snapshot.context.errorMessage} + /> + + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 5e00b16a71b..9d2dca6376e 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -11,12 +11,12 @@ import type { import { UserProfileAccountSectionView } from './user-profile-account-section.view'; import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view'; import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; -import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; +import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; import { styles } from './user-profile-profile-panel.styles'; import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; -export type { UserProfileEmail, UserProfilePhone, UserProfileConnectedAccount, UserProfileWeb3Wallet }; +export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet }; export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { connectedAccounts?: UserProfileConnectedAccount[]; @@ -28,7 +28,8 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect onManageWeb3Wallet?: (id: string) => void; onSetPrimaryWeb3Wallet?: (id: string) => void; onRemoveWeb3Wallet?: (id: string) => void; - onDeleteAccount?: () => void; + /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ + onDeleteAccount?: () => Promise; } export function UserProfileProfilePanelView({ diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx index 2ed2b1deadf..d4314011615 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx @@ -8,7 +8,7 @@ import type { UserProfileDevice, } from './user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; -import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; +import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; @@ -30,7 +30,8 @@ export interface UserProfileSecurityPanelViewProps extends Omit void; onRegenerateBackupCodes?: () => void; onRemoveMfaMethod?: (id: string) => void; - onDeleteAccount?: () => void; + /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ + onDeleteAccount?: () => Promise; } export function UserProfileSecurityPanelView({ From 11bab1ef6dc0351ffa04baf93db02c32b464136c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Aug 2026 20:34:03 -0400 Subject: [PATCH 2/6] fix(swingset): Remount the danger zone story after a delete Deleting is terminal, so the trigger was dead once the story ran. --- .../user-profile-delete-section.stories.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx index b4f1c8b377c..864ce525c33 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -1,4 +1,5 @@ import { UserProfileDeleteSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view'; +import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -17,7 +18,21 @@ export const meta: StoryMeta = { const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export function Default() { - return settleAfter(2000)} />; + const [runId, setRunId] = useState(0); + + // Deleting is terminal: the real flow signs the user out and the section goes away with the + // page. Nothing unmounts it here, so the story remounts it to make the demo repeatable. + const handleDelete = async () => { + await settleAfter(2000); + setRunId(current => current + 1); + }; + + return ( + + ); } export function WithError() { From 2a47dfe794dbc844f63e0db25abfc8c406329b16 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Aug 2026 20:39:23 -0400 Subject: [PATCH 3/6] refactor(ui): Narrow Destructive copy props to string Match Kumo's prop docs. description renders in Dialog.Description, so a node has no place there. --- packages/swingset/src/stories/destructive.mdx | 28 +++++------ .../mosaic/blocks/destructive/destructive.tsx | 50 ++++++++----------- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx index ab0987e0ef6..6f92a80c73a 100644 --- a/packages/swingset/src/stories/destructive.mdx +++ b/packages/swingset/src/stories/destructive.mdx @@ -62,20 +62,20 @@ 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` | `ReactNode` | Names what is about to be destroyed. | -| `description` | `ReactNode` | Spells out what is lost. Sits above the confirmation field. | -| `fieldLabel` | `ReactNode` | Labels the confirmation field. | -| `confirmationValue` | `string` | The phrase the user has to type back. Also the field's placeholder. | -| `actionLabel` | `ReactNode` | The destructive button's label. | -| `cancelLabel` | `ReactNode` | Optional. Defaults to `Cancel`. | -| `onDelete` | `() => void` | Asks the caller to run the action. Only reachable once the typed phrase matches. | -| `isDeleting` | `boolean` | Optional. Disables the field and renders the action pending. | -| `errorMessage` | `string` | Optional. Marks the field invalid and renders under it. | +| 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 be destroyed. | +| `description` | `string` | Spells out what is lost. Sits above the confirmation field. | +| `fieldLabel` | `string` | Labels the confirmation field. | +| `confirmationValue` | `string` | The phrase the user has to type back. Also the field's placeholder. | +| `actionLabel` | `string` | The destructive button's label. | +| `cancelLabel` | `string` | Optional. Defaults to `Cancel`. | +| `onDelete` | `() => void` | Asks the caller to run the action. Reached by the button or by Enter in the field, once the typed phrase matches. | +| `isDeleting` | `boolean` | Optional. Disables the field and renders the action pending. | +| `errorMessage` | `string` | Optional. Marks the field invalid and renders under it. | ## Driving it from a machine diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx index a5e294b64e7..b2902669272 100644 --- a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx +++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx @@ -1,4 +1,4 @@ -import type { FormEvent, ReactNode } from 'react'; +import type { FormEvent } from 'react'; import { useEffect, useId, useState } from 'react'; import { Button, SubmitButton } from '../../components/button'; @@ -11,44 +11,38 @@ import { Input } from '../../components/input'; import { Text } from '../../components/text'; export interface DestructiveProps { - /** Whether the confirmation is showing. Controlled, the way any dialog is. */ + /** Whether the dialog is open */ open: boolean; + /** Callback when open state changes */ onOpenChange: (open: boolean) => void; - /** Renders the button that asks to open the dialog. Omit to open it some other way. */ + /** Element that opens the dialog */ trigger?: DialogProps['trigger']; - /** Names what is about to be destroyed, e.g. `Delete account?`. */ - title: ReactNode; - /** Spells out what is lost. Sits above the confirmation field. */ - description: ReactNode; - /** Labels the confirmation field, e.g. `Type “Delete account” below to continue`. */ - fieldLabel: ReactNode; - /** - * The phrase the user has to type back. Also the field's placeholder. The block holds - * what is typed and compares it, so no flow has to carry a keystroke through a machine - * to find out whether two strings match. - */ + /** Dialog heading */ + title: string; + /** What the action destroys */ + description: string; + /** Label above the confirmation input */ + fieldLabel: string; + /** Phrase the user must type to confirm. Also the input's placeholder */ confirmationValue: string; - actionLabel: ReactNode; - /** @default 'Cancel' */ - cancelLabel?: ReactNode; - /** - * Asks the caller to run the action. Reached by pressing the action or by Enter in the - * confirmation field, and only once the typed phrase matches. - */ + /** Text of the delete button */ + actionLabel: string; + /** Text of the cancel button (default: "Cancel") */ + cancelLabel?: string; + /** Callback when delete is confirmed, by button or by Enter */ onDelete: () => void; - /** Keeps the dialog inert and the action pending while the caller works. */ + /** Whether the delete action is in progress */ isDeleting?: boolean; - /** Why the last attempt failed. Marks the field invalid and renders under it. */ + /** Error message to display if the delete action fails */ errorMessage?: string; } /** - * Type-to-confirm dialog for an action that cannot be undone: the destructive button stays - * inert until the typed phrase matches `confirmationValue`. + * Type-to-confirm dialog for an action that cannot be undone. The delete button stays inert + * until the typed phrase matches `confirmationValue`. * - * The block owns one thing, the typed phrase, because nothing outside it can use a - * half-typed string. Everything with a consequence stays with the caller: `open` closes the - * dialog, `isDeleting` marks it busy, `error` explains a failure. + * Controlled: the caller owns `open`, `isDeleting`, and `errorMessage`. The block holds only + * the typed phrase. * * @example * Date: Tue, 25 Aug 2026 20:39:57 -0400 Subject: [PATCH 4/6] fix(swingset): Collapse the Blocks sidebar group by default --- packages/swingset/src/components/app-sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 2f18c0d7c2b..c8a9cbcf053 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -24,7 +24,7 @@ import { getSidebarGroups } from '@/lib/registry'; const groups = getSidebarGroups(); -const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']); +const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks']); type SidebarEntry = ReturnType[number]['components'][number]; From ec10bf4e9263573bd594011ce761c425a3caa70e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Aug 2026 20:52:32 -0400 Subject: [PATCH 5/6] wrap pretty --- packages/ui/src/mosaic/components/field/field.styles.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/mosaic/components/field/field.styles.ts b/packages/ui/src/mosaic/components/field/field.styles.ts index 4022ee98189..ce762ce4eb3 100644 --- a/packages/ui/src/mosaic/components/field/field.styles.ts +++ b/packages/ui/src/mosaic/components/field/field.styles.ts @@ -23,6 +23,7 @@ export const styles = stylex.create({ alignItems: 'flex-start', color: colorVars['--cl-color-negative'], display: 'flex', + textWrap: 'pretty', }, errorIcon: { flexShrink: 0, From 624bf929f6cd01dce8b9dbc849b28ed5bfeb268f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 26 Aug 2026 11:58:16 -0400 Subject: [PATCH 6/6] refactor(ui): Rename the delete-account machine to a controller The controller is the layer that owns a section's flow state. It may be a state machine, as here, or plain React state when the flow is simpler. The view now reads plain props from `useUserProfileDeleteSectionController` and no longer knows which. --- ...profile-delete-section.controller.test.ts} | 32 ++++++++++++++- ...user-profile-delete-section.controller.ts} | 41 +++++++++++++++++++ .../user-profile-delete-section.view.tsx | 17 ++++---- 3 files changed, 80 insertions(+), 10 deletions(-) rename packages/ui/src/mosaic/user-profile/user-profile-delete-section/{user-profile-delete-section.machine.test.ts => user-profile-delete-section.controller.test.ts} (62%) rename packages/ui/src/mosaic/user-profile/user-profile-delete-section/{user-profile-delete-section.machine.ts => user-profile-delete-section.controller.ts} (52%) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.test.ts similarity index 62% rename from packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.test.ts rename to packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.test.ts index dec69b04073..3485de0a567 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.test.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.test.ts @@ -1,7 +1,11 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../machine/createActor'; -import { userProfileDeleteSectionMachine } from './user-profile-delete-section.machine'; +import { + userProfileDeleteSectionMachine, + useUserProfileDeleteSectionController, +} from './user-profile-delete-section.controller'; function start(deleteAccount: () => Promise) { const actor = createActor(userProfileDeleteSectionMachine, { context: { deleteAccount } }).start(); @@ -48,3 +52,29 @@ describe('userProfileDeleteSectionMachine', () => { expect(actor.getSnapshot().context.errorMessage).toBeUndefined(); }); }); + +describe('useUserProfileDeleteSectionController', () => { + it('holds the dialog open across confirming and deleting', async () => { + const { result } = renderHook(() => useUserProfileDeleteSectionController({ onDelete: () => Promise.resolve() })); + expect(result.current.isOpen).toBe(false); + + act(() => result.current.onOpenChange(true)); + expect(result.current.isOpen).toBe(true); + expect(result.current.isDeleting).toBe(false); + + act(() => result.current.onConfirm()); + expect(result.current.isOpen).toBe(true); + expect(result.current.isDeleting).toBe(true); + + await waitFor(() => expect(result.current.isOpen).toBe(false)); + }); + + it('cancels on close', () => { + const { result } = renderHook(() => useUserProfileDeleteSectionController({ onDelete: () => Promise.resolve() })); + act(() => result.current.onOpenChange(true)); + + act(() => result.current.onOpenChange(false)); + + expect(result.current.isOpen).toBe(false); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.ts b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts similarity index 52% rename from packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.ts rename to packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts index 0e792a1e968..693f24fb8e4 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.machine.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.controller.ts @@ -1,4 +1,5 @@ import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/useMachine'; export interface UserProfileDeleteSectionContext { /** Deletes the account. Injected by the view from its `onDelete` prop. */ @@ -49,3 +50,43 @@ export const userProfileDeleteSectionMachine = createMachine({ deleted: { type: 'final' }, }, }); + +export interface UserProfileDeleteSectionControllerOptions { + /** + * Deletes the account. Resolve and the flow finishes; reject with an `Error` and it returns to + * the confirmation step with that message. + */ + onDelete: () => Promise; +} + +export interface UserProfileDeleteSectionController { + /** Whether the confirmation dialog is open */ + isOpen: boolean; + /** Opens or closes the confirmation dialog */ + onOpenChange: (open: boolean) => void; + /** Starts the delete */ + onConfirm: () => void; + /** Whether the delete is in progress */ + isDeleting: boolean; + /** Why the last attempt failed */ + errorMessage: string | undefined; +} + +/** + * Drives the delete-account flow and hands the view plain props. A machine backs it because the + * flow has an async step, an error path back to a previous step, and a terminal state that must + * never reopen. A simpler section is free to hold its state in `useState`; the view cannot tell. + */ +export function useUserProfileDeleteSectionController({ + onDelete, +}: UserProfileDeleteSectionControllerOptions): UserProfileDeleteSectionController { + const [snapshot, send] = useMachine(userProfileDeleteSectionMachine, { context: { deleteAccount: onDelete } }); + + return { + isOpen: snapshot.value === 'confirming' || snapshot.value === 'deleting', + onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), + onConfirm: () => send({ type: 'CONFIRM' }), + isDeleting: snapshot.value === 'deleting', + errorMessage: snapshot.context.errorMessage, + }; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index 154b424c421..fafba9c90e8 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -1,8 +1,7 @@ import { Destructive } from '../../blocks/destructive'; import { Button } from '../../components/button'; import { Section } from '../../components/section'; -import { useMachine } from '../../machine/useMachine'; -import { userProfileDeleteSectionMachine } from './user-profile-delete-section.machine'; +import { useUserProfileDeleteSectionController } from './user-profile-delete-section.controller'; import { fill, userProfileDeleteSectionBase as m } from './user-profile-delete-section.messages'; export interface UserProfileDeleteSectionViewProps { @@ -14,8 +13,8 @@ export interface UserProfileDeleteSectionViewProps { } export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { - const [snapshot, send] = useMachine(userProfileDeleteSectionMachine, { - context: { deleteAccount: onDelete }, + const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({ + onDelete, }); return ( @@ -30,8 +29,8 @@ export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSect send({ type: open ? 'OPEN' : 'CANCEL' })} + open={isOpen} + onOpenChange={onOpenChange} trigger={