# Confirm popup

A small confirmation that opens beside the button that asked, with a message, Cancel and Confirm.

- **Import:** `import { ConfirmPopup, ConfirmPopupTrigger, ConfirmPopupContent } from "@booleanpress/ui/confirm-popup"`
- **Radix Popover:** <https://www.radix-ui.com/primitives/docs/components/popover>
- **APG Alert Dialog:** <https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/>
- **Page:** <https://ui.booleanpress.com/components/confirm-popup> · @booleanpress/ui 0.2.0

## Usage

Use it for a quick check on a single action in a row or a toolbar, where a full dialog would take the person away from what they were doing. The popup points at its trigger, and focus goes back there when it closes.

```tsx
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"

export function DeleteEntry({ onDelete }: { onDelete: () => void }) {
  return (
    <ConfirmPopup tone="destructive" onConfirm={onDelete}>
      <ConfirmPopupTrigger asChild>
        <Button variant="outline">Delete entry</Button>
      </ConfirmPopupTrigger>
      <ConfirmPopupContent message="Delete this log entry?" />
    </ConfirmPopup>
  )
}
```

`ConfirmPopup` holds the behaviour: `onConfirm`, `onCancel` (Cancel, Escape or a click outside), `tone` (`default` or `destructive`), `defaultFocus`, and `open`/`onOpenChange` to control it. When `onConfirm` returns a promise, the confirm button shows a spinner and the popup stays open until it settles; a rejection keeps it open with the error's message (an `Error`'s or a string's, or the provider's `actionFailed` when it has none).

