UI toolkit
The helpers a frontend layer builds screens with, from drawers, modals and confirm dialogs to export progress, header actions, cards, code snippets, block scrolling and shortcuts.
The base layers ship the pieces your custom Vue builds screens with, so your screens look and behave like the rest of the dashboard. For what they look like on screen, see row drawers, confirmation dialogs and forms. This page covers opening them from your own code. Error feedback covers showing a failed backend call.
Programmatic containers, useModal and useDrawer
useModal().open(options) and useDrawer().open(options) mount a component inside a themed container and return a handle whose result promise settles when it closes. Use them for a dialog your code controls, such as a record picker, a short wizard or a confirmation with a form inside.
const modal = useModal();
const instance = modal.open({
title: "Pick a template",
description: "The report will be generated from it",
component: MyTemplatePicker,
componentOptions: { category: "reports" },
});
const picked = await instance.result;
Both take the ContainerOptions below. useDrawer adds the options marked drawer only.
| Option | Purpose |
|---|---|
title, description? | The header texts. |
icon?, color? | An icon in a tinted well before the title, and the well's tint (primary by default). |
component, componentOptions? | The body component and its props. |
componentKey? | Mounts the body again when it changes. |
headerComponent?, headerComponentOptions? | A header that replaces the default one. |
containerId? | The container's identity. Generated when left out. |
size? | For useModal, sm to 5xl, or full. For useDrawer, the width a left or right drawer opens at from lg: a modal's width at that size, 600px without one. |
direction? | Drawer only. top, bottom, left or right. |
sizeKey? | Drawer only. The key the user's drawer size is remembered under. Defaults to containerId, then to the body component's name. |
rowHeader? | Drawer only. The DMS's row header in place of the title, as on a row action's drawer (below). |
The returned ContainerInstance has three members:
resultsettles with the value the body component emits onsuccess, or the value passed toclose(value).close(value?)dismisses the container.patch(changes)changes the open container in place: itstitleanddescription, its body'scomponentOptionsandcomponentKey, itsheaderComponentOptions, and on a drawer itsrowHeader. A newcomponentKeymounts the body again.
A drawer on a row of your own
A module that opens a drawer on a row of a list it draws itself, such as a data browser or a key editor, gets the header of a row action's drawer with rowHeader. The header shows the row's title, its position ("Key 4 of 12") and previous and next buttons beside the close button. J and K step the same way while the drawer is open, so do not bind them yourself. As the drawer steps, patch it with the new row's componentOptions, a componentKey to mount the body again, and its rowHeader, which replaces the previous one.
rowHeader field | Purpose |
|---|---|
title? | The row's title. The drawer's title without one. A long title wraps onto two lines. |
positionLabel? | A $ i18n key that receives {index} (from 1) and {total}, both grouped as the locale does. "{index} of {total}" without one. |
navigation? | A RowNavigation, { index, total, hasPrev, hasNext, prev(), next() }, which drives the position, the buttons, and J and K. Without one, there is no position and no buttons. |
titleClass? | Classes added to the title, such as font-mono for a key. Its size and weight stay. |
titleBreakAfter? | Wraps the title only after this separator, such as "." for a dotted key, never inside a segment. |
subtitle? | Text after the position on its line, after a "·". It is cut at the end of the line, with the whole text in its tooltip ("12 of 1,338 · helio › catalog"). |
subtitleClass? | Classes added to the subtitle, such as font-mono. |
component?, componentOptions? | A component drawn under the position line, in the title's column, such as status pills, and its props. Patch the rowHeader to update them. |
const { open } = useDrawer();
function openKey(index: number) {
const navigationAt = (at: number): RowNavigation => ({
index: at,
total: keys.value.length,
hasPrev: at > 0,
hasNext: at < keys.value.length - 1,
prev: () => show(at - 1),
next: () => show(at + 1),
});
const drawer = open({
title: "Key",
component: KeyEditor,
componentOptions: { keyId: keys.value[index].id },
rowHeader: { title: keys.value[index].path, positionLabel: "$lang.keys.position", navigation: navigationAt(index) },
});
function show(at: number) {
drawer.patch({
componentOptions: { keyId: keys.value[at].id },
componentKey: keys.value[at].id,
rowHeader: { title: keys.value[at].path, positionLabel: "$lang.keys.position", navigation: navigationAt(at) },
});
}
}
This header of a key editor shows the key in a monospace font that wraps after its dots, its namespaces after the position, and its status under them:
const header = (at: number): DrawerRowHeader => ({
title: keys.value[at].path,
titleClass: "font-mono",
titleBreakAfter: ".",
positionLabel: "$lang.keys.position",
subtitle: keys.value[at].crumbs,
subtitleClass: "font-mono",
component: KeyStatus,
componentOptions: { missing: keys.value[at].missing },
navigation: navigationAt(at),
});
A body that fills the drawer, and its footer
A drawer's body is a flex column. A body component whose root grows (class="flex flex-1 flex-col") is as tall as the drawer while its content is short, and the drawer scrolls once the content is taller. End the body with <DmsContainerFooter> for its buttons. The footer is a band under a thin rule, flush with the drawer's edges, at the bottom of the drawer, and it stays there while the content scrolls. It works in a modal too. Do not measure the drawer or use the --dms-form-foot-* variables, which belong to the layer.
<template>
<div class="flex flex-1 flex-col gap-4">
<SendSummary :send="send" />
<DmsContainerFooter justify="between">
<UButton variant="ghost" :label="t('mailing.send.preview')" @click="preview" />
<UButton :label="t('mailing.send.confirm')" @click="confirm" />
</DmsContainerFooter>
</div>
</template>
justify is start, end (the default) or between. A DmsForm in a drawer already draws its own footer the same way.
A code editor, such as one for a JSON value, can take the height the footer leaves. fill on DmsInputCode makes it grow in its flex parent and scroll inside. minLines stays its minimum, below which the drawer scrolls, and maxLines is ignored. fill on a stacked DmsFieldRow passes the height to its control. A UFormField between them passes it on through its ui:
<template>
<div class="flex w-full min-w-0 flex-1 flex-col gap-4">
<DmsFieldRow layout="stack" fill :inset="false" spacing="list" :label="column" label-for="json-cell" :description="t('db.json.help')" required>
<UFormField name="json" :error="parseError || false" :ui="{ root: 'flex flex-1 flex-col', container: 'flex flex-1 flex-col' }">
<DmsInputCode id="json-cell" v-model="draft" language="json" fill :min-lines="12" />
</UFormField>
</DmsFieldRow>
<DmsFormRequiredLegend />
<DmsContainerFooter>
<UButton variant="outline" color="neutral" size="lg" :label="t('db.cancel')" @click="emit('success')" />
<UButton size="lg" :label="t('db.json.apply')" :disabled="Boolean(parseError)" @click="save" />
</DmsContainerFooter>
</div>
</template>
What the body injects
The dashboard's overlay host mounts the drawer, not the component that opens it. The body still injects what that component provides, as a drawer written in its template would. A module's store or context provided by the page reaches the drawer without a prop. Call useDrawer() in the setup of the component whose provides the body needs. Called outside a setup, the body sees only the app's provides.
What the drawer provides itself wins: its container for the leave guards, and the form surface. A field in the body never joins a Nuxt UI form or form field around the caller, because the drawer is a surface of its own. The drawers the DMS opens itself (a row action's, a relation field's, a table's form) see only the app's provides.
The types a module names are public, from #dms-ui/app/composables/containers/types: DrawerOptions, DrawerRowHeader, DrawerInstance and DrawerPatch. RowNavigation is auto-imported, from #dms-ui/app/types/row-navigation.
Resizing
From lg, every drawer is resizable. A handle on its inner edge sets the width of a left or right drawer and the height of a top or bottom one. Drag it, or focus it and use the arrow keys, with Home and End for the limits. A double-click restores the default size. A side drawer stays between 360px and 90% of the window. An edge drawer keeps its natural height until resized, and stays below 90% of the window's height. Below lg, drawers keep their phone layout and grip, with no handle.
The size is remembered on the device, in the dms-drawer-sizes cookie, under the drawer's sizeKey, so the drawer opens again at the size it was left at. Without sizeKey, the key is the containerId, then the body component's name (its name, or a <script setup> component's file name). Pass sizeKey when one component opens drawers that should not share a size, when the containerId changes at every opening, or when the component is anonymous, such as a render function or a defineAsyncComponent wrapper. Widths and heights are stored apart, so a side drawer and a bottom drawer may share a key.
Closing
When a drawer or a modal starts to close, the page takes clicks again at once. The backdrop fades out in 100ms, or at once with reduced motion, while the panel still slides or scales out, and focus returns to where it was. The user can click the next row straight away. This holds for every dialog in the dashboard: useDrawer, useModal, a UModal, USlideover or UDrawer a module renders itself, the useConfirm dialogs and the DMS's own. A dialog that closes over one that stays open, such as a confirm over a drawer, gives the drawer its clicks back at once. A click that opens another dialog while the first is still closing keeps the new one open. The closing dialog returns focus only when nothing opened since.
A TableView (Tables) opens the same containers for its drawer and modal form modes, and an ActionTarget of type drawer or modal opens them too.
Confirmation, useConfirm
useConfirm().confirm(options) opens the standard confirm dialog and resolves to a boolean. Dismissing the dialog resolves false.
const { confirm } = useConfirm();
async function remove() {
const ok = await confirm({
title: "Delete this report?",
description: "This cannot be undone.",
color: "error",
});
if (!ok) return;
}
ConfirmOptions is the backend's ConfirmDialog with its texts already resolved:
| Option | Purpose |
|---|---|
title, description? | The dialog's texts. |
confirmLabel?, cancelLabel? | The button labels. |
color? | The dialog's colour. |
icon? | The header icon. It defaults per colour, and false gives the minimal layout. |
impact? | The dependents the action affects. |
blocked? | The action cannot run, and the dialog only explains why. |
confirmText?, confirmTextCaseInsensitive? | Text the user must type to confirm, compared ignoring case when the second is true. |
fields? | Fields filled in before confirming. |
component? | A component drawn in the body. |
On top of those, it takes what only the browser can give:
| Option | Purpose |
|---|---|
secondaryAction? | { label, icon?, run }, a button next to cancel and confirm. run is awaited with the dialog left open, and a rejection shows in the dialog's alert. |
confirmIcon? | The confirm button's leading icon. |
initialFocus? | "cancel" or "confirm", the button focused on open. |
body? | Extra content as a render function, run again when the state it reads changes. |
validate? | Checks the body fields on confirm. Returning false keeps the dialog open. |
onConfirm? | (values) => Promise<...>, the action run from inside the dialog with the fields' values. A rejection stays on screen, under its field when the server names one, and the dialog stays open for a retry. |
A dialog the server words, useConfirmFrom
A declarative action's confirm: { from: url } reads its dialog from a route (Dialogs the server words). Your own code asks for the same dialog with useConfirmFrom().confirmFrom(url, options?). It fills the URL's {{row.X}} tokens from params, reads the dialog with the user's session, resolves its texts with params and the dialog's own, and shows the standard dialog. It resolves like confirm, and never true for a blocked dialog. A failed read shows an error toast and resolves false.
const { confirmFrom } = useConfirmFrom();
async function remove(campaign: Campaign) {
const ok = await confirmFrom("/api/campaigns/{{row.id}}/delete-confirm", {
params: { id: campaign.id, name: campaign.name },
fallback: { color: "error", icon: "i-ph-trash" },
onConfirm: async (values) => {
await $authFetch(`/api/campaigns/${campaign.id}`, { method: "DELETE", body: values });
},
});
if (ok) refresh();
}
ConfirmFromOptions has three fields:
params?fills the URL's{{row.X}}tokens and the texts. The dialog's ownparamswin in its texts.fallback?is what the dialog leaves out, such as a colour, an icon or a confirm label.onConfirm?runs the action from inside the dialog with its fields' values, as forconfirm.
usePresenceEditWarning().warnBeforeEdit(editors) shows the dialog that warns someone is already editing a record, from a realtime presence list, and resolves the same way. A TableView calls it before it opens an edit form on a row someone else holds.
Async export progress, useExportJob
useExportJob().runJob(options) runs a backend export job from the browser. It starts the job, polls its status into a progress toast, downloads the file when the job completes, and can offer a retry on failure.
const { runJob } = useExportJob();
await runJob({
startUrl: "/api/report/export/start",
startQuery: { from: "2026-01-01" },
});
RunExportJobOptions:
| Option | Purpose |
|---|---|
startUrl | The route that starts the job. |
startMethod? | GET or POST. |
startQuery?, startBody? | The start request's parameters. |
statusUrl?, downloadUrl? | Functions of the returned ticket. They default to <startBase>/status/:jobId and <startBase>/download/:jobId. |
fallbackFilename? | A function of the ticket that returns a file name. The Content-Disposition file name wins when present. Defaults to <filename>.<extension> when the ticket names a file, and export-<jobId>.<extension> otherwise, with bin when the ticket has no extension. |
labels? | New labels for the progress toast. |
retryable? | Defaults to true, which offers a retry on failure. false removes the retry. |
The exportJob action target runs the same runner from a declaration.
Page header actions, usePageHeaderActions
A page's Vue component can put its own actions in the page header, next to the buttons the page layout serves (DefaultLayout({ headerActions }), Layout). Use it for an action that runs in the browser, such as an export built in the browser or a status pill, which the server cannot describe. usePageHeaderActions(render, options?) takes a render function and shows what it returns once the page has set up. The actions go away when the component unmounts. Call it before the first await of the setup, so the server renders the actions with the page and they never appear late. Outside a layout that draws a page header, the call does nothing.
<script setup lang="ts">
import { UButton } from "#components";
const { t } = useI18n();
usePageHeaderActions(
() => h(UButton, { label: t("lang.export"), icon: "i-ph-download-simple", color: "neutral", variant: "outline", onClick: exportCsv }),
{ order: -1 },
);
</script>
| Option | Type | Default | Description |
|---|---|---|---|
order | number | 0 | The place among the served buttons, which each have an order too, 0 by default. The header sorts them together, smallest first, and at an equal order the served buttons come first. -1 puts the actions before every served button left at 0. |
One component of the page sets the actions, and a second call replaces the first. To show several buttons, render them all from the one render function.
Record card, DmsRecordCard
DmsRecordCard is the card of one record, such as a skill, a plan or a feature, for a table view's card.component or any grid of records. It shows an icon well, a monospace title and subtitle, a badge and a description, then tags, a monospace note and buttons on a footer under a rule.
<DmsRecordCard
icon="i-ph-table"
:title="row.name"
:subtitle="row.owner"
:badge="{ label: 'Beta', tone: 'warning' }"
:description="row.summary"
:tags="[row.area]"
:meta="`${row.votes} votes`"
:actions="[{ label: 'Edit', icon: 'i-ph-pencil-simple', onClick: edit }]"
:selected="selected"
interactive
@open="open()"
/>
tone tints the icon well, primary by default. interactive makes the whole card open the record, emitting open on a click or Enter, while its buttons act without opening it. selected draws a ring around it, and muted dims it. The default slot goes between the description and the footer, aside replaces the badge, and actions replaces the buttons.
Required-fields legend, DmsFormRequiredLegend
DmsFormRequiredLegend is the line that a stock Form, and a confirm dialog with fields, print under a form with a required field: a red asterisk, then "Required fields" in the reader's language (dms.form.required_legend). Print it under a custom form, so its required fields read like the dashboard's.
<form @submit.prevent="save">
<!-- fields marked with the same asterisk -->
<DmsFormRequiredLegend v-if="hasRequiredFields" class="mt-4" />
</form>
It takes no props. A class goes on its paragraph.
Code snippet, DmsCodeSnippet
DmsCodeSnippet shows read-only code in the code editor's style. The CodeBlock draws it. The head shows the title, the language and a copy button, then the code highlighted in its language. Use it for a request body, a response or a generated snippet in a custom component, instead of a <pre> of your own.
<DmsCodeSnippet :code="body" language="json" title="Request body" max-height="20rem" />
<DmsCodeSnippet :code="curl" wrap="anywhere" />
| Prop | Type | Default | Purpose |
|---|---|---|---|
code | string | None | The code, as written. |
language | string | None | json, javascript, typescript, html, sql, shell or text. js, ts, bash, sh and plain read as their full names. |
title | string | None | Shown in the head, as written. |
copy | boolean | true | Shows a button that copies the code. |
maxLines | number | None | The lines shown before the code scrolls. |
maxHeight | string | None | A CSS height past which the code scrolls ("20rem"), when maxLines is not set. |
wrap | boolean | "anywhere" | false | true wraps long lines between words. "anywhere" also breaks a word too long for the line, such as a URL, a token or minified JSON. |
Without a title or a language, the snippet has no head. The copy button floats in the code's top corner, and the code keeps clear of it. Pass language="text" to keep a head that names plain text.
Scrolling to a block, scrollToBlock
scrollToBlock(key, options?) is auto-imported. It scrolls a block of the current page into view and returns true, or returns false without scrolling when the block is not on screen. A block's anchor is the key path it is declared under: its static field's key for a top-level block, then each .child() key down to it. The anchors are the page's own keys, so they are stable, and a custom component can name any block of its page:
static summary = StatGroup({ /* ... */ }); // anchor "summary"
static tabs = Tab({ /* ... */ }) // anchor "tabs"
.child("statistics", Grid(), { slot: "statistics" }); // anchor "tabs.statistics"
function showStatistics(): void {
if (!scrollToBlock("tabs.statistics", { behavior: "smooth" })) void navigateDms("?tab=statistics");
}
options are those of Element.scrollIntoView, { block: "start" } by default, applied to the block's first element. A block in a closed tab, a block the viewer may not see, and a key no block has all return false.
Declaring keyboard shortcuts
Components define their shortcuts with Nuxt UI's defineShortcuts. The DMS part is declaring them, so they appear on the built-in Shortcuts settings page. Each module has one registry, app/config/shortcuts-registry.ts, which the build finds by convention (Frontend layer). The registry's default export holds the metadata:
import type { ComponentShortcuts } from "#dms-ui/app/types/shortcuts";
export default [
{
component: "ReportViewer",
shortcuts: [
{ key: ["R"], descriptionKey: "$acme.shortcuts.refresh", component: "ReportViewer" },
],
},
] satisfies ComponentShortcuts[];
A ShortcutMetadata has the key combination as an array of keys, the owning component, a descriptionKey that follows the $ i18n convention, and an optional condition, { descriptionKey, check? }. The build gathers every module's registry into the settings page, grouped by component.
Search fields
"/" belongs to the navigation search, and on every settings page to the settings menu's search. A custom page with a search of its own must not bind "/" too, because two handlers on one key fight over the focus. Bind Ctrl / instead (⌘ / on macOS) with the shared map, buildPageSearchShortcuts. It is auto-imported, or imported from #dms-ui/app/composables/global/searchShortcuts, and it also covers keyboard layouts where "/" needs Shift, such as AZERTY. Show the same keys in the input's hint:
<script setup lang="ts">
import { buildPageSearchShortcuts } from "#dms-ui/app/composables/global/searchShortcuts";
const search = useTemplateRef<{ inputRef?: HTMLInputElement }>("search");
defineShortcuts(
buildPageSearchShortcuts(() => search.value?.inputRef?.focus()),
);
</script>
<template>
<UInput ref="search" v-model="query">
<template #trailing>
<UKbd value="meta" size="sm" />
<UKbd value="/" size="sm" />
</template>
</UInput>
</template>
Like every defineShortcuts key, both are ignored while the focus is in an input, a textarea or a contenteditable element, so typing "/" in a field never moves the focus. The Global group of the Shortcuts page already lists both.
<UKbd value="meta">, like ctrl and alt, prints the key of the keyboard platform that the dashboard's own hints use, which the dms-keyboard-platform cookie names. On a Mac, meta prints "⌘", ctrl prints "⌃" and alt prints "⌥". Elsewhere, meta and ctrl print "Ctrl" and alt prints "Alt". The server already prints the right key, so a returning visitor sees it from the first paint. On a first visit, before the browser has set the cookie, the hint shows the non-Mac key until the page has mounted.