# Dialog

> A family of composable components for modal dialogs with built-in focus trapping, ARIA support, and flexible placement — connected to triggers by shared ID, not nesting.

# Dialog

## Overview

Use the Dialog family when you need a modal overlay — confirmations, forms, product previews, alerts, or any content that demands focused user attention. The family is composed of five components that work together: the root `Dialog` holds the modal content, `DialogTrigger` opens it from anywhere on the page, and `DialogTitle`, `DialogDescription`, and `DialogClose` provide structure and accessibility inside.

Dialogs are unique among Etch components because **the trigger and the dialog are siblings, not parent and child**. They connect via a shared ID — `identity.dialogId` on the Dialog must match `targeting.targetDialogId` on the trigger. When opened, the dialog node is physically moved into a shared runtime host at the end of `document.body`, then restored to its authored position on close.

## Authoring Structure

```
DialogTrigger (separate from Dialog — anywhere on the page)
Dialog (the modal content)
├── DialogTitle
├── DialogDescription
├── (your content)
└── DialogClose
```

### Placement Rules

| Component | Placement | Role |
|---|---|---|
| **DialogTrigger** | Anywhere on the page — **not** inside `Dialog`. | Opens the dialog when clicked. Connects via `targetDialogId`. |
| **Dialog** | Separate from the trigger. | Root modal container. Holds all inner components. Connects via `dialogId`. |
| **DialogTitle** | Inside `Dialog`. | Provides accessible naming via `aria-labelledby`. Renders as a heading (`` by default). |
| **DialogDescription** | Inside `Dialog`. | Provides accessible context via `aria-describedby`. Renders as `` by default. |
| **DialogClose** | Inside `Dialog`. | Closes the active dialog when clicked. |

### Connection Rule

DialogTrigger and Dialog connect by ID — they are **never** nested:

- `Dialog` uses `identity.dialogId` to identify itself.
- `DialogTrigger` uses `targeting.targetDialogId` to specify which dialog it opens.
- These values **must match exactly** for the connection to work.
- Multiple triggers can target the same `dialogId`.
- Each dialog on a page must have a unique `dialogId`.

## Quick Start

<CodeExample title="Basic newsletter modal" language="jsx">
{`<OmeDialogTrigger
  targeting='{{"targetDialogId":"newsletter"}}'
  styling='{{"class":"btn"}}'
>
  {#slot default}Subscribe{/slot}
</OmeDialogTrigger>

<OmeDialog
  identity='{{"dialogId":"newsletter"}}'
  settings='{{"placement":"center"}}'
>
  {#slot default}
    <OmeDialogTitle>Stay updated</OmeDialogTitle>
    <OmeDialogDescription>Get the latest news delivered to your inbox.</OmeDialogDescription>
    <form>
      <input type="email" placeholder="you@example.com" />
      <button type="submit">Subscribe</button>
    </form>
    <OmeDialogClose>Close</OmeDialogClose>
  {/slot}
</OmeDialog>`}
</CodeExample>

The trigger and dialog are siblings — the trigger sits wherever you need it in the page, and the dialog can be authored anywhere. They connect because both reference the same ID (`"newsletter"`).

<CodeExample title="Bottom-right positioned dialog" language="jsx">
{`<OmeDialogTrigger
  targeting='{{"targetDialogId":"quick-view"}}'
  content='{{"label":"Quick view"}}'
/>

<OmeDialog
  identity='{{"dialogId":"quick-view"}}'
  settings='{{"placement":"bottom-right","closeOnOutsideClick":true}}'
  styling='{{"class":"ome-dialog-default card"}}'
>
  {#slot default}
    <OmeDialogTitle structure='{{"tag":"h3"}}'>Product details</OmeDialogTitle>
    <OmeDialogDescription>Quick product overview.</OmeDialogDescription>
    Product information goes here.
    <OmeDialogClose styling='{{"class":"btn--icon"}}'>
      {#slot default}&times;{/slot}
    </OmeDialogClose>
  {/slot}
</OmeDialog>`}
</CodeExample>

Use the `placement` setting to position the dialog anywhere in the viewport. When the trigger slot is empty, `content.label` renders a fallback text button.

---

## Family Components

### Dialog (Root)

The root component holds all modal content and manages dialog behavior: focus trapping, scroll locking, close-on-escape, close-on-outside-click, overlay rendering, and viewport placement. It does not render visible UI itself until opened — only a hidden container. When opened, the runtime physically moves the dialog node into a shared host element at the end of `document.body`.

<PropsTable group="Identity">
  <Prop name="dialogId" type="string" defaultValue='""'>
    Unique identifier for this dialog. `DialogTrigger` components use this ID to open the dialog via their `targetDialogId` prop. Must be unique across the page.
  </Prop>
</PropsTable>

