# Confirm

Asks a yes-or-no question from code: confirm() opens a confirmation dialog and resolves to true or false.

- **Import:** `import { ConfirmProvider } from "@booleanpress/ui/confirm"`
- **Radix Alert Dialog:** <https://www.radix-ui.com/primitives/docs/components/alert-dialog>
- **APG Alert Dialog:** <https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/>
- **Page:** <https://ui.booleanpress.com/components/confirm> · @booleanpress/ui 0.2.0

## Usage

Render `ConfirmProvider` once, inside `BooleanUIProvider`, around the part of the app that asks. Below it, `useConfirm()` returns `confirm`: call it from an event handler and await the answer. There is no dialog markup to write and no open state to keep.

```tsx
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"

function DeleteMailer({ onDelete }: { onDelete: () => void }) {
  const confirm = useConfirm()
  const remove = async () => {
    if (await confirm({ title: "Delete the Staging mailer?", description: "This cannot be undone.", tone: "destructive" })) {
      onDelete()
    }
  }
  return <Button variant="destructive" onClick={remove}>Delete mailer</Button>
}

export function App() {
  return (
    <ConfirmProvider>
      <DeleteMailer onDelete={() => {}} />
    </ConfirmProvider>
  )
}
```

`confirm(options)` takes `title` (the provider's `confirmTitle`, "Are you sure?", by default), `description`, `confirmLabel` and `cancelLabel` (the provider's `confirm` and `cancel`; `delete` for a destructive one), `tone` (`default` or `destructive`), `icon`, `defaultFocus`, `showCloseButton` and `returnFocusTo`. It resolves `true` when the person confirms and `false` for Cancel, the × or Escape.

Pass `onConfirm` to run the work inside the dialog: while its promise is pending the confirm button shows a spinner, Cancel and Escape wait, and the dialog stays open. Resolved, the dialog closes and `confirm()` resolves `true`; rejected, the dialog stays open with the error's message (an `Error`'s or a string's, or the provider's `actionFailed` when it has none), for another try or Cancel.

Confirmations asked while one is open wait their turn and open one after another. Each provider has its own queue, so a page may have several, and nothing is kept in module scope: it renders on the server. `useConfirm()` outside a provider throws. For a confirmation you write as markup, with your own content, use [Alert dialog](/components/alert-dialog); for a small one beside the button, [Confirm popup](/components/confirm-popup).

## Examples

### Basic

`await confirm()` with a title, a description and a confirm label; focus starts on Confirm.

```tsx
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"

function SendTest() {
  const confirm = useConfirm()
  const [result, setResult] = useState("")

  const send = async () => {
    const confirmed = await confirm({
      title: "Send a test email?",
      description: "A test message goes to admin@example.com through the Primary mailer.",
      confirmLabel: "Send",
    })
    setResult(confirmed ? "Test email sent." : "Nothing was sent.")
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <Button onClick={send}>Send test email</Button>
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {result}
      </p>
    </div>
  )
}

export default function ConfirmBasic() {
  return (
    <ConfirmProvider>
      <SendTest />
    </ConfirmProvider>
  )
}
```

### Destructive

`tone="destructive"`: a red Delete button, and focus starts on Cancel.

```tsx
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"

function DeleteMailer() {
  const confirm = useConfirm()
  const [deleted, setDeleted] = useState(false)

  const remove = async () => {
    const confirmed = await confirm({
      title: "Delete the Staging mailer?",
      description: "Emails queued for it stay in the log, but they are not sent. This cannot be undone.",
      tone: "destructive",
    })
    if (confirmed) setDeleted(true)
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <Button variant="destructive" onClick={remove} disabled={deleted}>
        Delete mailer
      </Button>
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {deleted ? "The Staging mailer was deleted." : ""}
      </p>
    </div>
  )
}

export default function ConfirmDestructive() {
  return (
    <ConfirmProvider>
      <DeleteMailer />
    </ConfirmProvider>
  )
}
```

### Custom labels and icon

Your own button labels, an icon before the description, and `defaultFocus="cancel"`.

```tsx
import { KeyRoundIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"

function RotateKey() {
  const confirm = useConfirm()
  const [rotated, setRotated] = useState(false)

  const rotate = async () => {
    const confirmed = await confirm({
      title: "Rotate the API key?",
      description: "Sites that use the current key stop sending until you paste the new one.",
      icon: <KeyRoundIcon />,
      confirmLabel: "Rotate key",
      cancelLabel: "Keep current key",
      defaultFocus: "cancel",
    })
    if (confirmed) setRotated(true)
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <Button variant="outline" onClick={rotate}>
        Rotate API key
      </Button>
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {rotated ? "A new key was created." : ""}
      </p>
    </div>
  )
}

export default function ConfirmCustom() {
  return (
    <ConfirmProvider>
      <RotateKey />
    </ConfirmProvider>
  )
}
```

### Async confirm

`onConfirm` returns a promise: the button shows a spinner and the dialog stays open until it resolves.

