Accessibility
The components own roles, keyboard paths, focus rings, and announcements, so a screen built only from tenet-ui components and semantic tokens starts accessible. App code keeps it that way by naming every control, keeping the DOM order equal to the visual order, and not adding motion or colour outside the system. These are the checks an audit applies.
- Every interactive element has an accessible name: visible text children on
ButtonandLink;aria-labelonIconButton;FormControl.Label(oraria-label) on every field;labelonCheckbox,Switch,Radio,RadioGroup,Spinner;aria-labelonTabs.List,SegmentedControl,Menu,Popover,Calendar,ProgressBar, and any secondBreadcrumbsorPaginationon a page. A placeholder is not a name. - Focus order equals visual order. Layout is done with
StackandGridin reading order;order,flex-direction: row-reverse, or positivetabIndexare never used to rearrange what is on screen. - Focus is visible. Every component ships a
:focus-visiblering: a--borderWidth-thickoutline in--borderColor-accent-emphasison buttons, links, tabs, and choice controls, and--borderColor-accent-emphasisplus--shadow-focuson fields. App code never setsoutline: noneor overrides the ring. - Text colour pairs come from the AA table in color.md. Text on the ochre solid is
--fgColor-onAttention;--fgColor-onEmphasisis never placed on--bgColor-emphasisor--bgColor-attention-emphasis. - Colour is never the only carrier of meaning. Status is a word (
Tag), a message (FormControl.Validation,Banner), or an icon withlabelalongside text. - Icons are decorative (
aria-hidden) unless they are the only carrier of meaning, in which case they takelabel(role="img"). AnIconButton’s icon is always decorative because the button hasaria-label. See iconography.md. - Motion honours
prefers-reduced-motion:Spinner,Skeleton,ProgressBar,Toast,Tooltip, andAccordionreduce or drop their animation under it. App code adds noanimationortransitionof its own, and any motion it must add is wrapped in@media (prefers-reduced-motion: no-preference). Dialogis the only modal. It traps Tab and Shift+Tab, moves focus in on open, returns focus to the trigger on close, locks body scroll, closes on Escape and backdrop, and setsaria-hiddenon the rest of the page (except theToastlayer). Itstitleis required because it is the dialog’s name;descriptionis wired asaria-describedby. App code never builds a fixed-position overlay.Popover,Menu,Select,DatePicker, andTooltipare non-modal and portal todocument.body. Theirtriggermust be a focusable element (aButtonorIconButton), never a<div>or<span>.- Tables are real tables.
Table.Headerrenders<th scope="col">; a sortable header setssortDirectionso the sort control is a real<button>andaria-sortreflectsascendingordescending(omitted whennone). Rows are never rebuilt from<div>s. - Forms are wired through
FormControl: it links the label withhtmlFor, the caption and validation witharia-describedby, and setsaria-invalidandrequired. App code never assigns these attributes by hand, and never clobbers a component’saria-describedby(components merge a caller’s value). - Announcements:
Toastrenders into a labelledrole="region"live area; a danger toast orBanner variant="danger"isrole="alert", the other variants arerole="status".Spinnerisrole="status"with a hiddenlabel.Skeletonisaria-hidden, so the surrounding container carriesaria-busy="true"and a hidden status text. Roles are never overridden to make a message louder. - Headings form an outline: one
Heading level={1}per page, ranks in order,asused to keep rank honest when size differs. Landmarks are not duplicated without distinct labels. Linknavigates andButtonacts; aLinknever has anonClickthat performs an action, and aButtonnever navigates.Link externaladdsrel,target, and a hidden “(opens in new tab)”.- Touch and pointer targets come from
size:medium(32px) is the default control height,small(28px) is for dense rows only, andlarge(40px) is for primary actions on touch-first layouts. Controls are never shrunk belowsmall.
Keyboard contracts
Section titled “Keyboard contracts”| Family | Components | Keyboard the component provides |
|---|---|---|
| Action | Button, IconButton, Link | Tab; Enter (and Space for buttons) activates |
| Field | TextInput, Textarea, NumberInput, DatePicker | Native editing; NumberInput arrows step within min/max; DatePicker opens on Enter/Space, Escape closes and refocuses |
| Choice | Checkbox, Switch, RadioGroup, SegmentedControl | Space toggles; arrows move within a radio group or segmented control (selection follows focus), Home/End jump |
| Listbox | Select | Up/Down (skip disabled, wrap), Home/End, typeahead, Enter/Space select, Escape and Tab close |
| Menu | Menu | Up/Down, Home/End, Enter/Space invoke, Escape closes and returns focus to the trigger, Tab closes |
| Tabs | Tabs | Left/Right move and select, Home/End jump, Tab enters the panel |
| Disclosure | Accordion | Enter/Space toggle, Up/Down/Home/End move between headers |
| Overlay | Dialog, Popover, Tooltip | Escape closes; Dialog traps Tab; Popover closes when focus leaves; Tooltip shows on focus |
| Grid | Calendar | Arrows move by day, Home/End by week edge, PageUp/PageDown by month, Enter/Space select |
| Navigation | Breadcrumbs, Pagination, Table.Header (sortable) | Real links and buttons; Tab and Enter |
Do / Don’t
Section titled “Do / Don’t”- ✅
<IconButton icon={<TrashIcon />} aria-label="Delete story" /> - ✅
<FormControl required><FormControl.Label>Email</FormControl.Label><TextInput type="email" /><FormControl.Validation variant="error">Enter a valid email address.</FormControl.Validation></FormControl>(name, required, and error all wired by the control) - ✅
<Tabs.List aria-label="Story sections">and<Menu aria-label="Story actions" trigger={<IconButton icon={<MoreHorizontalIcon />} aria-label="Story actions" />} items={items} /> - ✅
<Table.Header align="end" sortDirection={sort.dir} onClick={toggleSort}>Views</Table.Header>(a real sort button witharia-sort) - ✅
<div aria-busy="true"><span className="sr-only">Loading stories</span><Skeleton variant="text" /></div>(the container announces;sr-onlyis the app’s visually hidden utility) - ❌
<div onClick={open} className="button">Open</div>(no role, no keyboard, no name) - ❌
<Button style={{ outline: 'none' }}>(focus ring removed) - ❌
<Stack direction="horizontal" style={{ flexDirection: 'row-reverse' }}>(visual order no longer matches focus order) - ❌
<Tooltip content="Delete"><span>🗑</span></Tooltip>(a non-focusable trigger; keyboard users never see the tooltip) - ❌
<div style={{ position: 'fixed', inset: 0 }}>…</div>as a modal (no trap, no scroll lock, no Escape, no restore; useDialog) - ❌
<div className="card" style={{ transition: 'transform .3s' }} />with no reduced-motion guard (motion that ignores the user’s setting)