<PropsTable group="Settings">
  <Prop name="defaultOpen" type="boolean" defaultValue="false">
    Opens the dialog automatically when the runtime initializes. Useful for announcement or onboarding dialogs that should appear on page load.
  </Prop>
  <Prop name="closeOnEscape" type="boolean" defaultValue="true">
    Whether pressing `Escape` closes the active dialog.
  </Prop>
  <Prop name="closeOnOutsideClick" type="boolean" defaultValue="true">
    Whether clicking the overlay backdrop closes the active dialog.
  </Prop>
  <Prop name="trapFocus" type="boolean" defaultValue="true">
    Keeps keyboard focus trapped inside the open dialog. Tab and Shift+Tab cycle through focusable elements within the dialog only.
  </Prop>
  <Prop name="preventScroll" type="boolean" defaultValue="true">
    Prevents background page scrolling while the dialog is open.
  </Prop>
  <Prop name="restoreFocus" type="boolean" defaultValue="true">
    Returns focus to the element that opened the dialog (typically the trigger) after the dialog closes.
  </Prop>
  <Prop name="placement" type={`"center" | "top" | "bottom" | "left" | "right" | "bottom-center" | "bottom-left" | "bottom-right"`} defaultValue="center">
    Controls where the dialog surface appears inside the viewport. `center` is the standard modal position. Other values align the dialog to the corresponding edge or corner.
  </Prop>
  <Prop name="initialFocusSelector" type="string" defaultValue='""'>
    CSS selector targeting a specific element inside the dialog that should receive focus when it opens. When empty, focus moves to the first focusable element in the dialog.
  </Prop>
</PropsTable>

<PropsTable group="Styling">
  <Prop name="class" type="class" defaultValue="ome-dialog-default">
    CSS class applied to the dialog surface element.
  </Prop>
</PropsTable>

#### Placement Options

| Placement | Behavior |
|---|---|
| `center` | Centered both horizontally and vertically. Default modal position. |
| `top` | Aligned to the top edge, centered horizontally. |
| `bottom` | Aligned to the bottom edge, centered horizontally. |
| `left` | Aligned to the left edge, centered vertically. |
| `right` | Aligned to the right edge, centered vertically. |
| `bottom-center` | Same as `bottom`. |
| `bottom-left` | Bottom-left corner of the viewport. |
| `bottom-right` | Bottom-right corner of the viewport. |

#### CSS Custom Properties

These properties are available on the dialog surface element (`.ome-dialog-default`) for runtime customization:

| Property | Default | Description |
|---|---|---|
| `--ome-dialog-overlay-color` | `rgba(0,0,0,0.5)` | Background color of the overlay backdrop. |
| `--ome-dialog-offset-x` | `0px` | Horizontal offset from the computed placement position. |
| `--ome-dialog-offset-y` | `0px` | Vertical offset from the computed placement position. |

#### Keyboard Behavior

| Key | Action |
|---|---|
| `Escape` | Closes the dialog (when `closeOnEscape` is `true`). |
| `Tab` | Moves focus to the next focusable element inside the dialog (when `trapFocus` is `true`). |
| `Shift+Tab` | Moves focus to the previous focusable element inside the dialog (when `trapFocus` is `true`). |

#### Accessibility

The root component wires up ARIA attributes automatically:

- The dialog surface gets `role="dialog"` and `aria-modal="true"`.
- `aria-labelledby` is auto-wired to the `DialogTitle` element's generated ID.
- `aria-describedby` is auto-wired to the `DialogDescription` element's generated ID (if present).
- IDs are auto-generated when not explicitly set.

#### Behaviors

- **One dialog at a time.** Only one dialog can be active. Opening a new dialog closes the previous one without animation.
- **DOM teleport.** The dialog node is physically moved into a shared host element at the end of `document.body` while open, then restored to its authored DOM position on close. This ensures the dialog overlays all page content regardless of where it was authored.

---

### DialogTrigger

The interactive element users click to open a dialog. DialogTrigger is **always placed separately from Dialog** — they are siblings in the block tree, not parent and child. They connect via a shared ID: `targeting.targetDialogId` must match the dialog's `identity.dialogId`.

Multiple triggers can target the same dialog. When the trigger slot is empty, `content.label` renders a fallback text button.

<PropsTable group="Targeting">
  <Prop name="targetDialogId" type="string" defaultValue='""'>
    ID of the dialog this trigger should open. Must match the `identity.dialogId` of a `Dialog` component on the page.
  </Prop>
</PropsTable>

<PropsTable group="Content">
  <Prop name="label" type="string" defaultValue='""'>
    Fallback trigger label used when the trigger slot is empty. Renders as a plain text button.
  </Prop>
</PropsTable>

<PropsTable group="Settings">
  <Prop name="disabled" type="boolean" defaultValue="false">
    Disables the trigger so it cannot open the dialog.
  </Prop>
</PropsTable>

<PropsTable group="Styling">
  <Prop name="class" type="class" defaultValue="ome-dialog-default__trigger">
    CSS class applied to the trigger button element.
  </Prop>
</PropsTable>

#### Accessibility

- Triggers get `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` wired automatically.
- Pressing `Enter` or `Space` on a focused trigger opens the target dialog.

