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>
#endif

#if TARGET_OS_OSX // [macOS
#import <React/RCTConvert.h>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably shouldn't import the paper RCTConvert into Fabric. We likely need to duplicate the code in the fabric renderer

Comment on lines +525 to +545
#if !TARGET_OS_OSX // [macOS]
// `accessibilityTraits`
if (oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) {
#if !TARGET_OS_OSX // [macOS]
self.accessibilityElement.accessibilityTraits =
RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits);
}
#else // [macOS
// On macOS, accessibilityElement returns self (NSView*) which doesn't have accessibilityTraits.
// Set role directly on self based on traits.
// `role`, `accessibilityRole` and `accessibilityTraits`
// NSAccessibility has no traits, so all three resolve to a single role on the accessibility element.
if (oldViewProps.role != newViewProps.role || oldViewProps.accessibilityRole != newViewProps.accessibilityRole ||
oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) {
self.accessibilityElement.accessibilityRole =
RCTAccessibilityRoleFromViewProps(newViewProps, self.accessibilityElement);
}

// `accessibilityTraits`
if (oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) {
RCTUIAccessibilityTraits traits = RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits);
self.accessibilityRole = RCTAccessibilityRoleFromTraits(traits);
self.accessibilityEnabled = (traits & RCTUIAccessibilityTraitNotEnabled) == 0;
#endif // macOS]
}
#endif // macOS]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we simplify this diff? I'd like to follow the iOS handling more closely instead of diffing out the check of accessibilityTraits. It'd be nice if we can keep the diffs inside the if (accessibilityTraitsChanged) and would like more info why that pattern doesn't work.

Comment on lines +2234 to +2237
const macOSDynamicRoles: $ReadOnlyArray<
'menuitem' | 'option' | 'tab' | 'none',
> = ['menuitem', 'option', 'tab', 'none'];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what dynamic means here. Maybe "macOS specific"? Is there not a test already testing setting the role of an arbitrary view? I'f love for this to test macOS specifics if it's going to be a separate test

Comment on lines +81 to +100

// Setting an NSAccessibility state property makes AppKit advertise that attribute, so like Paper
// (`-[RCTView isAccessibilitySelectorAllowed:]`) only expose a state when JS provides it. `disabled` and `selected` are
// plain bools in `AccessibilityState`, so they count as provided whenever `accessibilityState` is set.
static BOOL RCTViewPropsProvideAccessibilityEnabled(const ViewProps &props)
{
const auto traits = RCTUIAccessibilityTraitsFromAccessibilityTraits(props.accessibilityTraits);
return props.accessibilityState.has_value() || (traits & RCTUIAccessibilityTraitNotEnabled) != 0;
}

static BOOL RCTViewPropsProvideAccessibilitySelected(const ViewProps &props)
{
const auto traits = RCTUIAccessibilityTraitsFromAccessibilityTraits(props.accessibilityTraits);
return props.accessibilityState.has_value() || (traits & RCTUIAccessibilityTraitSelected) != 0;
}

static BOOL RCTViewPropsProvideAccessibilityExpanded(const ViewProps &props)
{
return props.accessibilityState.has_value() && props.accessibilityState->expanded.has_value();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this file the best place for these functions? There isn't some fabric specific accessibility handlers file?

Comment on lines +291 to +303
- (void)setEnabled:(BOOL)enabled
{
if (_enabled == enabled) {
return;
}
_enabled = enabled;

// NSTextView has no enabled state. Behave like a disabled NSTextField: report AXEnabled = NO, refuse first responder
// (see -acceptsFirstResponder) and end editing if the view is focused.
self.accessibilityEnabled = enabled;
if (!enabled && self.window.firstResponder == self) {
[self.window makeFirstResponder:nil];
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer no early return and to do the work in the body

#if !TARGET_OS_OSX // [macOS]
self.enabled = editable;
#else // [macOS
// On macOS `editable={false}` makes the field read-only, not disabled: it stays enabled, focusable and selectable.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a behavior difference from iOS? Would prefer to match iOS / Web / React Native unless desktop or macOS call for divergent behavior

Comment on lines +259 to +271
// The backed text input owns focus, editing and the accessibility element; this view only hosts it. Keep it out of the
// key view loop and the responder chain so each TextInput is a single Tab stop. TextInput defaults `focusable` to true,
// which would otherwise make -[RCTViewComponentView acceptsFirstResponder] return YES. `focus()` targets the backed view.
- (BOOL)canBecomeKeyView
{
return NO;
}

- (BOOL)acceptsFirstResponder
{
return NO;
}
#endif // macOS]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simplify this comment a bit

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.

2 participants