Tessera UI

Components

Dialog

A modal dialog: it takes focus, makes the page behind it inert and stops it scrolling, and gives focus back when it closes.

The preview needs JavaScript. The code is in the Code tab.

Dialog-0.tess
<script>
const open = state(false)
</script>

<template>
  <div>
    <Button @press="open = true">Delete project</Button>
    <Dialog :open="open" @dismiss="open = false">
      <span slot="title">Delete this project?</span>
      This removes its files and cannot be undone.
      <div slot="actions">
        <Button tone="neutral" emphasis="outline" @press="open = false">Cancel</Button>
        <Button tone="danger" @press="open = false">Delete</Button>
      </div>
    </Dialog>
  </div>
</template>

Installation

npx @pixelscriptio/tessera add Dialog
pnpm exec tessera add Dialog
yarn tessera add Dialog
bunx @pixelscriptio/tessera add Dialog
  • Also installs Button, which it uses.
  • Works with Tessera 0.9.0 or newer.
  1. Copy and paste the code into your project

    Put it in system/components. Add Button the same way.

    system/components/Dialog.tess
    <contract>
    {
      "name": "Dialog",
      "role": "dialog",
      "description": "A modal dialog: it takes focus, makes the page behind it inert and stops it scrolling, and gives focus back when it closes.",
      "since": "0.5.0",
      "parts": ["root", "panel", "title", "body", "actions"],
      "variants": {
        "size": ["md", "sm", "lg"] // first value is the default
      },
      "states": ["open"],
      "behavior": "dialog",
      "slots": {
        "title": "the heading, which is also the dialog's accessible name",
        "default": "the content",
        "actions": "the buttons, such as Cancel and Save"
      },
      "events": {
        "dismiss": "fired with { reason: 'escape' | 'backdrop' | 'native' } when the person asks to close it; listen and set open to false"
      },
      "contrast": [
        { "foreground": "body", "background": "panel" },
        { "foreground": "title", "background": "panel" }
      ],
      "preview": {
        "slots": {
          "title": "Delete project?",
          "default": "This cannot be undone. The project and its files are removed for everyone.",
          "actions": [
            {
              "component": "Button",
              "props": {
                "tone": "neutral",
                "emphasis": "outline"
              },
              "slots": {
                "default": "Cancel"
              }
            },
            {
              "component": "Button",
              "props": {
                "tone": "danger"
              },
              "slots": {
                "default": "Delete"
              }
            }
          ]
        }
      }
    }
    </contract>
    
    <script>
    const { open, persistent } = props({ open: false, persistent: false })
    
    // The component owns whether it is open, starts from the prop and follows it when it changes.
    const isOpen = state(open.value)
    effect(() => {
      isOpen.value = open.value
    })
    
    // An id of its own, so the title can name the dialog however many there are.
    const titleId = `t-dialog-${Math.random().toString(36).slice(2, 9)}`
    
    function dismiss(detail) {
      // A persistent dialog only reports the request, so its owner can confirm first and close it by setting open.
      if (!persistent.value) isOpen.value = false
      emit('dismiss', detail)
    }
    </script>
    
    <template>
      <dialog
        class="root"
        :data-open="isOpen ? 'true' : null"
        :aria-labelledby="titleId"
        @dismiss="dismiss($event)"
        >
        <div class="panel">
          <h2 class="title" :id="titleId"><slot name="title" /></h2>
          <div class="body"><slot /></div>
          <div class="actions"><slot name="actions" /></div>
        </div>
      </dialog>
    </template>
    
    <style>
    .root {
      max-width: calc(100vw - 2rem);
      padding: 0;
      border: 1px solid $color.border.default;
      border-radius: $radius.lg;
      background: $color.surface.default;
      color: $color.text.default;
      box-shadow: $shadow.md;
      font-family: var(--t-font-family-sans);
    
      &::backdrop {
        /* No text goes on the backdrop; this says what would be readable on it, for the contrast check. */
        color: $color.surface.default;
        background: $color.surface.inverse;
        opacity: $opacity.disabled;
      }
    }
    
    .panel {
      display: flex;
      flex-direction: column;
      gap: $space.4;
      box-sizing: border-box;
      width: 28rem;
      max-width: 100%;
      padding: $space.6;
      background: $color.surface.default;
    }
    
    .root[data-size='sm'] .panel {
      width: 20rem;
    }
    
    .root[data-size='lg'] .panel {
      width: 40rem;
    }
    
    .title {
      margin: 0;
      color: $color.text.default;
      font-size: $font.size.lg;
      font-weight: $font.weight.bold;
    }
    
    .body {
      color: $color.text.muted;
      font-size: $font.size.base;
      line-height: 1.6;
    }
    
    .actions {
      display: flex;
      justify-content: flex-end;
      gap: $space.2;
    }
    
    .actions:empty {
      display: none;
    }
    </style>
    
  2. Check your design tokens

    Your design system needs these. tessera add reports the ones that are missing; by hand, run npx @pixelscriptio/tessera check.

    color.border.default color.surface.default color.surface.inverse color.text.default color.text.muted font.size.base font.size.lg font.weight.bold opacity.disabled radius.lg shadow.md space.2 space.4 space.6

Usage

Using Dialog

Use a dialog for something that needs an answer before the person goes on: confirming a deletion, a short form. For information that can wait, use the page.

  • Fill the title slot (it is the dialog's name for assistive technology), the default slot with the content, and the actions slot with the buttons.
  • It is closed until open is true. It takes focus, makes the page behind it inert, stops the page scrolling, keeps Tab inside, and gives focus back to what opened it when it closes.
  • Escape and a click outside the panel fire dismiss with a reason. The dialog closes itself; listen for dismiss and set open to false to keep your state in step. Set persistent to keep it open until you set open, for example to confirm before closing.
  • Always give a visible way to close it in actions: Escape and the backdrop are shortcuts.

The preview needs JavaScript. The code is in the Code tab.

Dialog-0.tess
<script>
const open = state(false)
</script>

<template>
  <div>
    <Button @press="open = true">Delete project</Button>
    <Dialog :open="open" @dismiss="open = false">
      <span slot="title">Delete this project?</span>
      This removes its files and cannot be undone.
      <div slot="actions">
        <Button tone="neutral" emphasis="outline" @press="open = false">Cancel</Button>
        <Button tone="danger" @press="open = false">Delete</Button>
      </div>
    </Dialog>
  </div>
</template>

API reference

Variants

VariantValues
sizemd default sm lg

Slots

SlotContent
titlethe heading, which is also the dialog's accessible name
defaultthe content
actionsthe buttons, such as Cancel and Save

Events

EventWhen
dismissfired with { reason: 'escape' | 'backdrop' | 'native' } when the person asks to close it; listen and set open to false

Anatomy

Roledialog
Behaviordialog
Partsroot panel title body actions
Statesopen
Since0.5.0

Design tokens it uses

Your design system needs these. tessera add checks that it has them, and can add the ones it lacks.

color.border.default color.surface.default color.surface.inverse color.text.default color.text.muted font.size.base font.size.lg font.weight.bold opacity.disabled radius.lg shadow.md space.2 space.4 space.6

Checksum

a07fb388b78003b2ba2de4f0cce5de10c0cb2e27d889c1667a9e35c6091b50fd