---

### DialogTitle

Provides accessible naming for the dialog. The root `Dialog` auto-wires `aria-labelledby` to this element's ID, so screen readers announce the title when the dialog opens. Must be placed inside the `Dialog` slot.

<PropsTable group="Structure">
  <Prop name="tag" type="string" defaultValue="h2">
    HTML tag used for the title element. Common values: `"h2"`, `"h3"`, `"h4"`.
  </Prop>
</PropsTable>

<PropsTable group="Styling">
  <Prop name="class" type="class" defaultValue="ome-dialog-default__title">
    CSS class applied to the title element.
  </Prop>
</PropsTable>

---

### DialogDescription

Provides additional context for screen readers. The root `Dialog` auto-wires `aria-describedby` to this element's ID. Must be placed inside the `Dialog` slot.

<PropsTable group="Structure">
  <Prop name="tag" type="string" defaultValue="p">
    HTML tag used for the description element.
  </Prop>
</PropsTable>

<PropsTable group="Styling">
  <Prop name="class" type="class" defaultValue="ome-dialog-default__description">
    CSS class applied to the description element.
  </Prop>
</PropsTable>

---

### DialogClose

Button that closes the active dialog. Must be placed inside the `Dialog` slot. You can include any content in its slot — text, icons, or custom markup.

<PropsTable group="Settings">
  <Prop name="disabled" type="boolean" defaultValue="false">
    Disables the close button so users cannot click it to dismiss the dialog.
  </Prop>
</PropsTable>

<PropsTable group="Styling">
  <Prop name="class" type="class" defaultValue="ome-dialog-default__close">
    CSS class applied to the close button element.
  </Prop>
</PropsTable>

---

## Common Mistakes

<CommonMistake title="Nesting DialogTrigger inside Dialog">
  `DialogTrigger` and `Dialog` are **siblings**, not parent and child. The trigger must be placed outside the dialog in the block tree. They connect by matching `targetDialogId` to `dialogId` — nesting breaks the connection and the trigger will not work.
</CommonMistake>

<CommonMistake title="Forgetting to set dialogId / targetDialogId">
  Without matching IDs, the trigger has no way to know which dialog to open. Both `identity.dialogId` on `Dialog` and `targeting.targetDialogId` on `DialogTrigger` must be set to the same value.
</CommonMistake>

<CommonMistake title="Duplicate dialogIds on the same page">
  Each dialog must have a unique `dialogId`. If two dialogs share the same ID, only the first one registers — the second will never open.
</CommonMistake>

<CommonMistake title="Expecting the dialog to appear where it is authored">
  When a dialog opens, its DOM node is physically moved into a shared runtime host at the end of `document.body`. It does not render in-place where you authored it. This ensures the overlay appears above all page content.
</CommonMistake>

---

## FAQs

<details>
<summary>Can I have multiple triggers for one dialog?</summary>

Yes. Any number of `DialogTrigger` components can target the same `dialogId`. Each trigger independently opens the same dialog. This is useful for pages that offer several entry points to the same form or overlay.

</details>

<details>
<summary>Can I have multiple dialogs on one page?</summary>

Yes. Give each dialog a unique `dialogId` and have its triggers target that specific ID. Only one dialog can be active at a time — opening a new dialog closes the previous one without animation.

</details>

<details>
<summary>Does DialogTitle have to be inside Dialog?</summary>

Yes. `DialogTitle` must be placed inside the `Dialog` slot. The runtime looks for it there to wire up `aria-labelledby`. If placed outside, the dialog will not have an accessible name.

</details>

<details>
<summary>What happens to the DOM when a dialog opens?</summary>

The dialog's DOM node is physically moved from its authored position into a shared host element appended to `document.body`. When the dialog closes, the node is moved back to its original position. This teleport ensures the overlay always renders above all other page content.

</details>

<details>
<summary>How do I position the dialog in the viewport?</summary>

Use the `settings.placement` prop on `Dialog`. Options are `center` (default), `top`, `bottom`, `left`, `right`, `bottom-center`, `bottom-left`, and `bottom-right`. For fine-tuning, use the CSS custom properties `--ome-dialog-offset-x` and `--me-dialog-offset-y` to shift the dialog from its computed position.

</details>

<details>
<summary>How do I change the overlay backdrop color?</summary>

Set the `--ome-dialog-overlay-color` CSS custom property on the dialog surface element (`.ome-dialog-default`). The default is `rgba(0,0,0,0.5)`. For a lighter backdrop: `--ome-dialog-overlay-color: rgba(0,0,0,0.2)`. For a solid backdrop: `--ome-dialog-overlay-color: rgba(0,0,0,0.85)`.

</details>

<details>
<summary>Can I auto-focus a specific element when the dialog opens?</summary>

Yes. Set `settings.initialFocusSelector` to a CSS selector matching the element you want focused (e.g. `"#email-input"`). When empty, focus moves to the first focusable element in the dialog.

</details>
