Repository navigation
fix(macos): Fabric accessibility roles and state, and TextInput read-only and Tab-stop fixes - #3136
Jason Morse (JasonVMo) wants to merge 6 commits into
Conversation
+[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> |
There was a problem hiding this comment.
We probably shouldn't import the paper RCTConvert into Fabric. We likely need to duplicate the code in the fabric renderer
| #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] |
There was a problem hiding this comment.
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.
| const macOSDynamicRoles: $ReadOnlyArray< | ||
| 'menuitem' | 'option' | 'tab' | 'none', | ||
| > = ['menuitem', 'option', 'tab', 'none']; | ||
|
|
There was a problem hiding this comment.
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
|
|
||
| // 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(); | ||
| } |
There was a problem hiding this comment.
Is this file the best place for these functions? There isn't some fabric specific accessibility handlers file?
| - (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]; | ||
| } |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Is this a behavior difference from iOS? Would prefer to match iOS / Web / React Native unless desktop or macOS call for divergent behavior
| // 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] |
There was a problem hiding this comment.
I think we can simplify this comment a bit
Summary:
On macOS, Fabric loses most accessibility information that Paper exposes, and TextInput handles focus and read-only state incorrectly:
role/accessibilityRolevalues such asmenuitem,option,tab,comboboxandtextboxreportAXUnknown.accessibilityStatedisabled,selectedandexpandednever reach AppKit.editable={false}is disabled instead of read-only. It can't be focused or selected, whileaccessibilityState.disableddoesn't disable it at all.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 onmainare tracked in #3135.Each commit is a separate fix and builds on its own:
RCTConvert: the ARIA path for array input passed the whole array instead of each element.rolewith the ARIA table, thenaccessibilityRolewith the legacy table, then the traits, then the element's default. Write it toaccessibilityElement, so TextInput's backing field receives it. Also add the missing legacytextboxmapping.disabled,selectedandexpanded, 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 withoutexpandeddoesn't report AXExpanded.editableonly controls editing; read-only fields stay enabled, selectable and reachable with Tab.accessibilityState.disableddrives a newenabledproperty on the backing input on both renderers.RCTUITextViewimplements it for multiline. On Paper, removing theaccessibilityStateprop now clears the previous state.RCTWrappedTextViewstay out of the key-view loop. The component view also refuses first responder, since TextInput defaultsfocusableto true. RNTester-macOS now recalculates its key-view loop, matchingRCTAppDelegate.AXUnknown. The new field then reportedAXUnknowninstead ofAXTextField.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:
roleoraccessibilityRolekeeps the previous value, because sharedAccessibilityPropsparsing treatsnullas unset.role="none"restores the default role.accessibilityValueoverride still appends "expanded", which may make VoiceOver say it twice now that AXExpanded is exposed.checkedandbusyaren't projected, and Fabric views expose no AXPress action.Test Plan:
RCT_NEW_ARCH_ENABLED=1 pod install(inpackages/rn-tester)xcodebuild -workspace packages/rn-tester/RNTesterPods.xcworkspace -sdk macosx -configuration Debug -scheme RNTester-macOS CODE_SIGNING_ALLOWED=NO buildxcodebuild -workspace packages/rn-tester/RNTesterPods.xcworkspace -sdk iphonesimulator -configuration Debug -scheme RNTester -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO buildyarn flow checkyarn exec eslint --max-warnings 0andyarn exec prettier --checkon the two example filesI 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.mainrole="menuitem"/"option"/"tab"AXUnknownAXMenuItem/AXStaticText/AXRadioButtonaccessibilityRole="textbox"AXTextField"none"AXUnknown"none"restoresAXUnknownrole="combobox", then"none"AXUnknownAXComboBox, thenAXTextFielddisabled,selected,expandedon and offexpandededitable={false}accessibilityState={{disabled: true}}, single-line and multilineAXUnknownAXTextFieldNot run:
maindoesn't build the legacy architecture; tracked in Backport macOS accessibility and TextInput fixes to 0.81-stable, with Paper validation #3135.RNTester-macOSUnitTestsalready fails to compile onmain(RCTImageLoaderTests.musesUIImage).