@sigx/zero
v0.17.0
Published
SignalX Zero — unstyled, accessible component primitives with a machine-readable anatomy. The design-system-neutral foundation every sigx design system builds on.
Maintainers
Readme
@sigx/zero
Unstyled, accessible component primitives for SignalX.
Components render a stable, machine-readable anatomy — data-scope,
data-part, data-state — and no styling; a design system is a separate CSS
artifact compiled by @sigx/zero-kit
(start with @sigx/zero-basic or @sigx/zero-daisyui).
npm install @sigx/zero sigxComing from 0.2.0-beta.1? The upgrade guide has a before/after for every breaking change.
import { Dialog } from '@sigx/zero/dialog';
import '@sigx/zero/css'; // layer order + token fallbacks
import '@sigx/zero-basic/css'; // ← the design system (swappable)
<Dialog.Root model={() => state.open}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Title>Native top layer</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Root>Components
Button · Tabs · Collapsible · Accordion · Dialog · Popover · Tooltip · HoverCard · Menu · Menubar · Select · Switch · Checkbox · CheckboxGroup · RadioGroup · Slider · Progress · Field · Fieldset · Avatar · AvatarGroup · Toast · Combobox · Toggle · ToggleGroup · NumberInput · RatingGroup · TreeView · Input · Textarea · Card · Alert · EmptyState · Badge · Divider · Skeleton · Spinner · Kbd · Status · Indicator · Stats · Timeline · Chat · ChatLog · RadialProgress · Join · Navbar · NavList · Breadcrumbs · Pagination · Steps · Drawer · Table · FileUpload · Carousel · Swap · Countdown · Diff Stack (Row/Col) · Spacer · Grid · Center · Box · Container
All state is one two-way model prop (sigx Define.Model) — bind a signal
property with model={() => state.open}, or leave it uncontrolled with
defaultOpen / defaultValue. No controlled/uncontrolled prop triplets.
The naming rule. Every model has a concept N and exactly two
companions: default<N> seeds it uncontrolled and <n>Change reports a
write (onOpenChange, onValueChange, onActiveChange). A named model
(model:open, model:expandedValues) has its name for a concept. The
anatomy declares each model (models on defineAnatomy, emitted into
manifest.json with the derived companion names, the value type, the
compound member that carries it, and whether it posts to a form), and a
parity test holds every component source to it — so a tool can tell a
Select from a Card without reading component code.
The binding law. Every zero model is a sigx Model, and every native
control zero renders binds to it with model= — never a hand-wired
value=/onInput pair. sigx's platform processor owns the write-back, so a
zero wrapper does everything the raw element does: modelModifiers
(trim, number, lazy, debounce, custom) work on Input.Root and
Textarea.Root exactly as on a bare <input>, a Checkbox.Root bound to a
string[] is sigx's array mode (several boxes toggling their own value's
membership), and a RadioGroup.Item is a real radio bound to the group's
model. A native control that binds a projection of the state binds a
derivedModel(read, write) — Slider's range binds the first value (its
write quantizes and clamps), NumberInput's text input binds the draft (its
write never touches the model; commit does). The only hand-wired controls
left are the ones sigx has no processor for: Slider's range projection
(hidden inputs, one per value) and FileUpload's FileList. Value transforms
run once at the component boundary; timing reaches the element.
createControllableState (public, @sigx/zero/behaviors) returns that
Model, and createInertState seeds a part's fallback context.
Value shapes follow multiple. Select, Combobox and ToggleGroup hold
one value in single mode (T | null / V | null for a data-driven Select or
Combobox, string for hand-written items and for ToggleGroup) and an array
under multiple — typed through the overloads, so a string signal never
binds a multiple group. RadioGroup takes items too (itemKey is the
posted value, itemLabel, itemDisabled, the item slot); its model stays
the string a native radio group posts.
One roving tab stop, whatever the model says. Tabs, ToggleGroup, Steps
and TreeView keep exactly one item in the tab order: the selected item while
it is rendered and enabled, else the first enabled item. Once the group has
mounted, a value that names nothing (a typo, a removed item) or only
disabled items never leaves it unreachable by keyboard. Server-rendered HTML
is the one gap: until hydration, a value that names nothing yields no stop,
because an unregistered value may still name an item that renders later.
Horizontal arrow keys follow the reading direction: under dir="rtl"
ArrowRight moves to the item on the visual right, which is the previous one
in DOM order.
A disabled TreeView node still navigates. A pointer can focus a disabled
node, and from there the arrow keys, Home/End and typeahead move to its
enabled neighbours (ArrowLeft on an open disabled branch climbs to the
parent), while selecting (Enter/Space) and expanding or collapsing it stay
blocked. The same holds for a disabled asChild Steps item: its arrows and
Home/End rove to the enabled steps beside it, and it never activates.
TreeView: pointer, * and loading branches. A click on a branch row
selects the branch, as Enter does, and — while TreeView.Root's
expandOnClick is on (the default) — toggles it too. With
expandOnClick={false} the row only selects, and a click on the
BranchIndicator only toggles. * expands every enabled branch at the
focused node's level, the focused node included, as APG's reference tree does
(one expandedValuesChange). A TreeView.Branch marked
loading (its children are being fetched) is aria-busy="true"; its
branch-indicator reads data-state="loading", and so does its
branch-content while open — closed content stays closed and hidden.
TreeView: multiple. TreeView.Root multiple makes the model a
string[] (defaultValue / valueChange follow; single mode keeps its
string, '' when none) and the tree aria-multiselectable="true"; each
selected node carries the same data-selected flag as in single mode, so a
recipe needs nothing new. The keys are APG's recommended multi-select tree:
Space toggles the focused node; Shift+ArrowDown/Up move focus and select from
the anchor to it; Shift+Space selects the anchor→focused range;
Ctrl/Cmd+Shift+Home/End extend it to the first/last visible node; Ctrl/Cmd+A
adds every visible enabled node (a selection collapsed out of sight stays).
Enter keeps its activation — it selects the focused node alone. A plain click
replaces the selection, Ctrl/Cmd+click toggles, Shift+click selects the range;
a modified click on a branch row never folds it. The anchor is the node last
selected alone or toggled, ranges run over the VISIBLE nodes in DOM order and
replace the selection, and a range whose anchor has since been collapsed away
selects its far end alone. No gesture puts a disabled node in the
selection, though a model that names one still renders it selected: disabled
blocks interaction, not state, as in single mode. The tab stop is the first
selected visible node. Because '' is single mode's "nothing selected", an
item or branch valued '' throws there (as a ToggleGroup item does); under
multiple it is an ordinary value.
TreeView: checkable. Bind model:checkedValues (a string[] of checked
LEAF values; defaultCheckedValues / checkedValuesChange follow), or set
checkable, and every treeitem carries aria-checked. A branch's state is
derived, never stored: true when every enabled descendant leaf is checked,
mixed when some are, false otherwise — the rule Checkbox.Root parent
uses, now shared as triState / toggleTriState in @sigx/zero/behaviors
(and createTreeController's new leavesOf(value) lists a branch's leaves).
A branch whose every leaf is disabled shows what they hold and cannot be
toggled. Space toggles the focused node's check (on a branch: every enabled
leaf beneath it, all or none; a disabled leaf keeps its value); Enter keeps
the selection, and under multiple Shift+Space keeps its range.
TreeView.NodeCheckbox — part node-checkbox, an aria-hidden span with
data-state="checked|unchecked|indeterminate" and data-disabled when
nothing can toggle it (the tree, the node, or a branch whose every leaf is
disabled) — is the paint hook: put it in an Item or a BranchTrigger row; a click on it
toggles the check and changes neither selection nor expansion. The selection
stays in use under multiple, a bound model or a defaultValue; without
any of them a checkable tree renders no aria-selected, and a click on (or
Enter at) a leaf row toggles its check — the row is the box's label. The
leaves a branch derives from are the ones REGISTERED, so a lazily loaded
branch with no children yet reads unchecked and toggles nothing, and a
server render shows branches unchecked until the tree mounts.
The form contract. Every posting control takes the same five props
(name, form, disabled, invalid, required — WithFormControl, plus
readonly on every value control — see below) and answers to a Field.Root for all
of them — and to an enclosing Fieldset.Root for disabled, readonly
and invalid (#285). A control posts only while it carries a name; a disabled control
never posts; form="id" associates it from outside the form's subtree; and
the owning form's reset() restores the component default into the model
and the DOM. Select, Combobox and ToggleGroup post through a real,
visually-hidden <select> — one field in single mode, a repeated field per
value under multiple — so required is a platform constraint (the invalid
focus lands on the trigger / input / the group's tab stop) rather than an
aria-required hint. RatingGroup posts through a visually-hidden text
<input> for the same reason (a type="hidden" input is barred from
constraint validation): a required rating left at 0 fails
checkValidity(), the invalid focus lands on its tab stop, and the
radiogroup carries aria-required. The runtime
half is createFormControl + onFormReset (@sigx/zero/behaviors), the
one VISUALLY_HIDDEN_STYLE beside them; a component of your own that renders
a non-native control reads an enclosing Fieldset's flags from
createFormControl for free, or directly through useFieldsetContext().
FileUpload checks its files, and reports what it refused (#273).
FileUpload.Root takes maxFiles, minFileSize and maxFileSize
(bytes) and validate(file) => code | code[] | null, beside accept. Both
paths — the picker and a drop — check every candidate: 'invalid-type',
'too-large', 'too-small', any string validate returns, and
'too-many' for an otherwise-valid file past maxFiles (single mode has
room for one). Accepted files join the model; the rest arrive once per
selection through the filesReject event as { file, errors }[] (an
event, not a model: a refusal is news, not state), and the input's own
FileList is re-synced so a refused file never posts. Render a refused
file through <FileUpload.Item file={f} invalid> for data-invalid.
FileUpload.ClearTrigger empties the model and hands focus to the
trigger; it renders nothing while there is nothing to clear, and its name
defaults to "Clear files". ItemRemove hands focus to the next file's
remove button, else the previous one, else the trigger. A required
upload left empty cancels the platform's bubble (it would point at a 1px
input), focuses the trigger and reads invalid until the files change.
directory sets webkitdirectory; capture ('user' | 'environment')
reaches the input.
<FileUpload.Root name="docs" multiple accept="image/*,.pdf" maxFiles={3} maxFileSize={5_000_000}
onFilesReject={(rejected) => { state.rejected = rejected; }}>
<FileUpload.Trigger>Add files…</FileUpload.Trigger>
<FileUpload.ClearTrigger>Clear</FileUpload.ClearTrigger>
…
</FileUpload.Root>Native constraints and keyboard hints are typed props. Input.Root
takes minlength, pattern, inputmode, enterkeyhint, spellcheck,
autocapitalize, autocorrect ('on' | 'off') and autofocus
beside autocomplete / maxlength; Textarea.Root takes minlength,
wrap ('soft' | 'hard'), spellcheck, autocapitalize,
enterkeyhint and autofocus. They render on the <input> /
<textarea>, so the platform's own constraint validation runs
(checkValidity() honours pattern), and a misspelt inputmode is a
compile error (InputMode, EnterKeyHint and Autocapitalize are
exported). spellcheck is a boolean that renders the enumerated
"true" / "false" token; unset leaves the browser's default. There is
no native size (the character width): size on a Root is the design
system's size axis (data-size), so width belongs to the recipe.
<Input.Root name="code" pattern="[0-9]{6}" inputmode="numeric" enterkeyhint="done"
autocomplete="one-time-code" autocorrect="off" spellcheck={false}>
<Input.Control><Input.Input /></Input.Control>
</Input.Root>An empty field says so (#416). Input's and NumberInput's root and
control carry data-placeholder while their text is empty, and so do
Textarea's root and Combobox's root and control. For a combobox, empty
means no typed query and nothing chosen; a trigger-mode combobox never sets
it, because the Textarea it wraps flags its own. It is the word Select already uses
for "nothing chosen", and it is what a floating label reads to rest inside
the field instead of above it. It follows what the field shows, so a
keystroke a lazy or debounce model has not taken yet already clears it.
It is kept off the native element on purpose: :placeholder-shown needs a
placeholder attribute to match at all, and re-rendering the native
element per keystroke would drop a pending debounce.
Input's control holds three affordances (#281). Input.Adornment
(part adornment, placement="start" | "end" → data-placement) puts
consumer content — an icon, a unit, a prefix — at a logical edge of the
control; a press on it that lands on nothing interactive focuses the input
and keeps its caret. aria-hidden is not forced: whether it speaks is the
app's call. Input.ClearTrigger (part clear-trigger) empties the value
the way typing would — the model writes, valueChange fires, and an
input event reaches the app's own listeners — then focuses the input. It
is out of the tab order (tabindex="-1"), points at the input through
aria-controls, renders nothing while the field is empty — what the
field shows, so text a lazy or debounce model has not taken yet
counts — is disabled with the field and while it is readonly, and its
name defaults to "Clear".
In a type="search" field Escape does the same while there is something
to clear, and cancels the key so an enclosing dialog or popover stays
open; an empty field lets Escape through. Input.VisibilityTrigger (part
visibility-trigger, states on|off) toggles model:visible on the Root
(defaultVisible, visibleChange); while it is on, a type="password"
input renders type="text". It is a toggle button: aria-pressed, one
constant name ("Show password"), aria-controls the input. Both triggers
take label, and render a default glyph when given no children; the
visibility trigger's slot receives { visible } to swap an icon. Every
skin lays them out in the control's row with order and logical padding,
so they flip with the reading direction.
<Input.Root type="password" model={() => state.password} model:visible={() => state.shown}>
<Input.Label>Password</Input.Label>
<Input.Control>
<Input.Adornment placement="start"><LockIcon /></Input.Adornment>
<Input.Input />
<Input.ClearTrigger />
<Input.VisibilityTrigger />
</Input.Control>
</Input.Root>Readonly reaches every value control, not only text (#267). Input,
Textarea, NumberInput, Combobox, RatingGroup — and Checkbox, Switch,
RadioGroup, Select and Slider — take readonly (WithReadonly), the prop OR
the Field's, so <Field.Root readonly> means the same thing whatever it
wraps. A readonly control stays focusable and renders data-readonly on its
root and the parts a design system paints (checkbox/switch control,
radio-group item/item-control, select trigger, slider control/
track/thumb), but refuses every user write while an app's model write
still lands: Checkbox and Switch cancel the native click (a native checkbox
ignores readonly), so no press, label click or Space toggles them;
RadioGroup cancels the radios' activation, so the platform's arrow-key
roving still moves focus but chooses nothing; Select does not open, and no
key — typeahead included — changes it; Slider neither steps on a key nor
moves on a press or drag, the native range's own input put back. ARIA
follows each role: aria-readonly on the checkbox input, the radiogroup,
the select's combobox trigger and every slider (thumb or native range);
the switch role does not support it, so a readonly Switch says so through
data-readonly alone. As on a native readonly control, a readonly
required control never blocks the submit — its value is not the user's
to fix. RadioGroup also restates invalid on each item and
item-control (Checkbox and Switch parity), and writes
aria-orientation on the radiogroup.
Menubar: the APG menubar, over Menu (#289). Menubar.Root renders one
role="menubar" row (aria-orientation and data-orientation, default
horizontal; a disabled flag that disables every trigger) and
coordinates the plain Menu.Roots inside it. Its one model is the open
menu's value ('' when none — model / defaultValue / valueChange),
so one menu is open at a time; each Menu.Root names itself with value
(a generated id otherwise), and inside a bar its open state follows the
bar's model — its own openChange still fires. A bar's Menu.Trigger
renders role="menuitem" (still aria-haspopup="menu" and
aria-expanded) with a roving tabindex: the bar is one tab stop, the
trigger that last had focus, else the first enabled one. ArrowLeft/
ArrowRight move between triggers (flipped under RTL; ArrowUp/ArrowDown when
vertical), Home/End jump, loop (default true) wraps. ArrowDown, Enter
and Space open a menu on its first item and ArrowUp on its last (vertical:
Enter, Space or the inline-end arrow). Inside an open menu, ArrowRight from
an item that opens no submenu — at any depth — and ArrowLeft from a
top-level item close the chain and open the adjacent menu on its first
item. With a menu open, hovering another trigger switches to its menu, and
a click on the open menu's own trigger closes it. Escape and a selection
hand focus back to that menu's trigger; Tab closes and lets focus move on.
<Menubar.Root aria-label="Editor">
<Menu.Root value="file">
<Menu.Trigger>File</Menu.Trigger>
<Menu.Popup>
<Menu.Item value="save" keyshortcuts="Control+S">
Save <Menu.Shortcut>Ctrl+S</Menu.Shortcut>
</Menu.Item>
</Menu.Popup>
</Menu.Root>
<Menu.Root value="edit">…</Menu.Root>
</Menubar.Root>CheckboxGroup: one string[] model for a set of boxes, and a derived
parent box (#282). CheckboxGroup.Root renders a role="group" named by
its CheckboxGroup.Label (referenced only while it is rendered) and hands
one model to the Checkbox.Roots inside it. A boxed child needs a value:
it is checked while the group's model includes that value, and toggling it
writes the group model — its own model is not needed. Every child posts
under the group's name/form; the group's disabled, invalid and
readonly (the prop OR an enclosing Field's) reach every box, ORed with the
box's own; its size sizes every box that sets none. required means "at
least one": the boxes carry the native required only while none is
checked, so the platform blocks the submit on the first box and lets go as
soon as one is. Inside a Field.Root the group ROOT is what the Field
labels and describes (its label joins the root's aria-labelledby, its
description and error the root's aria-describedby); the boxes keep ids of
their own, so no two inputs claim the field's control id. orientation
renders data-orientation (default vertical) for the skins' row layout.
Checkbox.Root parent is the tri-state "select all" box. Its state derives
from the group's allValues — by default, every child box's value —
checked when all are selected, unchecked when none, indeterminate
when some; toggling it selects all or none of them (values outside
allValues stay where they are). Its input names the child inputs in
aria-controls and posts nothing unless given a name of its own.
<CheckboxGroup.Root model={() => state.toppings} name="toppings" allValues={['ham', 'olives', 'basil']}>
<CheckboxGroup.Label>Toppings</CheckboxGroup.Label>
<Checkbox.Root parent>All toppings</Checkbox.Root>
<Checkbox.Root value="ham">Ham</Checkbox.Root>
<Checkbox.Root value="olives">Olives</Checkbox.Root>
<Checkbox.Root value="basil">Basil</Checkbox.Root>
</CheckboxGroup.Root>An indeterminate Checkbox.Root now stays indeterminate after a click
while its indeterminate prop is still true: the platform clears the
native property on activation, and the box writes it back after every
change, so data-state and the input assistive tech reads never disagree.
A sized Field sizes its control. A control with no size of its own
renders its Field's, the same way it adopts the Field's flags, so a compact
field is one prop — the label goes with visuallyHidden. The control's own
size still wins, and only size is inherited: a Field's color accents
its label, not the control.
<Field.Root size="xs">
<Field.Label visuallyHidden>Mode for Bash</Field.Label>
<Select.Root items={['ask', 'allow', 'deny']} defaultValue="ask" />
</Field.Root>A Field validates through the platform (#284). Every control inside a
Field.Root reports the element constraint validation runs on — Input's
and Textarea's own, NumberInput's text input, Checkbox's and Switch's hidden
checkbox, the first radio of a RadioGroup, FileUpload's file input, the
hidden <select> a named Select or Combobox posts through (an unnamed one
has none, so only validate runs), a Slider's native range. The Field
listens for that element's invalid event (a submit, reportValidity(),
checkValidity()), snapshots its ValidityState, and every control's
data-invalid/aria-invalid reads invalid || (validated && !valid).
validate(value, validity)— synchronous, over the control's model value (a string, a boolean, a number, the selected key(s),File[]) and a copy of its native validity taken with no custom message set. A message (or an array) goes throughsetCustomValidity, so a native submit blocks on it; it is kept current on every change, whatevervalidateOnsays. Async and schema orchestration stay the app's — setinvalidfrom them.validateOn— when the Field SHOWS its validity:'submit'(default, the first failed submit),'blur'(focus leaving the Field) or'change'. After a failed submit every change revalidates, and so does any change while an error shows, so fixing the value clears it.Field.Error match— aValidityStatekey (valueMissing,tooShort,patternMismatch,typeMismatch, …),'custom'for avalidatemessage, ortrue. With a key it renders only while that key is set, under an id of its own (<error id>-<key>), and the control'saria-describedbyfollows it; withoutmatch(or withtrue) it renders as before. With no children it renders the message: the platform's for a key,validate's forcustom, all of them otherwise.- A Field that renders a
Field.Errorcancels theinvalidevent, so the platform's bubble does not repeat the message, and focuses the FIRST invalid control of the form itself (the trigger, for a Select). Without one, the platform's bubble and focus stand. - A form
resetforgets what was shown.
<form>
<Field.Root validate={(v) => (v === 'admin' ? 'That username is reserved.' : null)}>
<Field.Label>Username</Field.Label>
<Input.Root name="user" required pattern="[a-z]{3,}">…</Input.Root>
<Field.Error match="valueMissing">Choose a username.</Field.Error>
<Field.Error match="patternMismatch">Three or more lowercase letters.</Field.Error>
<Field.Error match="custom" />
</Field.Root>
</form>A Fieldset groups controls and says their flags once (#285).
Fieldset.Root renders a native <fieldset> and Fieldset.Legend the
native <legend> — render it as the Root's first child: the platform names
the group from it (role="group", no aria-labelledby of zero's own).
disabled sets the native attribute, which disables every native control
inside; the controls zero draws itself (a Slider thumb, a RadioGroup item,
a RatingGroup star, a Select trigger's data-disabled) read the Fieldset's
context through createFormControl / Field.Root, so they render
data-disabled / aria-disabled, refuse input and never post either.
readonly and invalid have no native fieldset spelling and travel by the
context alone. A control's effective flag is its own OR its Field's OR any
enclosing fieldset's — nested fieldsets chain — and a Field.Root inside
takes them too, so its Label dims with the group. Controls inside the Legend
answer to the fieldsets outside it, as the platform exempts a legend's
controls from its fieldset's disabled: that is where the "enable this
section" checkbox goes. The root carries data-disabled / data-readonly
/ data-invalid, the legend data-disabled / data-invalid; the six
skins reset the UA frame (brutalist keeps it as an inked box) and undo its
min-inline-size: min-content.
<Fieldset.Root disabled={!state.shipElsewhere}>
<Fieldset.Legend>
<Checkbox.Root model={() => state.shipElsewhere}>Ship to a different address</Checkbox.Root>
</Fieldset.Legend>
<Field.Root>
<Field.Label>Street</Field.Label>
<Input.Root name="street">…</Input.Root>
</Field.Root>
<Slider.Root name="priority" defaultValue={[2]}>…</Slider.Root>
</Fieldset.Root>tooShort and tooLong are raised by the platform only for a value the
user last edited, and sigx's value binding writes the model back into the
element on every keystroke, which a browser counts as a script's change —
so in a real engine they do not report today. Spell a length rule with
pattern or validate until the binding skips an unchanged value.
Native-platform first: <dialog> +
top layer (no Portal), the popover attribute, <details>, real form
inputs. SSR-safe ids via app.use(zeroPlugin()) per request. Collapsible
and Accordion keep their model in step with the <details> they render: when
the browser opens one itself (find-in-page, a #fragment link into a closed
section), the native toggle event writes the model — an Accordion in single
mode then closes the others, as a click would — and a change the model
refuses (a disabled root or item) is put back on the element.
Accordion keyboard and regions (#276). Accordion follows the APG
accordion pattern: ArrowDown/ArrowUp move focus between the enabled triggers
(ArrowRight/ArrowLeft under orientation="horizontal", flipped in RTL),
wrapping unless loop={false}, and Home/End jump to the first/last. Arrival
opens nothing, and there is no roving tabindex — every trigger stays in the
Tab sequence. data-orientation rides the root and every trigger. Each
panel is role="region" labelled by its trigger (aria-labelledby); pass
regions={false} when many panels can be open at once — APG warns that more
than about six region landmarks become noise. A Collapsible panel is
labelled by its trigger too, with no role. Both triggers therefore own their
id (it is not a prop).
Disclosure closes can animate (#276). Removing open hides a
<details> at once in every engine, so no stylesheet can animate the close.
Collapsible and Accordion panels publish their measured content size as
--collapsible-panel-height/--collapsible-panel-width and
--accordion-panel-height/--accordion-panel-width (px, from
scrollHeight/scrollWidth, kept fresh while open and re-measured as a
close starts). On close, data-state flips to closed immediately while
the element stays open until the panel's own animations have finished,
and only then does it shut — so a recipe animates the panel's closed
state from var(--accordion-panel-height) to 0. No animation (none
declared, prefers-reduced-motion: reduce) closes at once; reopening
mid-exit cancels it; find-in-page and fragment navigation still sync the
model as above. Five of the six bundled skins animate it; brutalist cuts.
Collapsible's non-native mode (#453). A <summary> only works as the
first child of its <details>: nested anywhere else — in a Card header, a
table row, a toolbar — the browser inserts a default "Details" summary, the
nested one is not focusable, and the closed <details> hides the whole card
with it. native={false} on Collapsible.Root switches the construction:
- the root renders a
<div>(noopen, notoggle); - the trigger renders a
<button type="button" aria-expanded aria-controls>that can sit anywhere inside the root. It takesasChildandlend(#452): its bag can go to a raw element or be lent on to a zero part, and it is a lend host itself, so a tooltip chains through it. An asChild element that does not click itself from Enter/Space (aspan) gets both keys from the trigger; a disabled root saysaria-disabledthere; - the panel renders
hidden="until-found"while closed — hidden, but findable: find-in-page and a text fragment firebeforematch, which opens it through the model (a disabled root refuses, and the panel is hidden again). The close plays exactly as in native mode, the panel held shown until its exit has finished.
<Collapsible.Root native={false} model={() => state.open}>
<Card.Root>
<Card.Header>
<Card.Title>Release notes</Card.Title>
<Collapsible.Trigger asChild>
{(c) => <Button.Root lend={c}>Show details</Button.Root>}
</Collapsible.Trigger>
</Card.Header>
<Collapsible.Panel><Card.Body>…</Card.Body></Collapsible.Panel>
</Card.Root>
</Collapsible.Root>asChild and lend throw in native mode, naming native={false}. The
native default keeps what the non-native mode gives up: server-rendered HTML
toggles with no JS. native is read once, at mount. The anatomy's
element records the native default; the non-native elements are the
documented alternative. A design system collapses the closed panel itself
(&[hidden]: the UA gives until-found content-visibility: hidden, not
display: none, so the box stays) and animates the open on the panel, since
there is no ::details-content; all six bundled skins do, and reset the
UA button paint a <summary> never had. Accordion has no such mode yet.
A Tabs indicator can slide (#283). Tabs.Indicator is an optional,
aria-hidden span placed inside Tabs.List. It publishes the active tab's
box as --tabs-indicator-inset-inline-start,
--tabs-indicator-inset-block-start, --tabs-indicator-inline-size and
--tabs-indicator-block-size (px), relative to the list's padding box in its
scrolled content. The inline offset is measured from the list's inline-start
edge, so a recipe that positions the indicator absolute inside a
relative list with inset-inline-start lands on the tab in both writing
directions. The box is re-measured when the value changes and whenever the
list or a tab resizes; a scale or translate on the tab (a press effect) is
undone first. Until the first measurement, and while no tab is active, the
indicator is display: none, so a transition on those properties never
plays on first paint. basic, material and carbon slide their underline with
it and hand the active tab's own underline over to it; daisyui, brutalist
and heroui keep their static active style and render it display: none.
<Tabs.Root defaultValue="a" lazyMount>
<Tabs.List aria-label="Account">
<Tabs.Tab value="a">Profile</Tabs.Tab>
<Tabs.Tab value="b">Billing</Tabs.Tab>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Panel value="a">…</Tabs.Panel>
<Tabs.Panel value="b">…</Tabs.Panel>
</Tabs.Root>Tabs panels can render lazily (#283). With lazyMount on Tabs.Root, a
panel renders its content only once its tab has been active, and keeps it
afterwards. With unmountOnExit, a panel renders its content only while its
tab is active, so state inside it resets when the user leaves; together the
two behave like unmountOnExit. Either way the panel element itself always
renders (empty and hidden), so every tab's aria-controls resolves.
Popup exits play in every engine (#17). A design system animates a
popup's exit in CSS off data-state="closed", and on Chromium CSS overlay
keeps the element in the top layer while it plays. Firefox and WebKit have
no overlay, so there Dialog, Drawer, Popover, Menu, Tooltip, HoverCard,
Select and Combobox hold the native close() / hidePopover() back until the popup's
own exit transition or animation has finished — never longer than its
computed length, so a stalled one cannot leave a popup stuck open. For
that span the popup is still shown natively (a modal dialog stays modal),
and reopening it cancels the pending close. A close the platform makes by
itself — popover="auto" light dismiss, a <form method="dialog"> submit —
has already happened when zero hears of it, and stays instant there.
The peer-parity surfaces ship too: Menu has stateful items
(Menu.CheckboxItem, Menu.RadioGroup/Menu.RadioItem — APG
menuitemcheckbox/menuitemradio; toggling keeps the menu open unless the item
sets closeOnSelect), and its submenus predict the pointer: a mouse
heading diagonally for an open submenu does not hand hover to the sibling
items it crosses while it stays inside the safe triangle between where it
left the sub-trigger and the submenu's near edge — closeDelay still closes
the submenu if the pointer stops short. On the keyboard, ArrowDown on a
closed Menu.Trigger opens on the first enabled item and ArrowUp on the
last, Enter on an asChild <a href> item keeps its default, so the
link navigates as well as selecting, and Tab or Shift+Tab from any item
closes the whole chain (root and every open submenu) while the browser
moves focus on to the next or previous tab stop — it is not pulled back to
the trigger; focus leaving the menu any other way (a pointer, assistive
technology) closes it too. loop on Menu.Root (default true) decides
whether ArrowDown/ArrowUp wrap at the ends, at every level. Menu.Shortcut
is the visible shortcut hint inside an item (a decorative, aria-hidden
shortcut part the skins push to the row's end), and keyshortcuts on
Menu.Item, Menu.CheckboxItem and Menu.RadioItem renders
aria-keyshortcuts so the shortcut is announced once, in a form assistive
technology parses — zero binds no keys; Dialog has an alert-dialog preset
(role="alertdialog": no backdrop dismiss, initial focus on the
least-destructive Dialog.Cancel), and every Dialog/Drawer close reports
why on a close event that follows openChange(false) — { reason, value }
with reason one of close · cancel · escape · backdrop ·
programmatic (Drawer has no cancel, and adds swipe) and value from the closing
Dialog.Close value="…", the <form method="dialog"> + returnValue pair
in model form, so a confirm dialog needs no flag beside its model; a modal
Dialog or Drawer dismisses on the backdrop only when the press both starts
and ends outside its box, so dragging a text selection out over the backdrop
never closes it, and dismissible={false} holds however often Escape is
pressed (zero prevents the Escape keydown itself, because browsers let a page
cancel only the first close request made without a fresh user activation);
Slider's model accepts number[] for a
composed multi-thumb range (Slider.Track/Range/Thumb, thumbs clamp at
their neighbors, marks renders ticks) while a scalar model keeps the native
<input type=range>, and orientation="vertical" turns either projection
bottom-to-top (data-orientation on the root and every positioned part,
aria-orientation on the thumbs and the control, pointer mapped through
clientY, Up/Right increase with no RTL mirroring, the native control
spelled writing-mode: vertical-lr; direction: rtl); Select and Combobox group options
(Group/GroupLabel, the optgroup equivalent).
Overlays: focus targets, scroll lock, asking before dismissal (#277).
Dialog.Root and Drawer.Root take initialFocus and finalFocus, each a
function returning the element to focus (or null for the default).
initialFocus() is focused once the popup opens, over autofocus and the
platform's first-focusable rule. finalFocus() is focused after it closes;
the default is the element focused before it opened, then — when that
element sits in a popup that has since closed, like the Menu.Item that
opened a dialog — the trigger that controls that popup (followed through
aria-controls, outwards through submenus), then the component's own
Trigger. Popover.Root takes the same pair: initialFocus replaces the
first-tabbable move on open, finalFocus the return to the trigger. A modal
Dialog or Drawer sheet locks the document's scroll while it shows
(preventScroll, default true; a non-modal dialog, an inline or a docked
drawer never locks): overflow: hidden on <html> with the scrollbar's
width padded back on padding-inline-end (not under scrollbar-gutter:
stable), ref-counted across overlays, released once the exit has played and
on unmount. And the dismissals ask first: Dialog.Root, Drawer.Root,
Popover.Root and Menu.Root emit escapeKeyDown (the KeyboardEvent)
before Escape dismisses and interactOutside (the backdrop click, the
outside pointerdown, or — Menu — the focusin that left it) before an
outside interaction does; a handler that calls preventDefault() keeps the
overlay open. A prevented Escape never becomes a close request. A popover's
light dismiss cannot be cancelled, so a prevented outside press lets it
close and shows it again from its toggle event — the model never changes
and no openChange fires. Menu's escapeKeyDown is the root's: an Escape
that closes an open submenu is the submenu's own.
Range stepping shares one vocabulary (#272). Slider, NumberInput and
Diff's handle all move by step on the arrows and by largeStep on
PageUp/PageDown and Shift+Arrow (default ten steps; Diff's step 1 and
largeStep 10, in percent) — a native Slider.Control included, whose own
PageUp is engine-defined. Slider's minStepsBetweenThumbs (default 0; a fraction rounds up to
whole steps) keeps neighbouring thumbs that many steps apart, and each thumb announces the gap
in its aria-valuemin/aria-valuemax; a gap its neighbours leave no room
for degrades to plain no-crossing, so the bounds never invert or leave
[min, max], and they always hold the thumb's own value. Slider's valueCommit fires with
the model's shape once a drag is released, after each keyboard step, and on
the native control's change — each only when the value moved. It is an
event, not a model: there is no model:valueCommit to bind, and the value
model's own valueChange keeps firing on every intermediate value. A NumberInput value that sits off
the step grid (an off-grid max, a value written from outside) steps to the
neighbouring grid value in the direction of travel — 5 on step={2} goes
Up to 6 and Down to 4, where rounding to the nearest used to skip to 8.
Diff's handle speaks getValueText(value) as aria-valuetext (default
"50%"), and disabled on Diff.Root freezes it: data-disabled on the
root and handle, aria-disabled, out of the tab order, no keys, no drag.
NumberInput speaks a locale (#300). locale (a BCP 47 tag) and
formatOptions (Intl.NumberFormatOptions) on NumberInput.Root hand
display and parsing to Intl.NumberFormat — no bundled parser. The
committed value shows formatted (1.234,5 in de-DE, $1,299.99, 15%)
and aria-valuetext reads the same text; typed text parses back through
the locale's own symbols, read once per (locale, formatOptions) from
formatToParts: grouping is stripped (any space counts where the group is
a space, as fr-FR's narrow no-break space is), the decimal maps to .,
- and − both mean minus, the format's own currency, percent or unit
literals are optional, and anything else — letters, another currency, a
. in a comma-decimal locale that does not group with it, exponents,
accounting parentheses — is rejected, so the draft reverts. style:
'percent' divides by 100 (the model is the fraction, 0.15 shows as
15%) and defaults step to 0.01 (unless a custom parse owns the
model). inputmode is numeric when the
format allows no fraction digits and min >= 0, decimal otherwise. A
custom format or parse still wins over its locale counterpart, and the
hidden input keeps posting the canonical String(value). formatOptions
alone formats in the runtime's default locale, which can differ between
server and client — pass locale when rendering on the server. Digits
must be ASCII: a locale whose default numbering system is not Latin
(ar-EG) formats but does not parse back.
Select and Combobox are typed generic over their items. items is
the data; T infers from it, and the model holds the item unless
itemValue says what it holds (itemValue={(c) => c.code} makes a
string model; a number is as welcome) — null while nothing is selected
either way, multiple making it an array instead. itemKey is the string
identity (the DOM id, the typeahead target, the posted value), itemLabel
the display text, itemDisabled and itemGroup complete the accessors;
the defaults read an object's value / label / disabled / group or
the primitive itself. Labels resolve from data before anything mounts. With
items and no slot children the Root renders the full default composition
through the same anatomy (the item slot customises an option); explicit
children win entirely, and hand-written Select.Item children register
into the same collection. An items that is still undefined on the first
render (a list still loading) may arrive later: the root turns data-driven
the moment it does — Select, Combobox and RadioGroup alike — and "nothing
selected" becomes null from then on (an uncontrolled model seeded ''
still reads as empty). The types follow: items={query.data} typed
T[] | undefined picks the data overload, so the model is T | null (or
V | null with itemValue) with no cast. Passing [] while loading keeps
the model's shape fixed from the start. Combobox filters by default — a contains-match on
the label — filter replaces the rule and filter={false} shows a
server-filtered list as is; Combobox.Empty renders only while nothing is
visible. Under multiple, Combobox renders each chosen value as a tag in
the control (Combobox.Tags / Tag / TagLabel / TagRemove; the root's
tag slot supplies per-tag content), and allowCustom commits free text on
Enter.
The tags are keyboard-reachable (#411). They take real focus but are out of the Tab order, and so are their remove buttons, which are a pointer affordance like the trigger:
| Where | Key | Does |
|---|---|---|
| input | reading-start arrow (←, → under rtl) at caret 0 | focus the last tag; the list closes, the typed query stays |
| input (empty) | Backspace | focus the last tag — a second Backspace removes it |
| tag | ← / → | previous / next tag; past the last, back to the input |
| tag | Home / End | first tag / the input |
| tag | Backspace | remove it, focus the previous tag |
| tag | Delete | remove it, focus the tag taking its place (or the input) |
| tag | Escape, ↓ / ↑ | back to the input (the arrows open the list) |
| tag | a printable key | back to the input, where the character lands |
A focused tag carries data-focus-visible for a keyboard ring. Under
readonly the tags are still reachable but nothing is removed.
Both post through a real hidden <select> (every item as an
option in data mode, multiple under multiple). There is no separate
native select: the hidden <select> is the form control, and a native
projection would be a prop on this anatomy, never a second component.
The Combobox input shows what the form posts. When the list closes
(Escape, Tab, an outside press, the trigger) or focus leaves the combobox,
the typed text is resynced with the value. In single mode, text that was
never picked reverts to the chosen option's label, or to '' when nothing
is chosen, and emptied text clears the value. Under allowCustom the text
is committed as the value instead, and under multiple the query is
dropped (the tags are the value). The keyboard follows the APG editable
combobox: Alt+ArrowDown opens without moving the highlight (it lands on
the chosen option, or nowhere), Alt+ArrowUp commits the highlight and
closes, and Escape on a closed combobox clears the text and, in single
mode, the value. That Escape calls preventDefault only when it cleared
something, so on an empty combobox it still reaches an enclosing dialog.
openOnClick (default false) opens the list on a pointer click in the
input as well.
Auto-highlight (#448). By default, typing never highlights an option, so
Enter picks nothing until an arrow key moves the highlight. Under
allowCustom, Enter then commits the raw text, even when it names a listed
option only in part. autoHighlight on Combobox.Root highlights the first
enabled visible option as you type, and again whenever the query changes, so
Enter picks the best match. An arrow move holds until the query changes. It
applies only while the query is non-empty: an empty field or an
openOnClick open leaves no highlight, so Enter still reaches the form.
Under allowCustom, a query that matches nothing leaves no highlight, so
Enter commits the text. inlineComplete takes precedence, because its
highlight is the completion. Trigger mode always behaves this way.
Inline autocomplete (#301). inlineComplete on Combobox.Root (single
mode, not trigger mode) follows the APG list-with-inline-autocomplete
pattern, and the input says so with aria-autocomplete="both". When
typing or a paste inserts text at the end of the input, and the first
enabled visible option's label starts with that text, the input completes
to it. The match is case-insensitive, the same rule as the default filter.
The remainder is inserted selected, and that option is highlighted.
Deletions and IME compositions never complete, and neither does text
inserted before the end. model:inputValue keeps the typed text, so the
list filters on it and a server search reads it. The completion is only
what the input shows. Backspace or Delete removes just the selected
remainder. The first Escape takes the completion back, and the next one
follows the usual rules. Enter and Tab commit the highlighted option (Tab
still moves focus on). ArrowLeft, ArrowRight, Home and End, or a click in
the input, accept the completed text as text without choosing a value. ArrowUp and ArrowDown drop
the completion and walk the list. A blur resyncs as described above, so an
unaccepted completion never posts.
Clearing, separators and loading (#280). Select.ClearTrigger is a real
button in the tab order, a sibling of Select.Trigger inside the root
(never inside it — a button cannot hold a button, and the trigger's name
must not absorb it), aria-label "Clear selection" (label overrides). It
renders only while something is selected and the select is editable, and a
click writes the empty value (null, '' for hand-written items, [] under
multiple) and puts focus back on the trigger. While it renders — wherever
it is composed, not only as a direct child of the root — the trigger, value
and indicator carry data-clearable (#387), which a skin reads to reserve
the button's width (states: { clearable: { … } } on the part), with no
:has(). Combobox.ClearTrigger sits
in the control beside the input and trigger, a pointer affordance like the
trigger (tabIndex=-1, aria-label "Clear"; Escape on the closed input is
the keyboard's clear), rendered while there is a value or typed text; a
click empties both and refocuses the input. clearable on either Root adds
one to the default composition. Select.Separator / Combobox.Separator
draw a rule between runs of options: role="separator", aria-hidden
(ARIA's listbox owns only options and groups), and never an option — the
arrows, typeahead and a reader's option count walk straight past it.
Combobox.Root loading says the list is still arriving: the listbox is
aria-busy="true", Combobox.Loading renders its content (the data
expansion renders loadingText), and Combobox.Empty holds back — an
unfinished list is not an empty one. The popup's open state is untouched.
Like Empty, Loading is presentation in the listbox rather than a
status region, which a listbox may not own.
Trigger mode: @mentions over a Textarea. Combobox.Root trigger="@"
(or a RegExp matched before the caret, whose first group is the query)
turns the Textarea.Textarea composed inside it into the combobox's
control. It can also be a single-line Input.Input (#106), for a chat line
or a command bar with /-commands. The binding is the same, and Enter goes
to the app (or submits its form) only while the list is closed. It is the same scope, so the popup, items, groups and empty
state are the ones every design system already styles. The token at the
caret — the trigger at the start of the text or after whitespace, then
non-whitespace — is the query (model:inputValue holds it, and items
are filtered by it as usual). The list opens while there is a token and
something matches (or emptyText says nothing does), with the first option
highlighted. While it is open the textarea is an ARIA combobox (role,
aria-expanded, aria-activedescendant; aria-autocomplete and
aria-controls stay on all the time), and Arrow keys, Enter, Tab and
Escape belong to it — the app's own onKeydown does not see them, so a
composer's Enter-to-send only sends while the list is closed. Shift+Enter
is still a line break. A commit replaces the whole token with the trigger,
the label and a space (reusing one that already follows — never two)
through the editing stack (it undoes), keeps the
caret after it and emits insert ({ value, label, text }).
itemInsert replaces that default text (#107). It gets
{ item, value, key, label, prefix, query } and returns what goes in, which
is inserted as returned: no space is added, none is stepped over, and the
caret lands after it. Use it for a mention stored as an id, no trailing
space, or an emoji for a : trigger. item is undefined for a
hand-written option.
<Combobox.Root trigger="@" items={members}
itemInsert={({ prefix, label, value }) => `${prefix}[${label}](user:${value.id})`}>There is no selection: model is never written, and nothing posts but the
textarea. A press on the list never takes focus from the textarea. The data
expansion renders only the popup; hand-written items go in a
Combobox.Popup of your own beside the textarea.
Where the list opens is anchor (#105). Without it the popup docks to the
textarea's box, the way a chat app puts its list above the composer.
anchor={caretAnchor} opens it beside the typed @ instead, on the token's
own line, however tall the composer is. caretAnchor (from
@sigx/zero/behaviors, and the barrel) measures the caret with a hidden
mirror of the textarea: the same font, padding, width and wrapping. It
re-measures only when the text or width changes, and follows the page's and
the textarea's scroll. It is passed in, so a composer that docks to the box
never ships it (about 0.6 kB brotli). Under rtl a placement above or
below aligns to the reading direction, as every positioned popup's does, so
the default bottom-start puts the list's right edge at the @ and it
opens leftwards (data-placement still reads bottom-start). A vertical writing-mode
falls back to the box.
Any (control, index) => PositionAnchor | null works as an anchor; the
index is the token's first character.
import { caretAnchor } from '@sigx/zero/behaviors';
<Combobox.Root trigger="@" anchor={caretAnchor} items={members} itemKey={(m) => m.id} itemLabel={(m) => m.name}
onInsert={({ value }) => mention(value.id)}>
<Textarea.Root model={() => state.draft} minRows={1} maxRows={8}>
<Textarea.Label visuallyHidden>Message</Textarea.Label>
<Textarea.Textarea onKeydown={sendOnEnter} />
</Textarea.Root>
</Combobox.Root>
<Combobox.Root trigger="/" anchor={caretAnchor} items={commands}>
<Input.Root model={() => state.line}>
<Input.Label visuallyHidden>Command</Input.Label>
<Input.Control><Input.Input onKeydown={runOnEnter} /></Input.Control>
</Input.Root>
</Combobox.Root>Long lists: virtual. On a data-mode Select or Combobox,
virtual={virtualListbox} windows the options through createVirtualList
(#96): only the options near the popup's scroll position are in the
document, so ten thousand items cost a screenful of elements.
estimateItemSize (px, default 36) sizes an option until it has been
measured. The strategy lives in its own entry, @sigx/zero/virtual-listbox
(also on the barrel), so the select and combobox entries never carry
windowing: only a list that windows pays for it, about 3.3 kB brotli (#119).
import { virtualListbox } from '@sigx/zero/virtual-listbox';
<Select.Root items={timeZones} virtual={virtualListbox} itemLabel={(z) => z.name} model={() => state.zone} />
<Combobox.Root items={timeZones} virtual={virtualListbox} itemLabel={(z) => z.name} model={() => state.zone} />- Every highlight is real. Arrow keys, Home/End, typeahead and — only
while windowed — PageUp/PageDown (a viewport's worth of options) reach
options that were never rendered. The window scrolls to the highlighted
option, which stays rendered (pinned) even when the list is scrolled away
from it, so
aria-activedescendantalways names an element. Opening scrolls to the selection. - Each option says where it stands:
aria-setsizeis the number of visible (filtered) options, andaria-posinsetis the option's position among them. - The popup is the scroll viewport. Rows the window skips are stood in
for by
spacerparts (aria-hidden, sized inline). The popup must be bounded for anything to be windowed;css/base.cssbounds a windowed popup atmin(20rem, 60vh)in its lowest layer (zero.fallback), so a design system's popup recipe or your own CSS sets the real height. Options stack as blocks without margins. - Groups window too (#127). A
groupelement contains its options, so it cannot be split across a window. Windowed, eachitemGroupgroup is agroup-headingpart instead: a row of the window, beside the options rather than around them, and measured like one. It isaria-hidden; each option under it names it througharia-describedbywhile it is rendered, and the highlighted option's heading is pinned with it, so the optionaria-activedescendantnames always carries its group. A group the Combobox query empties has no heading. The keyboard walks groups in the order they render, windowed or not: a group's later members are pulled up under its heading, so they come next. - What stays whole. Hand-written
Select.Item/Combobox.Itemchildren are never windowed. They register as they mount, so nothing knows an item before it is rendered, andvirtualis ignored. To window a long list, pass it asitems. Undervirtual, the hidden<select>carries only the chosen options, not one per item.multiple, tags,allowCustomand trigger mode work as they do unwindowed.
Interaction state is published as data for the design system to style:
data-focus-visible, and press feedback on every interactive part —
data-pressed while the pointer/key is down (a press ends when the gesture
ends: captured pointers, like a slider drag, hold it until release),
data-press-animating until the press animation ends — finished, cancelled,
or destroyed with the stylesheet that declared it — with the press
point as --press-x / --press-y / --press-r. Checkable controls
(Switch, Checkbox, RadioGroup) take the press from anywhere in their label
row and surface it on the visible control. That is what makes a
pointer-anchored effect like Material's ink ripple — or its selection-control
halo and slider-thumb halo — expressible as pure CSS.
Swipe to dismiss (#293) is published the same way, on a Toast root and a
modal Drawer sheet (createSwipe, the one gesture behind both): once a
drag toward the dismiss edge passes a 10px slop the part carries
data-swiping, and the drag offset as --swipe-x / --swipe-y (px,
physical, clamped to the dismiss axis — the other way it gives a little,
with resistance). A release past 50px, or a flick faster than 0.11px/ms,
dismisses and keeps the offset, so the exit leaves from where the part was
let go; anything shorter clears both and the part springs back. A recipe
composes translate(var(--swipe-x, 0px), var(--swipe-y, 0px)) first into
the part's transform, drops its transition while [data-swiping] so the
part tracks the pointer, and sets touch-action so a touch drag reaches the
gesture instead of panning the page (a block-edge sheet's own long content
then scrolls in a box inside the panel). Both properties are web-only
runtime properties (RUNTIME_PROPERTIES). A press on an interactive
descendant, a secondary button, or a press while text in the part is
selected never starts a swipe.
Popup geometry is published the same way (#278). Every popup the built-in
position strategy places — Select, Combobox, Menu and its submenus, Popover,
Tooltip, HoverCard — carries, beside data-placement, the custom properties
POSITION_PROPERTIES names (@sigx/zero/contract), re-measured on every
update:
| Property | Value |
|---|---|
| --anchor-width / --anchor-height | the anchor's size, px |
| --available-width / --available-height | the room between the anchor and the viewport edge on the side the popup resolved to (after a flip), less the offset and the collision padding; the cross axis is the viewport less the padding at both ends |
| --transform-origin | the anchor-facing edge and the aligned point, physical keywords: top left for bottom-start (top right under rtl) |
So a listbox as wide as its trigger that never runs off screen is
box-sizing: border-box; min-width: var(--anchor-width, 12rem);
max-height: min(20rem, var(--available-height, 20rem)); overflow-y: auto
— what all six design systems now do for Select and Combobox; border-box
keeps the padding and border inside the available room — and a popup that
scales in grows out of its anchor with
transform-origin: var(--transform-origin, center). Read
them with a fallback: a substituted positionStrategy may not publish
them. They are left in place when the popup closes, so an exit transition
keeps its size and origin.
Those six roots also take collisionPadding (px, default 8): the margin a
popup keeps from the viewport edges. The flip treats it as the edge, the
shift clamps inside it, and the available sizes subtract it. And
alignOffset (px, default 0) moves a -start/-end popup along the cross
axis, away from the edge it aligns to — in the reading direction for an
alignment above or below. A centred placement ignores it. Menu.Sub takes
both too.
The arrow (#279). Popover, Tooltip, HoverCard and Menu take an arrow
part — Popover.Arrow, Tooltip.Arrow, HoverCard.Arrow, Menu.Arrow —
rendered only when the app
renders it, inside the popup: an empty <span aria-hidden="true"> the
design system draws. Only the strategy knows where the anchor's centre
lands on the popup edge that faces it once the flip and the shift have
run, so it writes that on the arrow — ARROW_PROPERTIES
(@sigx/zero/contract):
| Property | Value |
|---|---|
| --arrow-x | on a top*/bottom* popup: the arrow's left offset along that edge, px from the popup's padding edge |
| --arrow-y | on a side popup (left*, right*, start, end): its top offset, px |
One is set and the other removed. The offset centres the arrow on the
anchor, clamped to [arrowPadding, edge - arrowSize - arrowPadding] so it
never slides onto a rounded corner — and when the popup has been shifted
back on screen, it still points at the anchor rather than at the popup's
middle. arrowPadding (px, default 8) is a prop on each of the three roots.
Which edge the arrow sits on is the recipe's to read from the popup's
data-placement ([data-placement^="top"] > [data-part="arrow"] sits on
the bottom edge, and so on); popupArrow in @sigx/zero-kit/define is that
reading, ready-made. The arrow is the root popup's only: a Menu.Arrow
inside a Menu.SubPopup is never positioned, and no shipped skin paints it.
A popup that holds an arrow lets it paint outside its box
(overflow: visible in every skin that paints one), so give such a popup's scrolling
content its own scroller.
<Popover.Root placement="bottom">
<Popover.Trigger>Sharing</Popover.Trigger>
<Popover.Popup>
<Popover.Arrow />
<Popover.Title>Sharing</Popover.Title>
<Popover.Description>Shared with 3 people.</Popover.Description>
</Popover.Popup>
</Popover.Root>Popover.Description and Popover.Anchor. Popover.Description (a
<p>) describes the popup: the popup's aria-describedby names it only
while it is rendered, joined with any app aria-describedby, like Dialog's.
Popover.Anchor (a <div>, asChild) is what the popup is positioned
against in the trigger's place, while it is rendered — a whole row whose
trigger is a small button inside it. The trigger stays the toggle and the
element focus returns to on close; unmount the anchor and the trigger
anchors again.
ARIA wiring is presence-aware: an overlay references its Title /
Description ids only while those parts are actually rendered, so omitting a
title never leaves a dangling aria-labelledby (which would suppress the
accessible-name fallback). The same holds for Labels (#169): a
RadioGroup.Label names its radiogroup with or without a Field (joined with
the Field's label and any app aria-labelledby), a Progress.Label /
RadialProgress.Label is referenced only while rendered, a range slider's
thumbs without their own label are named by Slider.Label, and the
Label's and ValueText's for appear only while a native Slider.Control
is mounted. Presence reports land a microtask after mount, so RadioGroup,
Progress, RadialProgress and Slider render these references optimistically
until then: server markup keeps the Label reference (and, outside thumb
mode, the Label's for) a composed widget needs, and the client drops any that
would dangle once mounted. A Field works the same way (#266): every
control's aria-describedby names Field.Description and Field.Error
only while they are rendered — an Error rendered on and off with the
value's validity is followed — joined with any app aria-describedby, and
the attribute is absent when neither is (server markup names both). A
Slider.Control and every Slider.Thumb inside a Field take its
description ids too, and getValueText speaks for the native control as
it does for a thumb (aria-valuetext). A progressbar's
aria-valuenow is clamped to [min, max]. Escape dismissal is universal — a tooltip closes
from anywhere (WCAG 2.1 SC 1.4.13), and it is hoverable too: leaving the
trigger closes it only after a 120 ms pointer-leave grace period, so the
pointer can cross the offset gap onto the popup. Blur still closes at once,
and an explicit closeDelay replaces both (closeDelay={0} restores the
immediate pointer-leave close). A non-modal Dialog falls back to the
dismiss layer where the platform fires no cancel. That layer (and an
inline Drawer's) yields Escape to what sits inside it: an Escape a widget
already handled (defaultPrevented — a Combobox or Select closing its
list) and one from an open popup nested in the surface (a Menu,
Select or Popover) close only the inner widget, and the next Escape closes
the dialog. Close buttons
