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
11 changes: 11 additions & 0 deletions apps/design-system/__registry__/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"sheet-confirm-on-close-demo": {
name: "sheet-confirm-on-close-demo",
type: "components:example",
registryDependencies: ["alert-dialog","button","input","label","separator","sheet"],
component: React.lazy(() => import("@/registry/default/example/sheet-confirm-on-close-demo")),
source: "",
files: ["registry/default/example/sheet-confirm-on-close-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"sheet-demo": {
name: "sheet-demo",
type: "components:example",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Use Confirmation Modal when the user needs extra context to make a decision, suc

If the confirmation can be expressed as a single short paragraph, use [Alert Dialog](../components/alert-dialog). If the action is highly destructive and requires explicit typed intent, use [Text Confirm Dialog](../fragments/text-confirm-dialog). See [Modality](../ui-patterns/modality) for broader guidance on choosing the appropriate pattern.

For dirty-form dismissal in dialogs/sheets, prefer the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) rather than creating new local `CloseConfirmationModal` wrappers. The ad-hoc `CloseConfirmationModal` wrapper pattern is deprecated for new implementations.
For dirty-form dismissal in dialogs/sheets, use the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) instead of `ConfirmationModal`. Avoid creating custom wrapper components for this flow; wire `modalProps` from `useConfirmOnClose` directly into `DiscardChangesConfirmationDialog`.

<ComponentPreview name="confirmation-modal-demo" peekCode wide />

Expand Down
78 changes: 50 additions & 28 deletions apps/design-system/content/docs/ui-patterns/modality.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,6 @@ We have two main ways of handling modality:

As a general rule: use dialogs for short, focused tasks and use sheets for longer forms or more detailed views.

### Dirty form dismissal pattern

When a dialog or sheet contains a form, users should generally be allowed to attempt dismissal via all normal affordances (backdrop click, Escape key, close icon, and footer `Cancel` button).

If the form is clean, close immediately. If the form has unsaved changes, show a discard-confirmation dialog instead of closing immediately.

This pattern is implemented in Studio with `useConfirmOnClose` plus `DiscardChangesConfirmationDialog`.

- **Prompt on footer `Cancel` too:** If a button is labeled `Cancel`, users expect it to stop the current action, not silently discard edits. Prompting keeps behavior consistent with backdrop/Escape dismissal and prevents accidental loss.
- **Use explicit labels for no-prompt discard:** If you intentionally want a one-click destructive exit, label the action `Discard` (or `Discard changes`) rather than `Cancel`.
- **Guard close attempts, not unmounts:** This pattern should intercept controlled modal/sheet close attempts (`onOpenChange`, close buttons, footer actions). It should not attempt to block route changes or arbitrary component unmounts.

## Dialogs

Dialogs are centered overlays used for short, focused tasks. All dialogs should follow these best practices:
Expand All @@ -53,22 +41,6 @@ There are quite a few dialog components, each suited to a different task or cont

<ComponentPreview name="alert-dialog-demo" />

#### Discard changes confirmation dialog (pattern)

For dirty form dismissal, use a short discard-confirmation dialog after a close attempt instead of disabling dismissal entirely.

- The primary form remains in a `Dialog` or `Sheet` (dismissible).
- Closing is intercepted only when the form is dirty.
- The follow-up confirmation is an `AlertDialog` pattern (`DiscardChangesConfirmationDialog` in Studio).

Typical flow:

1. User attempts to close the dialog/sheet (backdrop, Escape, close icon, or `Cancel`)
2. If the form is clean, close immediately
3. If the form is dirty, show discard confirmation
4. `Keep editing` returns to the form
5. `Discard changes` closes and resets the form

#### Text Confirm Dialog

[Text Confirm Dialog](../fragments/text-confirm-dialog) adds a deliberate speed bump for highly destructive actions by requiring the user to type an exact confirmation string before proceeding. The confirm action remains disabled until the input matches.
Expand Down Expand Up @@ -102,3 +74,53 @@ Sheets are dialogs presented as side panels. Use them for content that is larger
[Sheet](../components/sheet) is modal by default, blocking interaction with the underlying page.

<ComponentPreview name="sheet-demo" />

## Best practices

### Dirty form dismissal

When a dialog or sheet contains a form, keep all normal dismissal affordances enabled (backdrop click, Escape key, close icon, and footer `Cancel` button).

Decision flow:

1. User attempts to close the dialog/sheet.
2. If the form is clean, close immediately.
3. If the form is dirty, show a discard-confirmation dialog.
4. `Keep editing` returns to the form.
5. `Discard changes` closes and resets the form.

Implementation checklist:

- Intercept close attempts from `onOpenChange`.
- Route footer `Cancel` through the same close guard.
- Render a separate discard confirmation dialog when dirty.
- Keep `Cancel` non-destructive; use `Discard`/`Discard changes` for one-click destructive exits.
- Guard controlled close attempts only; do not try to block route changes or arbitrary unmounts.

Studio implementation (preferred in Studio code):

```tsx
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'

const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
checkIsDirty: () => form.formState.isDirty,
onClose,
})

<Sheet open={visible} onOpenChange={handleOpenChange}>
...
<Button type="default" onClick={confirmOnClose}>
Cancel
</Button>
...
<DiscardChangesConfirmationDialog {...modalProps} />
</Sheet>
```

Generic implementation (outside Studio):

