Layout and Containers
Overview
Layout components hold other components. They come in two kinds.
| What you want to change | Use |
|---|---|
| The frame around a page: dashboard, settings, or no navigation | A page layout (DefaultLayout, SettingsLayout, EmptyLayout). |
| The arrangement of blocks inside the page | A container builder (Grid, stacks, or tabs). |
See the settings-page screenshot for the frame and the container previews for the blocks inside it. The reference below gives their exact contracts.
Containers are builders: Grid, GridRow, HStack, VStack, Spacer, Tab, Guard and Placeholder return a ComponentBuilder, and you nest components inside them with .child(id, component). They are assigned to a static field of a page class like any other component.
Page layouts are not builders: DefaultLayout, FormPageLayout, SettingsLayout and EmptyLayout return a plain ComponentInfo and are passed as the third argument of PageController — or as the layout option of a page or category, the default of everything declared under it — to say how the page's chrome is drawn.
CustomComponent sits outside both: it names a Vue component from the frontend registry and gives you a builder for it, which is the escape hatch when the vocabulary on this page is not enough.
import { Grid, GridRow } from "@antelopejs/interface-dms/base/grid";
import { HStack, Spacer, VStack } from "@antelopejs/interface-dms/base/stack";
import { Tab } from "@antelopejs/interface-dms/base/tab";
import { Guard } from "@antelopejs/interface-dms/base/guard";
import { DefaultLayout, EmptyLayout, FormPageLayout, SettingsLayout } from "@antelopejs/interface-dms/base/layouts";
import { Placeholder } from "@antelopejs/interface-dms/base/placeholder";
import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
Grid, GridRow, HStack, VStack, Spacer, Tab, Guard, Placeholder, DefaultLayout, FormPageLayout, SettingsLayout and EmptyLayout are also exported from the base barrel. CustomComponent is only exported from @antelopejs/interface-dms/base/custom.
Grid
Grid is a container whose direct children are GridRows. The number of columns follows the widest row.
import { Grid, GridRow } from "@antelopejs/interface-dms/base/grid";
import { KpiCard } from "@antelopejs/interface-dms/base";
static kpis = Grid({ gap: "1rem" }).child(
"kpiRow",
GridRow()
.child("proposed", KpiCard({ title: "Proposed", fetchUrl: "/metrics/proposed" }))
.child("approved", KpiCard({ title: "Approved", fetchUrl: "/metrics/approved" }))
.child("denied", KpiCard({ title: "Denied", fetchUrl: "/metrics/denied" })),
);
GridOptions:
| Option | Type | Description |
|---|---|---|
gap | string | Gap between rows and columns, as a CSS length. Defaults to "1rem". |
minColumnWidth | string | Width below which a column is dropped rather than squeezed. Defaults to "240px". |
GridRow() takes no options. Its children are laid out horizontally within the row.
.child() accepts a third argument of extra metadata, which is carried verbatim onto the serialized child alongside its id — that is how a child declares that it spans more than one column:
GridRow().child("chart", chartCard, { colSpan: 2 }).child("list", topListCard);
Stacks
HStack and VStack arrange their children along one axis; Spacer fills the leftover space between them.
import { HStack, Spacer } from "@antelopejs/interface-dms/base/stack";
static toolbar = HStack({ alignment: "center", distribution: "space-between", spacing: "16px" })
.child("title", titleComponent)
.child("gap", Spacer())
.child("actions", actionsComponent);
StackAlignment is "start" | "center" | "end" | "stretch" and StackDistribution is "start" | "center" | "end" | "space-between" | "space-around" | "space-evenly".
HStackOptions
| Option | Type | Description |
|---|---|---|
alignment | StackAlignment | Vertical alignment of children. Defaults to "center". |
distribution | StackDistribution | Horizontal distribution of children. Defaults to "start". |
spacing | string | Space between children, as a CSS length. Defaults to "8px". |
wrap | boolean | Wrap children onto the next line when the row runs out of space. Defaults to false. |
grow | number | Share of the parent stack's free space this stack takes, against its siblings. Unset, a stack nested in an HStack shares the row equally with its sibling stacks, or keeps its content width when the row has a Spacer. |
VStackOptions
| Option | Type | Description |
|---|---|---|
alignment | StackAlignment | Horizontal alignment of children. Defaults to "center". |
distribution | StackDistribution | Vertical distribution of children. Defaults to "start". |
spacing | string | Space between children, as a CSS length. Defaults to "8px". |
grow | number | Share of the parent stack's free space this stack takes, against its siblings. Unset, a stack nested in an HStack shares the row equally with its sibling stacks, or keeps its content width when the row has a Spacer. |
SpacerOptions
| Option | Type | Description |
|---|---|---|
minSize | string | Minimum size, as a CSS length. No minimum by default. |
maxSize | string | Maximum size, as a CSS length. No maximum by default. |
grow | number | Flex grow factor. Defaults to 1. |
The three builders apply their defaults into the options they carry, so a Spacer() with no arguments still ships { grow: 1 }.
Tabs
Tab declares a tab bar. Each TabItem names a slot, and the component that belongs in a tab is attached as a child with that slot.
import { Tab, TabVariant } from "@antelopejs/interface-dms/base/tab";
import { Color, Size } from "@antelopejs/interface-dms/base/types";
static tabs = Tab({
items: [
{ label: "Overview", slot: "overview", icon: "i-ph-gauge" },
{ label: "Members", slot: "members", icon: "i-ph-users", badge: 12 },
],
variant: TabVariant.pill,
color: Color.primary,
size: Size.medium,
persistState: true,
stateKey: "tenant-tabs",
})
.child("overview", overviewComponent, { slot: "overview" })
.child("members", membersTable, { slot: "members" });
TabProps
TabProps extends BaseComponentProps.
| Option | Type | Description |
|---|---|---|
items | TabItem[] | The tabs. Required. |
color | Color | Colour of the active tab. |
size | Size | Control size. |
variant | TabVariant | TabVariant.pill or TabVariant.link. |
orientation | AxeOrientation | Direction of the tab bar. |
fit | boolean | A horizontal pill list as wide as its tabs instead of the full width; a link list already sizes its tabs to their labels. Defaults to false. |
unmountOnHide | boolean | Discard a tab's content when it is not active. |
persistState | boolean | Keep the active tab in the URL by its slot (?<stateKey>=<slot>); an index (?tab=2) is still read. |
stateKey | string | Key the state is stored under. |
TabItem
| Field | Type | Description |
|---|---|---|
label | string | Tab label. Required. |
slot | string | Slot name the tab's content is attached to. Required. |
icon | string | Leading icon. |
badge | BlockText | number | BadgeProps | Badge on the tab; a $ key, a ComposedText and a BadgeProps label are translated. |
disabled | boolean | Make the tab unselectable. |
shortcut | string | Keyboard shortcut. |
avatar | { src?: string; alt?: string; size?: Size } | Leading avatar. |
BadgeProps is { label?: string | number; color?: string; variant?: "solid" | "outline" | "soft" | "subtle"; size?: Size }. Note that its variant union is its own, narrower than ButtonVariant.
TabEvents.TAB_CHANGE is "DmsComponent.Tab.Change".
Page Layouts
A page layout is the third argument of PageController. It is optional: a page that omits it is registered with DefaultLayout() and its default options.
import {
PageController,
pagesCategory,
RegisterPage,
} from "@antelopejs/interface-dms/page";
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
@RegisterPage()
export class OverviewPage extends PageController(
"overview",
{ displayName: "Overview", icon: "i-ph-gauge", category: pagesCategory },
DefaultLayout({ hideHeader: true }),
) {
static content = overviewGrid;
}
DefaultLayout(options?) returns the ComponentInfo for dms-default-layout.
| Option | Type | Description |
|---|---|---|
fullWidth | boolean | Let the content span the whole panel instead of a constrained column. Defaults to true. |
hideHeader | boolean | Omit the page header. |
fillHeight | boolean | Let the page fill the height of the panel instead of growing with its content, for a tool that scrolls inside itself — see Pages that fill the panel. Defaults to false. |
headerActions | CustomButton[] | Buttons right of the page title, in order — see Header actions. |
Dashboard pages are full-width unless they opt out, which is what a table, a grid of cards or a chart wants. Set fullWidth: false for a page that only holds a form, where a constrained column stays readable — or use FormPageLayout, which is exactly that:
import { FormPageLayout } from "@antelopejs/interface-dms/base/layouts";
const layout = FormPageLayout({ hideHeader: true });
Its optional argument accepts DefaultLayoutOptions without fullWidth; the return value is a ComponentInfo.
The generated new and edit pages of a table view use it, unless their page's category chain declares a layout, so a form page you write by hand matches them without your having to remember the flag.
SettingsLayout(options?) returns the ComponentInfo for dms-settings-layout: the dashboard frame with the settings navigation next to the page, in the column width every settings page shares. Its options are DefaultLayoutOptions without fullWidth. settingsCategory declares it as its layout, so the pages under the settings root get it without naming it (Settings pages).
EmptyLayout() takes no options and returns the ComponentInfo for dms-empty-layout, with an empty options object. Use it for pages that draw their own chrome — sign-in and onboarding screens, for instance.
Because they return a plain ComponentInfo rather than a ComponentBuilder, the page layouts have no .child(), .meta() or .watch().
Header actions
A page puts its main actions next to its title — a primary "New invoice", an "Export", a link to the docs — with headerActions: the same CustomButtons as a table's toolbar (see Tables), plus an order (PageHeaderButton), so a header button opens a link, runs a quick action, opens a drawer or a modal, calls an API or runs an export, with a confirmation and an availability of its own:
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
DefaultLayout({
headerActions: [
{
id: "docs",
label: "Documentation",
icon: "i-ph-book-open",
target: { type: "external", url: "https://antelopejs.com/docs", newTab: true },
},
// An empty label lends the quick action's name (and its icon).
{ id: "new", label: "", color: "primary", target: { type: "quickAction", id: "billing:new-invoice" } },
{
id: "audit",
label: "Audit log",
icon: "i-ph-scroll",
target: { type: "page", url: "/audit" },
permission: "audit", // pages.billing.audit on the page pages.billing
},
],
});
| Field | Type | Description |
|---|---|---|
id | string | Key of the button, unique in the header. Defaults to its place (header-0, …). |
label | string | Button text (i18n key with $ or literal). A quick action lends its own name to an empty label. |
icon | string | Icon name. A quick action lends its own icon. |
target | ActionTarget | What the button does: page or external (drawn as a link), quickAction ({ id }: category:id, or the bare id), drawer, modal, api, exportJob, download ({ url, filename? }: fetched with the user's session and saved). |
confirm | ConfirmDialog | { from } | Asked before the target runs. |
color | "primary" | "neutral" | … | Defaults to neutral, an outline button; any other color is solid. |
variant | ButtonVariant | Overrides the fill derived from the color. |
permission | string | Action | Action of the page, relative to its permission id ("export" on pages.sales → pages.sales.export), or an Action of any component, the user must hold to see the button. |
permissionId | string | Absolute permission id the user must hold to see the button; wins over permission. |
availability | CustomButtonAvailability | Disables the button for a request it answers { reason } with, the reason shown in a tooltip; leaves it out of the header for one it answers { hidden: true }. Receives the page URL's query as context.query. |
order | number | Place among the header's buttons, smallest first; defaults to 0. See Ordering the header. |
Buttons that follow the page URL
A page scoped by its URL — ?workspace=web, a record id in the path — declares its buttons once and lets them follow the URL. A target's URL (page, external, download, api, exportJob) takes the tokens of a block's fetchUrl: {{params.X}} is filled with the route parameter X of the page, {{query.X}} with its query parameter X, percent-encoded, again whenever the URL changes. A query pair whose value is a token the page has no value for is left out (?workspace={{query.workspace}} on a page without ?workspace= leads to the target's default), and any other token without a value is left empty. A download target's filename takes the same tokens, written as they are; while one has no value, the file is saved under the server's name:
{
id: "download-language",
label: "$lang.queue.download", // "Download {locale}.json"
icon: "i-ph-download-simple",
target: {
type: "download",
url: "/api/lang/translations/export?workspace={{query.workspace}}&locale={{query.locale}}",
filename: "{{query.locale}}.json",
},
}
A label's translation takes the page's route and query parameters as its own (a query parameter wins over a route parameter of the same name), so "Download {locale}.json" names the language of ?locale=fr and follows it as the URL changes.
An availability receives the page URL's query as context.query (one value per name; caller-supplied, so check it like any input). The header asks again whenever the query changes — only when one of its buttons declares an availability — and keeps the buttons it has until the answer lands. The keys the DMS writes for the view itself never cause a request: record (the row open in a side panel), live (the paused live mode) and a period selector's <id>.period, <id>.compare, <id>.from, <id>.to, <id>.compareFrom and <id>.compareTo. A query key may carry dots, so {{query.api.period}} reads a period selector's choice.
DefaultLayout({
headerActions: [
{
id: "matrix",
label: "$lang.queue.open_matrix",
icon: "i-ph-table",
target: { type: "page", url: "/modules/lang/translations?workspace={{query.workspace}}&missingIn={{params.locale}}" },
},
{
id: "download",
label: "$lang.queue.download",
target: { type: "api", url: "/api/lang/export?workspace={{query.workspace}}", successMessage: "$lang.exported" },
availability: async ({ query }) =>
(await workspaceExists(query?.workspace)) ? undefined : { reason: "$lang.workspace.unknown" },
},
],
});
A table view of the page places its own buttons in the header with placement: "header" — a custom button, or its built-in add (rowActions: { add: { placement: "header" } }): the server adds them after the layout's own buttons, served as the table view was (left out without its permission or when its availability hides it, disabled with the reason its availability gives), and the table view runs them, in place. They take the order 0.
Who sees a button is settled on the server when the layout is served: a button whose permission or permissionId the user lacks is left out of the layout, and a quick-action button only shows to the users the quick action is served to — that is, the users its page admits. A page drawn by a Vue page component can add its own buttons next to these with usePageHeaderActions; call it before the first await of the component's setup, so the server renders the buttons with the page and they never pop in. A page whose changes save as soon as they are made shows the shared Saved instantly pill there with useInstantSaveHeader (Instant save or a save bar).
Hidden or disabled
An availability has three answers. undefined leaves the button as declared, { reason } disables it with the reason in a tooltip — for something the user can change ("No seat left: upgrade the plan") — and { hidden: true } leaves it out of the header, as a missing permission does — for a button that makes no sense in the scope the page shows. Like a disabled one, a hidden button follows the page URL: the header asks again when the query changes, so a button can disappear on one ?workspace= and come back on another, and the server-rendered header already carries the answer for the URL it was rendered for.
DefaultLayout({
headerActions: [
{
id: "add-key",
label: "$lang.keys.add",
color: "primary",
target: { type: "drawer", component: AddKeyForm() },
availability: async ({ query }) =>
(await workspaceOf(query?.workspace))?.readOnly ? { hidden: true } : undefined,
},
],
});
Hiding only shapes the UI: the route behind the button must still refuse the operation on its own.
Ordering the header
The header shows, left to right, the buttons the layout declares, the buttons a table view places there (placement: "header"), then the actions a Vue component of the page adds with usePageHeaderActions (UI toolkit). To interleave them, give a button an order and the component's actions one too (usePageHeaderActions(render, { order })): the header sorts everything by order, smallest first, 0 when none is given, and at an equal order keeps the served buttons first, in the order above. So { order: -1 } on a component's actions puts them before every served button left at the default:
// The served "Add key" stays at the default order, 0.
DefaultLayout({ headerActions: [{ id: "add-key", label: "$lang.keys.add", color: "primary", target }] });
<script setup lang="ts">
import { usePageHeaderActions } from "#dms-layout/app/composables/layout/usePageHeaderActions";
// Export, built in the browser, goes before the served "Add key".
usePageHeaderActions(() => h(UButton, { label: t("lang.export"), icon: "i-ph-download-simple", onClick: exportCsv }), { order: -1 });
</script>
Pages that fill the panel
A page built around one tool — a file explorer, a data grid, a graph canvas — usually wants that tool to take the height of the panel and scroll inside itself, rather than make the whole page scroll. Set fillHeight on its layout:
DefaultLayout({ fillHeight: true })
The layout then lays the page out as a flex column the height of the panel. The page header keeps its own height and the height left under it goes to the last component of the page; the components above it keep theirs. Give the root of that component flex-1 min-h-0, and let an element inside it scroll:
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<MyToolbar />
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- the rows -->
</div>
</div>
</template>
A custom page — a page component your frontend layer registers under the page's slug — sits straight in the layout, so its own root takes flex-1 min-h-0 the same way.
Do not size the tool from the viewport, with h-[calc(100vh-…)] or an offset measured in the browser. The dashboard header, the layout banners, the page header and the panel padding all take height above the page, and that height changes: a banner shows up or is dismissed, a description wraps. The layout accounts for all of it.
- Minimum height. The page area, header included, never gets shorter than 30rem: on a shorter panel it stops shrinking and the panel scrolls again. The floor sits on the page area rather than on the tool, because a
min-h-*set inside the page overflows the column, and the panel then scrolls past its own bottom padding. A tool that needs more room than what is left to it at that floor — one that shares its component with a card of figures, say — can still set its ownmin-h-*, at that cost. - Every width. The page fills the panel on a phone too: the panel is the part of the dashboard that scrolls at every width. A page whose panes stack on a narrow screen (
grid-cols-1 lg:grid-cols-[…]) decides how they share the height there, or lets its own root scroll below that breakpoint (max-lg:overflow-y-auto). - Several components. Only the last one fills. Put the tool last, under whatever summarizes it, or keep both in one component that lays them out itself.
Guard
Guard is a container that renders its children unchanged unless a route says there is nothing for them; then it renders one EmptyState, its fallback, in their place. Several children stack with the spacing the page puts between its blocks.
import { Guard } from "@antelopejs/interface-dms/base/guard";
static statistics = Guard({
fetchUrl: "/api/routes/activity?route={{query.route}}", // { calls: 0 }
periodScope: "api",
field: "calls",
fallback: {
title: "$api.routes.statistics.idle_title",
actions: [{ label: "$api.routes.test", to: "/modules/api/routes?tab=tester" }],
},
}).child("charts", statisticsGrid);
GuardOptions:
| Option | Type | Description |
|---|---|---|
fetchUrl | string | The route read. It takes the {{params.X}} / {{query.X}} tokens of a block's fetchUrl; nothing is requested while one has no value. |
fetchUrlMethod | HttpMethod | Method of the request. |
periodScope | string | Id of the PeriodSelector the route is read with, again on each change. |
realtimeTopic | string | string[] | Topics whose events make the guard read the route again. |
when | GuardCondition | "empty" (the default): the value at field is absent, null, false, 0, "" or []. "notFound": the route answers 404. |
field | string | Dot path of the value "empty" checks; the whole answer without it. |
fallback | GuardFallback | An EmptyState's options without watchActions, all optional. The title defaults to "Nothing to show yet", or "Not found" for "notFound", in the reader's language; for "notFound" the description defaults to the 404 body's message, as for a block. |
An emptyState object in the answer, or in the body of the 404, is laid over fallback. The children show while the route loads, after any failure other than the condition, and whenever the value is set. The guide is Layout & containers.
Placeholder
Placeholder renders a labelled box. It exists so a layout can be assembled and reviewed before the real components are written. It takes PlaceholderOptions:
import { Placeholder } from "@antelopejs/interface-dms/base/placeholder";
static preview = Placeholder({ label: "Chart goes here", height: "200px" });
| Option | Type | Description |
|---|---|---|
label | string | Text shown in the box. |
height | string | CSS height. Defaults to "120px". |
width | string | CSS width. Auto by default. |
CustomComponent
CustomComponent(componentName) returns a bare ComponentBuilder for a frontend component resolved by name. Nothing else in the base domain is involved: the options you pass with .options() are handed to that component as-is, and the component itself comes from a frontend module registered with AddFrontendModule.
import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
static activity = CustomComponent("DmsAiActivityView").meta({
name: "Activity",
icon: "i-ph-clock-counter-clockwise",
});
Since the return value is an ordinary builder, .options(), .child(), .meta(), .watch(), .onCreated() and .onFilter() all apply, and the component can hold base components as children.
CustomComponent is also how a table view's formContainer.pages.*.customPage option is used: you register your own page carrying a custom component at the slug the table view would otherwise generate — declare that slug as the entry's urlSlug, which is taken relative to the page as it stands — see Tables.
Next Steps
Charts and Widgets
The chart module and the three card modules cover dashboard surfaces: a chart of a series, a single figure with a delta, a ranked list, and the period selector the others read their date range from.
Data Types
A data type is the single object that answers everything the DMS needs to know about one value: which input component edits it, which Zod schema validates it, which comparison modes its filter offers, and whether the…