Skip to content

Commit 459c4e1

Browse files
authored
feat(agents): add macOS accessibility knowledge topics (#3158)
<!-- Thanks for submitting a pull request! We appreciate you spending the time to work on these changes. Please provide enough information so that others can review your pull request. The four fields below are mandatory. --> <!-- This fork of react-native provides React Native for macOS for the community. It also contains some changes that are required for usage internal to Microsoft. We are working on reducing the diff between Facebook's public version of react-native and our microsoft/react-native-macos fork. Long term, we want this fork to only contain macOS concerns and have the other iOS and Android concerns contributed upstream. If you are making a new change then one of the following should be done: - Consider if it is possible to achieve the desired behavior without making a change to microsoft/react-native-macos. Often a change can be made in a layer above in facebook/react-native instead. - Create a corresponding PR against [facebook/react-native](https://github.com/facebook/react-native) **Note:** Ideally you would wait for Facebook feedback before submitting to Microsoft, since we want to ensure that this fork doesn't deviate from upstream. --> ## Summary: > Stacked on #3157. Review only the top commit (`fd13876983`). macOS accessibility in this fork depends on AppKit behavior that isn't obvious from headers. For example, a getter can return `AXUnknown` while AX clients see a different role. Paper and Fabric also resolve roles differently. Agents and contributors keep rediscovering these details, or they copy iOS patterns that don't hold on macOS. This PR records the verified behavior as knowledge topics. It also adds a repeatable way to check what VoiceOver and other AX clients actually see. New topics, loaded from a new `AGENTS.md` row for accessibility, focus, or Tab-order work: - **`a11y.appkit`:** AppKit AX behavior observed out of process. Rules: - Never copy an unset role. - Read a control's default role from its cell. - Gate state attributes. - Target the AX element. - Verify out of process. - **`a11y.roles`:** Role resolution in Paper and in Fabric, side by side. Rules: - Resolve `role`, then `accessibilityRole`, then the element's default role. - Restore the default role when both props are unset. - Don't import Paper's `RCTConvert` into Fabric. - **`a11y.state`:** How `accessibilityState` maps to AX attributes and value words. Rules: - Stop advertising state that's been removed. - Advertise `AXPress` only when the element handles it. - **`a11y.text-input`:** `editable={false}` semantics on every platform, plus the single-line (`RCTUITextField`) and multiline (`RCTWrappedTextView`) backings. - **`a11y.keyboard`:** `focusable`, first responder, and the key view loop, including why a Fabric TextInput takes two Tab stops on `main`. - **`testing.ax-probes`:** A procedure with a small Swift AX probe. It dumps elements by `testID`, reports focus, and clicks Fabric views, all driven with `osascript` keys. The topics state facts and the open questions in #3136 without recommending how to resolve them. They watch #3136, so they'll be updated when it lands. This PR also corrects `testing.rntester-macos`. `yarn start` fails under the repository's `nodeLinker: pnpm`, because Yarn resolves the `react-native-macos` binary under `.yarn/__virtual__`, which the pnpm linker never creates. Running `cli.js` directly without `NODE_PATH` starts Metro without its `/status` route, so the app reports "No script URL provided". The topic now gives a command that works. Knowledge files only, with no product code changes, so there's no corresponding facebook/react-native PR. ## Test Plan: Environment: macOS 27.2 (26B123), Xcode 26.4.1, Node 24.18.0. Run at `fd13876983`. **Knowledge checks (the steps CI runs):** | Command | Result | | --- | --- | | `node .agents/knowledge/tools/knowledge.mts check --ci` | Passed: `26 files checked: 0 errors, 0 warnings.` | | `node --test .agents/knowledge/tools/knowledge.test.mts` | Passed: 21 pass, 0 fail | | `yarn markdownlint-cli2 --no-globs AGENTS.md ".agents/knowledge/**/*.md" ".claude/skills/rnmacos-*/**/*.md" .github/copilot-instructions.md` | Passed: `Summary: 0 error(s)` | | `yarn format-check` | Passed (exit 0) | **Fact verification:** - Checked every code claim against `main`. The `asserts` front matter keeps key lines tied to source, and I confirmed it's enforced: a deliberately broken assert produced a warning. - Verified AppKit claims with the probe against small standalone AppKit apps: `NSView`, `NSTextField`, and `NSTextView`, before and after setting roles and state. **Procedure verification in RNTester-macOS on `main`:** | Step | Result | | --- | --- | | `RCT_NEW_ARCH_ENABLED=1 pod install` (Homebrew CocoaPods 1.17.0; local Ruby couldn't `bundle install`), then `.ado/scripts/xcodebuild.sh packages/rn-tester/RNTesterPods.xcworkspace macosx Debug RNTester-macOS build` | `Build Succeeded` | | `NODE_PATH="$PWD/node_modules" node ../react-native/cli.js start` from `packages/rn-tester`, then `curl localhost:8081/status` | `packager-status:running` (without `NODE_PATH`: `Cannot GET /status`) | | `testing.ax-probes` procedure, run verbatim from a fresh launch | Navigated APIs → Accessibility; `example_search role=AXTextField enabled=1` | | `ax dump` of the TextInput module | Both `secureTextEntry` inputs report `role=AXUnknown enabled=1`, which matches the `RCTCopyBackedTextInput` fact | | Tab from `example_search` in the TextInput module, with `autorecalculatesKeyViewLoop = YES` added to RNTester's `AppDelegate` locally (not committed) | Focus alternates `AXScrollArea`, `AXTextField`, `AXScrollArea`, `AXTextField`…; Shift-Tab returns to `AXScrollArea`. This matches `a11y.keyboard` | | Same Tab test without that change | Focus stays on `example_search`, which matches the `a11y.keyboard.test-recalculated-loop` rule |
2 parents 06aadf4 + f891c50 commit 459c4e1

10 files changed

Lines changed: 431 additions & 4 deletions

File tree

‎.agents/knowledge/a11y/appkit.md‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
id: a11y.appkit
3+
summary: AppKit NSAccessibility behavior that macOS view code depends on, and rules for setting roles, state, and the accessibility element.
4+
kind: rules
5+
status: active
6+
load_when: Writing or reviewing native macOS code that sets an NSAccessibility role, state, or element
7+
sources:
8+
- packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIAccessibilityTraits.h#RCTAccessibilityRoleFromTraits
9+
- packages/react-native/React/Views/RCTView.m#isAccessibilitySelectorAllowed
10+
- packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#accessibilityElement
11+
- packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm#RCTCopyBackedTextInput
12+
- packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m#forwardInvocation
13+
verified: 2026-10-28
14+
rules:
15+
- never-copy-unset-role
16+
- default-role-from-cell
17+
- gate-state-attributes
18+
- target-the-ax-element
19+
- verify-out-of-process
20+
related: [a11y.roles, a11y.state, testing.ax-probes]
21+
watch: [microsoft/react-native-macos#3136]
22+
asserts:
23+
- file: packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm
24+
contains: 'toTextInput.accessibilityRole = fromTextInput.accessibilityRole;'
25+
---
26+
27+
<!-- @format -->
28+
29+
# AppKit accessibility behavior
30+
31+
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).
32+
33+
## Rules
34+
35+
- **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`.
36+
- **a11y.appkit.default-role-from-cell:** Read an `NSControl`'s default role from `control.cell.accessibilityRole`. The control's own getter returns `AXUnknown`.
37+
- **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.
38+
- **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.
39+
- **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.
40+
41+
## Facts
42+
43+
Observed with an out-of-process AX client on macOS 27.2:
44+
45+
| Element | Getter in process | AX client sees |
46+
| --------------------------------------------------- | --------------------------------- | ------------------------------ |
47+
| `NSView` with no role set | `AXUnknown` | `AXUnknown`, no `AXEnabled` |
48+
| `NSTextField`, editable | `AXUnknown` (cell: `AXTextField`) | `AXTextField`, `AXEnabled=1` |
49+
| `NSTextField`, not editable but selectable | — | `AXStaticText` |
50+
| `NSTextView` | `AXTextArea` | `AXTextArea`, no `AXEnabled` |
51+
| `NSView` after `setAccessibilitySelected:NO` | — | `AXSelected=0`, now advertised |
52+
| `NSTextField` after its role is set to `AXUnknown` | — | `AXUnknown` |
53+
| `NSTextField` after its role is set to `AXTextArea` | — | `AXTextArea` |
54+
55+
- `NSTextView` isn't an `NSControl`, so it has no `enabled` property.
56+
- 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`.
57+
- 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.
58+
- `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.
59+
60+
## Pitfalls
61+
62+
- 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`.
63+
- The comment in `RCTUIAccessibilityTraits.h` says `RCTViewAccessibilityElement` does the role mapping. That class no longer exists; the view code maps roles.
64+
65+
## Verify
66+
67+
Run `xcrun swift ax.swift <pid> dump <testID>` from [testing.ax-probes](../testing/ax-probes.md) against a running app.

‎.agents/knowledge/a11y/keyboard.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
id: a11y.keyboard
3+
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.
4+
kind: rules
5+
status: active
6+
load_when: Changing focus, first responder, Tab order, or the key view loop on macOS
7+
sources:
8+
- packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#acceptsFirstResponder
9+
- packages/react-native/Libraries/Components/TextInput/TextInput.js#focusable
10+
- packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m#acceptsFirstResponder
11+
- packages/react-native/Libraries/AppDelegate/RCTAppDelegate.mm#autorecalculatesKeyViewLoop
12+
- packages/rn-tester/RNTester/AppDelegate.mm#NSWindow
13+
- packages/react-native/Libraries/NativeComponent/BaseViewConfig.macos.js#enableFocusRing
14+
verified: 2026-10-28
15+
rules: [one-tab-stop, test-recalculated-loop]
16+
related: [a11y.text-input, testing.ax-probes]
17+
watch: [microsoft/react-native-macos#3136]
18+
---
19+
20+
<!-- @format -->
21+
22+
# Keyboard focus and Tab order
23+
24+
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)).
25+
26+
## Rules
27+
28+
- **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.
29+
- **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.
30+
31+
## Facts
32+
33+
- A Fabric view's `acceptsFirstResponder` returns `focusable || [super acceptsFirstResponder]`. On macOS, `focusable`, `enableFocusRing`, `keyDownEvents`, and `keyUpEvents` are `View` props.
34+
- `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.
35+
- `RCTWrappedTextView` forwards `acceptsFirstResponder`, `becomeFirstResponder`, and `resignFirstResponder` to its `RCTUITextView`.
36+
- `RCTAppDelegate` creates its window with `autorecalculatesKeyViewLoop = YES`. RNTester's `AppDelegate` creates its own `NSWindow` and doesn't set it on `main`; #3136 adds it.
37+
38+
## Pitfalls
39+
40+
- A focused `AXScrollArea` between two fields is the extra host stop, not a scroll view taking focus.
41+
- Test Shift-Tab (`key code 48 using shift down`) as well as Tab.
42+
43+
## Verify
44+
45+
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)).

‎.agents/knowledge/a11y/roles.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
id: a11y.roles
3+
summary: How role and accessibilityRole become NSAccessibility roles in Paper and Fabric, where the tables differ, and rules for changing them.
4+
kind: rules
5+
status: active
6+
load_when: Changing or debugging how role or accessibilityRole maps to an NSAccessibility role
7+
sources:
8+
- packages/react-native/React/Base/RCTConvert.mm#accessibilityRoleFromAriaRole
9+
- packages/react-native/React/Base/RCTConvert.mm#traitOrRoleToAccessibilityRole
10+
- packages/react-native/React/Views/RCTViewManager.m#updateAccessibilityRole
11+
- packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp#precedentRoleValue
12+
- packages/react-native/ReactCommon/react/renderer/components/view/accessibilityPropsConversions.h#Unsupported
13+
- packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIAccessibilityTraits.h#RCTAccessibilityRoleFromTraits
14+
- packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#RCTAccessibilityRoleFromTraits
15+
verified: 2026-10-28
16+
rules: [role-wins, restore-default-role, no-paper-in-fabric]
17+
requires: [a11y.appkit]
18+
related: [a11y.state]
19+
watch: [microsoft/react-native-macos#3136]
20+
asserts:
21+
- file: packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
22+
contains: 'self.accessibilityRole = RCTAccessibilityRoleFromTraits(traits);'
23+
---
24+
25+
<!-- @format -->
26+
27+
# Accessibility roles
28+
29+
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)).
30+
31+
## Rules
32+
33+
- **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.
34+
- **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.
35+
- **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.
36+
37+
## Facts
38+
39+
How each architecture maps a role on `main`:
40+
41+
| Step | Paper (`RCTViewManager`, `RCTConvert`) | Fabric (`RCTViewComponentView`) |
42+
| ------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
43+
| `role` | ARIA table, `accessibilityRoleFromAriaRole:`; unknown strings fall back to the legacy table | Parsed into traits only |
44+
| `accessibilityRole` | Legacy table, `traitOrRoleToAccessibilityRole` | Parsed into traits only |
45+
| Neither set | `defaultView.accessibilityRole` | Whatever `RCTAccessibilityRoleFromTraits` returns, often `AXUnknown` |
46+
47+
- 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`.
48+
- 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.
49+
- `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`.
50+
- 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.
51+
- When a raw prop value is missing or `null`, the C++ parser keeps the previous value for `accessibilityRole` and the traits.
52+
- With the `enableCppPropsIteratorSetter` flag (off by default), `role` doesn't update the traits.
53+
- Fabric's only `RCTConvert.h` import is in `Fabric/Utils/RCTColorSpaceUtils.h`.
54+
55+
## Pitfalls
56+
57+
- Merging the two Paper tables silently changes `tab` and `none`.
58+
- Testing only `button` hides the problem, because `button` works on `main` through traits. Include a role with no trait, such as `menuitem`.
59+
60+
## Verify
61+
62+
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.

