# Dialog

Opens a layer over the page and keeps the reader in it until they are done.

Dialog puts a layer over the page and, while it is modal, takes the page out of
reach until the reader is done with it. Reach for it when a task needs its own
space and the page underneath can wait. When the answer cannot be postponed and
a stray click must not throw it away, use
[`AlertDialog`](/components/alert-dialog).

::component-preview{name="DialogPreview"}
```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"
  >
    <Dialog.Root>
      <Dialog.Trigger :class="button">Edit profile</Dialog.Trigger>

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

        <Dialog.Popup :class="popup">
          <div class="flex flex-col gap-1">
            <Dialog.Title :class="title">Edit profile</Dialog.Title>
            <Dialog.Description :class="description">
              Change the name other people see.
            </Dialog.Description>
          </div>

          <Field.Root
            class="group flex flex-col gap-1.5"
            :options="{ name: 'name' }"
          >
            <div :class="box">
              <div :class="stack">
                <span :class="line">
                  <Input
                    v-model="name"
                    :class="control"
                    placeholder=" "
                    autocomplete="off"
                  />
                </span>
                <Label :class="label">Name</Label>
              </div>
            </div>
          </Field.Root>

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

<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, Field, Input, Label } from '@maas/mirror/vue'

const name = ref('Robin')

const button = [
  '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-primary-solid text-primary-on-solid',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:bg-primary-solid-hover active:bg-primary-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'

const box = [
  'relative isolate flex h-12 w-full cursor-text items-center justify-between gap-1.5',
  'rounded-component-lg border-surface border-2 px-[0.875rem]',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-within:focus-ring',
].join(' ')

const stack = [
  'group/stack flex h-full max-h-full w-full flex-col-reverse',
  'items-center justify-center gap-0 px-1',
  'focus-within:gap-1 has-[input:not(:placeholder-shown)]:gap-1',
].join(' ')

const line = [
  'relative flex h-0 w-full items-end',
  'group-focus-within/stack:h-[0.9375rem]',
  'has-[input:not(:placeholder-shown)]:h-[0.9375rem]',
].join(' ')

const control = [
  'type-component-lg leading-[normal]! text-primary-solid',
  'block box-content h-[1lh] w-full py-1.25 -my-1.25 appearance-none bg-transparent outline-none',
  'placeholder:text-transparent',
].join(' ')

const label = [
  'type-component-lg leading-[normal]! text-primary-muted',
  'flex h-full w-full items-center [--mirror-label-cursor:text]',
  "before:absolute before:-inset-0.5 before:-z-10 before:content-['']",
  'group-focus-within/stack:h-auto group-focus-within/stack:text-[0.6875rem]',
  'group-has-[input:not(:placeholder-shown)]/stack:h-auto group-has-[input:not(:placeholder-shown)]/stack:text-[0.6875rem]',
].join(' ')
</script>
```
::

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

## Usage guidelines

- Give the dialog a `Dialog.Title`. The popup labels itself from whichever
  title is mounted, and without one a screen reader announces an unnamed
  dialog.
- Leave `modal` alone unless the reader genuinely has to keep working on the
  page behind. A modal dialog traps focus, marks the rest of the document
  `inert` and holds the page scroll, and those three go together.
- The popup positions itself. There is no positioner part, because a dialog is
  anchored to the viewport rather than to its trigger.
- Focus lands on the popup rather than on the first control inside it, so the
  title is read before a field is. Name a control through `focus.initial` where
  the dialog is mostly a form.

## Anatomy

Assemble the parts. `Dialog.Portal` is optional: omit it and the layer stays
where it is, positioned against the nearest containing block.

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

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

<template>
  <Dialog.Root>
    <Dialog.Trigger>Edit profile</Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Backdrop />
      <Dialog.Popup>
        <Dialog.Title>Edit profile</Dialog.Title>
        <Dialog.Description>Change the name other people see.</Dialog.Description>
        <Dialog.Close>Cancel</Dialog.Close>
      </Dialog.Popup>
    </Dialog.Portal>
  </Dialog.Root>
</template>
```

## Examples

### Centring the popup

The popup has no position of its own, so there are two ways to centre it. Give
the popup a position of its own, which is the one to reach for first.

```css
.popup {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
```

Or a wrapper inside the portal can do the centring, which is worth it once the
popup is tall enough to need the page to scroll around it.

```vue
<template>
  <Dialog.Portal class="fixed inset-0 grid place-items-center overflow-y-auto">
    <Dialog.Backdrop class="fixed inset-0" />
    <Dialog.Popup>…</Dialog.Popup>
  </Dialog.Portal>
</template>
```

A modal dialog switches pointer events off everywhere but its own layer, so a
wrapper that is meant to take the press that dismisses the dialog needs
`pointer-events: auto` of its own. `Dialog.Backdrop` already ships with it.

### Non-modal and focus-only

`modal` is three settings in one. Leave it at `true` and the dialog traps
focus, marks the rest of the document `inert` and locks the page scroll. Set it
to `'trap-focus'` and only the trap remains, which is what you want when the
page behind has to keep scrolling. Set it to `false` and the page is left alone
entirely.

```vue
<template>
  <Dialog.Root :options="{ modal: 'trap-focus' }" />
</template>
```

### Dismissal

Escape closes the topmost dialog, and a press outside the popup closes it.
Either can be switched off on its own.

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

`dismiss.focusOutside` is off by default. Focus cannot leave a modal dialog
anyway, and in a non-modal one a click into the page behind is already covered
by `pointerDownOutside`.

### Focus

`focus.initial` and `focus.final` take an element, a getter for one, or `false`
to leave focus where it is. Both getters receive the reason the dialog opened
or closed, so a cancel can send focus somewhere else than a save.

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

<template>
  <Dialog.Root :options="{ focus: { initial: () => nameField } }">
    <Dialog.Popup>
      <input ref="nameField" />
    </Dialog.Popup>
  </Dialog.Root>
</template>
```

Unset, focus lands on the popup on the way in and on the trigger on the way
out. `Dialog.Popup` takes `initial-focus` and `final-focus` as props too, for
the one dialog in a set that needs to differ.

### Nested dialogs

A dialog opened from inside another one finds its parent through the component
tree, so the portal boundary between the two makes no difference. Escape closes
the innermost one first, the inner popup carries `data-nested`, and the outer
one carries `data-has-nested-dialogs` and a count.

```css
.popup[data-has-nested-dialogs='true'] {
  scale: calc(1 - var(--mirror-dialog-popup-nested-dialogs) * 0.05);
}
```

Only the outermost dialog holds the page scroll, so closing the inner one does
not hand it back while the outer one is still open.

### Reaching the dialog from anywhere

Give the root an `id` and `useMirrorDialog(id)` opens, closes and reads that
dialog from anywhere in the app, including from a component that renders none
of its parts.

```vue
<script setup lang="ts">
const { isOpen, open } = useMirrorDialog(DialogId.EditProfile)
</script>

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

### Animating it

`transition.popup` and `transition.backdrop` name a Vue transition. Both are
unset by default, which leaves the popup to appear and disappear at once.

```vue
<template>
  <Dialog.Root :options="{ transition: { popup: 'dialog', backdrop: 'fade' } }" />
</template>
```

```css
.dialog-enter-active,
.dialog-leave-active {
  transition: all 150ms ease;
}

.dialog-enter-from,
.dialog-leave-to {
  opacity: 0;
  translate: 0 0.5rem;
}
```

Without a name the popup is still kept in the DOM until any CSS animation on it
finishes, which is the path to take when the popup is a consumer element under
`as-child`.

## API reference

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

### `Dialog.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 [`useMirrorDialog`](#reaching-the-dialog-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: DialogOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: modal
        description: 'How much of the page behind it the dialog takes over. See [non-modal and focus-only](#non-modal-and-focus-only).'
      - label: 'boolean | ''trap-focus'''
      - label: 'true'
  - 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 the dialog is closed.
      - label: boolean
      - label: 'false'
  - items:
      - label: dismiss.escape
        description: Escape closes the topmost dialog.
      - label: boolean
      - label: 'true'
  - items:
      - label: dismiss.pointerDownOutside
        description: A press outside the popup closes it.
      - label: boolean
      - label: 'true'
  - items:
      - label: dismiss.focusOutside
        description: Focus leaving the popup closes it.
      - label: boolean
      - label: 'false'
  - items:
      - label: focus.trapped
        description: Focus is trapped in the popup. Ignored while `modal` is `false`.
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.restore
        description: Closing returns focus.
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.initial
        description: 'Where [focus](#focus) goes on open.'
      - label: DialogFocusTarget
      - label: 'null'
  - items:
      - label: focus.final
        description: 'Where [focus](#focus) goes on close.'
      - label: DialogFocusTarget
      - 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
---
::

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

#### Emits

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

Slot props: `open`, `nested`.

### `Dialog.Trigger`

Renders a `<button>` with `aria-haspopup="dialog"`, `aria-expanded` and
`aria-controls`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: disabled
        description: Escape hatch over `options.disabled`.
      - label: 'boolean | undefined'
      - label: options.disabled
---
::

Slot props: `open`, `disabled`.

### `Dialog.Portal`

Renders a `<div>` inside a teleport, so the whole layer is one element in the
body.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: to
        description: Teleport target. Resolves to the body after mount.
      - label: 'string | RendererElement'
      - label: body
        plaintext: true
  - items:
      - label: disabled
        description: Leaves the layer where it is instead of teleporting it.
      - label: boolean
      - label: 'false'
  - items:
      - label: defer
        description: Waits a tick for a target that does not exist yet.
      - label: boolean
      - label: 'false'
---
::

Slot props: `open`.

### `Dialog.Backdrop`

Renders a `<div>` with `pointer-events: auto`.

#### 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
---
::

Slot props: `open`, `nested`.

### `Dialog.Popup`

Renders a `<div role="dialog">`, labelled by the title and described by the
description. `aria-modal` is present while `modal` is `true`.

#### 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`.
      - label: DialogFocusTarget
      - label: options.focus.initial
  - items:
      - label: finalFocus
        description: Escape hatch over `options.focus.final`.
      - label: DialogFocusTarget
      - label: options.focus.final
---
::

Slot props: `open`, `nested`, `hasNestedDialogs`.

### `Dialog.Title`

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

### `Dialog.Description`

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

### `Dialog.Close`

Renders a `<button>` that closes the dialog.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: disabled
        description: Disables the button.
      - label: boolean
      - label: 'false'
---
::

Slot props: `disabled`.

### Composable

`useMirrorDialog(id)` reaches a dialog from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: isOpen
        description: '`true` while the dialog is open.'
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: nestedCount
        description: How many dialogs opened from inside this one are open.
      - label: ComputedRef<number>
        escape: true
  - items:
      - label: open
        description: Opens the dialog.
      - label: '(reason?: DialogChangeReason) => void'
        escape: true
  - items:
      - label: close
        description: Closes the dialog.
      - label: '(reason?: DialogChangeReason) => void'
        escape: true
  - items:
      - label: toggle
        description: Opens or closes.
      - label: '(reason?: DialogChangeReason) => void'
        escape: true
---
::

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_dialog_context
        description: A `Dialog` part rendered outside `Dialog.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 trigger is a `<button>` with `aria-haspopup="dialog"`, `aria-expanded` and
`aria-controls` pointing at the popup, which keeps its identifier while it is
unmounted. The popup is a `role="dialog"`, labelled by `Dialog.Title` and
described by `Dialog.Description`, and carries `aria-modal` while it is modal.

::docs-table
---
columns:
  - label: Key
  - label: Behaviour
rows:
  - items:
      - label: '`Enter` / `Space` on the trigger'
        plaintext: true
      - label: Opens the dialog. On a non-native trigger, Space acts on `keyup`.
        plaintext: true
  - items:
      - label: Tab
      - label: Moves between the controls in the popup. While the dialog traps focus, it never leaves.
        plaintext: true
  - items:
      - label: Escape
      - label: Closes the innermost open dialog and returns focus to its trigger.
        plaintext: true
  - items:
      - label: '`Enter` / `Space` on the close button'
        plaintext: true
      - label: Closes the dialog.
        plaintext: true
---
::
