Readiness: Implemented
Build an app
UI components
uf ui add dialog writes a styled, accessible dialog into your project, and
from then on it is your file. Its look is yours to change. Its behaviour
(focus trap, Escape, focus restore, the ARIA) is imported from
@uniflowed/ui and keeps improving when you upgrade. Start with the command.
Reach for the headless parts underneath when you need a look the registry does
not have.
What you will be able to do: add components with uf ui add, import and
compose them, keep your copies in step with uf, and drop down to the headless
parts when you need to.
What you need first: a project — Your first project. Styling comes on the page after this one.
Start with uf ui add
uf ui list # every component the registry has
uf ui add button dialog tabs # write them, and whatever they import
Each component is one file in app/components/ui/, or in the directory
ui.directory names. dialog.js imports
./button.js, so adding dialog writes both. A package a component needs and
package.json does not list yet is added first, at this uf's version. The
command ends by printing how to import what you asked for:
ℹ import from a module in app/:
import { Button } from "./components/ui/button.js";
import * as Dialog from "./components/ui/dialog.js";
import * as Tabs from "./components/ui/tabs.js";
Import a copy as one name. A component with parts is a namespace: import the module, and its parts are members of it. A component that is one element is that name.
// @flow
import * as React from "@uniflowed/react";
import { Button } from "./components/ui/button.js";
import * as Dialog from "./components/ui/dialog.js";
import * as Tabs from "./components/ui/tabs.js";
export component Settings() {
return (
<Dialog.Root>
<Dialog.Trigger>Settings</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Settings</Dialog.Title>
</Dialog.Header>
<Tabs.Root defaultValue="general">
<Tabs.List aria-label="Sections">
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">…</Tabs.Panel>
<Tabs.Panel value="billing">…</Tabs.Panel>
</Tabs.Root>
<Dialog.Footer>
<Dialog.Close>Done</Dialog.Close>
<Button tone="primary">Save</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
}
This is the same convention @uniflowed/ui uses (Dialog.Root, Tabs.Tab),
so a page that uses both reads the same way.
Importing: one name per component explains
why.
Change it. The file is yours: change its tokens, spacing and variants, or the markup around the parts. The header of each file lists what to keep true while you do, such as "a dialog needs a name" and "the close button is last in the markup". Those rules are about accessibility, not about the look.
Keep it in step. The last line of each copy records the uf that wrote it and
a digest of what it wrote. uf ui diff dialog shows how your copy differs from
this uf's version, and whether each difference is your edit or the registry's
change. uf ui update dialog merges the registry's change into your edited
file, three-way, so updating never makes you choose between your edit and the
fix. Commands has all four commands, and
Components shows every component running,
beside its source.
Owned or headless
uf has both halves of a design system, and they are different kinds of thing.
uf ui add writes the styled half into your repository, one component per
file. @uniflowed/ui is the headless half, a package: the parts described
below, imported, upgraded, never copied. Every component uf ui add writes is
built on those parts.
Headless: @uniflowed/ui | Owned: uf ui add | |
|---|---|---|
| Where it lives | node_modules, upgraded with uf | app/components/ui/, committed with your code |
| What it owns | Roles, the keyboard, focus, the ARIA wiring, composition types | The look: tokens, spacing, variants, the markup around the parts |
| How you change it | className, render, data attributes | Edit the file |
| How a fix reaches you | Upgrading @uniflowed/ui | Behaviour by upgrading @uniflowed/ui, which the file still imports; the look through uf ui update, which merges the registry's change into your edited file |
Reach for the owned component when the application wants one look that its pages agree on and a person expects to open the file and change it, which is most product UI. Reach for the parts directly when the design system already exists somewhere else, when the styling is not StyleX, or when a surface is unusual enough that the owned component would be edited beyond recognition.
The two are not forks of each other. An owned dialog.js imports its parts
from @uniflowed/ui, so the focus trap, Escape, focus restore and the inert
page behind it keep arriving by upgrade, and what the file owns is only what a
person means by "our dialog". The composition constraints hold in the copy
too: the owned Tabs.List takes renders* the owned Tabs.Tab, so a
<button> dropped into it is a Flow error in your own file.
Every file uf ui add writes is StyleX. It calls stylex.create and reads
the tokens @uniflowed/stylex compiles, so
dark mode is one spread on the document, and a
stylex.createTheme of your own restyles every copy at once. The command
copies that source. It does not translate a copy into plain CSS, a CSS
Module, or a string of class names, and nothing you set asks it to. The style
engine is StyleX. A registry copy is a file written in it.
A project that does not compile StyleX does not start from those copies. It
imports the parts and puts its own class on them, which is the rest of this
page. Importing a part does not import StyleX.
npm/ui/headless-consumer.test.js is that project: a Switch from
@uniflowed/ui, a className the file wrote, and no @uniflowed/stylex.
The headless parts: @uniflowed/ui
Everything below this point is about the package the components are built on. Use it directly when your design system lives somewhere else, when your styling is not StyleX, or when a surface is too unusual for an owned component. Each of those parts is also what an owned component's behaviour comes from.
What you get, and what you do not
The package root exports more than thirty primitive families and five single-element controls. The full per-part reference is Components; this page is the shorter path through the pieces you are most likely to compose first.
| Family | Reach for it when |
|---|---|
Field, Checkbox, Switch, RadioGroup, Toggle, ToggleGroup, Slider, InputOtp, Calendar, DatePicker | a value has to be named, validated, changed, submitted, or announced |
Dialog, AlertDialog, Sheet, Drawer, Sidebar, Popover, Tooltip, HoverCard, Collapsible, Accordion | some part of the page appears, disappears, traps focus, or must not trap it |
Tabs, Menu, ContextMenu, Menubar, NavigationMenu, Breadcrumb, Pagination, Carousel, ScrollArea | movement is the feature: within panels, menus, pages, slides, or scrollable regions |
Combobox, Select, Table, Resizable, Progress, Toast, Alert, Avatar, Separator, Skeleton | the browser has a related primitive, but the app needs a richer contract around it |
They render semantic elements with the right roles and relationships, handle the keyboard the way the platform's own controls do, and have no opinion whatsoever about how any of it looks.
Start from the interaction, not the mockup shape:
| If the user is doing this | Start with |
|---|---|
| Choosing one value from a closed list | Select when the text is fixed, Combobox when they type or filter |
| Moving between panels in one surface | Tabs, then choose automatic or manual activation |
| Opening a page-level interruption | Dialog; AlertDialog only when the action is destructive or must be answered |
| Opening a narrow-edge surface | Sheet; Drawer when there is a drag gesture and therefore a keyboard equivalent |
| Revealing a small anchored surface | Popover for focusable content, Tooltip for a phrase, HoverCard for a preview with links |
| Listing site destinations behind expanding groups | NavigationMenu, not Menu; links stay links |
| Listing application commands | Menu, ContextMenu or Menubar, depending on how the command set opens |
| Naming a control and its error | Field, even when the control itself comes from somewhere else |
There is no stylesheet, no theme prop and no StyleX import. From
npm/ui/index.js:
Nothing here imports StyleX, and nothing here has a StyleX-shaped type. A consumer styling with plain CSS, CSS Modules or anything else gets exactly the same components with exactly the same behaviour.
Every part that renders an element spreads whatever you pass onto it, so
className, id, data-* and aria-* all arrive. className is the bridge —
a DOM prop, not a type from a styling library — which is what lets uf's own
presets style these without the package knowing they exist.
Styling is that half.
One part renders no element and so forwards nothing: Field.Control takes a
render function and that is its whole signature. A className on it would go
nowhere; it belongs on whatever render returns, beside the four attributes
that function is handed.
Changing the element a part renders
render is not only Field.Control's. It is how you say "this one has to be an
<a>" without copying anything:
<Menu.Item render={(props) => <a href="/settings" {...props} />}>Settings</Menu.Item>
The part computes every attribute, every id, every composed handler and every
ref exactly as it would have, and hands them over for you to put on your own
element; the children are in there too, so spreading and self-closing still
renders what you wrote between the tags. The part is still the part — a
Menu.Item rendered as an <a> is what Menu.Body's renders* constraint
accepts, and that constraint is the thing a copied source cannot keep.
The hatch is broad rather than ornamental. Every fixed-element part has it in
Accordion, Alert, AlertDialog, Avatar, Breadcrumb, Collapsible,
Dialog, Drawer, Menu, ContextMenu, Menubar, Pagination, Popover,
Sheet, Skeleton, Slider, Table, Tabs and ToggleGroup; so do the
single-part Checkbox, Progress, Separator, Switch and Toggle.
Field.Root, Field.Label, Field.Control, Field.Description,
Field.Status and Field.Error take it too, as do RadioGroup,
InputOtp.Separator, Tooltip.Trigger, Tooltip.Body, HoverCard.Trigger,
HoverCard.Body and Sidebar.Item.
The table in npm/ui/ui.test.js names every part and whether the hatch is
implemented, absent because there is no element, or still intentionally absent;
the suite fails if that table stops matching the files.
#303 is the argument, and
npm/ui/index.js's header is where the decision is recorded.
Importing: one name per component
Import from @uniflowed/ui, the one entry point the package has. A component
with parts is one name, a namespace, and its parts are members of it:
import { Checkbox, Dialog, Field, Menu, Select, Tabs, toast } from "@uniflowed/ui";
<Dialog.Root>
<Dialog.Trigger>Delete</Dialog.Trigger>
<Dialog.Body>
<Dialog.Title>Delete this project?</Dialog.Title>
</Dialog.Body>
</Dialog.Root>;
That is the only spelling. The rule has three cases:
| What it is | How you import it | Examples |
|---|---|---|
| A component with parts | One namespace; the parts are unprefixed members | Dialog.Root, Tabs.Tab, Menu.SubTrigger |
| A component that is one element | Its own name | Checkbox, Switch, Progress, ListBox |
| A type, hook or function | Its own name, flat | DialogRole, Side, toast, usePress, parseColor |
The components uf ui add writes follow the same rule. A copy with parts
exports them unprefixed and is imported as a module
(import * as Dialog from "./components/ui/dialog.js"). A copy with one part
is that name (import { Button } from "./components/ui/button.js"). A page
that uses both a copy and the package reads the same either way.
Why namespaces. Two names for one part means two things to search for and a
review decision in every file. Earlier releases exported DialogRoot next
to Dialog.Root, and the docs, the registry and the examples each picked a
different one. Each namespace is a real ES module namespace:
npm/ui/index.js says export * as Dialog from "./dialog.js". It is not an
object built at run time. That matters in three places:
- Tree-shaking. A bundler treats
Dialog.Rootas a static reference to one export ofdialog.js, so a page that never rendersDialog.Descriptiondoes not ship it. An object literal would have to be kept whole. - Flow.
Dialog.Roothas exactly the typedialog.jsdeclares, including itsrendersconstraints. Misspell it and Flow answers "did you meanRoot?". - Server Components and the client bundle. uf rewrites
import { Dialog } from "@uniflowed/ui"toimport * as Dialog from ".../dialog.js"before bundling. The rsc graph and the browser load that one module, not the package.
Each part is still declared under its full name (component DialogRoot) and
exported under the short one. React DevTools, component stacks and errors say
DialogRoot, not one of forty Roots.
Moving from the prefixed names. Run uf codemod after upgrading. It
rewrites import { DialogRoot, DialogTrigger } from "@uniflowed/ui" to
import { Dialog } from "@uniflowed/ui", and every use to Dialog.Root and
Dialog.Trigger, including JSX, typeof, renders and ui.DialogRoot on
a namespace import. A file that already has its own Dialog binding gets the
import as UiDialog. The same run moves the components uf ui add wrote to
the registry's new shape. dialog.js exports Root, Trigger and
Content, and every page that imported DialogTrigger from it now writes
import * as Dialog from "./components/ui/dialog.js" and <Dialog.Trigger>.
A copy nobody edited comes out as the registry's own text, so a later
uf ui update merges this uf's changes into it without a conflict. Anything the codemod cannot rewrite
safely is left alone and listed in .uf/migration-report.json.
The package exports no subpath, and you cannot import internal/. That is on
purpose. It holds the roving-focus reader, the controlled-state hook and the
prop merger, and each file's header says why publishing it would be a weaker
promise than keeping it internal.
Every example on this page was rendered and asserted before it was published;
the keyboard tables below cite the test in
npm/ui/ui.test.js
that holds each row. uf test npm/ui/ui.test.js runs the suite. There is
exactly one row no test holds, and rather than leaving it to be discovered it is
named where it appears, under Checkbox — a row with no test is how it came to
say the wrong thing.
Building a surface
The package is deliberately lower-level than "settings dialog", "command
palette" or "data table". Those names are product surfaces: they combine state,
fetching, layout, persistence and styling. @uniflowed/ui owns the parts that
must stay true no matter what the surface looks like.
The working order is:
| Step | Do this | Because |
|---|---|---|
| 1 | Name the user interaction: choose, disclose, navigate, interrupt, resize, announce | The interaction picks the primitive. The screenshot does not |
| 2 | Pick the primitive whose ARIA meaning matches it | A Menu of links and a NavigationMenu of commands look alike and say different things |
| 3 | Own the application state outside the primitive when it is business state | A combobox query, selected table rows, and dialog persistence belong to the app |
| 4 | Add visual styling with className, data attributes and custom properties | The package is headless; styles are an application or preset concern |
| 5 | Test by role, name, state and keyboard | That is the behaviour the package promises, and the part a visual test cannot see |
For a product UI this usually means a short stack rather than a new component:
| Surface | Compose |
|---|---|
| Settings | Dialog or Sheet, Tabs, Field, Switch, Checkbox, Select |
| Command palette | Dialog, Combobox, grouped options, useKeyCombo |
| Data table toolbar | Table, Checkbox, Menu, Pagination, Toast |
| Documentation navigation | NavigationMenu, Breadcrumb, Sidebar, ScrollArea |
| Date filter | Field, DatePicker, Calendar, Popover |
That is the line ubugeeei-redundancy.md draws for the project: do not ship
thin lookalikes merely because another ecosystem has a named component there.
Ship the behaviour that would be wrong if every application reimplemented it,
then show the composition that makes the larger surface obvious.
The same rule explains where styling lives. A button's colour, card padding and
badge radius are not behaviour, so they belong in the styling layer. A switch's
Enter key, a dialog's focus trap, a toast's live region and a table's
aria-rowindex are behaviour, so they belong here. When in doubt, ask whether
a screen reader or keyboard user would get a different truth if the component
were replaced with plain elements. If yes, it is probably @uniflowed/ui; if
not, it is probably a preset or application component.
The render hatch is a contract, not a style slot
A render function receives the props that make the part work. It should
spread them onto one element, and that element is the part as far as the rest of
the package is concerned:
<ToggleGroup.Root
aria-label="Formatting"
defaultValue={["bold"]}
render={(props) => <section {...props} />}
type="multiple"
>
<ToggleGroup.Item render={(props) => <a href="#bold" {...props} />} value="bold">
Bold
</ToggleGroup.Item>
<ToggleGroup.Item value="italic">Italic</ToggleGroup.Item>
</ToggleGroup.Root>
The group is still a role="group" with aria-orientation and the roving
keyboard handler; the custom item is still a role="button" with
aria-pressed, aria-disabled, the tab stop, the click handler and the key
handler. If you drop a prop, you dropped that behavior. uf does not try to
recover it by reaching into the element afterwards.
Two rules make the hatch predictable:
- Spread last when you mean uf to own the semantics.
render={(props) => <a href="/settings" {...props} />}keeps the menu item's role, ids and event composition. Spreading first and writingrolelater is an override, not a style. - Keep one focusable element. A render function that returns an anchor wrapping a button produces two controls where the part promised one. The package cannot make that accessible after the fact.
- Keep the returned element mounted where the part is mounted. Moving it to a portal, delaying it behind another boundary, or splitting its children away from the props turns the ids and refs the part handed you into guesses.
- Treat
event.preventDefault()as the opt-out switch. Caller handlers run first. If they prevent the default, the component's handler stands down; if they do not, the behaviour in the keyboard table still happens.
ToggleGroup is the case that shows why this is not only a DOM convenience.
type="single" delegates to RadioGroup, so render is passed through to the
radio parts and the group keeps radiogroup / radio semantics. type="multiple"
is a real toggle-button group, so ToggleGroup.Item adds role="button" when
you replace the native <button> with something else. The two modes look
similar, but they tell assistive technology different truths.
Field
A label, a control, help text and an error message, wired to each other. The control is whatever you render.
<Field.Root invalid={error != null}>
<Field.Label>Email</Field.Label>
<Field.Control render={(props) => <input type="email" {...props} />} />
<Field.Description>We will not share it.</Field.Description>
<Field.Error>{error}</Field.Error>
</Field.Root>
Field.Control takes a render function rather than children, and hands it
id, aria-labelledby, aria-describedby and aria-invalid to spread onto
whatever the control really is — an input, a textarea, a third-party editor.
| Part | Element | What it emits |
|---|---|---|
Root | div | Owns the ids. Takes invalid |
Label | label | for and id |
Control | yours | Calls render with the four attributes |
Description | p | An id that aria-describedby names |
Error | p | role="alert"; renders nothing while valid |
Three things it will not do, each with a test:
aria-invalidis absent when the field is valid, not"false"— "says nothing about validity while the field is valid".aria-describedbynames only ids that exist. No description and no error means no attribute — "never points aria-describedby at an element that is not there". Anaria-describedbypointing at nothing makes a reader announce nothing, which is worse than silence.- Your
idon the label loses to the field's — "keeps the field's ids authoritative" — because a caller id would break the label-control relationship, and break it invisibly.
A part used outside a Field.Root throws and says which part it was.
Tabs
<Tabs.Root defaultValue="one">
<Tabs.List aria-label="Sections">
<Tabs.Tab value="one">One</Tabs.Tab>
<Tabs.Tab value="two">Two</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="one">…</Tabs.Panel>
<Tabs.Panel value="two">…</Tabs.Panel>
</Tabs.Root>
defaultValue is required. value and onValueChange make it controlled;
orientation="vertical" moves the keys to the other axis; and
activationMode="manual" separates moving from choosing.
| Key | What happens | Test |
|---|---|---|
ArrowRight / ArrowLeft | Next / previous tab, wrapping | "moves between tabs with the arrow keys", "wraps at the ends" |
ArrowUp / ArrowDown | The same, when orientation="vertical" | "uses the up and down arrows when it is vertical, and says so" |
ArrowDown in a horizontal list | Nothing. The page scrolls | "leaves the page's own arrow keys alone in a horizontal list" |
Home / End | First / last enabled tab | "jumps to the first and last tab with Home and End" |
Enter / Space | Selects, in manual mode | "selects on Enter", "selects on Space" |
Tab | Leaves the list in one press | "keeps exactly one tab in the page's tab order" |
In automatic activation — the default — an arrow key moves focus and selects,
so one key press is one tab. In manual activation it only moves, and the panel
does not change until Enter or Space.
The ARIA: role="tablist" with aria-orientation, role="tab" with
aria-selected and a roving tabindex, role="tabpanel" with
aria-labelledby pointing back at its tab. A tab points at its panel with
aria-controls only while that panel is selected. Automatic activation
keeps the others mounted and hidden; manual activation does not mount them.
Either way an unselected tab does not name a place the reader cannot land. A
disabled tab keeps
role="tab" and gets aria-disabled, never the native disabled attribute,
so it stays in the accessibility tree and is skipped by the arrows rather than
vanishing from the list.
Dialog
<Dialog.Root>
<Dialog.Trigger>Delete</Dialog.Trigger>
<Dialog.Overlay />
<Dialog.Body>
<Dialog.Header>
<Dialog.Title>Delete this project?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Footer>
</Dialog.Body>
</Dialog.Root>
| Key or gesture | What happens | Test |
|---|---|---|
| Open | Focus moves to the first thing worth acting on | "moves focus to the first thing worth acting on" |
Tab at the end | Wraps to the start | "wraps Tab at the end rather than letting it leave" |
Shift+Tab at the start | Wraps to the end | "wraps Shift+Tab at the start" |
Tab with nothing focusable | Stays on the dialog | "keeps Tab inside a dialog with nothing focusable in it" |
Escape | Closes, focus returns to the trigger | "closes on Escape", "gives focus back to whatever opened it" |
| Press outside | Closes. Press inside does not | "closes on a press outside it", "stays open for a press inside it" |
While it is open, everything else on the page gets aria-hidden="true" and
inert, and the body stops scrolling — both undone exactly on close, and
reference-counted so the outer of two stacked dialogs still holds the page still
when the inner one closes.
Three decisions worth knowing before you reach for something else:
- It does not portal. From
dialog.js: "A portal solves a stacking-context problem that belongs to CSS, and it costs the thing this component is for: rendered where it is written, the dialog is next to its trigger in the accessibility tree, which is where a screen reader looks." Dialog.Headeris adiv, not aheader, because aheaderis abannerlandmark and a second banner inside a dialog is a landmark a reader will find in the landmark list and be unable to explain.Dialog.Overlayowns no behaviour. Closing on an outside press belongs to the dialog, so it still works for somebody who styles their own backdrop or renders none.
Dialog.Body claims a name only when there is a Dialog.Title to claim it
from, so an aria-label you pass survives.
Menu
<Menu.Root>
<Menu.Trigger>File</Menu.Trigger>
<Menu.Body>
<Menu.Group>
<Menu.Label>Recent</Menu.Label>
<Menu.Item onSelect={open}>Open…</Menu.Item>
</Menu.Group>
<Menu.Separator />
<Menu.Sub>
<Menu.SubTrigger>Export</Menu.SubTrigger>
<Menu.Body>
<Menu.Item onSelect={png}>PNG</Menu.Item>
</Menu.Body>
</Menu.Sub>
</Menu.Body>
</Menu.Root>
| Key | Where | What happens | Test |
|---|---|---|---|
ArrowDown | Closed trigger | Opens onto the first item | "opens onto the first item for ArrowDown" |
ArrowUp | Closed trigger | Opens onto the last item | "opens onto the last item for ArrowUp" |
ArrowDown / ArrowUp | Open menu | Moves, wrapping at both ends | "moves with the arrows and wraps at both ends" |
Home / End | Open menu | First / last item | "jumps to the ends with Home and End" |
| Letters | Open menu | Typeahead, 500 ms buffer | "goes to an item by its first letter", "accumulates the letters into a prefix" |
Escape | Open menu | Closes this level, focus back to its trigger | "closes on Escape and gives focus back to the trigger" |
Tab | Open menu | Closes the whole tree and moves on | "closes on Tab and lets the key carry on through the page" |
ArrowRight | SubTrigger | Opens the submenu onto its first item | "opens on ArrowRight and lands on the first item" |
ArrowLeft | Submenu | Closes it, focus back to the sub-trigger | "closes on ArrowLeft and comes back to the item that opened it" |
Typing the same letter twice cycles between the items starting with it; typing
something nothing matches leaves the highlight where it was. Matching reads
aria-label ?? textContent, collapsed and lower-cased.
A menu moves real focus onto its items rather than using
aria-activedescendant — the virtual kind belongs to the pattern where focus
cannot leave a text field, which is the combobox below. A separator is stepped
over, a disabled item keeps role="menuitem" and aria-disabled and is skipped
by both the arrows and the typeahead, and exactly one item is in the tab order
at a time.
Hover does not open a submenu, deliberately. From menu.js:
Opening on hover requires an intent heuristic — the "safe triangle" that lets the pointer travel diagonally across a sibling item to reach the submenu without it snapping shut — and a naive
onPointerEnterthat opens immediately is worse than no hover at all.
Combobox
<Combobox.Root inputValue={query} onInputValueChange={setQuery}>
<Combobox.Label>Country</Combobox.Label>
<Combobox.Input />
<Combobox.List>
{matches.map((each) => (
<Combobox.Option key={each} value={each}>{each}</Combobox.Option>
))}
</Combobox.List>
<Combobox.Empty>No matches.</Combobox.Empty>
<Combobox.Status />
</Combobox.Root>
It never filters. The options are the ones you rendered, and matching them
against inputValue is application logic — a decision combobox.js states
outright, because a filter built into a combobox is a filter you fight the first
time your data is remote, fuzzy or grouped.
Three values you can own independently, or leave alone: value (what was
chosen), inputValue (what was typed) and open.
| Key | What happens | Test |
|---|---|---|
ArrowDown / ArrowUp | Opens, moves the highlight, wrapping. Focus stays in the input | "moves a second cursor through the list without moving focus", "wraps at the ends of the list" |
Alt+ArrowDown | Opens without highlighting anything | "opens without choosing anything on Alt+ArrowDown" |
Alt+ArrowUp | Closes | "closes on Alt+ArrowUp" |
Enter with a highlight | Takes it and closes | "takes the active option on Enter and closes" |
Enter with none | Left to the form, so it submits | "leaves Enter to the form when nothing is highlighted" |
Escape | Closes; a second one clears the field | "closes on Escape, and clears the field on the next one" |
Tab | Closes without taking the highlight | "closes on Tab without taking the highlight" |
Home / End | Nothing. They belong to the text cursor | "leaves Home and End to the text cursor" |
Combobox.Status is a role="status" region that is mounted from the start —
an announcement region added at the moment it has something to say is an
announcement region that says nothing. It counts for you: "No results available.", "1 result available.", "3 results available.", and empty while
closed. Pass children to word it yourself.
The invariant the whole component is built around has its own test: "drops the
highlight when the option it named is filtered away". aria-activedescendant
is removed the moment the option it names leaves the document, so the attribute
never points at an element that is not there.
Options can be grouped, and a group is named by its own heading:
<Combobox.List>
<Combobox.Group>
<Combobox.GroupLabel>Europe</Combobox.GroupLabel>
<Combobox.Option value="FR">France</Combobox.Option>
</Combobox.Group>
</Combobox.List>
Combobox.Label is the field's label and Combobox.GroupLabel is a group's,
which is why there are two of them. The arrow keys cross a group boundary
without stopping on the heading, and Combobox.Status counts options rather
than headings. A command palette is this plus a Dialog and useKeyCombo,
which is why there is no Command component — the reference
page writes the composition out.
Checkbox and Switch
<Checkbox checked={checked} onCheckedChange={setChecked}>Ship it</Checkbox>
<Switch checked={on} onCheckedChange={setOn}>Notifications</Switch>
Both render a button with the matching role and aria-checked, both take the
native disabled attribute, and both are uncontrolled with defaultChecked
unless you pass checked.
The difference that makes them two components rather than one with a prop is the
third state. A checkbox is checked, unchecked or mixed —
aria-checked="mixed" is what a "select all" says while some of its rows are
selected — and a switch is on or off, with nowhere to put it. Checkbox takes
indeterminate for it, and clicking a mixed checkbox reports true rather than
the opposite of a boolean nobody set.
The keyboard is the other difference, and it follows from what each control is for:
| Key | Checkbox | Switch |
|---|---|---|
Space | Toggles | Toggles |
Enter | Submits the form | Toggles |
A switch operates something, so pressing Enter on it operates it. A checkbox
answers a question on the way to a submit button, and what a native <input type="checkbox"> does with Enter is implicit submission: the key does not
touch the control, and the form around it is submitted as though its default
button had been pressed.
That was the header's claim for a long time and neither half of it was true —
ubugeeei-prod/uf#324. checkbox.js renders a <button type="button">, which is
precisely the button that submits nothing; and leaving a key unhandled is not
the same as making it inert, because the default action of Enter on a focused
<button> is a click, and the click handler toggled. So a focused checkbox both
failed to submit and changed its own state.
Both keys are now handled and prevented, in both components. Space toggles.
Enter toggles a switch, and on a checkbox calls requestSubmit on the form
the control is in, naming the form's default button so a handler reading
event.submitter is told who pressed. Outside a form it does nothing, which is
again what the native control does. "submits the form on Enter rather than
toggling itself" is the test, and it presses Enter on a focused control,
because that is the only version of it that would ever have failed.
A complete settings surface
The pieces are meant to be composed. A settings dialog is not a new component in this package; it is the exact point where the existing contracts meet:
// @flow
import { Checkbox, Dialog, Field, Switch, Tabs } from "@uniflowed/ui";
component WorkspaceSettings(
email: string,
setEmail: (string) => mixed,
notifications: boolean,
setNotifications: (boolean) => mixed,
compactRows: boolean,
setCompactRows: (boolean) => mixed,
) {
return (
<Dialog.Root>
<Dialog.Trigger>Preferences</Dialog.Trigger>
<Dialog.Overlay />
<Dialog.Body>
<Dialog.Header>
<Dialog.Title>Preferences</Dialog.Title>
<Dialog.Description>Choose how this workspace behaves.</Dialog.Description>
</Dialog.Header>
<Tabs.Root activationMode="manual" defaultValue="account">
<Tabs.List aria-label="Preference sections">
<Tabs.Tab value="account">Account</Tabs.Tab>
<Tabs.Tab value="display">Display</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="account">
<Field.Root>
<Field.Label>Email</Field.Label>
<Field.Control
render={(props) => (
<input
{...props}
onChange={(event) => setEmail(event.currentTarget.value)}
type="email"
value={email}
/>
)}
/>
<Field.Description>Used for billing and security messages.</Field.Description>
</Field.Root>
<Switch checked={notifications} onCheckedChange={setNotifications}>
Email notifications
</Switch>
</Tabs.Panel>
<Tabs.Panel value="display">
<Checkbox checked={compactRows} onCheckedChange={setCompactRows}>
Use compact table rows
</Checkbox>
</Tabs.Panel>
</Tabs.Root>
<Dialog.Footer>
<Dialog.Close>Done</Dialog.Close>
</Dialog.Footer>
</Dialog.Body>
</Dialog.Root>
);
}
Nothing in that surface has to know about the other parts to stay accessible:
Dialogowns modality, focus restore, scroll locking and the page it hides.Tabskeeps one tab stop for the section list and mounts only the active panel.Fieldowns the ids connecting the label, help text and input.SwitchandCheckboxsay different things to a reader and use differentEntersemantics.
The test reads like the behaviour a user gets, not like markup trivia:
render(
<WorkspaceSettings
compactRows={false}
email="person@example.com"
notifications={true}
setCompactRows={() => {}}
setEmail={() => {}}
setNotifications={() => {}}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Preferences" }));
expect(screen.getByRole("dialog", { name: "Preferences" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "Display" }));
expect(screen.getByRole("checkbox", { name: "Use compact table rows" })).toHaveAttribute(
"aria-checked",
"false",
);
await userEvent.keyboard("{Escape}");
expect(screen.getByRole("button", { name: "Preferences" })).toHaveFocus();
That is the testing style the package itself uses: query by role and name, press the keys real users press, and assert the ARIA state or focus movement that would be broken by a lookalike component.
Four rules that hold across the package
No attribute ever names a missing element. A helper in the test file walks
every aria-labelledby, aria-describedby, aria-controls and
aria-activedescendant in the document and asserts the id it names exists. It
runs across the suite, and it is the single most valuable assertion in it: a
dangling reference does not look broken, it just makes a screen reader say
nothing.
One Escape is one dismissal. A menu inside a dialog closes the menu and
leaves the dialog open; so does a combobox list. Two tests, one for each,
because the natural implementation closes both.
Your props never disable the component. Pass a ref and the component still
gets its own; pass onKeyDown or onClick and yours runs first and the
component's behaviour still happens. Twelve tests cover that. The escape hatch is
explicit: call event.preventDefault() in your handler and the component stands
down, which is a decision rather than an accident.
The keyboard reads the DOM, not a registry. Roving focus asks the document which items are there, because mount order stops being document order the moment a list is filtered or reordered — and a registry that disagrees with the page sends the arrow keys somewhere the reader is not.
Where to go next
Rendering modes is next: where each route's document comes from — prerendered, rendered per request, or a client-side application — and what a click does. Styling and content styles these primitives without coupling the package to a theme.
| Need | Next page |
|---|---|
| Every part, prop and keyboard table | Components |
| A route, data loader, form, UI, tests and build in one app | Build a reading list |
| Styling these primitives without coupling the package to a theme | Styling and content |
| Querying the behaviour from tests | Testing |
Nothing here is a placeholder: each primitive is finished behaviour with a test suite, and the intentionally missing part is visual style. A card border, a pressed colour or an animation curve belongs to the application or to a preset; the package owns the behaviour that should survive when that styling is replaced.
Edit this pagedocs/app/guide/ui/$page.mdx