-
Notifications
You must be signed in to change notification settings - Fork 474
feat(ui): add Confirmation block #9734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8056a5d
2f773f2
167ef9f
d33ec5a
5fdbf3e
c6fcf9c
4a5e7b9
ef18395
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import * as Stories from './confirmation.stories'; | ||
|
|
||
| # Confirmation | ||
|
|
||
| ## Example | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Use the required MDX section order. Line 5 starts an As per coding guidelines: “Playground / Props / Usage are mandatory and always in this order.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| <Story | ||
| name='Default' | ||
| storyModule={Stories} | ||
| composition={[ | ||
| { name: 'Dialog', href: '/components/dialog', layer: 'Components' }, | ||
| { name: 'Card', href: '/components/card', layer: 'Components' }, | ||
| { name: 'Banner', href: '/components/banner', layer: 'Components' }, | ||
| { name: 'Button', href: '/components/button', layer: 'Components' }, | ||
| ]} | ||
| /> | ||
|
|
||
| ## Usage | ||
|
|
||
| A confirmation for a destructive action that is worth a second look but not worth making the user type for. Removing a connected account, revoking a session, signing out everywhere. For the actions that do warrant typing, use [Destructive](/components/destructive). | ||
|
|
||
| The block holds nothing of its own. Everything that decides what the dialog does next belongs to the caller. `open` closes it, `isConfirming` marks it busy, `errorMessage` explains a failure. | ||
|
|
||
| ```tsx | ||
| import { Confirmation } from '@clerk/ui/mosaic/blocks/confirmation'; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| import { Button } from '@clerk/ui/mosaic/components/button'; | ||
| import { useState } from 'react'; | ||
|
|
||
| const [open, setOpen] = useState(false); | ||
| const [isConfirming, setIsConfirming] = useState(false); | ||
| const [errorMessage, setErrorMessage] = useState<string>(); | ||
|
|
||
| const handleConfirm = async () => { | ||
| setIsConfirming(true); | ||
| setErrorMessage(undefined); | ||
| try { | ||
| await removeConnectedAccount(); | ||
| setOpen(false); | ||
| } catch { | ||
| setErrorMessage('Google could not be removed. Please try again.'); | ||
| } finally { | ||
| setIsConfirming(false); | ||
| } | ||
| }; | ||
|
|
||
| <Confirmation | ||
| open={open} | ||
| onOpenChange={setOpen} | ||
| trigger={<Button color='negative' variant='outline'>Remove</Button>} | ||
| title='Remove connected account' | ||
| description='Google will be removed from this account. You will no longer be able to use this connected account and any dependent features will no longer work.' | ||
| actionLabel='Remove' | ||
| onConfirm={() => void handleConfirm()} | ||
| isConfirming={isConfirming} | ||
| errorMessage={errorMessage} | ||
| />; | ||
| ``` | ||
|
|
||
| ## Failure | ||
|
|
||
| A failed attempt leaves the dialog up. Pass the sentence the user should read as `errorMessage`, and clear it when the next attempt starts. The message renders as a banner between the description and the actions. | ||
|
|
||
| <Story | ||
| name='WithError' | ||
| storyModule={Stories} | ||
| /> | ||
|
|
||
| ## One block, many rows | ||
|
|
||
| A table of members has a Remove in every row, but it needs one confirmation, not one per row. Create a handle, mount the block once after the table, and open it from any row with the member it is about. The copy props take a function of that payload, and `onConfirm` receives it. The block owns `open`, the pending state, and the error: a resolved promise closes it, a rejected one keeps it open showing why. | ||
|
|
||
| ```tsx | ||
| const removeMember = Confirmation.createHandle<Member>(); | ||
|
|
||
| <Menu.Item color='negative' onClick={() => removeMember.open(member)}>Remove</Menu.Item> | ||
|
|
||
| <Confirmation | ||
| handle={removeMember} | ||
| title='Remove member' | ||
| description={member => <><strong>{member.name}</strong> will be removed from the organization.</>} | ||
| actionLabel='Remove' | ||
| onConfirm={member => api.removeMember(member.id)} | ||
| /> | ||
| ``` | ||
|
|
||
| Opened from a menu item, focus returns to that menu's trigger when the dialog closes. | ||
|
|
||
| <Story | ||
| name='WithHandle' | ||
| storyModule={Stories} | ||
| /> | ||
|
|
||
| ## Props | ||
|
|
||
| Controlled: | ||
|
|
||
| | Prop | Type | Default | Description | | ||
| | -------------- | ------------------------- | ------------ | ------------------------------------------------------------------------------ | | ||
| | `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | | ||
| | `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | | ||
| | `trigger` | `ReactNode` | — | The button that asks to open the dialog. | | ||
| | `title` | `string` | — (required) | Names what is about to happen. | | ||
| | `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | | ||
| | `actionLabel` | `string` | — (required) | The destructive button's label. | | ||
| | `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | | ||
| | `onConfirm` | `() => void` | — (required) | Asks the caller to run the action. | | ||
| | `isConfirming` | `boolean` | `false` | Renders the action pending and ignores further presses. | | ||
| | `errorMessage` | `string` | — | Renders as a negative banner above the actions. | | ||
|
|
||
| With a handle: | ||
|
|
||
| | Prop | Type | Default | Description | | ||
| | ------------- | ---------------------------------------------- | ------------ | -------------------------------------------------------------------------------------- | | ||
| | `handle` | `ConfirmationHandle<Payload>` | — (required) | From `Confirmation.createHandle<Payload>()`. `handle.open(payload)` opens the block. | | ||
| | `title` | `string \| (payload: Payload) => string` | — (required) | Names what is about to happen. | | ||
| | `description` | `ReactNode \| (payload: Payload) => ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | | ||
| | `actionLabel` | `string \| (payload: Payload) => string` | — (required) | The destructive button's label. | | ||
| | `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | | ||
| | `onConfirm` | `(payload: Payload) => Promise<void> \| void` | — (required) | Runs the action. Resolve to close; reject with an `Error` to keep it open showing why. | | ||
|
|
||
| ## Driving it from a machine | ||
|
|
||
| A section that wires the block to a state machine maps the machine's state onto the same props: | ||
|
|
||
| ```tsx | ||
| <Confirmation | ||
| open={snapshot.value === 'confirming' || snapshot.value === 'removing'} | ||
| onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })} | ||
| onConfirm={() => send({ type: 'CONFIRM' })} | ||
| isConfirming={snapshot.value === 'removing'} | ||
| errorMessage={snapshot.context.errorMessage} | ||
| {...copy} | ||
| /> | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| import { Confirmation } from '@clerk/ui/mosaic/blocks/confirmation'; | ||
| import { Button } from '@clerk/ui/mosaic/components/button'; | ||
| import React from 'react'; | ||
|
|
||
| import type { StoryMeta } from '@/lib/types'; | ||
|
|
||
| // Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example | ||
| // renders a code footer with its function's source. See `StoryModule.__source`. | ||
| export { default as __source } from './confirmation.stories?raw'; | ||
|
|
||
| export const meta: StoryMeta = { | ||
| group: 'Blocks', | ||
| status: 'wip', | ||
| title: 'Confirmation', | ||
| source: 'packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx', | ||
| }; | ||
|
|
||
| // A real removal is a network round trip. Without one the action never renders its pending | ||
| // state, so both stories wait before they settle. | ||
| const settleAfter = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)); | ||
|
|
||
| const trigger = ( | ||
| <Button | ||
| color='negative' | ||
| variant='outline' | ||
| > | ||
| Remove | ||
| </Button> | ||
| ); | ||
|
|
||
| /** | ||
| * The block holds nothing of its own. `open` closes it, `isConfirming` marks it busy, | ||
| * `errorMessage` explains a failure. | ||
| */ | ||
| export function Default() { | ||
| const [open, setOpen] = React.useState(false); | ||
| const [isConfirming, setIsConfirming] = React.useState(false); | ||
|
|
||
| const handleConfirm = async () => { | ||
| setIsConfirming(true); | ||
| await settleAfter(2000); | ||
| setIsConfirming(false); | ||
| setOpen(false); | ||
| }; | ||
|
|
||
| return ( | ||
| <Confirmation | ||
| open={open} | ||
| onOpenChange={setOpen} | ||
| trigger={trigger} | ||
| title='Remove connected account' | ||
| description='Google will be removed from this account. You will no longer be able to use this connected account and any dependent features will no longer work.' | ||
| actionLabel='Remove' | ||
| onConfirm={() => void handleConfirm()} | ||
| isConfirming={isConfirming} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * A failed attempt leaves the dialog up. Pass the sentence the user should read as | ||
| * `errorMessage`, and clear it when the next attempt starts. | ||
| */ | ||
| export function WithError() { | ||
| const [open, setOpen] = React.useState(false); | ||
| const [isConfirming, setIsConfirming] = React.useState(false); | ||
| const [errorMessage, setErrorMessage] = React.useState<string | undefined>(undefined); | ||
|
|
||
| const handleConfirm = async () => { | ||
| setErrorMessage(undefined); | ||
| setIsConfirming(true); | ||
| await settleAfter(2000); | ||
| setIsConfirming(false); | ||
| setErrorMessage('Google is your only way to sign in. Add a password or another account first.'); | ||
| }; | ||
|
|
||
| // The error belongs to the caller, so the caller drops it. Without this a reopened dialog | ||
| // still shows why the last attempt failed. | ||
| const handleOpenChange = (next: boolean) => { | ||
| setOpen(next); | ||
| if (!next) { | ||
| setErrorMessage(undefined); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <Confirmation | ||
| open={open} | ||
| onOpenChange={handleOpenChange} | ||
| trigger={trigger} | ||
| title='Remove connected account' | ||
| description='Google will be removed from this account. You will no longer be able to use this connected account and any dependent features will no longer work.' | ||
| actionLabel='Remove' | ||
| onConfirm={() => void handleConfirm()} | ||
| isConfirming={isConfirming} | ||
| errorMessage={errorMessage} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| interface ConnectedAccount { | ||
| id: string; | ||
| provider: string; | ||
| } | ||
|
|
||
| const removeAccount = Confirmation.createHandle<ConnectedAccount>(); | ||
|
|
||
| const describeRemoval = (account: ConnectedAccount) => ( | ||
| <> | ||
| <strong>{account.provider}</strong> will be removed from this account. You will no longer be able to use this | ||
| connected account and any dependent features will no longer work. | ||
| </> | ||
| ); | ||
|
|
||
| /** | ||
| * One block for many rows. `handle.open(account)` opens it with the account it is about, and the | ||
| * promise `onConfirm` returns closes it or explains the failure. The block owns the rest. | ||
| */ | ||
| export function WithHandle() { | ||
| const [accounts, setAccounts] = React.useState<ConnectedAccount[]>([ | ||
| { id: 'eac_1', provider: 'Google' }, | ||
| { id: 'eac_2', provider: 'GitHub' }, | ||
| { id: 'eac_3', provider: 'Microsoft' }, | ||
| ]); | ||
|
|
||
| const handleConfirm = async (account: ConnectedAccount) => { | ||
| await settleAfter(2000); | ||
| if (account.provider === 'Google') { | ||
| throw new Error('Google is your only way to sign in. Add a password or another account first.'); | ||
| } | ||
| setAccounts(current => current.filter(item => item.id !== account.id)); | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <ul style={{ display: 'grid', gap: 8, margin: 0, padding: 0, listStyle: 'none' }}> | ||
| {accounts.map(account => ( | ||
| <li | ||
| key={account.id} | ||
| style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }} | ||
| > | ||
| {account.provider} | ||
| <Button | ||
| color='negative' | ||
| variant='outline' | ||
| onClick={() => removeAccount.open(account)} | ||
| > | ||
| Remove | ||
| </Button> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| <Confirmation | ||
| handle={removeAccount} | ||
| title='Remove connected account' | ||
| description={describeRemoval} | ||
| actionLabel='Remove' | ||
| onConfirm={handleConfirm} | ||
| /> | ||
| </> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.