# Alert Dialog

A modal dialog that closes only through the answers it renders, for decisions that cannot be undone.

Alert Dialog is [`Dialog`](/components/dialog) with the ways out taken away. It
is always modal, a press outside never closes it, and the only ways past it are
the buttons it renders. Reach for it when the answer decides something that
cannot be taken back, and use `Dialog` for everything else.

::component-preview{name="AlertDialogPreview"}
```vue
<template>
  <div
    class="border-surface rounded-component-2xl relative isolate flex h-80 w-full max-w-md items-center justify-center overflow-hidden border-2"
  >
    <AlertDialog.Root>
      <AlertDialog.Trigger :class="danger">
        Delete project
      </AlertDialog.Trigger>

      <AlertDialog.Portal disabled>
        <AlertDialog.Backdrop :class="backdrop" />

        <AlertDialog.Popup :class="popup">
          <div class="flex flex-col gap-1">
            <AlertDialog.Title :class="title">
              Delete project?
            </AlertDialog.Title>
            <AlertDialog.Description :class="description">
              Every deployment, token and log goes with it. This cannot be
              undone.
            </AlertDialog.Description>
          </div>

          <div class="flex items-center justify-end gap-2">
            <AlertDialog.Close :class="quiet">Cancel</AlertDialog.Close>
            <AlertDialog.Close :class="danger">Delete</AlertDialog.Close>
          </div>
        </AlertDialog.Popup>
      </AlertDialog.Portal>
    </AlertDialog.Root>
  </div>
</template>

<script setup lang="ts">
import { AlertDialog } from '@maas/mirror/vue'

const danger = [
  'inline-flex h-12 items-center justify-center gap-1.5 px-[1.125rem] whitespace-nowrap',
  'rounded-component-lg border-2 border-[transparent] type-component-lg',
  'bg-danger-solid text-danger-on-solid',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:bg-danger-solid-hover active:bg-danger-solid-active',
].join(' ')

const quiet = [
  'inline-flex h-12 items-center justify-center gap-1.5 px-[1.125rem] whitespace-nowrap',
  'rounded-component-lg border-2 border-[transparent] type-component-lg',
  'text-primary-on-subtle hover:bg-primary-subtle active:bg-primary-subtle-hover',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
].join(' ')

const backdrop = 'bg-surface-dimmer absolute inset-0'

const popup = [
  'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
  'flex w-[20rem] flex-col gap-5 p-6',
  'rounded-component-2xl bg-primary-inverted shadow-component-high',
  'outline-4 outline-transparent focus:outline-none',
  '[&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
].join(' ')

const title = 'type-component-xl text-primary-solid'
const description = 'type-component-2xs text-primary-muted'
</script>
```
::

The example switches the portal off and positions the layer absolutely, so the
alert dialog stays inside the preview frame rather than covering the viewport.

## Usage guidelines

- Give it a `AlertDialog.Title` that asks the question and a
  `AlertDialog.Description` that says what happens, so the reader has the
  consequence in front of them before they answer.
- Render both answers. A dialog whose only button is the destructive one leaves
  the reader nothing safe to press.
- Do not reach for it for anything reversible. A press outside is how people
  leave a layer, so take it away only where the next click destroys something.

## Anatomy

The parts are `Dialog`’s, under their own names. `AlertDialog.Portal` is
optional in the same way.

::component-anatomy
---
parts:
  - name: AlertDialog.Root
    required: true
    description: 'Renders its children. Owns the open state.'
    children:
      - name: AlertDialog.Trigger
        description: 'Renders a <button> with aria-haspopup="dialog".'
      - name: AlertDialog.Portal
        description: 'Optional. Renders a <div> in the body and teleports everything below it.'
        children:
          - name: AlertDialog.Backdrop
            description: 'Renders a <div> under the popup.'
          - name: AlertDialog.Popup
            required: true
            description: 'Renders a <div role="alertdialog">. The focus scope and the dismissable layer live here.'
            children:
              - name: AlertDialog.Title
                description: 'Renders an <h2> and labels the popup.'
              - name: AlertDialog.Description
                description: 'Renders a <p> and describes the popup.'
              - name: AlertDialog.Close
                description: 'Renders a <button> that closes the alert dialog.'
