Accessibility
An interactive component is two things stacked: a headless behavior core — what it selects, what it disables, how it moves under the keyboard — and a thin visual skin over it. This library writes the behavior first, from a small set of sanctioned hooks, and only then paints it. Get that order right and accessibility is not a later pass; it is the substrate.
Behavior before pixels
New interactive components do not invent their own state, selection, or keyboard handling. They compose the hooks in libs/native/components/src/lib/internal/behavior/, which are our local names over Adobe’s @react-stately — the same vocabulary React Aria uses on the web, made cross-platform.
The payoff is that the hard, invisible parts — controlled versus uncontrolled state, disabled-key semantics, roving focus, type-ahead, overlay containment — are written once, unit-tested once, and reused everywhere. A component author is left with the part that is actually specific to their component: how it looks. And because these hooks are thin aliases, the production swap to the real Adobe packages is an import change, not a rewrite.
One vocabulary: React Aria
Every state and selection prop is spelled the React Aria way, so a prop you learned on Checkbox means the same thing on Select, Tabs, and TagGroup.
isDisabled
The disabled flag, spelled the React Aria way. Not the bare disabled — the prop grammar standardises on isDisabled so state props read the same on every component.
isSelected / defaultSelected
Binary selection for a single control (a checkbox, a switch). Controlled with isSelected, uncontrolled with defaultSelected — never both.
selectedKey / defaultSelectedKey
The chosen item in a single-select collection (Select, Tabs, SegmentedControl), addressed by a stable string key rather than an index.
selectedKeys / defaultSelectedKeys
The chosen set in a multi-select collection (TagGroup, multi-select Menu). Held as a Set internally, surfaced as keys the caller owns.
onSelectionChange
The one change callback for every collection. The grammar forbids onValueChange — selection always reports through onSelectionChange.
disabledKeys
The keys a collection will not let you select. The state hooks enforce it, so a disabled row can never be toggled into or, on a bulk action, silently out of the selection.
The sanctioned hooks
These are the base for anything new. Reach for the one that matches the shape of your component; do not hand-roll a parallel.
useToggleState
Binary on/off, behind Checkbox, Toggle/Switch, and ToggleButton. Returns isSelected plus setSelected / toggle. This is Adobe’s @react-stately/toggle.
useSingleSelectState
One-of-many, shared by Select, Tabs, SegmentedControl, and single-select Menu. Speaks selectedKey / onSelectionChange / disabledKeys.
useMultiSelectState
Many-of-many, behind TagGroup and multi-select Menu. Speaks selectedKeys / toggleKey, and keeps an already-selected disabled key rather than dropping it on select-all or clear.
useControllableState
The controlled/uncontrolled bridge every hook above stands on — pass value or pass defaultValue, and the hook does the right thing. Adobe’s @react-stately/utils.
useOverlayState
Open/close for Dialog, Select, Menu, Popover, and Tooltip. Returns isOpen with open / close / toggle. Adobe’s @react-stately/overlays.
roving-focus
Keyboard navigation as pure, testable logic: rovingActionForKey maps a key to an action, nextFocusableKey resolves the target (skipping disabled, wrapping), and typeaheadMatch handles type-to-focus. The component wires it to platform key events.
focus-trap
Overlay containment on web: handleTabTrap cycles Tab within the overlay, getFocusableElements finds the stops, lockScroll freezes the page behind it. On native the platform Modal already does this.
Why new components start here
The hooks are not a convenience wrapper; they hold the correctness that is easy to get subtly wrong by hand.
Disabled-key semantics are the clearest example. useMultiSelectState will never newly select a disabled key, but it also keeps a disabled key that was already selected — a select-all or clear must not silently drop a selection the user can no longer restore. That rule is one line to get wrong and invisible until a specific sequence hits it. Solve it once, in the hook, and every collection inherits it.
The same holds for the controlled/uncontrolled bridge, for keyboard navigation (the roving-focus and type-ahead logic that answers the “no keyboard nav anywhere” gap once, as pure functions), and for overlay focus management. Building on the hooks means a new component is correct on the axes users feel but reviewers rarely test.
import {
PressableFeedback,
useToggleState,
} from '@ivim/native/components/internal';
function Switch({ isSelected, defaultSelected, onChange, 'aria-label': label }) {
// The behavior — controlled/uncontrolled, the toggle itself — is solved here.
const state = useToggleState({ isSelected, defaultSelected, onChange });
return (
<PressableFeedback
accessibilityRole="switch" // NFR-A1: without a role it
accessibilityState={{ checked: state.isSelected }} // announces as nothing.
aria-label={label}
onPress={state.toggle}
>
{/* everything below this line is skin: colour, size, the thumb. */}
</PressableFeedback>
);
}
The enforcement: NFR-A1 roles audit
Composing the right behavior hook gives you a correct widget. It does not, on its own, make that widget audible — that is a separate, checkable promise.
A pressable with no role announces as nothing
Nothing about the render or the behavior looks wrong — the tap target works, the state updates — but a screen reader reaches it and has nothing to say. This session shipped four such components (LinkButton, NavigationListItem, PressableSurface, and RadioGroup’s original rows); the audit is what found them.
The NFR-A1 rule in the catalog audit scans every library component that renders PressableFeedback or a bare Pressable and flags any that declare no accessibilityRole. It is not an allowlist: two delegation patterns are recognised from the source itself — accessible={false} for a tap target handing off to a real control (as InputOTP does for its hidden input), and accessibilityHint for a wrapper describing a child that owns the role (as Tooltip does for its trigger). Everything else must name its role, and the build fails until it does.
So the doctrine has two halves that need each other. The behavior hooks make a component work correctly for keyboard and selection; the roles audit makes sure it is announced at all. Build on the first, satisfy the second, and accessibility is a property of the component rather than a hope.