Reference
Components
@uniflowed/ui ships behaviour and no styles. Every part takes className and
every other DOM prop and passes it through; what it contributes is the keyboard
map and the screen-reader wiring, which is the half that does not show up in a
screenshot and the half that is missing from almost every hand-written
component.
Each primitive implements the WAI-ARIA authoring practices pattern for it — the
roles, the aria-* wiring, the focus management and the whole keyboard map.
Each module's header says which interaction it exists to get right and what a
naive version breaks; this page is the shorter version.
// @flow
import { Select } from "@uniflowed/ui";
// or, to import only what you use:
import { SelectRoot } from "@uniflowed/ui/select";
What ships
| Component | Pattern | Module |
|---|---|---|
Dialog | Modal dialog: focus trap, focus restore, scroll lock | dialog.js |
AlertDialog | The confirmation: alertdialog, no dismissal by a press outside, focus on Cancel | alert-dialog.js |
Sheet | A modal dialog attached to an edge, and one data-side contract | sheet.js |
Drawer | The sheet you can drag away, with the keyboard WCAG 2.5.7 requires | drawer.js |
Sidebar | Navigation beside the page that becomes a modal sheet on a narrow one | sidebar.js |
Menu | Menu button: arrow keys, typeahead, submenus, Escape stacking, checkable items. shadcn's Dropdown Menu is this. | menu.js |
ContextMenu | The same menu opened by the right button, by Shift+F10 and by a long press, at the pointer | context-menu.js |
Menubar | A row of menus as one control: one tab stop, and arrows that walk between the open ones | menubar.js |
Combobox | Editable combobox: aria-activedescendant over a list you filter, in groups or not | combobox.js |
Select | Select-only combobox: typeahead, option groups, a form value | select.js |
Popover | A dialog that is not modal: focus in, Escape out, Tab leaves | popover.js |
Tooltip | A phrase on hover and focus that WCAG 2.1 SC 1.4.13 would accept | tooltip.js |
HoverCard | The preview a name expands into, with links Tab can reach | hover-card.js |
Tabs | Tabs: a roving tabindex, automatic or manual activation | tabs.js |
Calendar | A month as one tab stop: two axes, month boundaries, and a live region that says which month | calendar.js |
DatePicker | The field, the button and the calendar in a popover | date-picker.js |
Toast | Notifications: a live region that was already watching, and timers that stop | toast.js |
Slider | A value in a range, with the slider role on the thumb | slider.js |
Resizable | The window splitter: a separator that behaves like a slider | resizable.js |
Progress | How far along something is, or that it is under way at all | progress.js |
Table | The sort that is announced, the selection that can be mixed, the rows a page is not showing | table.js |
Pagination | Navigation with a name, one current page, and a sentence saying it moved | pagination.js |
Field | The label, description, error, aria-invalid and aria-required wiring — for one control or for a group | field.js |
Switch, Checkbox | The two two-state controls, including mixed, and the Enter that submits rather than toggles | switch.js, checkbox.js |
Carousel | Slides that can be stopped, and that Tab cannot fall into | carousel.js |
ScrollArea | An overflow container a keyboard can actually scroll | scroll-area.js |
InputOtp | Six boxes drawn over one real <input>, so autofill still works | input-otp.js |
Composition is type-checked. Tabs.List declares renders* Tabs.Tab, so a
<button> in a tab list is a type error rather than a review comment;
Menu.Body and Select.List state the same constraint about what may appear
inside a menu and a listbox, which ARIA also requires and which nothing else
checks.
Twelve containers here state a constraint like that, and each of them is held
to it. tests/type-tests/composition.js writes out the wrong child for every
one — a <button> in a tab list, a <div> in a listbox, a Select.Option in
a menu, a render function that returns something that is not a Toast.Root —
beside the composition that must keep compiling, and uf check is run over the
pair. The guarantee that cannot be removed without a test going red is the only
kind worth stating on this page.
Select
A button that opens a list of options and takes one of them — the select-only
combobox of ARIA 1.2, where Combobox is the editable one.
<Select.Root defaultValue="GB" name="country">
<Select.Label>Country</Select.Label>
<Select.Trigger>
<Select.Value placeholder="Choose one" />
</Select.Trigger>
<Select.List>
<Select.Group>
<Select.GroupLabel>Europe</Select.GroupLabel>
<Select.Option value="GB">United Kingdom</Select.Option>
<Select.Option value="FR">France</Select.Option>
</Select.Group>
<Select.Separator />
<Select.Option value="JP">Japan</Select.Option>
</Select.List>
</Select.Root>
Use a native <select> if a native <select> will do
That is the recommendation and not a disclaimer. A <select> is announced
correctly by software this package has never been tested against, is a wheel
the thumb already knows on a phone, autofills, and validates. Nothing here
improves on it.
What it cannot do is be styled. Its popup is drawn by the operating system, so
an option cannot hold an icon, a second line, a shortcut or a checkmark. A
design that needs any of those has two options — this pattern, or a div with
a click handler that no keyboard reaches. Select is the first one, done
properly: role="combobox" on the trigger, typeahead, and the arrow keys
moving a cursor a screen reader is told about.
Parts
| Part | Renders | Notes |
|---|---|---|
Select.Root | div | value, open, name, disabled. Controlled the moment you pass value or open |
Select.Label | label | Names the field. Without it the trigger has no accessible name at all |
Select.Trigger | button role="combobox" | aria-haspopup="listbox", aria-expanded, aria-activedescendant |
Select.Value | span | What the trigger shows for the current value |
Select.List | div role="listbox" | In the document only while open |
Select.Option | div role="option" | value, optional label, disabled |
Select.Group | div role="group" | Named by its Select.GroupLabel, and only when one is rendered. Takes options and that label, and nothing else |
Select.GroupLabel | div role="presentation" | Names a group of options |
Select.Separator | div aria-hidden | A rule between groups. Decoration, so it says so |
Select.Label names the field and Select.GroupLabel names a group of
options. shadcn has one SelectLabel and it is the second of those; a select
needs both.
Select.Label is a real <label htmlFor> and the trigger's
aria-labelledby, and the second one is not redundant. role="combobox" is not
a role that takes its name from its own content, and a <label for> does not
name a <button> either — so a select with only the label is a combobox
announced as "combobox" and nothing else, while looking correct in the markup.
The keyboard
Focus never leaves the trigger. The arrow keys move aria-activedescendant, a
second cursor naming which option is current, which is how a screen reader is
told the option changed — a highlight drawn in CSS moves the same pixels and
says nothing.
| Key | Closed | Open |
|---|---|---|
ArrowDown / ArrowUp | Open, cursor on the chosen option or the first / last | Move the cursor. Does not wrap |
Home / End | Open, cursor on the first / last option | Cursor to the first / last option |
Enter, Space | Open | Take the option under the cursor, close |
Alt+ArrowDown | Open with no cursor | — |
Alt+ArrowUp | — | Take the option under the cursor, close |
Escape | Passes through, so a dialog around it still closes | Close, changing nothing |
Tab | Moves on | Take the option under the cursor, then move on |
| A letter | Open, cursor on the first match | Move the cursor to the match |
Two of those differ from Combobox on purpose, and the pair is the clearest
statement of why there are two components:
HomeandEnd. A combobox leaves them to the text cursor, because a combobox that steals them has made its own text field harder to edit than a plain<input>. A select has no text, so they go to the ends of the list.Tab. A combobox closes without taking the highlight: the reader typed something, the highlight is a suggestion about text they still own, and committing it turns "leave this field" into an edit. A select has nothing typed and nothing to lose, so moving the cursor is the act of choosing, and APG's rule for this pattern is thatTabtakes it.
Moving the cursor does not change the value. A native <select> on Windows does
the opposite, and copying it would fire your onValueChange — and with it your
validation, your form store or your server mutation — once per arrow press.
What a form submits
Give Select.Root a name and it renders a hidden control carrying the
value:
<form action={save}>
<Select.Root name="country">…</Select.Root>
</form>
Without a name nothing is submitted, which is right for a select that drives a
filter. With one, the entry carries GB and not "United Kingdom" — the bug this
exists for, since a div wearing an ARIA role is not one of a form's listed
elements and a form collected nothing at all for it. A disabled select submits
nothing, as a native <select disabled> does. Combobox.Root takes the same
name and does the same thing, for the same reason: Combobox.Input renders
the label, which is not the value.
It is an <input type="hidden"> rather than a concealed <select> on purpose.
A real <select> is focusable, so a reader tabbing in hears the styled combobox
and then a second, invisible one with the same options; aria-hidden on a
focusable element is itself the violation it would be reached for to avoid. Two
things are given up with it, and both are written down rather than hidden: the
browser will not autofill a hidden input, and required gets no native
constraint validation. The second is not a loss here — the browser reports a
constraint failure by focusing the control and drawing a bubble at it, and on a
control with no box it refuses to submit the form with nothing shown to the
reader — so a headless select's required belongs to @uniflowed/validator,
with the message in Field.Error.
With @uniflowed/form
No name, and no import in either direction. That library keeps its values in
its own store and prevents the native submission, so it never builds a
FormData and never sees the hidden control. The binding is useController,
whose own header names this exact case:
// @flow
import { useController } from "@uniflowed/form";
import { Select } from "@uniflowed/ui";
component CountryField(control: Control<Values>) {
const { field } = useController({ control, name: "country" });
return (
<Select.Root onValueChange={field.onChange} value={field.value}>
<Select.Label>Country</Select.Label>
<Select.Trigger>
<Select.Value placeholder="Choose one" />
</Select.Trigger>
<Select.List>…</Select.List>
</Select.Root>
);
}
What Select.Value shows
In order: its own children if you passed any; the label of the option with
that value, learned from the options themselves and remembered after the list
closes; the value itself; the placeholder when nothing is chosen at all.
The third of those is a boundary worth knowing about. A select whose value
arrived from outside — a defaultValue from a saved form — has no option
rendered for it until the list has been opened once, so the trigger shows GB
rather than "United Kingdom". That is wrong and true; showing the placeholder
there would be wrong and confident, telling a reader nothing is chosen when
something is. Pass children to say what the value is called:
<Select.Value placeholder="Choose one">{COUNTRIES[value]}</Select.Value>
Not shipped, and why
shadcn's Select has ScrollUpButton and ScrollDownButton. Both exist to
scroll a popup that Radix positions and sizes; this package positions nothing,
so a scroll button here would be a button with no idea what to scroll. The
behaviour they are actually for — the cursor staying visible as the arrow keys
move it — is already in the component, as the scrollIntoView({ block: "nearest" }) every move performs, and it works inside your own scroll
container without either button.
Combobox: groups, and the palette they make possible
The rest of Combobox is in the guide and in combobox.js's own
header; this is the part Select had and it did not.
<Combobox.Root inputValue={query} onInputValueChange={setQuery}>
<Combobox.Label>Country</Combobox.Label>
<Combobox.Input />
<Combobox.List>
<Combobox.Group>
<Combobox.GroupLabel>Europe</Combobox.GroupLabel>
<Combobox.Option value="GB">United Kingdom</Combobox.Option>
<Combobox.Option value="FR">France</Combobox.Option>
</Combobox.Group>
<Combobox.Group>
<Combobox.GroupLabel>Asia</Combobox.GroupLabel>
<Combobox.Option value="JP">Japan</Combobox.Option>
</Combobox.Group>
</Combobox.List>
<Combobox.Empty>No matches.</Combobox.Empty>
<Combobox.Status />
</Combobox.Root>
| Part | Renders | Notes |
|---|---|---|
Combobox.List | div role="listbox" | renders* (Combobox.Option | Combobox.Group) |
Combobox.Group | div role="group" | Named by its Combobox.GroupLabel, and only when one is rendered |
Combobox.GroupLabel | div role="presentation" | Names a group of options. Combobox.Label names the field |
Three things follow from a listbox being allowed to own option and group
and nothing else.
Combobox.ListandCombobox.Optionaredivs. They were a<ul>of<li>s, and an<li>cannot contain an<li>, so a group could not hold its options without a second list role between them. If your stylesheet namesulorliinside a combobox, this is the change to make.- The arrow keys still move by option. A group is not a listbox, so the keys cross a boundary without stopping, and the heading is never somewhere the cursor can land — it is not an option.
- There is no
Combobox.Separator. A rule between two groups cannot be arole="separator"inside a listbox, so it isaria-hiddendecoration, which is adivyou write.Select.Separatorexists because a select's options are a fixed list; a combobox's are whatever survived your filter, and a fixed rule between them is decoration in the wrong place.
Combobox.Status counts options, not headings.
A command palette
There is no Command component and there will not be one. A palette is a
Combobox inside a Dialog, and now that groups exist every part of it is
already here:
useKeyCombo("mod+k", () => setOpen(true));
<Dialog.Root onOpenChange={setOpen} open={open}>
<Dialog.Body aria-label="Command palette">
<Combobox.Root inputValue={query} onInputValueChange={setQuery}>
<Combobox.Input placeholder="Type a command…" />
<Combobox.List>
<Combobox.Group>
<Combobox.GroupLabel>Files</Combobox.GroupLabel>
{files.map((each) => (
<Combobox.Option key={each} value={each}>{each}</Combobox.Option>
))}
</Combobox.Group>
</Combobox.List>
<Combobox.Empty>No commands matched.</Combobox.Empty>
<Combobox.Status />
</Combobox.Root>
</Dialog.Body>
</Dialog.Root>
useKeyCombo is @uniflowed/hooks/keyboard. The active
option scrolls itself into view, the count is announced, and Escape closes
the list before it closes the dialog, because the innermost thing wins.
Shipping that composition as a seventh module would be a module whose entire
content is what you just read — the argument ubugeeei-redundancy.md makes
against small lookalikes, applied to this package. So it is a page.
One thing a Command module would genuinely add is not here, because it is not
implemented anywhere: a palette whose filter matched nothing still traps focus,
so Tab cycles around an empty dialog while the reader is told there are no
results. That is a question about Dialog — a modal with nothing to reach is
the general case — and it is open rather than answered by a component that
would only look as though it had.
Toast
Notifications, and the live region that was watching before them.
// @flow
import { Toast, toast } from "@uniflowed/ui";
// Once, in the layout.
<Toast.Region>
{(each) => (
<Toast.Root>
<Toast.Title>{each.content}</Toast.Title>
<Toast.Action onClick={undo}>Undo</Toast.Action>
<Toast.Close />
</Toast.Root>
)}
</Toast.Region>;
// Anywhere at all.
toast("Saved");
toast("Could not save", { urgency: "assertive" });
The bug it exists for
Every hand-written toast mounts a <div role="alert"> at the moment the
message arrives, and that is exactly the commit that makes it silent: a screen
reader announces a live region's changes, so it has to have been watching the
region before the change. Mounting the region and its first message together
gives it nothing to have been watching.
It looks perfect on screen, it passes review, and the reader who most needs to
be told that the save failed is told nothing. Toast.Region renders on mount
and stays, holding nothing; notifications are appended into it. That is the
whole component in one sentence, and everything else here follows from it.
Parts
| Part | Renders | Notes |
|---|---|---|
Toast.Region | div role="region" | The named landmark, and the two live regions inside it. Render it once |
Toast.Root | div role="group" | One notification, and its countdown |
Toast.Title | div | Names the group |
Toast.Description | div | Describes it |
Toast.Action | button | Undo, Retry, View. Taking it dismisses the notification |
Toast.Close | button | label becomes its accessible name, defaulting to "Dismiss" |
| Function | What it does |
|---|---|
toast(content, options?) | Queues a notification; returns its id |
updateToast(id, changes) | Changes one already queued |
dismissToast(id) | Takes one away |
dismissAllToasts() | Empties the queue |
Toast.Region takes label (default "Notifications") and limit (default
3). toast takes urgency ("polite" or "assertive") and duration in
milliseconds, or null for one that never expires.
Two regions, not one
"Saved" and "could not save" interrupt a reader differently and should, and
which of the two a notification is belongs to the notification rather than to
the application. That cannot be a role that changes: swapping a live region's
role while it is being watched is the same bug as mounting it late. So there
are two regions inside the landmark, role="status" and role="alert", both
mounted from the start and both empty, and a notification is appended into the
one that matches it.
What that costs is chronological order between the two: a failure and a success on screen together are in separate containers, and no CSS interleaves them. It buys the thing that matters more, which is that what a reader hears is exactly what a reader sees — the alternative, one visible stack plus hidden announcers mirroring its text, puts every notification in the document twice.
Both regions are aria-atomic="true", so a change presents the whole region.
With one notification showing that is exactly right. With three, adding a fourth
reads all four — so limit is not only how tall the stack may get, it is how
much a reader is made to listen to.
Focus, and the key that gives it
Nothing here moves focus when a notification appears. That interrupts whatever the reader was typing, and it is the single failure that makes people turn notifications off.
But a notification carrying an Undo button that vanishes after four seconds is a
control no keyboard reader can operate, so the region is a named landmark and
F6 — the key the APG suggests for moving between panes — moves focus into
it. Focus lands on the region itself rather than on the first button in it, so a
screen reader reads the region's name and its contents; Tab from there reaches
the buttons. Pressing F6 again while focus is inside gives it back to wherever
it came from. The binding fires while the reader is typing, because a
notification that arrives during a form fill is the one most likely to be about
the form.
Timers that stop
WCAG 2.2.1, Timing Adjustable, applies to anything that disappears on its own. The countdown stops while the pointer is over a notification, while focus is inside it, and while the document is hidden — the third because a reader who switches tabs for a minute should not come back to an empty region.
It stops; it does not restart. What is left is banked and resumed, so brushing
the stack with the pointer does not hand a notification its whole life again.
Anything carrying an action should be given duration: null and dismissed by
the action or the close button: the countdown stopping while focus is inside
makes the button reachable, and that is not a promise that five seconds was
enough time to decide.
The queue
toast("Saved") is called from an event handler, from a catch, from a Server
Action's error path — none of which have a component to put state in. So the
queue lives outside React, as a store at module scope in toast.js read
through useSyncExternalStore, and one page has one set of notifications:
a function in a module has no context with which to find a scoped store, so
there is nothing to scope.
That store would be an atom in @uniflowed/state — a queue is what atoms are
for — and it was one. @uniflowed/ui is published to npm and
@uniflowed/state is not yet: binding a name that has never been published
takes a person with an npm login session and a 2FA prompt, which is
uf#210. A published package
whose dependency is missing installs as nothing, so @uniflowed/ui carries its
own queue until that name exists, and goes back to @uniflowed/state when it
does.
The queue is what makes the fiddly parts ordinary. limit notifications are
shown and the rest wait behind them, oldest first — and a waiting one's
countdown has not started, because it is not rendered. updateToast changes one
in place, so "Uploading…" becoming "Uploaded" is one notification rather than
two contradicting each other.
A value in a range
Three components report a number between two others, and all three make the
same four promises through aria-valuemin, aria-valuemax, aria-valuenow
and aria-valuetext. The arithmetic that keeps those true about each other
lives once, in internal/range.js.
Slider
<Slider.Root defaultValue={[20, 60]} valueText={(each) => `£${each}`}>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumb aria-label="Minimum" index={0} />
<Slider.Thumb aria-label="Maximum" index={1} />
</Slider.Root>
role="slider" goes on the thumb, not on the track and not on the wrapper.
That single placement is the difference between a control a keyboard reader can
operate and a decorative div, because the element carrying the role is the
element carrying tabindex="0".
It follows that a range is two sliders. Two thumbs are two elements with
role="slider", each in the tab order, each named — "Minimum" and "Maximum" —
and each bounded by its neighbour's current value rather than by the slider's
ends. Announcing both as 0–100 while the behaviour stops them passing each
other is worse than not shipping the range at all: the reader is told they may
set the low thumb to 90, they try, and the control silently refuses.
| Key | What it does |
|---|---|
ArrowRight / ArrowUp | Add a step |
ArrowLeft / ArrowDown | Subtract a step |
PageUp / PageDown | Move by largeStep |
Home / End | That thumb's own ends, which for a range thumb is its neighbour |
The horizontal keys mirror in a right-to-left page — ArrowRight means
"further along", and further along is to the left there. The vertical keys and
Home/End are unaffected.
valueText is the reason to reach for this component rather than an
<input type="range"> you cannot style. aria-valuenow="3" is announced as
"3"; if the scale is Low, Medium, High then 3 is the implementation and
"Medium" is the meaning. It is a function on the root rather than a string on
the thumb, because an uncontrolled slider's value is the component's and a
caller cannot write down a string for a number they have not been told.
A press anywhere on Slider.Track moves the nearest thumb there. That is WCAG
2.5.7, Dragging Movements: a control operated by dragging needs a way that is
not a drag, and the arrow keys are only half of it.
Nothing here has a width or a position. Each thumb carries
--uf-slider-fraction and the range carries --uf-slider-start and
--uf-slider-end, all numbers between 0 and 1, and your stylesheet decides
what to do with them. A caller who writes no CSS sees nothing.
Resizable
<Resizable.PanelGroup defaultValue={30}>
<Resizable.Panel primary>Files</Resizable.Panel>
<Resizable.Handle label="Resize the file list" />
<Resizable.Panel>Editor</Resizable.Panel>
</Resizable.PanelGroup>
The APG calls this a window splitter and defines it as a separator that
behaves like a slider, which is why it is documented beside one. Almost every
resizable panel on the web is pointer-only — a WCAG 2.1.1 failure, no keyboard
operation at all — so here the keyboard came first: the arrows move the
splitter by a step, Home and End go to the ends of its range, and Enter
collapses the primary pane and restores it.
It drags too. A press takes hold of the handle without moving it, the pointer
is captured so a drag that wanders off a four-pixel bar keeps arriving, and the
position is read out of the group's box — so panes laid out with flex, with
grid or by hand all behave the same. aria-valuenow follows the drag, which is
the half a pointer-only splitter has nothing to say about.
aria-controls names the primary pane, and only while one is rendered.
aria-valuenow is that pane's percentage of the group.
step is the keyboard's. It defaults to 10 because ten presses of an arrow
key ought to cross the pane, and snapping a drag to it would move the bar in
tenths under a pointer that moves smoothly. A drag moves by one percent, which
is not a second prop to set: the value is already a percentage of the group, so
one is the smallest move that means anything, and both paths go through the
same snapping and clamping — a drag cannot leave the pane anywhere an arrow key
could not put it.
min and max bound the drag rather than scaling it. Dragging to five percent
of a group whose min is 20 leaves the pane at 20; the handle stops where the
pane stops, instead of the pointer being mapped across the range the way a
slider's is.
Two more things are worth knowing:
aria-orientationdescribes the separator, not the layout. Two panes side by side are divided by a vertical line, so aPanelGroupwhoseorientationis"horizontal"renders a handle whosearia-orientationis"vertical". ARIA's default for the role ishorizontal, so a vertical splitter that says nothing is announced as a horizontal rule.- This is not
Menu.Separator. Both arerole="separator"and only one is a control. A menu's separator is a rule between groups: not focusable, no value. A splitter is focusable, carries a value, and changes the layout.
Two panes is the pattern rather than a limitation — the window splitter is defined between a primary pane and the rest. A group of five panels is a layout-constraint problem and a different component.
Progress
<Progress aria-label="Uploading" max={10} value={3} valueText="3 of 10 files" />
<Progress aria-label="Uploading" />
The second one is indeterminate, and its one line is the line everybody gets
wrong: a progress bar that does not know how far along it is omits
aria-valuenow entirely. It does not set it to 0.
The two are opposite statements. aria-valuenow="0" says "nothing has happened
yet", and a reader who asks again in ten seconds and hears zero again concludes
the operation is stuck. Omitting it says "in progress, amount unknown", which
is what a spinner means and what is actually true.
valueText replaces what is announced without touching aria-valuenow, so a
reader hears "3 of 10 files" and anything drawing a gauge still has the number.
It is a prop rather than something you add afterwards because the two have to
agree.
This one renders on a server: it holds no state, listens to nothing and manages no focus.
Table
<Table.Root onSortChange={setSort} rowCount={500} rowOffset={90} sort={sort}>
<Table.Caption>People</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>
<Table.SelectAll checked={all} onCheckedChange={setAll} />
</Table.Head>
<Table.Head column="name">Name</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{page.map((person, at) => (
<Table.Row index={at} key={person.id}>
<Table.Cell>
<Table.RowSelect
checked={chosen.has(person.id)}
label={`Select ${person.name}`}
onCheckedChange={(on) => choose(person.id, on)}
/>
</Table.Cell>
<Table.RowHeader>{person.name}</Table.RowHeader>
</Table.Row>
))}
</Table.Body>
</Table.Root>
The markup is not what this owns. A <table> with <th scope="col"> and a
<caption> is already accessible, and a component that only renames those
elements has added a dependency and no behaviour. What it owns is four things
that are invisible until somebody uses a screen reader.
Sorting. aria-sort goes on the sorted header only — omitted on the
others, not set to "none", because eleven headers each announcing "not
sorted" is eleven announcements of nothing on every pass through the table. The
header's content is a button, so the sort is reachable at all. And the
re-order is announced: the rows change places, nothing a reader is looking
at moves, and without a sentence they are told nothing happened.
Selection. Table.SelectAll takes checked: boolean | "mixed", and the
union is the point. A boolean would let you pass false for "two of three rows
are selected", and a reader would be told nothing is selected while three
checkboxes below say otherwise. Choosing a mixed box reports true — select
all — which is the state a reader expects it to move to, and not the "clears
itself on the first click" behaviour every table gets wrong.
Table.RowSelect requires label. "Select row" repeated forty times is
forty identical announcements with no way to tell which row you are on.
Counting. rowCount is how many rows the whole set has — five hundred
people, not the ten on this page — and rowOffset is where the rendered ones
start. The component works out aria-rowcount (the data plus the header row)
and each row's aria-rowindex, because a reader on page ten being told "row 1
of 10" looks exactly like a reader being told the truth. A table showing
everything it has needs neither, and gets neither.
table, not grid
"Make it a role="grid"" is the advice that circulates and it is usually
wrong. A grid takes the arrow keys away from the reader and gives them to the
component — right for a spreadsheet, wrong for a list of records, because a
screen reader already has table-reading commands that work perfectly on a plain
<table> and readers rely on them.
There is no role="grid" opt-in here, and that is deliberate rather than
missing. A grid is not an attribute; it is a two-dimensional keyboard contract,
and role="grid" without one is strictly worse than the table it replaced,
because the reader is told the arrow keys will do something and they do
nothing. An editable grid is a component of its own.
DataTable is not here, for shadcn's own reason
The benchmark ships a guide rather than a component, and composes TanStack
Table with its Table. uf has no TanStack Table equivalent — @uniflowed/query
is the fetching layer, not table state — so a DataTable would mean shipping
that library first. Table is the half that is uf's to own: the accessibility
of a table whose state you hold.
Pagination
<Pagination.Root page={4} pageCount={25}>
<Pagination.Content>
<Pagination.Previous disabled={page === 1} href={hrefFor(page - 1)}>‹</Pagination.Previous>
<Pagination.Item href={hrefFor(3)}>3</Pagination.Item>
<Pagination.Item current href={hrefFor(4)}>4</Pagination.Item>
<Pagination.Next href={hrefFor(page + 1)}>›</Pagination.Next>
</Pagination.Content>
</Pagination.Root>
A <nav> with a name, because a page has more than one and an unnamed one is
announced as "navigation" with no way to tell it from the site's menu. The
current page is aria-current="page" — not a class, not bold text. Previous and
next are named in words, because "link, single left-pointing angle quotation
mark" is not something anybody can act on; the glyph stays, the name is words.
And the change is announced, in a region that was already in the document.
The controls are <a> elements, which constrains you and is meant to: page four
of a table is a place, and a reader expects to open it in a new tab, copy it
and come back to it. A list paginated with buttons is a list whose fourth page
does not exist as far as the rest of the web is concerned. An application that
genuinely has no URL for a page is the case where this is the wrong component.
disabled drops the href rather than adding an attribute, because there is no
such thing as a disabled link — an <a> with no href is out of the tab order
and is not announced as a link, which is exactly what "there is no previous
page" means.
Overlays that follow their trigger
Popover, Tooltip and HoverCard are one component seen from three
distances: one you click, one you hover, and one you hover and then read. They
share where they go and they share nothing else, because what a reader is told
differs in every one.
| Opens on | Role | Takes focus | Describes its trigger | |
|---|---|---|---|---|
Popover | Click, Enter, Space, tap | dialog, never aria-modal | Yes, and gives it back | No |
Tooltip | Hover, focus | tooltip | Never | aria-describedby, while it is open |
HoverCard | Hover, focus | None | No, and Tab reaches into it | No |
A popover is not a Dialog with a flag
Dialog is modal and only modal: it writes aria-modal, marks everything
outside inert, locks the page's scroll and traps Tab. Every one of those is
wrong for a popover, and the last one is the one that matters — Tab out of a
popover is how a reader leaves it, and a trap turns it into a hole in the page.
So they are two modules rather than one with a modal prop: a flag would be one
flag every behaviour had to read, and the behaviour that forgot would announce a
modal that is not one.
<Popover.Root>
<Popover.Trigger>Filters</Popover.Trigger>
<Popover.Body align="start" side="bottom" sideOffset={8}>
<label>
Only mine <input type="checkbox" />
</label>
</Popover.Body>
</Popover.Root>
Focus moves to the first thing worth acting on when it opens. Escape closes it
and gives focus back to the trigger. A press outside closes it and leaves focus
where the reader put it. Tab past the last control inside leaves — and
dismisses the popover on the way, because a non-modal overlay the reader has
left is one they can no longer press Escape at.
Its accessible name comes from the button that opened it, unless you pass
aria-label or aria-labelledby, in which case yours is kept.
A tooltip, and the specification it usually fails
WCAG 2.1 SC 1.4.13, Content on Hover or Focus, requires content shown that way
to be dismissible with Escape, hoverable — the pointer can travel onto
it without it vanishing — and persistent. A title attribute fails all
three. A div that appears on :hover fails the first two.
<Tooltip.Provider delayDuration={700} skipDelayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger aria-label="Bold">B</Tooltip.Trigger>
<Tooltip.Body>Bold (⌘B)</Tooltip.Body>
</Tooltip.Root>
</Tooltip.Provider>
The tooltip never takes focus and holds no tab stop: a focusable tooltip is a
stop in the tab order the reader did not ask for and cannot leave in the
direction they expect. It carries role="tooltip", it is named in the trigger's
aria-describedby only while it is in the document, and it has no
aria-haspopup — that attribute promises something to interact with, and a
reader sent to a tooltip finds nothing there.
The pointer waits; focus does not. A reader who tabbed to a control has
already said what they want, and a delay exists to filter out intent nobody
expressed. Tooltip.Provider shares one clock between a group of tooltips, so
the second icon in a toolbar opens at once rather than making a reader who has
already waited out the delay wait it out again — that is skipDelayDuration,
the window after one closes during which the next opens immediately. A tooltip
outside a provider is a complete tooltip with a delay of its own.
Tooltip.Trigger renders a <button>, or whatever you pass to render:
<Tooltip.Trigger render={(props) => <a href="/bold" {...props}>Bold</a>} />
Whatever it renders has to be something the keyboard can reach, and the
component throws if it is not. A tooltip on a <span> is one only a mouse can
find, and it looks perfect in the markup.
It does not open on touch. There is no hover on a touch screen, and the only
gesture left is the tap the control itself needs — a tooltip that opened on it
would either swallow that tap or show something the next tap dismisses. What
follows is a rule for you rather than for the component: a tooltip may not be
the only place something is said, so give an icon button an aria-label and let
the tooltip repeat it.
A hover card holds links
<HoverCard.Root>
<HoverCard.Trigger render={(props) => <a href="/ada" {...props}>@ada</a>} />
<HoverCard.Body>
<p>Ada Lovelace</p>
<a href="/ada/notes">Notes</a>
</HoverCard.Body>
</HoverCard.Root>
It is not a tooltip, because its contents are reachable — by pointer and by
Tab — and not a dialog, because nothing about it is modal and announcing a
dialog the reader never asked for is worse than announcing nothing. Its content
sits immediately after the trigger, so the reading order carries it. Escape
closes it, and gives focus back to the trigger when focus was inside.
Like a tooltip it does not open on touch, which makes it an enrichment: the link under it has to go somewhere useful on its own, because a reader on a phone will only ever get the link.
Where they go
All three take the same placement props:
| Prop | Default | What it does |
|---|---|---|
side | "bottom", "top" for a tooltip | Which side of the trigger it opens onto |
align | "center" | Where it sits along the trigger's other axis |
sideOffset | 0 | The gap between the trigger and the overlay |
alignOffset | 0 | A nudge along the other axis, in the direction the page reads |
avoidCollisions | true | Whether it may move to stay on the screen |
collisionPadding | 0 | How close to the edge of the page it may come |
side is a union of "top" | "right" | "bottom" | "left" and align is
"start" | "center" | "end". Misspelling one is a type error at the call rather
than an overlay that quietly opens somewhere else, and
tests/type-tests/anchoring.js is that promise written down as the code
that must fail to check, and tests/type-tests/overlays.js is the same file for
a sheet's edge, a sidebar's side, a modal's role and what a one-time code is
made of.
When there is no room, the overlay does two things, and needing both is why it is measured rather than left to CSS:
- it flips to the opposite side — the menu near the bottom of a long page that opens upwards;
- it slides along the trigger to stay on the screen. A flip alone cannot answer an overlay that is wider than the room on either side of a trigger near the edge: both alignments overflow, and only sliding it back brings it in.
Where it ended up is reported on the overlay as data-side and data-align, so
a stylesheet can point an arrow without measuring anything. data-align is
always the alignment you asked for — a slide moves where the overlay sits, not
which end of it the arrow belongs on.
Five custom properties are written on the overlay, and two of them are measurements you cannot make from CSS:
| Property | What it holds |
|---|---|
--uf-anchor-trigger-width | The trigger's width, for a popup that must match it |
--uf-anchor-trigger-height | The trigger's height |
--uf-anchor-available-width | How much room there was across the page |
--uf-anchor-available-height | How much room was left on the side it chose |
--uf-anchor-shift | How far it slid, so an arrow can be moved back |
Menu.Body, Combobox.List and Select.List use the same module, which is
what makes it a primitive rather than three copies: a menu in a table row is no
longer cut off at the row's edge, a menu near the bottom of a long page opens
upwards and says data-side="top", and a select's popup can be exactly as wide
as the button it came out of because the button's width is written on it.
A submenu asks for side="inline-end" rather than side="right". LogicalSide
is the four physical names plus inline-start and inline-end, which resolve
against the trigger's own writing direction — so a submenu opens to the left in
an Arabic page, which is the side the key that opens it already pointed at.
data-side always reports the physical answer, because a stylesheet drawing an
arrow needs the direction it will be drawn in.
An overlay is position: fixed, which is also how it escapes an ancestor's
overflow: hidden — a popover in a table row or a scroll container is not cut
in half — without being moved away from its trigger in the accessibility tree.
The exception is an ancestor with transform, filter or will-change, which
becomes the containing block for anything fixed inside it.
Why it is measured, and not CSS anchor positioning
The platform grew this: anchor-name, position-area and
position-try-fallbacks place an element against another in the compositor,
with no JavaScript on the scroll path. It is the better mechanism and it is
deliberately not used, because it cannot express the slide. position-area
chooses one of a grid of regions around the anchor and offers no spelling for
"and then move it back inside"; every position-try fallback, built-in or
hand-written, is another placement rather than a nudge. Shipping the
declarative tier where it works and the measured one elsewhere would put the
same overlay in two different places, and which one a reader got would depend on
their browser. One tier both engines run is worth more than the faster one for
some of them.
A month, and the field beside it
A calendar is role="grid": a row of column headers naming the days, rows of
gridcells, and one roving tab stop, so the whole month is one stop in the
page's tab order. It is the one pattern internal/roving-focus.js does not
cover, and internal/date-grid.js says why at length — the short version is that
the movement is arithmetic on a date rather than an index in a list of elements,
and that the cell an arrow key asks for is often one the grid has not rendered
yet.
// @flow
import { Calendar } from "@uniflowed/ui";
<Calendar.Root defaultValue="2026-10-14" onValueChange={setWhen}>
<Calendar.Previous>Previous month</Calendar.Previous>
<Calendar.Next>Next month</Calendar.Next>
<Calendar.Month />
</Calendar.Root>;
The keyboard
| Key | What it does |
|---|---|
ArrowRight / ArrowLeft | A day, mirrored in a right-to-left page |
ArrowDown / ArrowUp | A week |
Home / End | The ends of the week, not of the month |
PageUp / PageDown | The previous and next month |
Shift + either | The previous and next year |
Enter, Space | Choose the focused day |
Running off the end of a month shows the next one and leaves focus on its
first day — a cell that did not exist when the key was pressed, which is why the
grid carries the same pendingFocus machinery Menu.Body uses to open onto its
last item.
Three things a month of buttons in a grid does not do
An unavailable day stays reachable. isDateDisabled marks a day
aria-disabled="true" and the arrow keys still land on it. That is the
deliberate opposite of what a disabled menu item does — moveTo steps over
those — because a reader arrowing through October has to be able to pass over
the days that cannot be booked. A grid that skipped them presents a month with
holes in it and no way to find out what is in the holes.
The month is announced when it changes. A sighted reader sees the caption
change; a screen reader is told nothing, because nothing it was watching moved.
Calendar.Root renders a role="status" that is in the document from the first
render and empty until the month actually changes — the same constraint
Combobox.Status documents, and the reason it is not a part a caller could
forget to place.
Today and the selection are different marks. aria-current="date" on today,
aria-selected="true" on the chosen day, and nothing on the rest: a grid where
every cell says aria-selected="false" makes a reader hear "not selected" on all
thirty-one of them.
Dates are Temporal's
Every date in and out is a PlainDate from @uniflowed/temporal, or the ISO
8601 string it reads. Nothing here computes a month length or a day of the week
with Date — a package that ships a temporal library and then reaches for
new Date(y, m + 1, 0) is the opposite of building uf with uf, and Date's
zero-based months and local-timezone arithmetic are where calendar bugs come
from. PlainDate.dayOfWeek, PlainDate.daysInMonth and
PlainDate.toLocaleString were added to @uniflowed/core/temporal for this, in
the shapes native Temporal already has.
The month name, the day names and the day the week starts on come from the
locale. weekStartsOn overrides it, and is what a test names so that a suite
does not depend on the host's locale data.
Date Picker
The composition, and the field is the control:
// @flow
import { Calendar, DatePicker } from "@uniflowed/ui";
<DatePicker.Root onValueChange={setWhen} value={when}>
<DatePicker.Input aria-label="Arrive on" />
<DatePicker.Trigger>Choose a date</DatePicker.Trigger>
<DatePicker.Calendar>
<Calendar.Previous>Previous month</Calendar.Previous>
<Calendar.Next>Next month</Calendar.Next>
<Calendar.Month />
</DatePicker.Calendar>
</DatePicker.Root>;
A date picker whose only input is the grid is slower for everybody who already
knows the date and unusable for anybody who cannot operate a grid, so the text
field is the primary control and the calendar is the second way in. Escape and
a chosen date both put focus back in the field; the calendar opens with focus
on the date rather than on the button that steps back a month, which is
Popover.Body's initialFocus meeting Calendar.Root's focusedDayRef.
format and parse are ISO 8601 both ways unless you pass your own. Reading
12/03/26 is the locale's date patterns — CLDR data, which is what
@uniflowed/temporal's calendar surface waits on the native runtime for — and a
UI package that guessed at it would be a wrong date in production rather than a
missing feature.
Range selection is not implemented. One date. A range is two values with both
ends announced, aria-selected across everything between them and a second set
of keyboard rules for extending it; none of that is written, and a value that
quietly accepted a pair would be the lookalike this package refuses.
The four built on Dialog
AlertDialog, Sheet, Drawer and Sidebar are all Dialog underneath, and
each differs from it in a specific way. They are four components rather than one
with a variant, because the bundles do not line up: an alert dialog sets three
things, a sheet sets none of them and adds an edge, a sidebar is most often not
modal at all.
What dialog.js grew for them is three props, and each is a behaviour that
fails silently when it is hard-coded:
| Prop | Default | What it decides |
|---|---|---|
role | "dialog" | "alertdialog" makes a screen reader announce the description the moment focus arrives |
dismissOnOutsidePress | true | Whether a press beside the dialog closes it. Escape closes it either way |
initialFocus | The first focus stop inside | A ref to the element focus should land on instead |
role is a union of "dialog" | "alertdialog", so role="alertdailog" is a
type error at the call rather than a modal announced as a div.
Alert dialog
<AlertDialog.Root>
<AlertDialog.Trigger>Delete the project</AlertDialog.Trigger>
<AlertDialog.Overlay />
<AlertDialog.Body>
<AlertDialog.Header>
<AlertDialog.Title>Delete this project?</AlertDialog.Title>
<AlertDialog.Description>This cannot be undone.</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onClick={remove}>Delete</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Body>
</AlertDialog.Root>
role="alertdialog", a press outside that does nothing, and focus on
AlertDialog.Cancel — the least destructive action, which is a fact about which
part it is rather than a ref you remember to pass. Escape still closes it: a
modal a reader cannot leave from the keyboard is a trap, and declining is what
Escape means.
AlertDialog.Description is required, and AlertDialog.Body raises without
one. aria-describedby is the whole of what the role buys, so an alert dialog
with nothing to announce has interrupted the reader to say only its title —
which is worse than the Dialog it replaced.
Action and Cancel rather than one Close with a variant: they mean opposite
things, and one of them is where focus goes.
Sheet
<Sheet.Root side="left">
<Sheet.Trigger>Filters</Sheet.Trigger>
<Sheet.Overlay />
<Sheet.Body>
<Sheet.Title>Filters</Sheet.Title>
<Sheet.Close>Done</Sheet.Close>
</Sheet.Body>
</Sheet.Root>
Deliberately small. Every modal promise is Dialog's, unchanged; what a sheet
adds is the two things a class name cannot be — side as a checked union of
"top" | "right" | "bottom" | "left", and data-side on the body and the
overlay, which is the same attribute Popover.Body writes. Drawer and
Sidebar are both defined in terms of this module, so a drawer and a
sidebar-on-a-phone cannot come to disagree about what "left" means.
The edge is not announced. There is no aria-* for "this came in from the
right", and a sheet that put its edge in its accessible name would have a reader
hearing "Filters, right" and wondering what "right" meant.
Drawer
<Drawer.Root side="bottom" snapPoints={[0.4, 1]}>
<Drawer.Trigger>Details</Drawer.Trigger>
<Drawer.Overlay />
<Drawer.Body>
<Drawer.Handle label="Resize the details" />
<Drawer.Title>Details</Drawer.Title>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Body>
</Drawer.Root>
A sheet plus a gesture, and the gesture is where the specifications land.
WCAG 2.2 SC 2.5.7, Dragging Movements, requires anything achievable by
dragging to be achievable with a single pointer and no drag — so
Drawer.Body raises when it holds a Drawer.Handle and no Drawer.Close.
WCAG 2.1.1 requires it from the keyboard, so Drawer.Handle is a
role="slider" over the snap points:
| Key | What it does |
|---|---|
| The arrow away from the edge | One snap point larger |
| The arrow towards the edge | One snap point smaller, and closes at the smallest |
Home, End | The smallest and the largest snap point |
Escape | Closes it, like any dialog |
snapPoints are fractions of the drawer's full size, ascending; the state is
the index into that list, which is what makes aria-valuemin, aria-valuemax
and aria-valuenow true about it and aria-valuetext the percentage a reader
can act on. The current fraction is written as --uf-drawer-snap and the live
drag offset as --uf-drawer-drag, so a stylesheet sizes the drawer and this
module has no opinion about how it looks. usePrefersReducedMotion puts
data-reduced-motion="true" on the body for a reader who asked their system to
stop moving things; the drag still follows the finger, because direct
manipulation is not animation.
Sidebar
<Sidebar.Root defaultOpen={fromCookie}>
<Sidebar.Trigger>Menu</Sidebar.Trigger>
<Sidebar.Body label="Main">
<Sidebar.Item label="Settings">
<Gear /> Settings
</Sidebar.Item>
</Sidebar.Body>
</Sidebar.Root>
The one of the four that is usually not a dialog. It is part of the page:
nothing behind it is inert, nothing is scroll-locked, there is no focus trap,
and the trigger is a disclosure button carrying aria-expanded. Sidebar.Body
is a named <nav> landmark rather than a <div>, which is how a screen reader
offers to jump to it.
Then the viewport gets narrow and it becomes a Sheet — modal, over the
content, gone when it is closed. Focus moves into it when it opens and back to
the trigger when it closes, because at that point it is a dialog; on a wide
screen opening it moves focus nowhere, because nothing was taken away.
Collapsed to icons is where this pattern usually goes wrong: a button whose name
came from its text is announced as "button". Sidebar.Item takes a label,
which becomes aria-label the moment the sidebar collapses — so the name
survives whatever the stylesheet does to the text — and shows the same words in
a Tooltip for a reader looking at the icon.
open is yours to own. shadcn persists it in a cookie so the server renders the
state the reader left it in, and that matters more under RSC than it looks: an
uncontrolled sidebar renders expanded on the server and collapses on hydration,
which is a layout shift on every navigation.
The three that replace something the browser does
Carousel, ScrollArea and InputOtp each draw their own version of something
the platform already provides. That makes them the three most likely to make a
page worse than the plain HTML they replaced, so each module's header opens by
saying what it gives that the plain element does not — and if that ever stops
being true, the component should be deleted rather than fixed.
Carousel
<Carousel.Root autoplay={5000} count={3} label="Featured">
<Carousel.Pause />
<Carousel.Content>
<Carousel.Item index={0}>…</Carousel.Item>
<Carousel.Item index={1}>…</Carousel.Item>
<Carousel.Item index={2}>…</Carousel.Item>
</Carousel.Content>
<Carousel.Previous />
<Carousel.Next />
</Carousel.Root>
For a reader who can see it, a carousel gives nothing a list does not. So the component is the part that keeps the replacement from being worse:
- It can be stopped. WCAG 2.2.2, Pause, Stop, Hide.
Carousel.Pauseis that mechanism and it must be the first focusable thing inside the carousel — a pause button after the slides is one nobody reaches in time — so an autoplaying carousel that has not been given one, or has put it second, raises. - It says what it is.
aria-roledescription="carousel"on a named group, andaria-roledescription="slide"with "3 of 7" on each slide. - It stops announcing itself while it moves. The slide container is
aria-live="off"while it rotates and"polite"while it does not. Tabcannot walk into a slide nobody can see. The slides that are not showing areinert, which takes them out of the tab order and out of the accessibility tree.prefers-reduced-motionturns rotation off, and rotation also stops while the pointer is over it or focus is inside it — and stays stopped onceCarousel.Pausehas been pressed, because that was a decision.
count and index are yours, the way Table.Root's rowCount and
Table.Row's index are: counting the children is wrong the first time a slide
is rendered conditionally, and it is wrong silently.
Scroll area
<ScrollArea.Root label="Release notes">
<ScrollArea.Viewport>…</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" />
</ScrollArea.Root>
The thing a custom scrollbar takes away is keyboard scrolling. Firefox makes a
scrollable region focusable and Chromium historically does not, so a region that
must be scrolled to be read is, in most browsers, one a keyboard reader can see
the top of and nothing else. ScrollArea.Viewport is role="region" with a
name and tabindex="0", and then intercepts no key at all: every key that
scrolls a native overflow container scrolls this one, because it is one.
scrollIntoView({ block: "nearest" }) still works, which matters because
Combobox and Select both call it to keep the active option visible. And the
scroll position survives a re-render: replacing the content of a scroll
container makes the browser clamp the offset and never put it back, so the
viewport remembers where the reader actually was — from the scroll event, so a
reader who scrolled to the top themselves is never dragged back down.
ScrollArea.Scrollbar is aria-hidden and holds no controls. That is what
keeps it clear of WCAG 2.5.7: nothing here is achievable only by dragging. It
reports --uf-scroll-thumb-size and --uf-scroll-thumb-offset (and the -x
pair for the other axis), both between 0 and 1, so a stylesheet draws a thumb
without measuring anything.
Input OTP
<InputOtp.Root label="One-time code" length={6} name="code">
<InputOtp.Group>
<InputOtp.Slot index={0} />
<InputOtp.Slot index={1} />
<InputOtp.Slot index={2} />
</InputOtp.Group>
<InputOtp.Separator>-</InputOtp.Separator>
<InputOtp.Group>
<InputOtp.Slot index={3} />
<InputOtp.Slot index={4} />
<InputOtp.Slot index={5} />
</InputOtp.Group>
</InputOtp.Root>
There is one real <input>, and the slots are a picture of its value. That
decision is the component. <input autocomplete="one-time-code" inputmode="numeric"> is already an excellent one-time-code field — the
operating system offers the code it just received by SMS, a password manager
fills it, a screen reader announces one field with one name — and six
<input maxlength="1">es lose every one of those silently.
So pasting fills every box, because a paste into one input is a single input
event carrying the whole string. Backspace, the arrow keys, Home and End
are the browser's text editing, unmodified. A <form> submits one value under
one name. kind is "numeric" | "alphanumeric" and decides three things at
once — which characters survive typing and pasting, which keyboard a phone
shows, and the field's pattern.
What it costs is that the input has to be drawn over the slots by your
stylesheet, which is why InputOtp.Root puts your DOM props on the input and
renders no wrapper of its own: the input is the field, so Field.Control's
id, aria-describedby and aria-invalid land where they mean something.
The four menus, which are three modules
shadcn ships Dropdown Menu, Context Menu, Menubar and the checkable items all
three share. uf ships three modules, because DropdownMenu is Menu — a
dropdown menu is a menu whose trigger is a button, and a second name for one
component is a second surface to keep in step. crates/uf_lib/src/ui.rs says so
in one line rather than carrying a fourth entry.
Menu.CheckboxItem, Menu.RadioGroup and Menu.RadioItem are the checkable
kinds, available from all three. They do not close the menu when they are
chosen, and a plain Menu.Item does:
<Menu.Body>
<Menu.Item onSelect={rename}>Rename…</Menu.Item>
<Menu.CheckboxItem defaultChecked>Show hidden files</Menu.CheckboxItem>
<Menu.RadioGroup defaultValue="name" onValueChange={sortBy}>
<Menu.Label>Sort by</Menu.Label>
<Menu.RadioItem value="name">Name</Menu.RadioItem>
<Menu.RadioItem value="date">Date modified</Menu.RadioItem>
</Menu.RadioGroup>
</Menu.Body>
Ticking three boxes is one visit to the menu on every platform, which is why the
defaults differ; closeOnSelect says otherwise for a part, and
onSelect={(event) => event.preventDefault()} says it for one press — the same
"I handled this" composeHandlers already reads between a caller's handler and
the component's.
A context menu is reachable from the keyboard, which is the half that is
usually left out: Shift+F10 and the ContextMenu key open it, because a
command reachable only by right-click is reachable only by a pointer. Its
trigger is therefore in the tab order — pass tabIndex={-1} when the trigger
already contains something focusable — and its body needs an aria-label,
because arbitrary content is not a name. Opened by the pointer it appears at
the pointer; opened by the keyboard it appears against the trigger.
A menubar has one tab stop for the whole bar. ArrowLeft / ArrowRight
move between the menus, mirrored in a right-to-left page, and — with a menu open
— close it and open the adjacent one, so File → Edit → View needs no Escape
between them. Escape closes the open menu and leaves focus on its trigger.
Animating a disclosure
Collapsible and Accordion can carry the height their content would have,
which is the one number a stylesheet cannot compute: height: 0 → var(--uf-collapsible-height) has to know its destination while the panel is
still closed, and a closed panel has no box for ResizeObserver,
getBoundingClientRect or scrollHeight to report on.
<Collapsible.Root measure>
<Collapsible.Trigger>Details</Collapsible.Trigger>
<Collapsible.Content className="panel">…</Collapsible.Content>
</Collapsible.Root>
.panel { overflow: hidden; transition: height 150ms; height: 0 }
.panel:not([hidden]) { height: var(--uf-collapsible-height) }
measure is opt-in, and on Accordion.Root it covers every panel. It is a prop
rather than a default because whether a stylesheet reads the property is not
something the component can ask, and the alternative is a forced layout per
panel per render on every page that has a collapsible — animated or not.
Field and @uniflowed/form
Both packages know how to make a control accessible, and both used to produce
aria-invalid and aria-describedby. Spread onto one input the later one won,
so the control was described by the form's message or by Field.Description
and never by both. One hook ends it:
component EmailField(form: UseFormReturn<Signup>) {
const email = useFieldSource(form, "email", { required: "We need one" });
return (
<Field.Root field={email}>
<Field.Label>Email address</Field.Label>
<Field.Control render={(props) => <input type="email" {...props} />} />
<Field.Description>We will not share it.</Field.Description>
<Field.Error />
</Field.Root>
);
}
useFieldSource returns facts and a binding — invalid, required, the message,
and the name/ref/onChange/onBlur the store needs — and no aria-* at
all. Field composes every attribute from them in one place, so
aria-describedby is a token list naming the description and the message, and
Field.Error shows what the store said without being handed it. name is a key
of the form's values, so a renamed schema key is a type error at the call.
It lives in @uniflowed/form rather than in @uniflowed/ui because the
dependency can only run one way: @uniflowed/ui is on npm and
@uniflowed/form is not, and tools/ci/publishable.sh refuses a published
package that depends on an unpublished one.
Field.Root group is for a set with no single control to point a <label for>
at — a radio group, a checkbox group, three selects making a date. The root
becomes role="group" named by the label, the label stops being a <label>,
and the description and the error describe the set.
Two things are deliberately not here. A server error needs no new part:
setError(name, …, { shouldFocus: true }) reaches the same Field.Error and
moves focus the same way a client-side one does. An error summary at the top
of a long form is a documented pattern rather than a component: it is a list of
links to the fields that failed, and what makes it work is the ids Field
already owns — a component would have to be told what every field on the page is
called, which is the form's knowledge and not a container's.
What this page is
A reference for what @uniflowed/ui guarantees, not a gallery. The guarantees
are stated as tests in tests/library/ui.test.js, where every assertion is
either "a reader is told X" or "this key does Y" — a snapshot of the markup
would pass while every one of them was broken.
Sections for Dialog, Tabs, Switch and Checkbox are still to be written,
and Combobox has only its groups and the palette they make possible; until
they are, each module's header is the documentation, and it is written to be
read.
For a live region that is only a live region — a route change announced, a
count that updates where nothing visible moves — Announcer in
@uniflowed/web/regions is the smaller thing, and the
right one. The test that module states is whether a sighted reader would notice
its absence: if only a screen reader would, it belongs there rather than here.