---
::

```vue
<script setup lang="ts">
import { AlertDialog } from '@maas/mirror/vue'
</script>

<template>
  <AlertDialog.Root>
    <AlertDialog.Trigger>Delete project</AlertDialog.Trigger>
    <AlertDialog.Portal>
      <AlertDialog.Backdrop />
      <AlertDialog.Popup>
        <AlertDialog.Title>Delete project?</AlertDialog.Title>
        <AlertDialog.Description>This cannot be undone.</AlertDialog.Description>
        <AlertDialog.Close>Cancel</AlertDialog.Close>
        <button @click="destroy">Delete</button>
      </AlertDialog.Popup>
    </AlertDialog.Portal>
  </AlertDialog.Root>
</template>
```

## Examples

### The options it overrules

`modal` is not an option here, and neither are the two outside dismissals. They
are re-applied after the options are merged, so an object that names them is
read and then overwritten.

```ts
resolveAlertDialogOptions({ modal: false })
// → { modal: true, dismiss: { escape: true, pointerDownOutside: false, focusOutside: false }, … }
```

Escape is the one way out that stays configurable, because a keyboard reader
needs some way to leave the layer.

```vue
<template>
  <AlertDialog.Root :options="{ dismiss: { escape: false } }" />
</template>
```

Switch it off only where the surrounding flow gives the reader another way
back.

### Focusing the safe answer

Focus lands on the popup by default, so a screen reader announces the title
before it reaches a button. Name the cancel button through `focus.initial`
where the destructive answer sits closest to the pointer.

```vue
<script setup lang="ts">
const cancel = ref<HTMLElement | null>(null)
</script>

<template>
  <AlertDialog.Root :options="{ focus: { initial: () => cancel } }">
    <AlertDialog.Popup>
      <AlertDialog.Close ref="cancel">Cancel</AlertDialog.Close>
    </AlertDialog.Popup>
  </AlertDialog.Root>
</template>
```

Set it to `false` instead and focus stays where it was.

### Asking from a dialog

An alert dialog opened from inside a dialog counts against it the same way a
nested dialog does, so the dialog underneath carries `data-has-nested-dialogs`
and Escape answers the alert first.

```vue
<template>
  <Dialog.Popup>
    <AlertDialog.Root>
      <AlertDialog.Trigger>Discard changes</AlertDialog.Trigger>
    </AlertDialog.Root>
  </Dialog.Popup>
</template>
```

### Reaching it from anywhere

Give the root an `id` and `useMirrorAlertDialog(id)` opens, closes and reads
that alert dialog from anywhere in the app.

```vue
<script setup lang="ts">
const { isOpen, open } = useMirrorAlertDialog(AlertDialogId.DeleteProject)
</script>

<template>
  <Button :aria-expanded="isOpen" @click="open()">Delete project</Button>
</template>
```

An alert dialog and a dialog with the same `id` stay separate. The two keep
their own stores, so `useMirrorDialog('delete')` and
`useMirrorAlertDialog('delete')` reach different components.

## API reference

**Module.** A bundled `options` object on the root, and
`useMirrorAlertDialog(id)` as the programmatic API. Every part takes `id` to
resolve an alert dialog it is not nested inside.

### `AlertDialog.Root`

