@surstromming/dialog
| Install | |
|---|---|
npm install @surstromming/dialog |
|
| Latest Version: | 0.1.7 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 17, 2026 |
| Links: | GitHub · npm |
@surstromming/dialog
A modal that takes over the screen until it's dealt with. Two modes on one component:
dialog(default) — a form, a detail view. An ✕,Escapeand an outside click all close it.alertdialog— a decision that can't be waved away (delete, discard). No dismissal; the user must choose an action. This is shadcn'sAlertDialog, folded in as a role rather than a second package.
And two placements: in the middle of the screen, or flush with one of its
edges as a sheet — a menu drawer on the left, details on the right, a
phone's sheet from the bottom. That is shadcn's Sheet, folded in as side
for the same reason: the focus trap, the scroll lock and the dismissal are the
same modal's, and only where the panel sits differs.
Dependency graph
graph LR
dialog["@surstromming/dialog"]
backdrop["@surstromming/backdrop"]
design["@surstromming/design"]
icon["@surstromming/icon"]
scroll_area["@surstromming/scroll-area"]
util["@surstromming/util"]
dialog --> backdrop
backdrop --> design
dialog --> design
dialog --> icon
dialog --> scroll_area
scroll_area --> design
scroll_area --> util
Usage
<template>
<Button @click="open = true">Edit profile</Button>
<Dialog v-model:open="open" title="Edit profile" description="Change your details.">
<Input v-model="name" />
<template #footer>
<Button variant="outline" @click="open = false">Cancel</Button>
<Button @click="save">Save</Button>
</template>
</Dialog>
<!-- Confirmation that must be answered -->
<Dialog v-model:open="confirm" role="alertdialog" title="Delete project?"
description="This cannot be undone.">
<template #footer>
<Button variant="outline" @click="confirm = false">Cancel</Button>
<Button variant="destructive" @click="remove">Delete</Button>
</template>
</Dialog>
<!-- A menu drawer, its ✕ named in the app's language -->
<Dialog v-model:open="menu" side="left" title="Menu" close-label="Закрыть меню">
<nav>…</nav>
</Dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog } from '@surstromming/dialog'
import { Button } from '@surstromming/button'
import { Input } from '@surstromming/input'
</script>
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
v-model:open |
boolean |
false |
The consumer owns the state |
title |
string |
— | Wires aria-labelledby |
description |
string |
— | Wires aria-describedby |
role |
dialog | alertdialog |
dialog |
alertdialog removes dismissal |
side |
center | top | right | bottom | left |
center |
An edge makes it a sheet — see below |
closeLabel |
string |
Close |
The ✕'s accessible name, in the app's language |
Dismissal follows role — a dialog closes on ✕/Escape/outside click; an
alertdialog has none of those (choose it deliberately, and give the footer a
Cancel). There's no dismissible override: an absent boolean prop is false,
not undefined, so it can't carry a role-dependent default without lying.
Sheets
On an edge the panel runs the edge's length: full height on the left and the
right, at most spacing(80) wide; full width at the top and the bottom, as
tall as its content up to the screen less spacing(8). It is square, and
bordered only where it meets the page. It slides in from its edge while the
overlay fades, and slides back out on close; reduced motion turns both off.
The sheet's rules carry no more weight than the panel's own (:where()), so a
class on the dialog sets a sheet's width exactly as it sets a dialog's.
closeLabel is there because the ✕ is the component's, not the consumer's,
and an English "Close" read out in a Russian interface is a label the app
cannot fix from outside.
Slots
| Slot | Description |
|---|---|
default |
The body. |
header |
Replaces the title/description pair. |
footer |
Actions, right-aligned. Required for alertdialog — it's the only way out. |
Surface
The panel paints popover, not background. They're the same white in light
mode, but in dark mode they differ (0.205 vs 0.145) — and a panel painted in
the page's own colour has nothing to stand out against, however much the scrim
dims what's behind it. Every other overlay here (Popover, DropdownMenu) is
already on that surface; the dialog just joins them.
Anatomy
Backdrop // dim + blur, at `modal - 1`
div.overlay.side-* // fixed, places the panel; outside-click target
└─ div.panel[role] // aria-modal, focus-trapped
├─ button.close // ✕, dialog + dismissible only
├─ div.header // title + description (or #header)
├─ ScrollArea.body // the only part that scrolls
│ └─ div.content // <slot />
└─ div.footer // #footer
div.content exists for one reason: a scroller clips at its own padding edge,
and a full-width control ends exactly there — so the focus ring design.field
paints outside the border box was cut flat. The wrapper pads by spacing(1)
and .body takes the same off its margin, so the content still lines up with
the header and the footer.
Behavior & accessibility
- Focus trap — opening moves focus to the first focusable inside (or the
panel);
Tab/Shift+Tabcycle within and can't escape. Focusable means what the browser would tab to: anything with a negativetabindexis skipped, the body scroller's own step arrows included — they sit ahead of the footer in the DOM, so before0.1.5every dialog with body content opened with focus on one and wrapped Tab around them. Closing returns focus to whatever was focused before, so the keyboard user lands back where they left. The return is a macrotask behind the close: restored any sooner, the closing keydown's own default action — activating the focused element — lands on the opener and clicks it, and the dialog reopens. If you wire Enter in a field to close the dialog,preventDefault()anyway (@keydown.enter.prevent): it states that the press was consumed. autofocuspicks the landing spot. The first focusable is the ✕, which is the wrong place to start a dialog that asks a question —Enterthere dismisses it instead of answering it. Mark the control you want and it wins:<Input v-model="path" autofocus />. The first visible[autofocus]in the panel is used; with none, the first focusable is still it. A watcher of your own onopencan't do this — the dialog's runs after yours and takes focus back.- Scroll lock —
bodyoverflow is pinned while open and restored on close. role+aria-modal="true", witharia-labelledby/aria-describedbywired fromtitle/description.alertdialogignores Escape and outside clicks by design — put a Cancel in the footer.
Notes
- No animation-aware unmount beyond a fade+scale
Transition(a fade+slide on an edge); the panel unmounts whenopenis false. - Scroll lock doesn't compensate for the scrollbar's width, so a page with a
visible scrollbar can shift by a pixel or two on open. Fine for most apps;
a
scrollbar-gutterpass can come later if it matters.
Related Packages
Everything related to design system, including colors, typography, and spacing.
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.1.7 | ^3.4.0 | MIT | |
| 0.1.6 | ^3.4.0 | MIT | |
| 0.1.5 | ^3.4.0 | MIT | |
| 0.1.4 | ^3.4.0 | MIT | |
| 0.1.3 | ^3.4.0 | MIT | |
| 0.1.2 | ^3.4.0 | MIT | |
| 0.1.1 | ^3.4.0 | MIT | |
| 0.1.0 | ^3.4.0 | MIT |