```tsx
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"

// Stands in for the request that revokes the key.
const revokeKey = () => new Promise<void>((resolve) => setTimeout(resolve, 1500))

function RevokeKey() {
  const confirm = useConfirm()
  const [revoked, setRevoked] = useState(false)

  const revoke = async () => {
    const confirmed = await confirm({
      title: "Revoke the Production key?",
      description: "Requests signed with it are refused from now on.",
      tone: "destructive",
      confirmLabel: "Revoke",
      onConfirm: revokeKey,
    })
    if (confirmed) setRevoked(true)
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <Button variant="outline" onClick={revoke} disabled={revoked}>
        Revoke key
      </Button>
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {revoked ? "The Production key was revoked." : ""}
      </p>
    </div>
  )
}

export default function ConfirmAsync() {
  return (
    <ConfirmProvider>
      <RevokeKey />
    </ConfirmProvider>
  )
}
```

### Async error

The first attempt rejects: the dialog stays open with the error's message, and Revoke can be tried again.

```tsx
import { useRef, useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"

function PurgeQueue() {
  const confirm = useConfirm()
  const attempts = useRef(0)
  const [purged, setPurged] = useState(false)

  // The first attempt fails, as a timed-out request would; the second succeeds.
  const purgeQueue = () =>
    new Promise<void>((resolve, reject) => {
      attempts.current += 1
      const fails = attempts.current === 1
      setTimeout(() => (fails ? reject(new Error("The queue did not answer in time. Try again.")) : resolve()), 1200)
    })

  const purge = async () => {
    const confirmed = await confirm({
      title: "Purge the email queue?",
      description: "The 38 emails waiting to be sent are removed.",
      tone: "destructive",
      confirmLabel: "Purge",
      onConfirm: purgeQueue,
    })
    if (confirmed) setPurged(true)
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <Button variant="outline" onClick={purge} disabled={purged}>
        Purge queue
      </Button>
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {purged ? "The queue is empty." : ""}
      </p>
    </div>
  )
}

export default function ConfirmAsyncError() {
  return (
    <ConfirmProvider>
      <PurgeQueue />
    </ConfirmProvider>
  )
}
```

### Queued

Two confirmations asked at once open one after the other; focus returns to the button at the end.

```tsx
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"

const MAILERS = ["Primary SMTP", "Transactional SES"]

function ImportMailers() {
  const confirm = useConfirm()
  const [result, setResult] = useState("")

  // Both questions are asked at once; the second dialog opens when the first is answered.
  const importMailers = async () => {
    const answers = await Promise.all(
      MAILERS.map((name) =>
        confirm({
          title: `Replace ${name}?`,
          description: `The imported file has settings for ${name}. Replacing them changes how its emails are sent.`,
          confirmLabel: "Replace",
          cancelLabel: "Skip",
        })
      )
    )
    const replaced = MAILERS.filter((_, index) => answers[index])
    setResult(replaced.length ? `Replaced: ${replaced.join(", ")}.` : "Nothing was replaced.")
  }

  return (
    <div className="flex flex-col items-center gap-3">
      <Button variant="outline" onClick={importMailers}>
        Import 2 mailers
      </Button>
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {result}
      </p>
    </div>
  )
}

export default function ConfirmQueued() {
  return (
    <ConfirmProvider>
      <ImportMailers />
    </ConfirmProvider>
  )
}
```

## Accessibility

**Semantics.** The library's Alert dialog: a `div` with `role="alertdialog"` and `aria-modal="true"`, named by the title and described by the description. The rest of the page is hidden from assistive technology while it is open. While `onConfirm` runs, the dialog is `aria-busy` and the confirm button `aria-busy` and `aria-disabled`.

**Labels.** The title names the dialog; give every confirmation a question as its title. The × is named by the provider's `close` string; the buttons' default labels are `confirm`, `delete` and `cancel`. An error from `onConfirm` is shown in a `role="alert"` paragraph, so it is read at once; without a message of its own it reads the provider's `actionFailed`.

**Focus.** Focus starts on Confirm, or on Cancel when `tone` is `destructive` (`defaultFocus` chooses), and stays inside. When the last waiting confirmation closes, focus returns to the element that had it when the first was asked (the menu's button when a menu item asked, since the item closes with its menu), or to `returnFocusTo` when that is gone too. Between queued confirmations it moves straight to the next dialog. The buttons keep focus while `onConfirm` runs.

**Known limits.**

- One confirmation at a time: the others wait, in the order they were asked.
- There is no text-input (prompt) variant. Ask for text in a [Dialog](/components/dialog) with a form.
- A click on the backdrop does nothing, as in every alert dialog: the person answers with a button or Escape.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter or Space | Activates the focused button: Confirm resolves `true`, Cancel and the × resolve `false`. |
| Escape | Cancels: the dialog closes and `confirm()` resolves `false`. Ignored while `onConfirm` runs. |
| Tab | Moves to the next button inside the dialog, wrapping from the last to the first. |
| Shift + Tab | Moves to the previous button inside the dialog, wrapping from the first to the last. |

## API

### ConfirmProvider

**Also exported:** `useConfirm`, a hook for the parts' shared state; call it inside the component's provider.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="confirm-content"` (ConfirmProvider), and `data-tone`, `data-loading`.

**Provider strings:** `actionFailed`, `confirmTitle`, `cancel`, `delete`, `confirm`, `close` (`BooleanUIProvider`'s `strings`).

## Theming

The dialog is the library's Alert dialog at 352 px: `--popover` and `--popover-foreground`, the `--mask` backdrop, the Button colours for its buttons (Cancel is `secondary`; Confirm `default`, or `destructive` with `tone="destructive"`).

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--destructive-strong` | text |
| `--foreground` | text |
| `--muted-foreground` | text |
| `--subtle` | background |