Renders its children and no element of its own.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: 'The instance ID, for [`useMirrorAlertDialog`](#reaching-it-from-anywhere).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: open
        description: Open state. `v-model:open`.
      - label: 'boolean | undefined'
      - label: undefined
  - items:
      - label: defaultOpen
        description: Initial open state when uncontrolled.
      - label: boolean
      - label: 'false'
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: AlertDialogOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: disabled
        description: Disables the trigger and refuses opening.
      - label: boolean
      - label: 'false'
  - items:
      - label: forceMount
        description: Keeps the popup and the backdrop in the DOM while it is closed.
      - label: boolean
      - label: 'false'
  - items:
      - label: dismiss.escape
        description: Escape closes the topmost dialog.
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.trapped
        description: Focus is trapped in the popup.
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.restore
        description: Closing returns focus.
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.initial
        description: 'Where [focus](#focusing-the-safe-answer) goes on open.'
      - label: AlertDialogFocusTarget
      - label: 'null'
  - items:
      - label: focus.final
        description: Where focus goes on close. Unset, the trigger takes it back.
      - label: AlertDialogFocusTarget
      - label: 'null'
  - items:
      - label: transition.popup
        description: Vue transition name for the popup.
      - label: 'string | undefined'
      - label: undefined
  - items:
      - label: transition.backdrop
        description: Vue transition name for the backdrop.
      - label: 'string | undefined'
      - label: undefined
---
::

`AlertDialogFocusTarget` and `AlertDialogChangeReason` are `Dialog`’s types
under their own names.

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:open
        description: The alert dialog opens or closes.
      - label: boolean
---
::

Slot props: `open`, `nested`.

### `AlertDialog.Trigger`

Renders a `<button>` with `aria-haspopup="dialog"`, `aria-expanded` and
`aria-controls`. Props and slot props are
[`Dialog.Trigger`](/components/dialog#dialogtrigger)’s.

### `AlertDialog.Portal`

Renders a `<div>` inside a teleport. Props are
[`Dialog.Portal`](/components/dialog#dialogportal)’s.

### `AlertDialog.Backdrop`

Renders a `<div>` with `pointer-events: auto`. Props are
[`Dialog.Backdrop`](/components/dialog#dialogbackdrop)’s.

### `AlertDialog.Popup`

Renders a `<div role="alertdialog" aria-modal="true">`, labelled by the title
and described by the description. Props are
[`Dialog.Popup`](/components/dialog#dialogpopup)’s.

### `AlertDialog.Title`

Renders an `<h2>` and writes its ID into the popup’s `aria-labelledby`. No
props beyond the primitive ones.

### `AlertDialog.Description`

Renders a `<p>` and writes its ID into the popup’s `aria-describedby`. No props
beyond the primitive ones.

### `AlertDialog.Close`

Renders a `<button>` that closes the alert dialog. Props are
[`Dialog.Close`](/components/dialog#dialogclose)’s.

### Composable

`useMirrorAlertDialog(id)` reaches an alert dialog from anywhere in the app,
and returns what `useMirrorDialog` returns: `isOpen`, `nestedCount`, `open`,
`close` and `toggle`.

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_alert_dialog_context
        description: An `AlertDialog` part rendered outside `AlertDialog.Root` and received no `id`.
  - items:
      - label: missing_instance_id
        description: A part could not resolve an instance ID.
  - items:
      - label: missing_element
        description: The focus scope found no element to trap focus in.
---
::

## Accessibility

The popup is a `role="alertdialog"` with `aria-modal="true"`, labelled by
`AlertDialog.Title` and described by `AlertDialog.Description`. Assistive
technology announces the description along with the title, so put the
consequence in the description rather than in the title.

::docs-table
---
columns:
  - label: Key
  - label: Behaviour
rows:
  - items:
      - label: '`Enter` / `Space` on the trigger'
        plaintext: true
      - label: Opens the alert dialog. On a non-native trigger, Space acts on `keyup`.
        plaintext: true
  - items:
      - label: Tab
      - label: Moves between the answers. Focus never leaves the popup.
        plaintext: true
  - items:
      - label: Escape
      - label: Closes the innermost open dialog and returns focus to its trigger, unless `dismiss.escape` is off.
        plaintext: true
  - items:
      - label: '`Enter` / `Space` on the close button'
        plaintext: true
      - label: Closes the alert dialog.
        plaintext: true
---
::
