Skip to the content

ComponentsOverlay

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"

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.

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; for a small one beside the button, Confirm popup.

Examples

Basic

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

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.

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

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.

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.

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.

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 with a form.
  • A click on the backdrop does nothing, as in every alert dialog: the person answers with a button or Escape.

Keyboard

Keyboard
KeyBehaviour
EnterorSpaceActivates the focused button: Confirm resolves true, Cancel and the ร— resolve false.
EscapeCancels: the dialog closes and confirm() resolves false. Ignored while onConfirm runs.
TabMoves to the next button inside the dialog, wrapping from the last to the first.
ShiftTabMoves 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").

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentbackground
--destructive-strongtext
--foregroundtext
--muted-foregroundtext
--subtlebackground