Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wild-mangos-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 30 additions & 5 deletions .claude/skills/mosaic/references/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Form onSubmit={() => send({ type: 'SUBMIT' })}>
<Input
value={snapshot.context.name}
disabled={snapshot.value === 'saving'}
onChange={event => send({ type: 'TYPE_NAME', value: event.target.value })}
/>
<SubmitButton
isPending={snapshot.value === 'saving'}
disabled={!canSubmit}
>
Save
</SubmitButton>
</Form>
```

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
<Destructive
open={snapshot.value === 'confirming' || snapshot.value === 'deleting'}
resourceName={snapshot.context.organizationName}
confirmationValue={snapshot.context.confirmationValue}
onConfirmationValueChange={value => send({ type: 'TYPE_CONFIRMATION', value })}
onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })}
trigger={<Button color='negative'>Delete organization</Button>}
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}
/>
```

Expand Down
3 changes: 3 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
'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')),
Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getSidebarGroups>[number]['components'][number];

Expand Down Expand Up @@ -167,7 +167,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarContent className='gap-0'>
{groups.map(({ group, groupSlug, components }) => (
<React.Fragment key={group}>
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
{group === 'Blocks' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
<Collapsible
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
className='group/collapsible'
Expand Down
15 changes: 15 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import {
meta as cardComponentMeta,
} from '../stories/card.component.stories';
import { meta as collapsibleMeta } from '../stories/collapsible.stories';
import {
Default as DestructiveDefault,
meta as destructiveMeta,
WithError as DestructiveWithError,
} from '../stories/destructive.stories';
import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories';
import { meta as dialogMeta } from '../stories/dialog.stories';
import { meta as drawerMeta } from '../stories/drawer.stories';
Expand Down Expand Up @@ -132,6 +137,7 @@ import {
import {
Default as UserProfileDeleteSectionDefault,
meta as userProfileDeleteSectionMeta,
WithError as UserProfileDeleteSectionWithError,
} from '../stories/user-profile-delete-section.stories';
import {
Default as UserProfileMfaSectionDefault,
Expand Down Expand Up @@ -352,6 +358,13 @@ const userProfileWeb3WalletsSectionModule: StoryModule = {
const userProfileDeleteSectionModule: StoryModule = {
meta: userProfileDeleteSectionMeta,
Default: UserProfileDeleteSectionDefault,
WithError: UserProfileDeleteSectionWithError,
};

const destructiveModule: StoryModule = {
meta: destructiveMeta,
Default: DestructiveDefault,
WithError: DestructiveWithError,
};

export const registry: StoryModule[] = [
Expand All @@ -376,6 +389,8 @@ export const registry: StoryModule[] = [
userProfileConnectedAccountsSectionModule,
userProfileWeb3WalletsSectionModule,
userProfileDeleteSectionModule,
// Blocks — flows assembled from components, wired by the caller's machine.
destructiveModule,
// Components
avatarModule,
badgeModule,
Expand Down
93 changes: 93 additions & 0 deletions packages/swingset/src/stories/destructive.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as Stories from './destructive.stories';

# Destructive

A type-to-confirm dialog for an action that cannot be undone. The action stays inert until the user types the confirmation phrase back.

## Example

<Story
name='Default'
storyModule={Stories}
composition={[
{ name: 'Dialog', href: '/components/dialog', layer: 'Components' },
{ name: 'Card', href: '/components/card', layer: 'Components' },
{ name: 'Field', href: '/components/field', layer: 'Components' },
{ name: 'Button', href: '/components/button', layer: 'Components' },
]}
/>

## 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);
Comment thread
alexcarpenter marked this conversation as resolved.
const [isDeleting, setIsDeleting] = useState(false);

const handleDelete = async () => {
setIsDeleting(true);
await deleteAccount();
setIsDeleting(false);
setOpen(false);
};

<Destructive
open={open}
onOpenChange={setOpen}
trigger={<Button color='negative' variant='outline'>Delete account</Button>}
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}
/>;
Comment thread
alexcarpenter marked this conversation as resolved.
```

## 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.

<Story
name='WithError'
storyModule={Stories}
/>

## 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 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. |
Comment thread
alexcarpenter marked this conversation as resolved.

## 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
<Destructive
open={snapshot.value === 'confirming' || snapshot.value === 'deleting'}
onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })}
onDelete={() => send({ type: 'CONFIRM' })}
isDeleting={snapshot.value === 'deleting'}
errorMessage={snapshot.context.errorMessage}
{...copy}
/>
```
103 changes: 103 additions & 0 deletions packages/swingset/src/stories/destructive.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Story>` 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<void>(resolve => setTimeout(resolve, ms));

const trigger = (
<Button
color='negative'
variant='outline'
>
Delete account
</Button>
);

/**
* 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);
};
Comment thread
alexcarpenter marked this conversation as resolved.

return (
<Destructive
open={open}
onOpenChange={setOpen}
trigger={trigger}
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}
/>
);
}

/**
* 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<string | undefined>(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 (
<Destructive
open={open}
onOpenChange={handleOpenChange}
trigger={trigger}
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}
errorMessage={errorMessage}
/>
);
}
4 changes: 2 additions & 2 deletions packages/swingset/src/stories/user-page.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function Default() {
isVerified: true,
},
]),
onDeleteAccount: () => undefined,
onDeleteAccount: () => Promise.resolve(),
onEditProfilePicture: () => undefined,
onManageEmail: () => undefined,
onManagePhone: () => undefined,
Expand Down Expand Up @@ -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: () =>
Expand Down
7 changes: 7 additions & 0 deletions packages/swingset/src/stories/user-profile-delete-section.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Story
name='WithError'
storyModule={Stories}
/>
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
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 { useState } from 'react';

import type { StoryMeta } from '@/lib/types';

Expand All @@ -9,9 +10,38 @@ 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<void>(resolve => setTimeout(resolve, ms));

export function Default() {
return <UserProfileDeleteSectionView onDelete={() => undefined} />;
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 (
<UserProfileDeleteSectionView
key={runId}
onDelete={handleDelete}
/>
);
}

export function WithError() {
return (
<UserProfileDeleteSectionView
onDelete={async () => {
await settleAfter(2000);
throw new Error('Your subscription is still active. Cancel it before you delete your account.');
}}
/>
);
}
Loading
Loading