Skip to content
LyEve UI v0.60.1

Overlays

Dialog

A promise-based imperative dialog system for confirmations and one-field prompts. Dialogs stack on top of each other, each with its own focus trap, backdrop, and ESC handler. A form goes in a Drawer, which sizes itself to its fields. A one-field prompt may sit in a Modal. In a console a row is created and edited in a Drawer.

Setup

Mount <DialogContainer /> once near the root of your layout. It reads from the module-level state and renders the dialog stack.

src/routes/+layout.svelte svelte
<script lang="ts">
  import { DialogContainer } from '@lyeve-labs/ui-kit';
  let { children } = $props();
</script>

{@render children()}
<DialogContainer />

Confirm dialog

The confirm() shortcut is the simplest path: open a small dialog, get back a boolean. Its third argument names the buttons, and the ConfirmDialog page shows it live.

src/routes/+page.svelte svelte
<script lang="ts">
  import { confirm } from '@lyeve-labs/ui-kit';

  async function handleDelete(id: string) {
    const ok = await confirm('Delete this item?', 'This action cannot be undone.', {
      confirmLabel: 'Delete',
      detail: id,
    });
    if (!ok) return;
    await fetch(`/api/items/${id}`, { method: 'DELETE' });
  }
</script>

Custom body and footer

Use openDialog() for full control. Pass a body and a footer snippet, then call closeDialog() with a payload or dismissDialog() to cancel. A snippet declared at the top of the component can be handed to it by name.

src/routes/+page.svelte svelte
<script lang="ts">
  import { Button, Input, closeDialog, dismissDialog, openDialog } from '@lyeve-labs/ui-kit';

  let name = $state('');

  async function openCreate() {
    // Resolves with the payload passed to closeDialog(). Dismissing the
    // dialog rejects it instead: see Error handling below.
    const result = await openDialog<string>({
      title: 'Create project',
      body: createBody,
      footer: createFooter,
    });
    console.log('Created:', result);
  }
</script>

