# 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.Teleport disabled>
        <AlertDialog.Backdrop :class="backdrop" />

        <AlertDialog.Content :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.Content>
      </AlertDialog.Teleport>
    </AlertDialog.Root>
  </div>
</template>

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

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 teleport off and positions the layer absolutely, so the
alert dialog stays inside the preview frame rather than covering the viewport.

## Usage guidelines

::usage-guidelines

## Anatomy

The parts are `Dialog`’s, under their own names. `AlertDialog.Teleport` 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.Teleport
        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.Content
            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/AlertDialog'
</script>

<template>
  <AlertDialog.Root>
    <AlertDialog.Trigger>Delete project</AlertDialog.Trigger>
    <AlertDialog.Teleport>
      <AlertDialog.Backdrop />
      <AlertDialog.Content>
        <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.Content>
    </AlertDialog.Teleport>
  </AlertDialog.Root>
</template>
```

## Examples

### The options it overrules

`modal` is not an option here, and neither is dismissal from outside. An alert
dialog is always modal, and a press outside the popup never closes it.

The close keys are the one way out that stays configurable, because a keyboard
reader needs some way to leave the layer. `keyListener.close` lists the keys
that close the alert dialog, matched against `event.key`, and defaults to
`['Escape']`. `false` and `[]` both mean no key closes it.

```vue
<template>
  <AlertDialog.Root :options="{ keyListener: { close: 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 the `focus.initial`
option 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.Content>
      <AlertDialog.Close ref="cancel">Cancel</AlertDialog.Close>
    </AlertDialog.Content>
  </AlertDialog.Root>
</template>
```

Set it to `false` instead and focus stays where it was. The popup’s
`initialFocus` prop overrides the option per part.

### 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.Content>
    <AlertDialog.Root>
      <AlertDialog.Trigger>Discard changes</AlertDialog.Trigger>
    </AlertDialog.Root>
  </Dialog.Content>
</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: 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: initial.open
        description: Opens the alert dialog on mount while no `v-model:open` is bound.
      - label: boolean
      - label: 'false'
  - 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: keyListener.close
        description: 'The keys, matched against `event.key`, that close the alert [dialog](#the-options-it-overrules). `false` or `[]` switches closing by key off.'
      - label: 'Array<string> | false'
        escape: true
      - label: "['Escape']"
  - items:
      - label: focus.trap
        description: Focus is trapped in the popup while it is open.
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.restore
        description: 'Closing returns focus. It goes to `focus.final`, or back to the trigger.'
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.initial
        description: 'Where [focus](#focusing-the-safe-answer) goes on open. `null` sends it to the popup.'
      - label: AlertDialogFocusTarget
      - label: 'null'
  - items:
      - label: focus.final
        description: 'Where focus goes on close. `null` hands it back to the trigger.'
      - label: AlertDialogFocusTarget
      - label: 'null'
  - items:
      - label: scrollLock
        description: 'Holds the page scroll while the alert dialog is open. `padding` pads the page by the scrollbar width, so nothing shifts. `false` switches the lock off.'
      - label: 'boolean | { padding: boolean }'
      - label: '{ padding: true }'
  - items:
      - label: transition.content
        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
---
::

#### 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.Teleport`

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

### `AlertDialog.Backdrop`

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

### `AlertDialog.Content`

Renders a `<div role="alertdialog" aria-modal="true">`, labeled by the title
and described by the description.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: forceMount
        description: Escape hatch over `options.forceMount`.
      - label: 'boolean | undefined'
      - label: options.forceMount
  - items:
      - label: initialFocus
        description: 'Escape hatch over [`options.focus.initial`](#focusing-the-safe-answer).'
      - label: AlertDialogFocusTarget
      - label: options.focus.initial
  - items:
      - label: finalFocus
        description: Escape hatch over `options.focus.final`.
      - label: AlertDialogFocusTarget
      - label: options.focus.final
  - items:
      - label: restoreFocus
        description: Escape hatch over `options.focus.restore`.
      - label: 'boolean | undefined'
      - label: options.focus.restore
---
::

`AlertDialogFocusTarget` is `boolean | HTMLElement | ((reason: AlertDialogChangeReason) => HTMLElement | boolean | null) | null`.
`AlertDialogChangeReason` is one of `trigger-press`, `outside-press`,
`escape-key`, `close-press`, `focus-out` or `imperative`.

### `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`.

### Emitter

Every open change and every transition hook is broadcast app-wide through
`useMirrorEmitter`, so a listener needs no shared ancestor with the alert
dialog. Import it from `@maas/mirror/vue` and call it inside `<script setup>`.

`alert-dialog:openChange` fires whenever the alert dialog opens or closes. Its
payload carries the instance `id`, the new `open` state and the `reason` it
changed for.

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

const emitter = useMirrorEmitter()

emitter.on('alert-dialog:openChange', ({ id, open, reason }) => {
  if (id === AlertDialogId.DeleteProject && !open) {
    trackAnswer(reason)
  }
})
</script>
```

`AlertDialogChangeReason` is one of `trigger-press`, `outside-press`,
`escape-key`, `close-press`, `focus-out` or `imperative`.

The six transition hooks are broadcast as well, as `alert-dialog:beforeEnter`,
`alert-dialog:enter`, `alert-dialog:afterEnter`, `alert-dialog:beforeLeave`,
`alert-dialog:leave` and `alert-dialog:afterLeave`. Each payload carries the
instance `id` and the `part` that is transitioning, which is `content` or
`backdrop`.

```vue
<script setup lang="ts">
emitter.on('alert-dialog:afterEnter', ({ id, part }) => {
  if (id === AlertDialogId.DeleteProject && part === 'content') {
    startCountdown()
  }
})
</script>
```

The hooks belong to the Vue transition, so they fire only while
`options.transition.content` or `options.transition.backdrop` names one. Both
are unset by default, and until you name one the alert dialog broadcasts no
lifecycle events at all. An alert dialog that is already open when it first
renders fires no enter hooks, and a part kept in the DOM with
`options.forceMount`, or with its own `forceMount` prop, never enters or
leaves, so it fires none of them either.

::docs-table
---
columns:
  - label: Event
  - label: Payload
rows:
  - items:
      - label: alert-dialog:openChange
        description: The alert dialog opened or closed.
      - label: "{ id: string, open: boolean, reason: AlertDialogChangeReason }"
        escape: true
  - items:
      - label: alert-dialog:beforeEnter
        description: A part is about to enter.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: alert-dialog:enter
        description: A part has started entering.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: alert-dialog:afterEnter
        description: A part has finished entering.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: alert-dialog:beforeLeave
        description: A part is about to leave.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: alert-dialog:leave
        description: A part has started leaving.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
  - items:
      - label: alert-dialog:afterLeave
        description: A part has finished leaving and is out of the DOM.
      - label: "{ id: string, part: 'content' | 'backdrop' }"
        escape: true
---
::

`AlertDialogLifecyclePayload` types the lifecycle payload, and the whole map is
`AlertDialogEvents`. [Emitter](/components/emitter) covers the channel itself.

### 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"`, labeled 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: Behavior
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 `keyListener.close` leaves it out.
        plaintext: true
  - items:
      - label: '`Enter` / `Space` on the close button'
        plaintext: true
      - label: Closes the alert dialog.
        plaintext: true
---
::
