@hozo/patterns
v0.4.0
Published
Accessible composite UI patterns for Hozo applications.
Maintainers
Readme
@hozo/patterns
Accessible stateful widgets for Hozo: Dialog, Tabs, Menu, Listbox, Combobox,
RadioGroup, Checkbox, Switch, Accordion, Slider, Toolbar, Tree, Tooltip, Popover,
BottomSheet, Drawer, Chip, Avatar, Pagination, Stepper, OtpInput, CommandPalette and ColorPicker. Each carries its WAI-ARIA
keyboard contract on the Web and the matching accessibility semantics on React Native.
Checkbox and Switch are one control with two roles, and choosing between them chooses what a
person hears: a checkbox is "checked" or "not checked" and can also be "partially checked", while
a switch is "on" or "off" and ARIA gives its role no third value. The checkbox is a <button role="checkbox">
rather than an <input> because a real checkbox's indeterminate state is a DOM property with no
attribute — it cannot be rendered on a server, and aria-checked="mixed" can. Both carry
data-hozo-state (checked / unchecked / mixed) so one class list can draw all three states
without the application passing its own state back in.
Accordion takes multiple rather than being two components, and its headers are each their own
tab stop — the opposite of Tabs, where the strip is one stop and the arrows move within it. That
difference is what the two controls are for: a tab strip is a chooser, so stopping on each tab would
make a reader pass six things to reach the content, while an accordion's headers are each a thing to
act on. The arrow keys move between headers as well, because the Authoring Practices offer them and
a ten-item accordion is unpleasant to Tab through. headingLevel is a prop with a guessed default,
because a heading level is a fact about the page rather than about the component.
Slider keeps its arithmetic in slider-rules.ts and exports it, because the hard part of a slider
is not the markup: where a drag lands, how a step snaps, and what a key does are all pure functions
that both platform halves import, so the two cannot disagree. On the Web the gesture is pointer
events with setPointerCapture, which is what keeps a drag working after the pointer leaves the
track — exactly when a person is trying hardest to reach the end. On React Native it is a
PanResponder, and the thumb also answers increment and decrement, because with a screen reader
on, a swipe sends those instead of a pan and a slider that only listens to the pan cannot be moved
at all. valueText becomes aria-valuetext: "3" is not an answer to "how loud".
Popover is #142's equation and nothing else:
FloatingPositioner + FocusScope + DismissableLayer. It needs nothing new from @hozo/behaviors,
which is what makes it worth having — the composition already existed inside DatePicker, and
this is the same arrangement with the contents left to the caller.
Two decisions in it are worth knowing. It is not modal by default, because aria-modal and a
Tab trap have to travel together or each does its worse half: a reader stops offering the page
while the keyboard walks straight out of the panel. DatePicker is modal for a reason — a grid you
arrow around is unusable with Tab escaping it — and a card with two links in it is not that. And a
non-modal panel closes when focus leaves it, which is the half people forget; without it you get
an open panel sitting behind the page. On React Native there is no DismissableLayer: no Escape key,
and an outside press needs a full-screen catcher, which is a modal by another name. So the trigger
dismisses it, and accessibilityViewIsModal stands in for aria-modal.
It is aria-haspopup="dialog" rather than "menu", which is the line between it and Menu: a
reader told "menu" expects commands and a popover holds a form.
BottomSheet is the other half of #142:
Portal + FocusScope + DismissableLayer and a gesture, which is the part that is deliberately not
a Behaviour -- press and pan are delegated to React Native's responder system rather than owned, so
the arithmetic goes in sheet-rules.ts and each platform writes the gesture in its own vocabulary.
Pointer events with setPointerCapture on the Web, a PanResponder on React Native, and one module
deciding which detent a flick lands on so the two cannot disagree. Its velocity convention is the
same on both sides without a conversion, because gestureState.vy and a clientY delta are both
positive downward and both in pixels per millisecond.
It is modal, which is the opposite end of Popover's argument and the same rule: a sheet has a
scrim, something that dims the page has already said the page is unavailable, so aria-modal and the
Tab trap have to say it too. The drag handle is the interesting piece of its accessibility. With one
detent there is nothing to resize, so it is decoration a reader is not shown -- drag-to-dismiss
already has the tap alternative WCAG 2.5.7 asks for, in the scrim. With several it becomes a named
button that cycles detents and answers the arrows, because then dragging is the only way to change
the size. On React Native the sheet is a Modal, so Android's back button dismisses it through
onRequestClose -- the thing Popover cannot offer there -- and the escape accessibility action
answers VoiceOver's two-finger scrub.
Drawer closes #142 and is the same composition against a side edge, with the gesture on one
platform only. That is #142's own split rather than a shortcut: the Web drawer is asked for an
off-canvas panel, a focus trap and a scroll lock, and the gesture is listed under Native. A drawer
has no grabber, so a swipe has to drag the panel's own body -- which fights text selection on a
desktop and fights nothing on a phone, where there is also no Escape key to fall back on. The
arithmetic is still sheet-rules.ts: it is written as "fraction showing", "travel away from open"
and "velocity away from open" and knows nothing about which direction away is, so a left-hand drawer
negates one axis and reuses every line of it.
side is 'start' or 'end' as well as 'left' or 'right'.
'start'is the edge a line of text begins at, so a navigation drawer writtenside="start"opens from the right in Arabic or Hebrew without the application choosing a side per language.- The direction comes from
HozoI18nProvider'sdirwhen there is one (decision 008). Otherwise it comes from the platform: the document's resolved direction on the Web,I18nManager.isRTLon React Native. useHozoDrawerSideis exported for a look that needs the resolved edge, as@hozo/ui's does.- The default is still
'left', so existing drawers do not move.
Edge-swipe-to-open is deliberately absent, in both halves: a closed drawer cannot listen at the screen's edge without keeping an invisible catcher over the application's content for as long as it is closed, which is a decision about the screen rather than about the drawer, and it collides with the platforms' own back gestures. It can never be the only way in either, since nobody who cannot swipe could reach it.
useScrollLock is the one thing a real <dialog> gives away for free and a position: fixed panel
does not, so both Drawer and BottomSheet call it: without it a wheel over the scrim scrolls the
page a reader has just been told is unavailable. The locks are counted, because a drawer can open a
sheet and two overlays each restoring what they found would leave the page locked for good.
They are built on the headless engines in @hozo/behaviors -- focus scopes, roving focus,
typeahead, dismissal and floating positioning -- which stay reusable on their own. Applications
usually import the widgets from @hozo/core, which re-exports this package.
import { Dialog, Tabs, Text } from '@hozo/core'
export function Settings({ open, onClose }) {
return (
<Dialog open={open} onClose={onClose} accessibilityLabel="Settings">
<Tabs
accessibilityLabel="Settings sections"
tabs={[
{ label: 'Profile', content: <Text>Profile</Text> },
{ label: 'Notifications', content: <Text>Notifications</Text> },
]}
/>
</Dialog>
)
}Semantic state of Chip class slots
Chip's selection uses aria-pressed:* classes on its standalone toggle.
Native still announces that toggle as togglebutton with checked; that
platform announcement does not create a Web aria-checked attribute.
With removal, className belongs to the outer span/View, so it does not
match the inner toggle's aria-pressed:*, disabled:* or enabled:*.
removeClassName belongs to the separate button and only gets that button's
disabled/enabled state, never selection. Neither Web owner writes
aria-checked or aria-disabled; those attributes must not be inferred
from Native announcements. Literal classes use the same owner facts through
private compiler callbacks, including negation and supported stacks, with
explicit styles last. This does not expand arbitrary dynamic-class lowering
or the candidate resolver's existing variant support.
Semantic state of Pagination class slots
Pagination's button controls match disabled:*/enabled:*; link controls
match neither, and disabled links instead carry aria-disabled="true".
The same distinction drives Native candidate classes and literal compiled
slot callbacks. Native still announces the current item as selected, but
that does not make aria-selected:* match: Web uses aria-current="page".
Use currentItemClassName for current-page decoration; arbitrary aria-current
value selectors are not added here. Boundary activation stays disabled on
Native for both roles, and explicit slot styles retain precedence.
Semantic state of Stepper class slots
Stepper's four step/status class props belong to a button when onStepPress
is provided, otherwise to a descriptive list item. Only the button can match
disabled:*/enabled:*. A descriptive row matches neither positive predicate,
but does match their negations in literal compiled classes, even when its
step data says disabled. Event predicates, including negated ones, still
require the optional button; its absence must not activate not-hover:*.
Neither owner writes aria-selected or aria-disabled. Native's current-step
selected announcement and real disabled activation guard remain unchanged;
they are not CSS attributes. Use currentStepClassName for current status
(Web uses aria-current="step" on the list item).
Candidate classes and private compiled callbacks use the same owner facts. Indicators and descriptions do not borrow disabled/enabled or ARIA semantics; text still inherits the step's typography before explicit slot overrides. This adds no compiled decorative state handling and does not expand arbitrary dynamic classes, runtime negation/enabled variants, or arbitrary aria-current value selectors. Existing hover/press propagation is unchanged, not a new cross-platform parity claim.
Semantic state of ColorPicker swatches
swatchClassName and selectedSwatchClassName belong to each preset's radio
button, not the picker group or its hex input. Literal compiled classes can
read that radio's disabled:*/enabled:* and aria-checked:*, including
negation and supported stacks, without an authored disabled or
accessibilityState prop. Selection changes update the same snapshot used
by runtime candidate classes and compiler-only callbacks. The selected slot
is still evaluated only on the checked preset.
As on Web, only the first valid colour-matching preset is checked; equivalent
hex spellings do not create multiple checked radios, and invalid presets'
fallback colours are not treated as matches.
Web writes aria-checked and disabled, not aria-disabled or aria-selected;
Native's disabled announcement must not make those absent attributes match.
Native radio roles and disabled activation suppression are unchanged.
Explicit slot styles keep their precedence; the preset's background colour
remains last because it communicates the colour itself. This does not add
semantic state to the picker group, track, thumb or hex-input class props,
or expand arbitrary dynamic-class or runtime negation/enabled support.
Semantic state of CommandPalette options
itemClassName and activeItemClassName belong to Web option divs: neither
:disabled nor :enabled matches them. Use aria-disabled:* for unavailable
commands and aria-selected:* for the highlighted return-key target; literal
compiled classes support their negations and supported stacks. not-disabled:*
and not-enabled:* both match an option. It has no aria-checked attribute.
These facts are shared by candidate classes and generated callbacks, without
requiring a corresponding prop on the palette itself.
Native still presents real buttons and suppresses disabled activation. The active slot follows the same filtered/enabled target as Return; a target is not a press. A decorative shortcut owns none of the option's semantic attributes, but inherits its text styles before its own class/style overrides. Explicit item and active-item styles keep their precedence.
This does not wire static focus variants on option slots (Web input retains focus), or static semantic variants on the shortcut, wrapper, panel or input. Existing runtime item focus and decorative hover/press behavior is unchanged, not a new cross-platform focus/pointer claim. Runtime negation/enabled and arbitrary dynamic-class support are not expanded here.
Semantic state of OtpInput
inputClassName belongs to the one real text/password input. Its internal
disabled/enabled and read-only facts drive both candidate classes and literal
compiler callbacks, including supported negations/stacks, even when no driver
props were authored. CSS read-only includes disabled fields; readonly alone
does not disable the field. Native announcements do not create Web
aria-disabled/selected/checked attributes.
The wrapper div and decorative cell spans match neither disabled nor enabled. They do match read-only because they are not editable HTML elements; they do not borrow the field's focus or ARIA attributes. Existing active/filled class gates, one-field accessibility, transparent glyphs, fixed overlay geometry, caret behavior and explicit style precedence remain unchanged. Decorative semantic facts are constants at build time, not input subscriptions. Unknown ARIA and cell focus/pointer variants remain diagnosed; runtime negation/enabled limitations are not broadened.
The selector distinction follows the HTML selector definition and is checked against actual Web DOM/Tailwind separately from Native hosts.
Dynamic secondary class slots on Native
The compiler leaves dynamic class expressions on registered state-owning
secondary slots until the actual pattern owner can supply its facts:
Chip.removeClassName, Pagination item/current/disabled-item classes,
ColorPicker swatch/selected-swatch classes, Stepper step/status-step
classes, CommandPalette item/active-item classes and OtpInput.inputClassName.
For example, removeClassName={classes} uses the remove button's own hover,
focus and disabled state, not the ancestor's state or an early lookup without
state. Literal classes still compile ahead of time through private callbacks.
Both paths use the existing slot composition order. Explicit styles follow their matching class list; a later status-specific slot still follows the base slot. A changed/absent dynamic string does not retain its old styles. The expression is evaluated by the author render, not inside a state callback. No extra event listeners, wrapper hosts or variant evaluator are added.
Dynamic expressions retain the build-time warning: only scanned candidates
and the runtime resolver's supported conditions work. Breakpoints, negation
and missing owner facts still warn; a descriptive step does not gain focus or
pointer state. This slice does not change dynamic root className lowering,
other secondary slots, or unsupported static variant refusals.
CommandPalette options are not the Web focus owner: its input retains DOM
focus and announces the active option with aria-activedescendant. Dynamic
item/active-item focus predicates therefore stay false even when the Native
button receives an OS focus callback. Pointer/active-target decoration,
activation and Native accessibility focus remain independent. Literal item
focus variants keep their existing compiler refusal; use activeItemClassName
to decorate the current return-key target.
Focus ownership of decorative slots
OtpInput has one focus owner: its real input. Put focus:* and
focus-visible:* on inputClassName. The wrapper and virtual cells do not
borrow that focus on either platform.
Use unconditional classes in activeCellClassName to decorate the current
insertion cell while the field is focused, and filledCellClassName for cells
containing a character. Neither adds a focus stop. The virtual caret follows
the actual input selection independently of these class lists.
Negated runtime state variants still report their existing unsupported-class
diagnostics; this does not expand which variants compile on a virtual cell.
CommandPalette's shortcutClassName styles a decorative Web span / Native
View, not another focusable command. Native shortcuts do not borrow an item's
focus:* or focus-visible:*; unconditional shortcut decoration and explicit
shortcutStyle remain available. This does not change the actual Native
button's focus handling, the Web input's active-descendant model, or which
static shortcut variants the compiler accepts.
Stepper's clickable step owns focus, not its indicator or description.
Use stepClassName or its status-specific step class props for the button's
focus ring. Decorative indicator/status-indicator and description class props
do not borrow focus:* or focus-visible:*. Their text still inherits the
step's typography, including focus-driven text styles, unless the decorative
slot overrides it. Explicit slot styles retain precedence. Static decorative
focus variants remain diagnosed; this does not change the existing hover or
press propagation, or add a focus stop to descriptive steps.
Verification Matrix: Chip, Avatar, Pagination, Stepper, OtpInput, CommandPalette, ColorPicker
These arrived after 0.2.0 (#141, #144, #149, #150, #152, #153). What has checked them, and what has not:
| | axe (every PR) | Virtual reader (every PR) | NVDA (weekly) | VoiceOver, macOS (weekly) | TalkBack | iOS VoiceOver |
|---|---|---|---|---|---|---|
| Chip | ✅ | ✅ approved | ✅ approved | ✅ approved | ⬜ | ⬜ |
| Avatar | ✅ | ✅ approved | ✅ approved | ✅ approved | ⬜ | ⬜ |
| Pagination (buttons, links) | ✅ | ✅ approved | ✅ approved | ✅ approved | ⬜ | ⬜ |
| Stepper (list, buttons) | ✅ | ✅ approved | ✅ approved | ✅ approved | ⬜ | ⬜ |
| OtpInput (code, PIN) | ✅ | ✅ approved | ⬜ not yet read | ⬜ not yet read | ⬜ | ⬜ |
| CommandPalette (closed, open) | ✅ | ✅ approved | ⬜ not yet read | ⬜ not yet read | ⬜ | ⬜ |
| ColorPicker | ✅ | ✅ approved | ⬜ not yet read | ⬜ not yet read | ⬜ | ⬜ |
- axe and the virtual reader run on the
Patterns/…Storybook stories (examples/storybook-demo/scripts/check-a11y.mjs,check-utterances.mjs). The virtual reader computes announcements from the ARIA and HTML-AAM specifications. It is not NVDA or VoiceOver. Its approved readings are inexamples/storybook-demo/utterances/. - NVDA and VoiceOver walk every
patterns-story weekly and on any change toexamples/screen-readers/(.github/workflows/screen-readers.yml). Their first reading of these stories was run 37790021610, and the phrases approved from it are inexamples/screen-readers/expected/.Stepperread correctly on NVDA. On VoiceOver it ran a step and its description together ("completedEmail and password").- A space between them did not survive (run 37796061776), because the step's sentence is visually hidden and absolutely positioned.
- So the description is now part of that sentence, after a full stop, and hidden where it is drawn. Both forms are approved from run 37802449478, which reads "completed. Email and password" on both readers.
- TalkBack and iOS VoiceOver have read none of them. The unit tests assert the accessibility props React Native is given (roles, labels,
selected,disabled). They do not show what a reader says.
A ✅ in the reader columns means a person read the phrases and approved them, never only that a job ran.
Decorative control indicators
Checkbox, Switch and RadioGroup accept an optional indicator callback. It
receives { checked, disabled } from the same state used for the control's
accessibility attributes (Checkbox includes 'mixed'). Return
{ className, markClassName? } for an outline/track and optional mark/knob.
The pattern owns their span/View hosts, hides them from assistive technology,
and leaves their look entirely to those classes. Native resolves each slot
through the project's class resolver. No indicator nodes are added when this
prop is omitted. This is how @hozo/ui paints its indicators without duplicating
the pattern's controlled or uncontrolled state.
Part of Hozo, a universal UI framework for React Native, compiled by Rust, with native semantics on every platform, first-class accessibility, and diagnostics for humans and AI agents alike. Most applications install @hozo/core and one build integration; see Getting started. Source and issues: github.com/iray-tno/hozo.