`ConfirmPopupContent` holds what it says: `message` and `icon`, or your own content as children; `confirmLabel` and `cancelLabel` (the provider's `confirm`, `delete` for a destructive one, and `cancel`); `side` and `align` (`bottom` and `start` by default; it flips when there is no room); and `returnFocusTo` for when the trigger is gone after the action.

For a decision that needs more words, or that must stop the page, use [Confirm](/components/confirm) or [Alert dialog](/components/alert-dialog).

## Examples

### Basic

An icon, the message, Cancel and Save, below the trigger and aligned to its start; focus starts on Save.

```tsx
import { TriangleAlertIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"

export default function ConfirmPopupBasic() {
  const [saved, setSaved] = useState(false)

  return (
    <div className="flex flex-col items-center gap-3">
      <ConfirmPopup onConfirm={() => setSaved(true)}>
        <ConfirmPopupTrigger asChild>
          <Button variant="outline">Save</Button>
        </ConfirmPopupTrigger>
        <ConfirmPopupContent
          icon={<TriangleAlertIcon />}
          message="Save the changes to the Primary mailer?"
          confirmLabel="Save"
        />
      </ConfirmPopup>
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {saved ? "Changes saved." : ""}
      </p>
    </div>
  )
}
```

### Destructive

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

```tsx
import { InfoIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"

export default function ConfirmPopupDestructive() {
  const [deleted, setDeleted] = useState(false)

  return (
    <div className="flex flex-col items-center gap-3">
      <ConfirmPopup tone="destructive" onConfirm={() => setDeleted(true)}>
        <ConfirmPopupTrigger asChild>
          <Button variant="outline" severity="danger" disabled={deleted}>
            Delete entry
          </Button>
        </ConfirmPopupTrigger>
        <ConfirmPopupContent icon={<InfoIcon />} message="Delete this log entry?" />
      </ConfirmPopup>
      <p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
        {deleted ? "The entry was deleted." : ""}
      </p>
    </div>
  )
}
```

### Custom content

Your own content in place of the message, and an `onConfirm` that waits for a request.

```tsx
import { KeyRoundIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"

// Stands in for the request that issues a new key.
const regenerate = () => new Promise<void>((resolve) => setTimeout(resolve, 1200))

export default function ConfirmPopupCustomContent() {
  return (
    <ConfirmPopup onConfirm={regenerate}>
      <ConfirmPopupTrigger asChild>
        <Button variant="outline">
          <KeyRoundIcon />
          Regenerate key
        </Button>
      </ConfirmPopupTrigger>
      <ConfirmPopupContent confirmLabel="Regenerate">
        <div className="flex flex-col gap-1.5">
          <p className="font-medium text-foreground">Regenerate the Staging key?</p>
          <p className="text-muted-foreground">
            The key ending in <code className="rounded-sm bg-muted px-1 font-mono text-xs">7f3a</code> stops working.
          </p>
        </div>
      </ConfirmPopupContent>
    </ConfirmPopup>
  )
}
```

### Placement

`side` on each of the four sides, with `align="center"`.

```tsx
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"

const SIDES = ["top", "right", "bottom", "left"] as const

export default function ConfirmPopupPlacement() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      {SIDES.map((side) => (
        <ConfirmPopup key={side}>
          <ConfirmPopupTrigger asChild>
            <Button variant="outline" className="capitalize">
              {side}
            </Button>
          </ConfirmPopupTrigger>
          <ConfirmPopupContent side={side} align="center" message="Resend the welcome email?" confirmLabel="Resend" />
        </ConfirmPopup>
      ))}
    </div>
  )
}
```

### Async error

`onConfirm` returns a promise that rejects: the popup stays open and shows the error's message.

```tsx
import { TriangleAlertIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"

// A pretend request that fails after 800 ms: the popup keeps its spinner until then, then stays open with the message.
const publish = () =>
  new Promise<void>((_, reject) => {
    setTimeout(() => reject(new Error("Acme Mail could not be reached. Try again in a minute.")), 800)
  })

export default function ConfirmPopupAsyncError() {
  return (
    <ConfirmPopup onConfirm={publish}>
      <ConfirmPopupTrigger asChild>
        <Button variant="outline">Publish</Button>
      </ConfirmPopupTrigger>
      <ConfirmPopupContent
        icon={<TriangleAlertIcon />}
        message="Publish the Welcome template to all subscribers?"
        confirmLabel="Publish"
      />
    </ConfirmPopup>
  )
}
```

## Accessibility

**Semantics.** A `div` with `role="alertdialog"` and `aria-modal="true"`, named by its message (or your content). The rest of the page is hidden from assistive technology while it is open. The trigger has `aria-haspopup="dialog"` and `aria-expanded`. While `onConfirm` runs, the popup is `aria-busy` and the confirm button `aria-busy` and `aria-disabled`.

**Labels.** Write the message as the question. The buttons' default labels are the provider's `confirm`, `delete` and `cancel`. An error from `onConfirm` is shown in a `role="alert"` paragraph.

**Focus.** Focus moves to Confirm when it opens, or to Cancel when `tone` is `destructive` (`defaultFocus` chooses), and stays inside. It returns to the trigger when the popup closes, or to `returnFocusTo` when the trigger is gone.

**Known limits.**

- It is modal: while it is open, a click outside closes it (as Cancel) and does not reach what it lands on.
- Keep the message to a line or two; a longer explanation belongs in a dialog.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter or Space | On the trigger, opens the popup; on a button, activates it. |
| Escape | Cancels and closes the popup; focus returns to the trigger. Ignored while `onConfirm` runs. |
| Tab | Moves between Cancel and Confirm, wrapping. |
| Shift + Tab | Moves between Confirm and Cancel, wrapping. |

## API

### ConfirmPopup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultFocus` | `"confirm" \| "cancel"` |  | The button focused when the popup opens: Confirm, or Cancel when `tone` is `destructive`. |
| `defaultOpen` | `boolean` | `false` | Whether it starts open, when it controls itself. |
| `onCancel` | `(() => void)` |  | Called when the popup closes without confirming: Cancel, Escape, or a click outside. |
| `onConfirm` | `(() => unknown)` |  | Runs when the person confirms. When it returns a promise, the confirm button shows a spinner and the popup stays open until it settles: resolved, it closes; rejected, it stays open with the error's message (the provider's `actionFailed` when it has none). |
| `onOpenChange` | `((open: boolean) => void)` |  | Called with `true` or `false` when it opens or closes. |
| `open` | `boolean` |  | Whether it is open, when you control it. Pair it with `onOpenChange`. |
| `tone` | `"default" \| "destructive"` | `default` | `destructive` paints the confirm button red and starts focus on Cancel. |

### ConfirmPopupTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  | Render the child element instead, with this part's behaviour merged onto it. |

### ConfirmPopupContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align` | `"center" \| "start" \| "end"` | `start` | Its alignment along that side: `start` (default), `center` or `end`. |
| `alignOffset` | `number` |  | Pixels to shift it along that side. |
| `arrowPadding` | `number` |  |  |
| `asChild` | `boolean` |  |  |
| `avoidCollisions` | `boolean` |  |  |
| `cancelLabel` | `ReactNode` |  | The cancel button's text. Defaults to the provider's `cancel`. |
| `children` | `ReactNode` |  | Your own content in place of the icon and message; it names the popup. |
| `collisionBoundary` | `Boundary \| Boundary[]` |  |  |
| `collisionPadding` | `number \| Partial<Record<"top" \| "bottom" \| "left" \| "right", number>>` |  | Pixels it keeps from the window's edges. 8 by default. |
| `confirmLabel` | `ReactNode` |  | The confirm button's text. Defaults to the provider's `confirm`, or `delete` when `tone` is `destructive`. |
| `deferPointerDownOutside` | `boolean` |  | When `true`, a `'pointerdown'` event outside of the layered element will wait for the interaction's click event before dispatching, allowing third-party code to stop propagation of later events and cancel dismissal. |
| `forceMount` | `true` |  | Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries. |
| `hideWhenDetached` | `boolean` |  |  |
| `icon` | `ReactNode` |  | An icon before the message, 20 px. |
| `message` | `ReactNode` |  | The question. |
| `onCloseAutoFocus` | `((event: Event) => void)` |  | Event handler called when auto-focusing on close. Can be prevented. |
| `onEscapeKeyDown` | `((event: KeyboardEvent) => void)` |  | Event handler called when the escape key is down. Can be prevented. |
| `onFocusOutside` | `((event: FocusOutsideEvent) => void)` |  | Event handler called when the focus moves outside of the `DismissableLayer`. Can be prevented. |
| `onInteractOutside` | `((event: FocusOutsideEvent \| PointerDownOutsideEvent) => void)` |  | Event handler called when an interaction happens outside the `DismissableLayer`. Specifically, when a `pointerdown` event happens outside or focus moves outside of it. Can be prevented. |
| `onOpenAutoFocus` | `((event: Event) => void)` |  | Event handler called when auto-focusing on open. Can be prevented. |
| `onPointerDownOutside` | `((event: PointerDownOutsideEvent) => void)` |  | Event handler called when the a `pointerdown` event happens outside of the `DismissableLayer`. Can be prevented. |
| `returnFocusTo` | `ReturnFocusTarget` |  | Where focus goes when the popup closes and its trigger is gone, for example after the confirmed delete. |
| `side` | `"top" \| "bottom" \| "left" \| "right"` | `bottom` | The side of the trigger it opens on: `top`, `right`, `bottom` (default) or `left`. It flips when there is no room. |
| `sideOffset` | `number` |  |  |
| `sticky` | `"always" \| "partial"` |  |  |
| `updatePositionStrategy` | `"always" \| "optimized"` |  |  |

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

**Data attributes:** `data-slot="confirm-popup-trigger"` (ConfirmPopupTrigger), `data-slot="confirm-popup-content"` (ConfirmPopupContent), and `data-tone`, `data-loading`.

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

## Theming

A floating surface: `--popover`, `--popover-foreground` and `--border`, 6px radius, the overlay shadow; its arrow takes the same fill and edge. The buttons are the small Button: Cancel `outline` in the muted text colour, Confirm `default` or `destructive`.

| Token | Used for |
| --- | --- |
| `--border` | stroke |
| `--destructive-strong` | text |
| `--foreground` | text |
| `--muted-foreground` | text |
| `--popover` | fill |
