# Dialog

> Modal dialog built on the native dialog element. Six classes and no JavaScript, with an optional component for servers that cannot call showModal().

The class goes on a `<dialog>`, and the rest is the browser’s own element. If your app runs JavaScript in the browser, you need nothing from LO-TEK: open the dialog with `showModal()` and close it with `close()`.

```js
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/dialog.css"
```

Opening it **must** be a call. `showModal()` moves focus inside, keeps focus within the dialog, closes on Escape and makes the rest of the page inert. A `<dialog open>` in the markup does none of this, and is just a box on the page. There is no declarative way to open a modal dialog, neither in LO-TEK nor in HTML.

That is a problem for only one kind of app: one with nowhere to make the call from, because all markup is produced on the server. For that there is `<lt-dialog>`, [further down the page](#when-the-server-opens-the-dialog). It is optional, in the same way that `<lt-field>` is optional next to `lt.field()`.

## How to use it

The “Try it” tab is the dialog in action. The other four are the dialog written for one environment each, and each of them is the complete recipe. The first three make the call themselves; the Datastar version lets `<lt-dialog>` do it, because a server that only sends HTML has nowhere to call from. The stylesheets are the same in all four, and are listed in each tab.

**Plain HTML**

```html
<!-- The stylesheets the page needs. Without a build step it is `<link>`,
     not `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/dialog/dialog.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->
```

```html
<button class="lt-button" data-variant="danger" id="open-confirm" type="button">
  Delete the application
</button>


<dialog class="lt-dialog" id="confirm" aria-labelledby="confirm-title">
  <h2 class="lt-dialog__title" id="confirm-title">Delete the application?</h2>
  <div class="lt-dialog__body">
    <p>The application and its attachments will be gone. This cannot be undone.</p>
  </div>
  <form method="dialog" class="lt-dialog__footer">
    <button class="lt-button" data-variant="secondary" value="cancel">Cancel</button>
    <button class="lt-button" data-variant="danger" value="delete">Delete the application</button>
  </form>
</dialog>


<script type="module">
  const dialog = document.getElementById("confirm")


  document.getElementById("open-confirm").addEventListener("click", () => {
    // Escape and close() without a value leave the previous value in place.
    dialog.returnValue = ""
    dialog.showModal()
  })


  dialog.addEventListener("close", () => {
    if (dialog.returnValue === "delete") {
      // Delete the application here
    }
  })
</script>
```

**React**

```tsx
import { useRef } from "react"
import { lt } from "@lo-tek/interfaces/react"


import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/dialog.css"
import "@lo-tek/interfaces/button.css"


export function DeleteApplication({ remove }: { remove: () => void }) {
  const dialog = useRef<HTMLDialogElement>(null)
  const box = lt.dialog({ titleId: "confirm-title" })


  return (
    <>
      <button
        {...lt.button({ variant: "danger" })}
        type="button"
        onClick={() => {
          if (!dialog.current) return
          // Escape and close() without a value leave the previous value in place.
          dialog.current.returnValue = ""
          dialog.current.showModal()
        }}
      >
        Delete the application
      </button>


      <dialog
        {...box.dialog}
        ref={dialog}
        onClose={(e) => {
          if (e.currentTarget.returnValue === "delete") remove()
        }}
      >
        <h2 {...box.title}>Delete the application?</h2>
        <div {...box.body}>
          <p>The application and its attachments will be gone. This cannot be undone.</p>
        </div>
        <form method="dialog" {...box.footer}>
          <button {...lt.button({ variant: "secondary" })} value="cancel">Cancel</button>
          <button {...lt.button({ variant: "danger" })} value="delete">Delete the application</button>
        </form>
      </dialog>
    </>
  )
}
```

**Astro**

```astro
---
import { lt } from "@lo-tek/interfaces"


import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/dialog.css"
import "@lo-tek/interfaces/button.css"


const box = lt.dialog({ titleId: "confirm-title" })
---


<button {...lt.button({ variant: "danger" })} type="button" id="open-confirm">
  Delete the application
</button>


<dialog {...box.dialog} id="confirm">
  <h2 {...box.title}>Delete the application?</h2>
  <div {...box.body}>
    <p>The application and its attachments will be gone. This cannot be undone.</p>
  </div>
  <form method="dialog" {...box.footer}>
    <button {...lt.button({ variant: "secondary" })} value="cancel">Cancel</button>
    <button {...lt.button({ variant: "danger" })} value="delete">Delete the application</button>
  </form>
</dialog>


<script>
  const dialog = document.getElementById("confirm") as HTMLDialogElement


  document.getElementById("open-confirm")?.addEventListener("click", () => {
    // Escape and close() without a value leave the previous value in place.
    dialog.returnValue = ""
    dialog.showModal()
  })


  dialog.addEventListener("close", () => {
    if (dialog.returnValue === "delete") {
      // Delete the application here
    }
  })
</script>
```

**Datastar**

```html
<!-- Datastar itself. Without it the `data-` attributes below do nothing. -->
<script type="module"
        src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>


<!-- The stylesheets the page needs. Without a build step it is `<link>`,
     not `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/dialog/dialog.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->


<!-- In the page template, once. Registration happens at startup, not on
     every patch, and it must sit outside the region the server sends again.


     The address is a URL and not "@lo-tek/interfaces/dialog", because this
     is the path without build tools: a browser cannot resolve a package
     name without an import map or a build step. -->


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtDialog } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/dialog/lt-dialog.js"


  defineLtDialog()
</script>


<!-- The region the server sends, and sends again on every patch. When
     `open` is on the host, the component calls showModal(). No client code
     opens anything. -->
<lt-dialog id="confirm" open>
  <dialog class="lt-dialog" open>
    <h2>Delete the application?</h2>
    <div class="lt-dialog__body">
      <p>The application and its attachments will be gone. This cannot be undone.</p>
    </div>
    <form method="dialog" class="lt-dialog__footer">
      <button class="lt-button" data-variant="secondary" value="cancel"
              data-on:click="@post('/cancel')">Cancel</button>
      <button class="lt-button" data-variant="danger" value="delete"
              data-on:click="@post('/delete')">Delete the application</button>
    </form>
  </dialog>
</lt-dialog>
```

The button in “Try it” does what a server would otherwise do: it sets `open` on `<lt-dialog>`, as the Datastar recipe does. The dialog in “Try it” also has a coloured header with a subtitle, described under [Coloured header](#coloured-header).

## What all four have in common

Opening the dialog **must** be a call. `showModal()` moves focus inside, keeps it there, closes on Escape and makes the rest of the page inert. `<dialog open>` alone does none of this, and is just a box on the page. The first three recipes make the call themselves; the Datastar recipe lets `<lt-dialog>` do it, because a server that only sends HTML has nowhere to call from. Pick one of the two: inside `<lt-dialog>` it is `open` on the host that opens the dialog. A `showModal()` call of your own there is not seen by the component: it does not warn about it, and it closes the dialog the next time `open` or `server-controlled` on the host changes, since the host does not say `open`. A patch that only touches the content leaves it open.

`<form method="dialog">` closes the dialog when a button is pressed, and puts the button’s `value` in `dialog.returnValue`. Then you need no listeners on the buttons, and Enter works as expected.

Escape closes the dialog on its own. To prevent that, for example in the middle of a save, cancel the `cancel` event. Do it rarely: a dialog that cannot be closed is a trap.

## Without showModal()

Without `showModal()` the markup is a box on the page. That is the state the server sends, and the one you see if JavaScript never runs.

**Plain HTML**

```html
<dialog class="lt-dialog" aria-labelledby="delete-title" open>
<h2 class="lt-dialog__title" id="delete-title">Delete the application?</h2>
<div class="lt-dialog__body">
  <p>The application and its attachments will be gone. This cannot be undone.</p>
</div>
<div class="lt-dialog__footer">
  <button class="lt-button" data-variant="secondary" type="button">Cancel</button>
  <button class="lt-button" data-variant="danger" type="button">Delete the application</button>
</div>
</dialog>
```

## When the server opens the dialog

This section only applies to apps without code of their own in the browser. If you have JavaScript there, skip it: `showModal()` is one call, and a component in between gives you nothing.

An app in Datastar, htmx, a Go template or a Razor view has no such place. It sends `open` on `<lt-dialog>`, and the component makes the call.

```js
import { defineLtDialog } from "@lo-tek/interfaces/dialog"


defineLtDialog()
```

```html
<lt-dialog open>
  <dialog class="lt-dialog" open>
    <h2>The decision is registered</h2>
    <div class="lt-dialog__body">
      <p>The case is closed and has been sent to the archive.</p>
    </div>
    <form method="dialog" class="lt-dialog__footer">
      <button class="lt-button" value="close">Close</button>
    </form>
  </dialog>
</lt-dialog>
```

Inside `<lt-dialog>` the component names the dialog from the first heading outside the body: `aria-labelledby` pointing at the heading’s id, which it creates if it is missing, and the class `lt-dialog__title` on the heading. A heading inside `lt-dialog__body` belongs to the content, not to the dialog, and never becomes the title. If the server wrote `aria-labelledby` or `aria-label`, that stays. If the dialog has neither and no heading outside the body either, the component warns in the console. When the markup is produced with JavaScript, `lt.dialog({ titleId })` writes the same.

**Give the button that closes the dialog a `value`.** Without JavaScript the dialog is a box on the page, and `<form method="dialog">` closes it before any script has run. The component recognises what has already happened from `returnValue`, which the browser sets to the button’s value. If it is empty, the component sees a host that says “open” and a closed dialog, and opens it again.

When the user closes the dialog, with Escape or with a button in `<form method="dialog">`, the component removes `open` from `<lt-dialog>` again, so the markup says the same as the screen. A click on the backdrop does not close it; neither does it on a plain `<dialog>`. The component reports with the `dialog-toggle` event, which has `detail.open` and `detail.returnValue`, the value of the button that closed it. That is how you tell “Cancel” from “Delete”. The component clears the value every time it opens the dialog, so a dialog closed without a button, with Escape or from the server, reports an empty value and not the one from last time.

The two `open` attributes in the example are not the same:

| Where | Set by | What happens in an update |
| - | - | - |
| On `<lt-dialog>` | The server, when the dialog is to be shown | The server decides. If it sends `open` again, the dialog opens again |
| On `<dialog>` | The server when the dialog is to be shown, and the browser in `showModal()` | The component puts it back as long as the dialog is in the top layer |

`server-controlled` on `<lt-dialog>` turns off the latter. Then every update decides whether the dialog is shown, also while it is in the top layer.

That the host follows the server is a choice and not an oversight. If the server sends the region again with `open` still set, the dialog opens again; for a dismissal to last, the app has to tell the server, as for any other state the server decides.

The attribute on the `<dialog>` itself is something else: the browser sets it in `showModal()`, and the server did not send it, so an update tears it off and hides the dialog the moment the component opened it. The component puts it back while the dialog is still in the top layer, so your template needs nothing extra.

The server still writes it when the dialog is to be shown, and `lt.dialog({ titleId, open: true })` puts it on both elements. The reason is that a `<dialog>` without `open` is hidden: without JavaScript, the content the server meant to show would not be there at all.

Inside `<lt-dialog>` such a dialog is styled as a modal before it is one: centred in the window, with the area behind it painted by a shadow, since `::backdrop` only exists in the top layer. It also has the same maximum width the browser gives a modal. The geometry is then the same in both states, and the dialog stays still when the component calls `showModal()`, also on a page that takes time to load. The layer can be set with `--lt-dialog-layer`.

One caveat: the dialog is fixed-positioned before it becomes modal, while a real modal sits in the top layer. If it is inside an element with `transform`, `filter` or `contain: layout`, that element becomes the containing block for the first, and the window for the second. Then it jumps after all, and how far depends on where that element is. Keep the dialog outside such elements.

None of this applies outside the host. A `.lt-dialog` with `open` and no `<lt-dialog>` around it stays where it is in the flow, as the browser’s own style places it.

## Types

```ts
// src/lotek.d.ts, gives <lt-dialog> types in JSX
import "@lo-tek/interfaces/react-jsx"
```

```tsx
import { lt } from "@lo-tek/interfaces/react"


const box = lt.dialog({ titleId: "receipt-title", open: true })


<lt-dialog {...box.host}>
  <dialog {...box.dialog}>
    <h2 {...box.title}>The decision is registered</h2>
    <div {...box.body}>The case is closed and has been sent to the archive.</div>
    <form method="dialog" {...box.footer}>
      <button {...lt.button()} value="close">Close</button>
    </form>
  </dialog>
</lt-dialog>
```

The import goes to `/react` here, since the example is JSX. From there the keys are `className` and `htmlFor`. If you write the markup in something else, import `@lo-tek/interfaces` and get `class` and `for`.

`lt.dialog.title`, `lt.dialog.body` and `lt.dialog.footer` give the class names alone. See [Type-safe use](/type-safe-use.md).

If you produce the markup with JavaScript and control everything in the browser, you need no component: call `showModal()` yourself, as in the “Plain HTML” recipe above. Then do not pass `open` to `lt.dialog()`. The attribute is the server’s message to `<lt-dialog>`, and `showModal()` throws `InvalidStateError` on a dialog that is already open.

In React the attribute is also the only thing that matches when the server renders the dialog open. The component sets `open` on `<dialog>` before React hydrates, so if it was not in the server’s HTML, React reported a mismatch on every single page load.

## Coloured header

When the dialog should say how things went before the reader has read a word, it gets a coloured header. `lt.dialog({ titleId, color })` gives `data-color` on the dialog and a `header` part that wraps the heading. The colours are the same as the [alert](/components/alert.md) uses, with `brand` in addition for the brand colour.

| `color` | Means |
| - | - |
| `neutral` | No colour. Default |
| `brand` | The brand colour, for a header that says who is speaking |
| `info` | For information |
| `success` | It went well |
| `warning` | Look at this |
| `danger` | Something is wrong, or cannot be undone |

```astro
---
const box = lt.dialog({ titleId: "result-title", color: "success" })
---
<dialog {...box.dialog}>
  <div {...box.header}>
    <h2 {...box.title}>Full marks</h2>
    <p {...box.subtitle}>25 of 25 points</p>
  </div>
  <div {...box.body}>…</div>
  <form method="dialog" {...box.footer}>…</form>
</dialog>
```

Without the builder function it is the same classes, written out:

```html
<dialog class="lt-dialog" data-color="success" aria-labelledby="result-title">
  <div class="lt-dialog__header">
    <h2 class="lt-dialog__title" id="result-title">Full marks</h2>
    <p class="lt-dialog__subtitle">25 of 25 points</p>
  </div>
  <div class="lt-dialog__body">…</div>
  <form method="dialog" class="lt-dialog__footer">…</form>
</dialog>
```

The header is optional, and without it the dialog looks as before. The colour applies to the header, so without `header` in the markup it has nothing to colour. A header without colour is not a stripe either: it then drops its bottom padding, and the distance down to the text is the same as otherwise.

## Attributes

| Attribute | Type | Effect |
| - | - | - |
| `open` | boolean | Whether the dialog is shown. The server sets it when the dialog is to be shown, and the component removes it when the user closes it. For the server to show the dialog, `open` must also be on `<dialog>`. |
| `server-controlled` | boolean | If an update from the server removes `open` from `<dialog>` while the dialog is open, the component closes it and sends `dialog-toggle`. Without the attribute it puts `open` back. See [When the server opens the dialog](#when-the-server-opens-the-dialog). |

## Classes you can style

| Class | What it is |
| - | - |
| `.lt-dialog` | The dialog itself. `data-color` colours the header |
| `.lt-dialog__header` | The header, with the heading and an optional subtitle inside. Optional: with it, the padding moves from the dialog to the parts, so a coloured band reaches all the way to the edges |
| `.lt-dialog__title` | The heading |
| `.lt-dialog__subtitle` | A line below the heading, inside the header |
| `.lt-dialog__body` | The content, which scrolls when the dialog gets too tall. Must be a direct child of `.lt-dialog`, otherwise the dialog scrolls instead, and the heading and the button row scroll out of view with the text |
| `.lt-dialog__footer` | The row of buttons at the bottom |

`display` is the one property you cannot set freely on `.lt-dialog`. A `<dialog>` without `open` is hidden by the browser’s own stylesheet, and an author rule with `display` beats it regardless of layer and specificity, so a closed dialog stays on the page as a card. If you need a different layout, add `dialog.lt-dialog:not([open]):not(:is(:popover-open)) { display: none }` yourself, and make it at least as specific as your own rule. The system sets `display: flex` only when `.lt-dialog__body` is a direct child, and does exactly this alongside it.

| Variable | Default |
| - | - |
| `--lt-dialog-width` | `min(32rem, 100%, calc(100vw - var(--lt-spacing-8)))` |
| `--lt-dialog-padding` | `var(--lt-spacing-5)` |
| `--lt-dialog-radius` | `var(--lt-spacing-2)` |
| `--lt-dialog-layer` | `40` |

`--lt-dialog-layer` only applies in the brief moment the dialog is open without being modal, before the component has called `showModal()`. It is not in the top layer yet then, and must be stacked like everything else. Once the dialog is modal, it is above everything regardless.

`100%` in the width applies when the dialog is not in the top layer. A `<dialog>` opened with `showModal()` relates to the viewport regardless, but if you make a box on the page of it, as in the preview above, `100vw` would make it wider than the box.

The area behind the dialog is `::backdrop`, and only exists when the dialog was opened with `showModal()`. The colour comes from `--lt-color-overlay` and is stronger in the dark theme, where a black overlay would otherwise be invisible.

See [Customising](/customising.md) for how the variables and the layer work.

## Accessibility

- The dialog needs a name. `lt.dialog({ titleId })` writes `aria-labelledby` pointing at the heading, and inside `<lt-dialog>` the component sets it from the first heading outside the body when it is missing. If you have no heading, use `aria-label`.
- The heading should ask the question: “Delete the application?”, not “Confirm”.
- The button text should say what happens. “Delete the application” is clearer than “OK”, especially for someone who hears the button without seeing the heading.
- After the dialog closes, the browser returns focus to the button that opened it. If you move focus yourself in the meantime, you have to put it back.
- Do not use a dialog for something that could have been on the page. It takes over the whole screen for someone using magnification.
- `<lt-dialog>` always opens modally. A `show()` would give a box that looks the same, but does not trap focus and does not close on Escape. The difference is `:modal`, and it is tested in all three browser engines.
