diff --git a/.changeset/native-hidden-describedby.md b/.changeset/native-hidden-describedby.md new file mode 100644 index 0000000..c698f9e --- /dev/null +++ b/.changeset/native-hidden-describedby.md @@ -0,0 +1,18 @@ +--- +'@dunky.dev/native-state-machine': patch +--- + +`normalize` fixes two accessibility translations that silently misfired: + +- `hidden` now maps to RN's web-aligned `aria-hidden` instead of folding into `accessibilityState` — which has no `hidden` slot, so the value was stored and ignored. `aria-hidden` is fanned out per platform by RN itself (`accessibilityElementsHidden` on iOS, `importantForAccessibility: 'no-hide-descendants'` on Android). +- `describedBy` is now dropped. RN has no describe-by-reference slot (there is no `aria-describedby`); the old mapping routed it into `accessibilityLabelledBy`, which misnamed the element and clobbered `labelledBy` whenever a part emitted both — a dialog's content was announced by its description instead of its title. + +```ts +normalize({ hidden: true }) +// before: { accessibilityState: { hidden: true } } -> ignored by RN +// after: { 'aria-hidden': true } + +normalize({ labelledBy: 'title', describedBy: 'desc' }) +// before: { accessibilityLabelledBy: 'desc' } -> named by its description +// after: { accessibilityLabelledBy: 'title' } +``` diff --git a/.changeset/native-role-prop.md b/.changeset/native-role-prop.md new file mode 100644 index 0000000..3723d25 --- /dev/null +++ b/.changeset/native-role-prop.md @@ -0,0 +1,13 @@ +--- +'@dunky.dev/native-state-machine': patch +--- + +`normalize` now passes `role` through to React Native's web-aligned `role` prop instead of mapping it to the legacy `accessibilityRole`. + +`accessibilityRole` takes a narrow enum that Android validates in native code — any ARIA role outside it (e.g. `dialog`, emitted by the dialog machine) crashed on device with `Invalid accessibility role value`. The `role` prop (RN 0.71+) accepts the full ARIA vocabulary, takes precedence over `accessibilityRole`, and degrades gracefully for roles a platform can't map. + +```ts +normalize({ role: 'dialog' }) +// before: { accessibilityRole: 'dialog' } -> native crash on Android +// after: { role: 'dialog' } +``` diff --git a/packages/native/README.md b/packages/native/README.md index 21fd010..e968111 100644 --- a/packages/native/README.md +++ b/packages/native/README.md @@ -91,7 +91,7 @@ What happened: - `useMachine` built the machine and connector **once** (the first render's props seeded the initial state), started it on mount, stops it on unmount. - The component re-renders only when the `connect()` output actually changes. -- `normalize` turned `role` / `expanded` into `accessibilityRole` / +- `normalize` turned `role` / `expanded` into `role` / `accessibilityState.expanded` — the **same** config and `connect` drive the web through the react package's `normalize`. Only this edge differs. @@ -142,12 +142,12 @@ into React Native's prop vocabulary: ```ts const rnProps = normalize(api.triggerProps) -// { onPress, accessibilityRole, accessibilityState: { expanded }, ... } +// { onPress, role, accessibilityState: { expanded }, ... } ``` The machine binding maps handlers (`onPress` → `onPress`, `onPointerDown` → `onPressIn`, `onContextMenu` → `onLongPress`), accessibility props -(`labelledBy` → `accessibilityLabelledBy`, `role` → `accessibilityRole`, +(`labelledBy` → `accessibilityLabelledBy`, `role` → `role`, `id` → `nativeID`), and accessibility state (`expanded` / `checked` / … fold into `accessibilityState`, `valueMin/Max/Now/Text` into `accessibilityValue`). [Check out the full mapping here](./src/normalize.ts). diff --git a/packages/native/src/normalize.ts b/packages/native/src/normalize.ts index 5f5130b..4d19a3e 100644 --- a/packages/native/src/normalize.ts +++ b/packages/native/src/normalize.ts @@ -1,14 +1,41 @@ /** * Translate the machine layer's logical surface to React Native props. * + * How the maps are set up — and where each side comes from: + * - Input keys are the substrate-agnostic vocabulary a connect() emits: + * `EventBindings` / `AttrBindings` in `@dunky.dev/state-machine-bindings`. + * Every vocabulary key must be accounted for here — mapped, folded, or + * deliberately dropped; an unlisted key would leak to the host untranslated. + * - Output keys are verified against RN's own vendored source, not its docs: + * - `ReactAndroid/.../uimanager/BaseViewManager.java` — the `@ReactProp` + * setters: which view props exist on Android and how each validates. + * - `ReactAndroid/.../uimanager/ReactAccessibilityDelegate.kt` — `Role` + * (web-aligned; unknown values resolve to null and degrade) vs + * `AccessibilityRole` (legacy enum whose `fromValue` throws natively on + * unknown values — never target it). + * - `Libraries/Components/View/ViewAccessibility.d.ts` — the accessibility + * prop surface; `AccessibilityState` has exactly the disabled / selected / + * checked / busy / expanded slots. + * - `React/Views/RCTViewManager.m` — the iOS side; values go through + * RCTConvert, which defaults instead of throwing. + * - Rule for new mappings: only target props whose native setters degrade + * gracefully on values they don't recognize — Android setters that throw + * crash the whole surface at mount, before JS can catch anything. + * * Notable differences from the DOM normalizer: * - `onPress` keeps its name; `onPointerDown`/`onPointerUp` → `onPressIn`/`onPressOut`. * - No hover — pointer move/enter/leave/cancel are dropped. * - `onContextMenu` → `onLongPress`; `onDoublePress`/`onWheel` dropped (no RN analog). - * - `expanded`/`selected`/`disabled`/`hidden`/`checked`/`busy` fold into `accessibilityState`. + * - `expanded`/`selected`/`disabled`/`checked`/`busy` fold into `accessibilityState`. + * - `hidden` → `aria-hidden`: the web-aligned alias RN fans out per platform. * - `valueMin`/`valueMax`/`valueNow`/`valueText` fold into `accessibilityValue`. * - `live` → `accessibilityLiveRegion`; `'off'` → `'none'`. - * - `controls`/`hasPopup`/`modal` and most ARIA-only attrs are dropped. + * - `controls`/`hasPopup`/`modal`/`describedBy` and most ARIA-only attrs are + * dropped (`describedBy`: RN has no describe-by-reference slot). + * - `role` passes through unchanged: RN's web-aligned `role` prop takes the + * full ARIA vocabulary and degrades gracefully, while the legacy + * `accessibilityRole` enum throws natively on Android for values outside + * it (e.g. 'dialog'). */ const HANDLER_MAP: Record = { @@ -36,16 +63,24 @@ const HANDLER_DROP = new Set([ ]) const ATTR_MAP: Record = { - describedBy: 'accessibilityLabelledBy', + // Android-only (iOS has no id-reference labelling); the setter takes a + // nativeID string or an array (first element wins). labelledBy: 'accessibilityLabelledBy', - role: 'accessibilityRole', id: 'nativeID', label: 'accessibilityLabel', + // The web-aligned alias, not the legacy pair: RN's own components fan it out + // per platform (accessibilityElementsHidden on iOS, no-hide-descendants on + // Android) — accessibilityState has no hidden slot. + hidden: 'aria-hidden', + // `role` is deliberately absent — it passes through as RN's `role` prop. // `live` needs a value transform ('off' → 'none'), handled inline in normalize(). } -// No clean RN analog — stripped. +// No clean RN analog — stripped. `describedBy` included: RN has no +// describe-by-reference slot (no aria-describedby); routing it into the +// label slot would misname the element and clobber labelledBy. const ATTR_DROP = new Set([ + 'describedBy', 'controls', 'hasPopup', 'modal', @@ -74,8 +109,8 @@ const ATTR_DROP = new Set([ 'atomic', ]) -// RN's accessibilityState slots. -const A11Y_STATE_KEYS = new Set(['disabled', 'expanded', 'selected', 'hidden', 'checked', 'busy']) +// RN's accessibilityState slots — exactly these; anything else is stored and ignored. +const A11Y_STATE_KEYS = new Set(['disabled', 'expanded', 'selected', 'checked', 'busy']) // Logical key → RN's accessibilityValue sub-key (`{ min, max, now, text }`). const A11Y_VALUE_KEYS: Record = { diff --git a/packages/native/tests/normalize.test.ts b/packages/native/tests/normalize.test.ts index 2320fce..82df0fb 100644 --- a/packages/native/tests/normalize.test.ts +++ b/packages/native/tests/normalize.test.ts @@ -5,10 +5,11 @@ * Native props. It encodes the RN-specific divergences the SPECs call out: * - No hover model → pointer-move/enter/leave handlers are dropped. * - Keyboard handlers (onKeyDown/onKeyUp) are dropped (RN has no DOM keys). - * - a11y state (disabled/expanded/selected/hidden) folds into - * accessibilityState. - * - role → accessibilityRole, describedBy/labelledBy → - * accessibilityLabelledBy, id → nativeID. + * - a11y state (disabled/expanded/selected) folds into accessibilityState; + * hidden → aria-hidden (RN fans it out per platform). + * - role passes through to RN's web-aligned `role` prop, + * labelledBy → accessibilityLabelledBy (describedBy dropped — no RN + * describe-by-reference slot), id → nativeID. */ import { describe, expect, it, vi } from 'vitest' import { normalize } from '@dunky.dev/native-state-machine' @@ -51,17 +52,23 @@ describe('native normalize — handlers', () => { }) describe('native normalize — attributes', () => { - it('maps role to accessibilityRole', () => { - expect(normalize({ role: 'menu' })).toEqual({ accessibilityRole: 'menu' }) + it('passes role through to the web-aligned role prop, not accessibilityRole', () => { + // accessibilityRole is RN's legacy enum — Android throws natively on ARIA + // values outside it (e.g. 'dialog'); the role prop takes the full ARIA + // vocabulary and degrades gracefully. + expect(normalize({ role: 'dialog' })).toEqual({ role: 'dialog' }) }) - it('maps describedBy and labelledBy to accessibilityLabelledBy', () => { - expect(normalize({ describedBy: 'x' })).toEqual({ - accessibilityLabelledBy: 'x', - }) + it('maps labelledBy to accessibilityLabelledBy and drops describedBy', () => { + // RN has no describe-by-reference slot (no aria-describedby); routing + // describedBy into the label slot would misname the element and clobber + // labelledBy when a part emits both (dialog content does). expect(normalize({ labelledBy: 'y' })).toEqual({ accessibilityLabelledBy: 'y', }) + expect(normalize({ labelledBy: 'y', describedBy: 'x' })).toEqual({ + accessibilityLabelledBy: 'y', + }) }) it('maps id to nativeID', () => { @@ -70,23 +77,28 @@ describe('native normalize — attributes', () => { }) }) - it('folds disabled/expanded/selected/hidden into accessibilityState', () => { + it('folds disabled/expanded/selected into accessibilityState', () => { const out = normalize({ disabled: true, expanded: false, selected: true, - hidden: false, }) expect(out).toEqual({ accessibilityState: { disabled: true, expanded: false, selected: true, - hidden: false, }, }) }) + it('maps hidden to aria-hidden, not accessibilityState', () => { + // accessibilityState has no hidden slot — the key would be stored and + // ignored. aria-hidden fans out per platform inside RN's View + // (accessibilityElementsHidden on iOS, no-hide-descendants on Android). + expect(normalize({ hidden: true })).toEqual({ 'aria-hidden': true }) + }) + it('omits accessibilityState entirely when no a11y-state keys are present', () => { const out = normalize({ role: 'menu' }) expect('accessibilityState' in out).toBe(false) @@ -120,7 +132,7 @@ describe('native normalize — combined surface (tooltip content shape)', () => }) expect(out).toEqual({ nativeID: 'tooltip:1:content', - accessibilityRole: 'tooltip', + role: 'tooltip', 'data-state': 'delayed-open', 'data-side': 'bottom', }) @@ -283,7 +295,7 @@ describe('native normalize — realistic slider shape', () => { onValueChange, }) expect(out).toMatchObject({ - accessibilityRole: 'slider', + role: 'slider', accessibilityLabel: 'Volume', accessibilityValue: { min: 0, max: 100, now: 40, text: '40%' }, accessibilityState: { disabled: false }, diff --git a/website/src/content/docs/libs/react-native.mdx b/website/src/content/docs/libs/react-native.mdx index 122a6bd..6cab02b 100644 --- a/website/src/content/docs/libs/react-native.mdx +++ b/website/src/content/docs/libs/react-native.mdx @@ -27,7 +27,7 @@ import { normalize } from '@dunky.dev/native-state-machine' ``` The machine binding maps handlers (`onPress` → `onPress`, `onPointerDown` → `onPressIn`), -accessibility props (`labelledBy` → `accessibilityLabelledBy`, `role` → `accessibilityRole`, +accessibility props (`labelledBy` → `accessibilityLabelledBy`, `role` → `role`, `id` → `nativeID`), and accessibility state (`expanded` → `accessibilityState.expanded`). [Check out the full mapping here](https://github.com/dunky-dev/state-machine/blob/main/packages/native/src/normalize.ts).