‎.agents/knowledge/a11y/state.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
id: a11y.state
3+
summary: How accessibilityState and accessibility actions reach macOS AX clients in Paper and Fabric, the gaps on main, and rules for exposing them.
4+
kind: rules
5+
status: active
6+
load_when: Changing how accessibilityState, AXEnabled, AXSelected, AXExpanded, or AXPress behave on macOS
7+
sources:
8+
- packages/react-native/React/Views/RCTView.m#isAccessibilitySelectorAllowed
9+
- packages/react-native/React/Views/RCTView.m#accessibilityPerformPress
10+
- packages/react-native/React/Views/RCTViewManager.m#RCT_CUSTOM_VIEW_PROPERTY(accessibilityState
11+
- packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#accessibilityValue
12+
- packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#RCTUIAccessibilityTraitNotEnabled
13+
verified: 2026-10-28
14+
rules: [clear-on-removal, gate-press]
15+
requires: [a11y.appkit]
16+
related: [a11y.roles, a11y.text-input]
17+
watch: [microsoft/react-native-macos#3136]
18+
---
19+
20+
<!-- @format -->
21+
22+
# Accessibility state and actions
23+
24+
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)).
25+
26+
## Rules
27+
28+
- **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)).
29+
- **a11y.state.gate-press:** Advertise `AXPress` only when the element handles it, through `onAccessibilityTap` or an `activate` action, as Paper's `RCTView` does.
30+
31+
## Facts
32+
33+
What an AX client sees for each state on `main`:
34+
35+
| JS | Paper (`RCTView`) | Fabric (`RCTViewComponentView`) |
36+
| ----------------- | -------------------------------------------------------- | -------------------------------------------------- |
37+
| `disabled` | `AXEnabled`, only when the key is set | Nothing; `AXEnabled` stays `1` |
38+
| `selected` | `AXSelected`, only when the key is set | Nothing |
39+
| `expanded` | `AXExpanded`, only when the key is set | The word "expanded" in `AXValue`, only when `true` |
40+
| `checked`, `busy` | Words in `AXValue` | Words in `AXValue` |
41+
| Press | `AXPress` when `onAccessibilityTap` or `activate` exists | No `AXPress` |
42+
43+
- Paper gates the attributes and actions in `isAccessibilitySelectorAllowed:`.
44+
- 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`.
45+
- Both architectures build `AXValue` from localized words for `checked`, `unchecked`, `mixed`, `busy`, and `expanded`, and Fabric adds role words for `checkbox` and `radio`.
46+
- On `main`, Paper's `accessibilityState` setter returns early when the prop is removed, so the previous state stays. #3136 clears it on macOS.
47+
48+
## Pitfalls
49+
50+
- An AX client can't press a Fabric view on `main`. Probes must click by position instead ([testing.ax-probes](../testing/ax-probes.md)).
51+
- A test that only sets state once misses stale state. Toggle each key on, off, and then remove it.
52+
53+
## Verify
54+
55+
Give the element a `testID`, toggle its state, and dump it with [testing.ax-probes](../testing/ax-probes.md) after each change.

0 commit comments

Comments
 (0)