- If Studio-only helpers are unavailable, recreate the same behavior with `AlertDialog`.
- The demo below shows the same flow and API shape (`confirmOnClose`, `handleOpenChange`, `modalProps`).

<ComponentPreview name="sheet-confirm-on-close-demo" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Input_Shadcn_ as Input,
Label_Shadcn_ as Label,
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'

interface EndpointValues {
endpointUrl: string
secretHeader: string
}

interface ConfirmOnCloseModalProps {
visible: boolean
onClose: () => void
onCancel: () => void
}

const defaultValues: EndpointValues = {
endpointUrl: '',
secretHeader: '',
}

const useConfirmOnClose = ({
checkIsDirty,
onClose,
}: {
checkIsDirty: () => boolean
onClose: () => void
}) => {
const [visible, setVisible] = useState(false)

const confirmOnClose = useCallback(() => {
if (checkIsDirty()) {
setVisible(true)
return
}

onClose()
}, [checkIsDirty, onClose])

const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
confirmOnClose()
}
},
[confirmOnClose]
)

const onConfirm = useCallback(() => {
setVisible(false)
onClose()
}, [onClose])

const onCancel = useCallback(() => {
setVisible(false)
}, [])

const modalProps: ConfirmOnCloseModalProps = useMemo(
() => ({
visible,
onClose: onConfirm,
onCancel,
}),
[visible, onConfirm, onCancel]
)

return {
confirmOnClose,
handleOpenChange,
modalProps,
}
}

const DiscardChangesAlertDialog = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => {
const isConfirmingRef = useRef(false)

useEffect(() => {
if (visible) {
isConfirmingRef.current = false
}
}, [visible])

const handleConfirm = useCallback(() => {
isConfirmingRef.current = true
onClose()
}, [onClose])

const handleOpenChange = useCallback(
(open: boolean) => {
if (open) return

if (isConfirmingRef.current) {
isConfirmingRef.current = false
return
}

onCancel()
},
[onCancel]
)

return (
<AlertDialog open={visible} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
Any unsaved changes to this endpoint will be lost.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction variant="danger" onClick={handleConfirm}>
Discard changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

export default function SheetConfirmOnCloseDemo() {
const [open, setOpen] = useState(false)
const [savedValues, setSavedValues] = useState<EndpointValues>(defaultValues)
const [draftValues, setDraftValues] = useState<EndpointValues>(defaultValues)

const isDirty = useMemo(
() =>
draftValues.endpointUrl !== savedValues.endpointUrl ||
draftValues.secretHeader !== savedValues.secretHeader,
[draftValues, savedValues]
)

const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose: () => {
setDraftValues(savedValues)
setOpen(false)
},
})

const openSheet = () => {
setDraftValues(savedValues)
setOpen(true)
}

const saveChanges = () => {
setSavedValues(draftValues)
setOpen(false)
}

return (
<>
<Button type="default" onClick={openSheet}>
Open endpoint sheet
</Button>

<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Edit endpoint</SheetTitle>
</SheetHeader>
<Separator />
<SheetSection className="space-y-4">
<div className="space-y-2">
<Label htmlFor="endpoint-url">Endpoint URL</Label>
<Input
id="endpoint-url"
value={draftValues.endpointUrl}
placeholder="https://api.example.com/webhooks/supabase"
onChange={(event) =>
setDraftValues((current) => ({
...current,
endpointUrl: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="secret-header">Secret header</Label>
<Input
id="secret-header"
value={draftValues.secretHeader}
placeholder="Bearer top-secret-value"
onChange={(event) =>
setDraftValues((current) => ({
...current,
secretHeader: event.target.value,
}))
}
/>
</div>
</SheetSection>
<Separator />
<SheetFooter>
<Button type="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button onClick={saveChanges} disabled={!isDirty}>
Save changes
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<DiscardChangesAlertDialog {...modalProps} />
</>
)
}
6 changes: 6 additions & 0 deletions apps/design-system/registry/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,12 @@ export const examples: Registry = [
registryDependencies: ['separator'],
files: ['example/separator-demo.tsx'],
},
{
name: 'sheet-confirm-on-close-demo',
type: 'components:example',
registryDependencies: ['alert-dialog', 'button', 'input', 'label', 'separator', 'sheet'],
files: ['example/sheet-confirm-on-close-demo.tsx'],
},
{
name: 'sheet-demo',
type: 'components:example',
Expand Down
6 changes: 4 additions & 2 deletions apps/docs/components/GuidesSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc'
import { Feedback } from '~/components/Feedback'
import { useTocAnchors } from '../features/docs/GuidesMdx.state'
import { Chatgpt } from 'icons'
import { Claude } from 'icons'

interface TOCHeader {
id?: string
Expand Down Expand Up @@ -61,7 +63,7 @@ function AiTools({ className }: { className?: string }) {
rel="noreferrer noopener"
className="flex items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground transition-colors"
>
<ExternalLink size={14} strokeWidth={1.5} />
<Chatgpt size={14} strokeWidth={0} />
Ask ChatGPT
</a>
<a
Expand All @@ -70,7 +72,7 @@ function AiTools({ className }: { className?: string }) {
rel="noreferrer noopener"
className="flex items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground transition-colors"
>
<ExternalLink size={14} strokeWidth={1.5} />
<Claude size={14} strokeWidth={0} />
Ask Claude
</a>
</div>
Expand Down
Loading
Loading