{#snippet createBody()}
  <Input label="Project name" bind:value={name} />
{/snippet}

{#snippet createFooter()}
  <Button variant="secondary" onclick={() => dismissDialog()}>Cancel</Button>
  <Button onclick={() => closeDialog(name)}>Create</Button>
{/snippet}

<Button onclick={openCreate}>New project</Button>

Blocking a close

Pass onClose to block dismissal when there is unsaved work. Return false to keep the dialog open.

const result = await openDialog({
  title: 'Rename view',
  persistent: false,
  onClose: () => {
    if (nameChanged) return false; // stays open
  },
  body: renameSnippet,
  footer: actionsSnippet,
});

Persistent dialogs

Set persistent: true to block ESC and backdrop-click dismissal. Only an explicit dismissDialog(id) (or confirming with closeDialog()) will close it.

src/routes/+page.svelte svelte
<script lang="ts">
  import { Button, closeDialog, dismissDialog, openDialog } from '@lyeve-labs/ui-kit';

  async function askTerms() {
    const result = await openDialog<string>({
      id: 'terms',
      title: 'Required action',
      persistent: true,
      body: termsBody,
      footer: termsFooter,
    });
    console.log('Terms:', result);
  }
</script>

{#snippet termsBody()}
  <p>Accept the updated terms to keep using the workspace.</p>
{/snippet}

{#snippet termsFooter()}
  <Button variant="ghost" onclick={() => dismissDialog('terms')}>Decline</Button>
  <Button onclick={() => closeDialog('accepted', 'terms')}>Accept</Button>
{/snippet}

<Button onclick={askTerms}>Review terms</Button>

Stack and lifecycle

Dialogs stack last-on-top. openDialog() pushes, closeDialog() resolves, dismissDialog() rejects. dismissDialog() without an id does not dismiss persistent dialogs.

import {
  openDialog,
  closeDialog,
  dismissDialog,
  dismissAllDialogs,
  getDialogStack,
} from '@lyeve-labs/ui-kit';

const a = openDialog({ title: 'First' });
const b = openDialog({ title: 'Second' });

getDialogStack();           // [{ depth: 0, ... }, { depth: 1, ... }]

dismissDialog();            // dismisses "Second"
closeDialog('done');        // resolves "First" with "done"

dismissAllDialogs();        // rejects every open dialog

Sizes

A dialog takes a rung of the shared overlay ladder, the same one Modal and Drawer use. It has no auto size. openDialog() opens at md unless the call names a rung, and confirm() names sm. That suits the confirmation and the one-field prompt the stack is for. A form goes in a Drawer, which measures its fields and takes the rung they need. A one-field prompt may sit in a Modal.

  • sm: 448px, the default for confirm()
  • md: 576px, the default for openDialog()
  • lg: 704px
  • xl: 896px
  • full: 1088px, for a dialog holding a table

Under a touch screen

The close cross takes a 44px target, as the crosses on Modal and Drawer do. It is drawn the same size as under a mouse.

Error handling

Dismissed dialogs (backdrop, ESC, or dismissDialog()) reject the promise with a DOMException named AbortError. Catch it explicitly or use .catch():

try {
  const result = await openDialog({ title: 'Edit' });
  await save(result);
} catch (err) {
  if (err instanceof DOMException && err.name === 'AbortError') {
    // the reader canceled, so there is nothing to do
    return;
  }
  throw err;
}

API

DialogContainer

No props. Mount once at the app root. It reads the reactive dialog stack and renders each entry as a <Dialog>.

Dialog

The frame DialogContainer draws for each entry in the stack: the focus trap, Escape and the backdrop, the motion and the stacking. A page opens it through openDialog() and rarely renders it directly.

PropTypeDefaultDescription
entry requiredDialogEntry<any> no defaultThe stack entry it draws: its options, its place in the stack and how it answers.
children Snippet no defaultThe body, when the entry carries none and is not a confirm.

openDialog

PropTypeDefaultDescription
id string no defaultCustom id. Auto-generated dialog-N if omitted.
title string | Snippet no defaultDialog heading.
body Snippet no defaultMain content.
footer Snippet no defaultFooter actions. No footer if omitted and non-persistent.
size DialogSize'md'A rung of the overlay ladder: 448, 576, 704, 896, 1088px from sm to full. There is no auto, so a form goes in a Drawer. A one-field prompt may sit in a Modal.
persistent booleanfalseBlock ESC and backdrop dismissal.
onClose () => boolean | void no defaultReturn false to prevent closing.

Imperative functions

PropTypeDefaultDescription
confirm(title, message?, options?) Promise<boolean> no defaultShortcut: opens a sm confirm dialog. Resolves true if confirmed, false if canceled. options names the buttons and adds a detail line.
openDialog<T>(options) Promise<T> no defaultOpens a dialog. Resolves on closeDialog(), rejects on dismissDialog().
closeDialog(value, id?) void no defaultResolves the dialog promise. Omit id to close the topmost.
dismissDialog(id?) void no defaultRejects the dialog promise with AbortError. Ignores persistent dialogs unless id is explicit.
dismissAllDialogs() void no defaultRejects every open dialog and resets body scroll lock.
setDialogMeta(id, meta) void no defaultMerge metadata into a dialog entry. Keys: confirmTitle, confirmMessage, confirmDetail, confirmLabel, cancelLabel.
getDialogStack() readonly DialogEntry[] no defaultSnapshot of the current dialog stack.

Types

PropTypeDefaultDescription
DialogSize 'sm' | 'md' | 'lg' | 'xl' | 'full' no defaultMax-width preset.
DialogOptions<T> interface no defaultid, title, body, footer, size, persistent, onClose.
DialogEntry<T> interface no defaultid, options, depth, meta. Depth 0 is the bottom dialog.