Skip to content

fix(macos): Fabric accessibility roles and state, and TextInput read-only and Tab-stop fixes - #3136

Open
Jason Morse (JasonVMo) wants to merge 6 commits into
mainfrom
user/jasonvmo/a11y-fixes
Open

Jason Morse (JasonVMo) wants to merge 6 commits into
mainfrom
user/jasonvmo/a11y-fixes

Conversation

@JasonVMo

Copy link
Copy Markdown

Summary:

On macOS, Fabric loses most accessibility information that Paper exposes, and TextInput handles focus and read-only state incorrectly:

  • role / accessibilityRole values such as menuitem, option, tab, combobox and textbox report AXUnknown.
  • accessibilityState disabled, selected and expanded never reach AppKit.
  • A single-line TextInput with editable={false} is disabled instead of read-only. It can't be focused or selected, while accessibilityState.disabled doesn't disable it at all.
  • Each Fabric TextInput takes two Tab presses.

FluentUI React Native works around these with a Yarn patch on 0.81.9 (microsoft/fluentui-react-native#4386). This PR fixes them on main. All changes are macOS-only and tagged. The 0.81 backport and the Paper checks that can't run on main are tracked in #3135.

Each commit is a separate fix and builds on its own:

  1. RCTConvert: the ARIA path for array input passed the whole array instead of each element.
  2. Fabric role: resolve the role the way Paper does: role with the ARIA table, then accessibilityRole with the legacy table, then the traits, then the element's default. Write it to accessibilityElement, so TextInput's backing field receives it. Also add the missing legacy textbox mapping.
  3. Fabric state: project disabled, selected and expanded, including the NotEnabled and Selected traits, onto the accessibility element. As in Paper, isAccessibilitySelectorAllowed: exposes an attribute only when JS provides it, so a view without expanded doesn't report AXExpanded.
  4. Read-only vs. disabled: on macOS, editable only controls editing; read-only fields stay enabled, selectable and reachable with Tab. accessibilityState.disabled drives a new enabled property on the backing input on both renderers. RCTUITextView implements it for multiline. On Paper, removing the accessibilityState prop now clears the previous state.
  5. One Tab stop per TextInput: the Fabric component view and the multiline RCTWrappedTextView stay out of the key-view loop. The component view also refuses first responder, since TextInput defaults focusable to true. RNTester-macOS now recalculates its key-view loop, matching RCTAppDelegate.
  6. Optional, recycled TextInput role: swapping a recycled TextInput's backing view copied an unset role, which reads back as AXUnknown. The new field then reported AXUnknown instead of AXTextField.

New RNTester examples cover each fix: Accessibility "Roles from role and accessibilityRole" and "Disabled, selected and expanded state"; TextInput "Read-only and disabled" and "Tab stops".

Follow-ups outside this PR:

  • Removing role or accessibilityRole keeps the previous value, because shared AccessibilityProps parsing treats null as unset. role="none" restores the default role.
  • The shared accessibilityValue override still appends "expanded", which may make VoiceOver say it twice now that AXExpanded is exposed.
  • checked and busy aren't projected, and Fabric views expose no AXPress action.

Test Plan:

Command Result
RCT_NEW_ARCH_ENABLED=1 pod install (in packages/rn-tester) Passed
xcodebuild -workspace packages/rn-tester/RNTesterPods.xcworkspace -sdk macosx -configuration Debug -scheme RNTester-macOS CODE_SIGNING_ALLOWED=NO build Passed at every commit
xcodebuild -workspace packages/rn-tester/RNTesterPods.xcworkspace -sdk iphonesimulator -configuration Debug -scheme RNTester -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build Passed
yarn flow check Passed (0 errors)
yarn exec eslint --max-warnings 0 and yarn exec prettier --check on the two example files Passed at every commit

I ran the new examples in RNTester-macOS (new architecture) and inspected them from a separate process with AXUIElementCopyAttributeValue. Both runs used the RNTester key-view change, so Tab can move between views.

Check main This PR
role="menuitem" / "option" / "tab" AXUnknown AXMenuItem / AXStaticText / AXRadioButton
accessibilityRole="textbox" not recorded AXTextField
Cycling a View's role, including "none" always AXUnknown follows the prop; "none" restores AXUnknown
TextInput role="combobox", then "none" AXUnknown AXComboBox, then AXTextField
Toggling disabled, selected, expanded on and off AXEnabled stays 1; AXSelected and AXExpanded absent 0 / 1 / 1, then 1 / 0 / 0
View whose state has no expanded n/a no AXExpanded
editable={false} AXEnabled = 0; clicking doesn't focus it AXEnabled = 1; focusable, select-all works, typing rejected
accessibilityState={{disabled: true}}, single-line and multiline single-line still enabled (AXEnabled = 1) and focusable AXEnabled = 0; skipped by Tab and refuses focus. Removing the prop re-enables both
Three TextInputs, Tab then Shift+Tab 2 presses per input 1 press per input
Recycled TextInput after a multiline-to-single-line swap AXUnknown AXTextField

Not run:

+[RCTConvert accessibilityRoleFromTraits:useAriaMappings:] passed the whole
array, rather than the current element, to the ARIA lookup, so array input
never matched an ARIA role.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ityRole

On macOS, RCTViewComponentView mapped only accessibilityTraits to a role. The
C++ props convert just a few role strings to traits, so roles such as
menuitem, option, tab, combobox and textbox reported AXUnknown. The role was
also set on the component view rather than its accessibility element, so the
field behind a TextInput never received it.

Resolve the role the way Paper does: role with the ARIA table, then
accessibilityRole with the legacy table, then the traits, and otherwise the
element's default role. Write it to accessibilityElement. Add the missing
legacy textbox mapping and an RNTester example.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fabric ignored accessibilityState on macOS, so VoiceOver and automation
reported disabled elements as enabled and never reported AXSelected or
AXExpanded.

Project the state, including the NotEnabled and Selected traits, onto the
accessibility element. This replaces the trait-only accessibilityEnabled
update, which wrote to the component view instead of the element. As in
Paper, expose an attribute only when JS provides it: setting an
NSAccessibility state makes AppKit advertise the attribute, so an element
without expanded must not report AXExpanded. Add an RNTester example.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…State.disabled

editable={false} disabled the single-line NSTextField, so read-only fields
could not be focused, selected or copied and reported AXEnabled = NO, while
accessibilityState.disabled did not disable the field at all.

- RCTUITextField: editable now only controls editing. Read-only fields stay
  enabled, selectable and reachable with Tab.
- accessibilityState.disabled sets a new enabled property on the backing
  text input on both renderers. RCTUITextView implements it for multiline
  input: it refuses first responder and reports AXEnabled = NO.
- Paper: clear the previous state when accessibilityState is removed.

Disabled fields now use AppKit's disabled appearance; read-only fields no
longer do. Add an RNTester example.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
RCTTextInputComponentView and, for multiline input, RCTWrappedTextView only
host the editor but were in the key view loop, so each TextInput took two
Tab presses. TextInput's default focusable={true} also let the component
view become first responder.

Keep both wrappers out of the key view loop and the responder chain; focus()
already targets the editor. RNTester now recalculates its key view loop like
RCTAppDelegate, so Tab moves between views in the new example.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… view

Changing multiline or secureTextEntry, including on a recycled view, replaces
the backing view and copies its accessibility role. An unset role reads back
as AXUnknown, so the new NSTextField reported AXUnknown instead of
AXTextField. Do not copy an unset role.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant