Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .agents/knowledge/a11y/appkit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
id: a11y.appkit
summary: AppKit NSAccessibility behavior that macOS view code depends on, and rules for setting roles, state, and the accessibility element.
kind: rules
status: active
load_when: Writing or reviewing native macOS code that sets an NSAccessibility role, state, or element
sources:
- packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIAccessibilityTraits.h#RCTAccessibilityRoleFromTraits
- packages/react-native/React/Views/RCTView.m#isAccessibilitySelectorAllowed
- packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#accessibilityElement
- packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm#RCTCopyBackedTextInput
- packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m#forwardInvocation
verified: 2026-10-28
rules:
- never-copy-unset-role
- default-role-from-cell
- gate-state-attributes
- target-the-ax-element
- verify-out-of-process
related: [a11y.roles, a11y.state, testing.ax-probes]
watch: [microsoft/react-native-macos#3136]
asserts:
- file: packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm
contains: 'toTextInput.accessibilityRole = fromTextInput.accessibilityRole;'
---

<!-- @format -->

# AppKit accessibility behavior

Use when: you write native macOS code that sets an NSAccessibility role, a state attribute, or the accessibility element. Not for: how JS props become roles, which is [a11y.roles](roles.md), or checking the result, which is [testing.ax-probes](../testing/ax-probes.md).

## Rules

- **a11y.appkit.never-copy-unset-role:** Never copy or restore a role you read from an `accessibilityRole` getter. Copy it only when it isn't `NSAccessibilityUnknownRole`.
- **a11y.appkit.default-role-from-cell:** Read an `NSControl`'s default role from `control.cell.accessibilityRole`. The control's own getter returns `AXUnknown`.
- **a11y.appkit.gate-state-attributes:** Set `accessibilityEnabled`, `accessibilitySelected`, or `accessibilityExpanded` only when JS provides that state, and gate the selectors in `isAccessibilitySelectorAllowed:` as Paper's `RCTView` does.
- **a11y.appkit.target-the-ax-element:** Set accessibility properties on the object AX clients query, and confirm which object that is with a probe. A Fabric TextInput's `accessibilityElement` isn't always its text view.
- **a11y.appkit.verify-out-of-process:** Prove AX behavior with an out-of-process client ([testing.ax-probes](../testing/ax-probes.md)). In-process getters miss cell roles and don't show which attributes AppKit advertises.

## Facts

Observed with an out-of-process AX client on macOS 27.2:

| Element | Getter in process | AX client sees |
| --------------------------------------------------- | --------------------------------- | ------------------------------ |
| `NSView` with no role set | `AXUnknown` | `AXUnknown`, no `AXEnabled` |
| `NSTextField`, editable | `AXUnknown` (cell: `AXTextField`) | `AXTextField`, `AXEnabled=1` |
| `NSTextField`, not editable but selectable | — | `AXStaticText` |
| `NSTextView` | `AXTextArea` | `AXTextArea`, no `AXEnabled` |
| `NSView` after `setAccessibilitySelected:NO` | — | `AXSelected=0`, now advertised |
| `NSTextField` after its role is set to `AXUnknown` | — | `AXUnknown` |
| `NSTextField` after its role is set to `AXTextArea` | — | `AXTextArea` |

- `NSTextView` isn't an `NSControl`, so it has no `enabled` property.
- UIKit's traits don't exist on macOS. RCTUIKit defines `RCTUIAccessibilityTraits` as a `uint64_t` bitmask with the iOS constants so shared code compiles, and `RCTAccessibilityRoleFromTraits` maps traits to an `NSAccessibilityRole`.
- A Fabric `View`'s `accessibilityElement` is the view itself. A Fabric TextInput's is `_backedTextInputView`: an `RCTUITextField` when single-line, or an `RCTWrappedTextView` when multiline on macOS.
- `RCTWrappedTextView` is an `NSView` that wraps an `RCTUITextView` in a scroll view. It forwards first-responder calls, and forwards through `forwardInvocation:` only selectors that `NSView` lacks, such as `setEditable:`. NSAccessibility setters stay on the wrapper.

## Pitfalls

- On `main`, `RCTCopyBackedTextInput` copies `accessibilityRole` unconditionally when `multiline` or `secureTextEntry` changes. The old view's getter returns `AXUnknown`, so the new `NSTextField` reports `AXUnknown` instead of `AXTextField`.
- The comment in `RCTUIAccessibilityTraits.h` says `RCTViewAccessibilityElement` does the role mapping. That class no longer exists; the view code maps roles.

## Verify

Run `xcrun swift ax.swift <pid> dump <testID>` from [testing.ax-probes](../testing/ax-probes.md) against a running app.
45 changes: 45 additions & 0 deletions .agents/knowledge/a11y/keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
id: a11y.keyboard
summary: How focusable, first responder, and the key view loop decide Tab order on macOS, and why a TextInput takes two Tab stops on main.
kind: rules
status: active
load_when: Changing focus, first responder, Tab order, or the key view loop on macOS
sources:
- packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#acceptsFirstResponder
- packages/react-native/Libraries/Components/TextInput/TextInput.js#focusable
- packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m#acceptsFirstResponder
- packages/react-native/Libraries/AppDelegate/RCTAppDelegate.mm#autorecalculatesKeyViewLoop
- packages/rn-tester/RNTester/AppDelegate.mm#NSWindow
- packages/react-native/Libraries/NativeComponent/BaseViewConfig.macos.js#enableFocusRing
verified: 2026-10-28
rules: [one-tab-stop, test-recalculated-loop]
related: [a11y.text-input, testing.ax-probes]
watch: [microsoft/react-native-macos#3136]
---

<!-- @format -->

# Keyboard focus and Tab order

Use when: you change focus, first responder, Tab order, or the key view loop on macOS. Not for: TextInput `editable` semantics ([a11y.text-input](text-input.md)).

## Rules

- **a11y.keyboard.one-tab-stop:** Give each control one Tab stop. A host view that wraps a focusable AppKit view must not accept first responder itself.
- **a11y.keyboard.test-recalculated-loop:** Test Tab order in a window with `autorecalculatesKeyViewLoop = YES`, which apps built on `RCTAppDelegate` get. On `main`, RNTester's macOS window doesn't set it.

## Facts

- A Fabric view's `acceptsFirstResponder` returns `focusable || [super acceptsFirstResponder]`. On macOS, `focusable`, `enableFocusRing`, `keyDownEvents`, and `keyUpEvents` are `View` props.
- `TextInput.js` sends `focusable` as `true` unless the app passes `false`. The Fabric TextInput host doesn't override first-responder methods, so on `main` it becomes a Tab stop before its field. Each TextInput then takes two Tab presses, and an AX client reports the enclosing `AXScrollArea` as focused on the first one. #3136 makes the host refuse focus.
- `RCTWrappedTextView` forwards `acceptsFirstResponder`, `becomeFirstResponder`, and `resignFirstResponder` to its `RCTUITextView`.
- `RCTAppDelegate` creates its window with `autorecalculatesKeyViewLoop = YES`. RNTester's `AppDelegate` creates its own `NSWindow` and doesn't set it on `main`; #3136 adds it.

## Pitfalls

- A focused `AXScrollArea` between two fields is the extra host stop, not a scroll view taking focus.
- Test Shift-Tab (`key code 48 using shift down`) as well as Tab.

## Verify

Focus the first element, press Tab with `osascript -e 'tell application "System Events" to key code 48'`, and read the focus with `ax.swift <pid> focused` after each press ([testing.ax-probes](../testing/ax-probes.md)).
62 changes: 62 additions & 0 deletions .agents/knowledge/a11y/roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
id: a11y.roles
summary: How role and accessibilityRole become NSAccessibility roles in Paper and Fabric, where the tables differ, and rules for changing them.
kind: rules
status: active
load_when: Changing or debugging how role or accessibilityRole maps to an NSAccessibility role
sources:
- packages/react-native/React/Base/RCTConvert.mm#accessibilityRoleFromAriaRole
- packages/react-native/React/Base/RCTConvert.mm#traitOrRoleToAccessibilityRole
- packages/react-native/React/Views/RCTViewManager.m#updateAccessibilityRole
- packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp#precedentRoleValue
- packages/react-native/ReactCommon/react/renderer/components/view/accessibilityPropsConversions.h#Unsupported
- packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIAccessibilityTraits.h#RCTAccessibilityRoleFromTraits
- packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#RCTAccessibilityRoleFromTraits
verified: 2026-10-28
rules: [role-wins, restore-default-role, no-paper-in-fabric]
requires: [a11y.appkit]
related: [a11y.state]
watch: [microsoft/react-native-macos#3136]
asserts:
- file: packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
contains: 'self.accessibilityRole = RCTAccessibilityRoleFromTraits(traits);'
---

<!-- @format -->

# Accessibility roles

Use when: you change or debug how `role` or `accessibilityRole` becomes an NSAccessibility role. Not for: state attributes ([a11y.state](state.md)) or TextInput roles ([a11y.text-input](text-input.md)).

## Rules

- **a11y.roles.role-wins:** Resolve `role` first, then `accessibilityRole`, then the element's default role. Paper's `updateAccessibilityRole:` and the C++ traits parser both use this order.
- **a11y.roles.restore-default-role:** When both props are unset, restore the element's default role, read as [a11y.appkit](appkit.md) describes, not the last role you set.
- **a11y.roles.no-paper-in-fabric:** Don't import Paper's `RCTConvert` into Fabric component views to reuse its tables. Maintainers asked for Fabric-local code on #3136.

## Facts

How each architecture maps a role on `main`:

| Step | Paper (`RCTViewManager`, `RCTConvert`) | Fabric (`RCTViewComponentView`) |
| ------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `role` | ARIA table, `accessibilityRoleFromAriaRole:`; unknown strings fall back to the legacy table | Parsed into traits only |
| `accessibilityRole` | Legacy table, `traitOrRoleToAccessibilityRole` | Parsed into traits only |
| Neither set | `defaultView.accessibilityRole` | Whatever `RCTAccessibilityRoleFromTraits` returns, often `AXUnknown` |

- The two Paper tables disagree. `tab` maps to `AXRadioButton` in the ARIA table and to `AXButton` in the legacy table. `none` maps to `AXGroup` and to `AXUnknown`.
- The C++ traits parser (`fromString` for `AccessibilityTraits`) knows about 20 strings, such as `button`, `link`, `header`, `image`, and `switch`. Every other string, such as `menuitem`, `option`, `tab`, or `checkbox`, becomes no traits.
- `RCTAccessibilityRoleFromTraits` checks traits in a fixed order and returns `AXUnknown` when none match. On `main`, an AX client sees `AXUnknown` for `role="menuitem"`, `"option"`, and `"tab"`, while `role="button"` reports `AXButton`.
- Fabric stores `role` as the C++ `Role` enum and `accessibilityRole` as a string. Strings with no `Role` value, such as `textbox`, `listbox`, `image`, and `search`, log "Unsupported Role value" and become `Role::None`, so they never reach an ARIA table. The JS `Role` type doesn't allow them either.
- When a raw prop value is missing or `null`, the C++ parser keeps the previous value for `accessibilityRole` and the traits.
- With the `enableCppPropsIteratorSetter` flag (off by default), `role` doesn't update the traits.
- Fabric's only `RCTConvert.h` import is in `Fabric/Utils/RCTColorSpaceUtils.h`.

## Pitfalls

- Merging the two Paper tables silently changes `tab` and `none`.
- Testing only `button` hides the problem, because `button` works on `main` through traits. Include a role with no trait, such as `menuitem`.

## Verify

Give each element under test a `testID`, and dump its role with [testing.ax-probes](../testing/ax-probes.md). Each role must match the table its prop goes through. #3136 adds `macos-role-*` examples to RNTester.
55 changes: 55 additions & 0 deletions .agents/knowledge/a11y/state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
id: a11y.state
summary: How accessibilityState and accessibility actions reach macOS AX clients in Paper and Fabric, the gaps on main, and rules for exposing them.
kind: rules
status: active
load_when: Changing how accessibilityState, AXEnabled, AXSelected, AXExpanded, or AXPress behave on macOS
sources:
- packages/react-native/React/Views/RCTView.m#isAccessibilitySelectorAllowed
- packages/react-native/React/Views/RCTView.m#accessibilityPerformPress
- packages/react-native/React/Views/RCTViewManager.m#RCT_CUSTOM_VIEW_PROPERTY(accessibilityState
- packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#accessibilityValue
- packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#RCTUIAccessibilityTraitNotEnabled
verified: 2026-10-28
rules: [clear-on-removal, gate-press]
requires: [a11y.appkit]
related: [a11y.roles, a11y.text-input]
watch: [microsoft/react-native-macos#3136]
---

<!-- @format -->

# Accessibility state and actions

Use when: you change how `accessibilityState` or accessibility actions reach AX clients on macOS. Not for: roles ([a11y.roles](roles.md)) or TextInput's `editable` ([a11y.text-input](text-input.md)).

## Rules

- **a11y.state.clear-on-removal:** When JS removes `accessibilityState` or one of its keys, stop advertising that attribute. Setting it to `NO` isn't enough, because AppKit keeps advertising it ([a11y.appkit](appkit.md)).
- **a11y.state.gate-press:** Advertise `AXPress` only when the element handles it, through `onAccessibilityTap` or an `activate` action, as Paper's `RCTView` does.

## Facts

What an AX client sees for each state on `main`:

| JS | Paper (`RCTView`) | Fabric (`RCTViewComponentView`) |
| ----------------- | -------------------------------------------------------- | -------------------------------------------------- |
| `disabled` | `AXEnabled`, only when the key is set | Nothing; `AXEnabled` stays `1` |
| `selected` | `AXSelected`, only when the key is set | Nothing |
| `expanded` | `AXExpanded`, only when the key is set | The word "expanded" in `AXValue`, only when `true` |
| `checked`, `busy` | Words in `AXValue` | Words in `AXValue` |
| Press | `AXPress` when `onAccessibilityTap` or `activate` exists | No `AXPress` |

- Paper gates the attributes and actions in `isAccessibilitySelectorAllowed:`.
- Fabric's state block in `updateProps` is iOS-only. On macOS, Fabric sets `accessibilityEnabled` only from the `NotEnabled` trait, inside the traits block, so any element whose traits change advertises `AXEnabled`.
- Both architectures build `AXValue` from localized words for `checked`, `unchecked`, `mixed`, `busy`, and `expanded`, and Fabric adds role words for `checkbox` and `radio`.
- On `main`, Paper's `accessibilityState` setter returns early when the prop is removed, so the previous state stays. #3136 clears it on macOS.

## Pitfalls

- An AX client can't press a Fabric view on `main`. Probes must click by position instead ([testing.ax-probes](../testing/ax-probes.md)).
- A test that only sets state once misses stale state. Toggle each key on, off, and then remove it.

## Verify

Give the element a `testID`, toggle its state, and dump it with [testing.ax-probes](../testing/ax-probes.md) after each change.
Loading
Loading