Skip to content

HabModal ​

A centred dialog over a scrim, for a decision that must be made before continuing.

Pick the width by content, not by taste
vue
<HabModal v-model:open="open" title="Move to archive?" note="The session stays searchable.">
	<p>Archiving hides this session from the active list.</p>

	<template #actions>
		<HabButton
			variant="text"
			tone="neutral"
			@click="open = false"
			>Cancel</HabButton
		>
		<HabButton @click="open = false">Archive</HabButton>
	</template>
</HabModal>

Never for something the user did not have to act on ​

A modal takes the screen, the focus and the keyboard. Spending all three on information is the most common way this component gets misused — if the user does not have to answer, it belongs in a HabBanner or a HabToastHost toast.

The escalation ladder among the overlays:

NeedUse
A label for an iconHabTooltip
Content anchored to a triggerHabPopover
Confirmation of something that is doneHabToastHost
A decision, before anything continuesHabModal

Size ​

sm is a confirm, md holds a short form, lg is the one that needs a table or two columns, and xl (1056) is a workspace — an editor with panes of its own. Widths come from --hab-modal-*; pick by content, not by taste.

A workspace: flush and title-append ​

An editor inside a dialog lays out its own regions, so it wants things a confirm does not:

A workspace: xl, flush, two panes that scroll on their own
  • flush drops the body's padding and lets it fill the sheet under a rule, so panes reach the edges. Pair it with xl.
  • The sheet takes the full available height instead of growing with its content, so a workspace does not jump as its panes change.
  • The body's one root element is stretched to that height. A pane inside it needs nothing but overflow-y-auto to scroll by itself — give the body a single root and lay the panes out in it.
  • A click beside the sheet does not close it. A workspace holds edits, and a stray click must not discard them. Escape and the × still close it; to confirm unsaved changes, control v-model:open and keep it true until the user has answered.
  • The title-append slot sits beside the title for what the dialog is about — the record it edits, a read-only badge. It is kept OUT of the title element on purpose: the title is the dialog's accessible name, and a name that absorbed every chip would be read out as one long run-on.
vue
<HabModal v-model:open="open" title="Axiom" size="xl" flush close-label="Close editor">
	<template #title-append>
		<HabBadge tone="neutral">Order.shippingTier</HabBadge>
	</template>
	<div class="grid grid-cols-[240px_minmax(0,1fr)]">
		<ul class="overflow-y-auto">…</ul>
		<div class="overflow-y-auto">…</div>
	</div>
	<template #actions>…</template>
</HabModal>

xl and flush are additions to the design source, which sizes a modal sm/md/lg; see design-source.md.

tone="error" changes the primitive, not just the color ​

tone=error is an alertdialog — the scrim no longer dismisses it

A destructive dialog does two things differently: the title takes error ink, and the scrim stops dismissing it, so the decision cannot be dropped by a stray click.

Underneath, that is not a styled dialog — it is an alertdialog. The difference is the part styling cannot fake: assistive tech announces an alertdialog's contents on open rather than waiting to be explored. A confirmation the user has to hear is exactly the case that role exists for.

Put the consequence in note. For a destructive dialog it is the sentence that does the work — "the recording and all 412 messages are deleted permanently" — and it becomes the dialog's description.

Blocking ​

dismissible: false removes the close affordance and makes both Esc and the scrim inert, for a decision that has to be answered in the footer.

Use it rarely. A dialog with no exit and no actions is a trap, and the component warns when you build one.

HabFormActions ​

Not a dialog — the sticky save bar at the foot of a settings form, for when the form is longer than the viewport and a footer button would sit below the fold.

Unsaved changes
Discard sits left of Save, never the reverse
vue
<HabFormActions note="Unsaved changes">
	<HabButton
		variant="text"
		tone="neutral"
		>Discard</HabButton
	>
	<HabButton>Save</HabButton>
</HabFormActions>

It ships beside HabModal because the two share one rule: Discard sits left of Save, never the reverse. The confirming action is last in reading order in both, so muscle memory carries between them.

Accessibility ​

  • title is the accessible name, via aria-labelledby. It is not optional — the component warns without one, because a dialog announced as "dialog" tells the user nothing.
  • note becomes the description, via aria-describedby.
  • Focus moves in on open, is trapped while open, and returns to whatever opened it on close — all from the primitive. The design contract hand-rolls this and its own comment says why it must exist: "the scrim stops the mouse and nothing stopped the keyboard." The primitive additionally handles what those 20 lines miss — nested overlays, scroll locking, and a trigger that unmounts while open.
  • The rest of the page is hidden from the accessibility tree while open.
  • Not asserted in the specs: the focus trap itself. happy-dom has no layout, so offsetParent is null for every element and "which elements are reachable" has no answer. It needs the browser test layer — see architecture.md.
  • Known limitation: with the dialog open, axe reports aria-hidden-focus against reka's own focus sentinels. Primitive markup, not ours.

API ​

Props ​

PropTypeDefaultDescription
closeLabelstringundefinedAccessible name for the close affordance. Required unless dismissible is false: Habitus ships no default, so the string is the host application's to translate.
dismissiblebooleantrueSet false for a dialog the user must answer: no close affordance, Esc and the scrim both inert. Use it only when there is genuinely no way out but a choice in the footer — a dialog with no exit and no action is a trap.
flushbooleanfalseA workspace: the body loses its padding and fills the dialog under a rule, for content that lays out its own regions — panes, a sidebar, a toolbar — and must reach the sheet's edges. The sheet takes the full available height, and the body's one root element is stretched to fill it, so a pane scrolls with just overflow-y-auto. A click outside no longer dismisses it — it holds edits — while Escape and the × still do.
notestringundefinedSecond line under the title — what this will affect.
openbooleanundefinedControlled open state.
size"md" | "sm" | "lg" | "xl""md"—
titlestringundefinedThe decision being asked for. Becomes the dialog's accessible name.
tone"error" | "neutral""neutral"—

Events ​

EventPayloadDescription
update:openvalue: boolean—

Slots ​

SlotBindingsDescription
actionsanyFooter buttons. Cancel first, the confirming action last.
defaultanyThe body — what the decision is about.
title-appendanyBeside the title, not inside it: what the dialog is about — the record it edits, a state badge. Kept out of the title element so the dialog's accessible name stays the title alone rather than absorbing every chip.

HabFormActions ​

Props ​

PropTypeDefaultDescription
notestring—Left-aligned status — "Unsaved changes", "Saved 2 minutes ago".

Slots ​

SlotBindingsDescription
defaultanyThe buttons: Discard, then Save.

Proprietary — internal use only.