diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index 8e7169c30d33..3fea815048a7 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -27,6 +27,7 @@ @implementation RCTUITextView { NSArray *_acceptDragAndDropTypes; #if TARGET_OS_OSX // [macOS BOOL _enableFocusRing; + BOOL _enabled; NSArray *_readablePasteboardTypes; #endif // macOS] } @@ -66,6 +67,7 @@ - (instancetype)initWithFrame:(CGRect)frame self.canDrawSubviewsIntoLayer = YES; self.allowsUndo = YES; _enableFocusRing = YES; + _enabled = YES; #endif // macOS] _textInputDelegateAdapter = [[RCTBackedTextViewDelegateAdapter alloc] initWithTextView:self]; @@ -280,6 +282,39 @@ - (BOOL)enableFocusRing { return _enableFocusRing; } + +- (BOOL)isEnabled +{ + return _enabled; +} + +- (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]; + } +} + +- (BOOL)acceptsFirstResponder +{ + return _enabled && [super acceptsFirstResponder]; +} + +- (void)setAccessibilityState:(NSDictionary *)accessibilityState +{ + // Paper routes `accessibilityState` to the backed text view through -[RCTBaseTextInputView reactAccessibilityElement]. + [super setAccessibilityState:accessibilityState]; + id disabled = accessibilityState[@"disabled"]; + self.enabled = !([disabled isKindOfClass:[NSNumber class]] && [disabled boolValue]); +} #endif // macOS] - (void)setDefaultTextAttributes:(NSDictionary *)defaultTextAttributes diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m index d716f199955e..7ed3c9925630 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m @@ -108,6 +108,12 @@ - (NSResponder *)responder return _forwardingTextView; } +- (BOOL)canBecomeKeyView +{ + // The forwarding text view is the Tab stop; this wrapper would otherwise add a second one. + return NO; +} + - (BOOL)acceptsFirstResponder { return _forwardingTextView.acceptsFirstResponder; @@ -123,6 +129,16 @@ - (BOOL)resignFirstResponder return [_forwardingTextView resignFirstResponder]; } +- (BOOL)isEnabled +{ + return _forwardingTextView.isEnabled; +} + +- (void)setEnabled:(BOOL)enabled +{ + _forwardingTextView.enabled = enabled; +} + #pragma mark - #pragma mark Text Input delegate forwarding diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h index 63a8a57e47c7..e202fd65a050 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h +++ b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h @@ -40,6 +40,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL textWasPasted; @property (nonatomic, readonly) NSResponder *responder; @property (nonatomic, assign) BOOL enableFocusRing; +// Driven by `accessibilityState.disabled`; `editable` only controls whether the text can be changed. +@property (nonatomic, assign, getter=isEnabled) BOOL enabled; #endif // macOS] @property (nonatomic, assign, readonly) BOOL dictationRecognizing; @property (nonatomic, assign) UIEdgeInsets textContainerInset; diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index dbee656c95e6..fd5534a12c0f 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -398,18 +398,42 @@ - (void)_updatePlaceholder - (BOOL)isEditable { +#if !TARGET_OS_OSX // [macOS] return self.isEnabled; +#else // [macOS + return [super isEditable]; +#endif // macOS] } - (void)setEditable:(BOOL)editable { -#if TARGET_OS_OSX // [macOS - // on macos the super must be called otherwise its NSTextFieldCell editable property doesn't get set. +#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. + // `accessibilityState.disabled` disables it instead. The super call is required for NSTextFieldCell's editable state. [super setEditable:editable]; + self.selectable = YES; #endif // macOS] - self.enabled = editable; } +#if TARGET_OS_OSX // [macOS +- (BOOL)acceptsFirstResponder +{ + // NSTextField refuses first responder when it is not editable, which would make read-only fields unreachable with + // Tab. Accept it while the field is enabled and selectable; disabled fields are skipped. + return self.isEnabled && self.isSelectable; +} + +- (void)setAccessibilityState:(NSDictionary *)accessibilityState +{ + // Paper routes `accessibilityState` to the backed text field through -[RCTBaseTextInputView reactAccessibilityElement]. + [super setAccessibilityState:accessibilityState]; + id disabled = accessibilityState[@"disabled"]; + self.enabled = !([disabled isKindOfClass:[NSNumber class]] && [disabled boolValue]); +} +#endif // macOS] + #if !TARGET_OS_OSX // [macOS] - (void)setSecureTextEntry:(BOOL)secureTextEntry { diff --git a/packages/react-native/React/Base/RCTConvert.mm b/packages/react-native/React/Base/RCTConvert.mm index 51287bfe5477..7e3a160d7ccd 100644 --- a/packages/react-native/React/Base/RCTConvert.mm +++ b/packages/react-native/React/Base/RCTConvert.mm @@ -1664,6 +1664,7 @@ + (NSString*)accessibilityRoleFromTrait:(NSString*)trait @"tab": NSAccessibilityButtonRole, // no exact match on macOS @"tablist": NSAccessibilityTabGroupRole, @"text": NSAccessibilityStaticTextRole, // also a legacy iOS accessibilityTraits + @"textbox": NSAccessibilityTextFieldRole, @"timer": NSAccessibilityStaticTextRole, // no exact match on macOS @"toolbar": NSAccessibilityToolbarRole, // Roles/traits that are macOS specific and are used by some of the core components (Lists): @@ -1689,7 +1690,7 @@ + (NSString *)accessibilityRoleFromTraits:(id)json useAriaMappings:(BOOL)useAria return useAriaMappings ? [RCTConvert accessibilityRoleFromAriaRole:json] : [RCTConvert accessibilityRoleFromTrait:json]; } else if ([json isKindOfClass:[NSArray class]]) { for (NSString *trait in json) { - NSString *accessibilityRole = useAriaMappings ? [RCTConvert accessibilityRoleFromAriaRole:json] : [RCTConvert accessibilityRoleFromTrait:trait]; + NSString *accessibilityRole = useAriaMappings ? [RCTConvert accessibilityRoleFromAriaRole:trait] : [RCTConvert accessibilityRoleFromTrait:trait]; if (![accessibilityRole isEqualToString:NSAccessibilityUnknownRole]) { return accessibilityRole; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 802f8db49f53..c67e41029711 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -255,6 +255,21 @@ - (void)reactUpdateResponderOffsetForScrollView:(RCTScrollViewComponentView *)sc #pragma mark - RCTViewComponentView overrides +#if TARGET_OS_OSX // [macOS +// 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] + - (NSObject *)accessibilityElement { return _backedTextInputView; @@ -305,6 +320,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _backedTextInputView.editable = newTextInputProps.traits.editable; } +#if TARGET_OS_OSX // [macOS + // `accessibilityState.disabled` disables the backed text input; `editable={false}` only makes it read-only. + if (newTextInputProps.accessibilityState != oldTextInputProps.accessibilityState) { + _backedTextInputView.enabled = !newTextInputProps.accessibilityState.value_or(AccessibilityState{}).disabled; + } +#endif // macOS] + #if !TARGET_OS_OSX // [macOS] if (newTextInputProps.multiline && newTextInputProps.traits.dataDetectorTypes != oldTextInputProps.traits.dataDetectorTypes) { diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm index b3fe74cbc3cb..b9f9cf4a5e4e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm @@ -33,8 +33,12 @@ void RCTCopyBackedTextInput( toTextInput.accessibilityHelp = fromTextInput.accessibilityHelp; toTextInput.accessibilityIdentifier = fromTextInput.accessibilityIdentifier; toTextInput.accessibilityLabel = fromTextInput.accessibilityLabel; - toTextInput.accessibilityRole = fromTextInput.accessibilityRole; + // An unset role reads back as NSAccessibilityUnknownRole; copying it would hide the new view's default role. + if (![fromTextInput.accessibilityRole isEqualToString:NSAccessibilityUnknownRole]) { + toTextInput.accessibilityRole = fromTextInput.accessibilityRole; + } toTextInput.autoresizingMask = fromTextInput.autoresizingMask; + toTextInput.enabled = fromTextInput.enabled; #endif // macOS] #if TARGET_OS_IOS // [macOS] [visionOS] toTextInput.inputAccessoryView = fromTextInput.inputAccessoryView; diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 473eab307dca..5616936e4d32 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -36,6 +36,7 @@ #endif #if TARGET_OS_OSX // [macOS +#import #import #import #import @@ -45,6 +46,60 @@ const CGFloat BACKGROUND_COLOR_ZPOSITION = -1024.0f; +#if TARGET_OS_OSX // [macOS +// The role an accessibility element reports when React Native does not override it. NSControl subclasses, such as the +// NSTextField behind a single-line TextInput, report their role through their cell; the control's own +// `accessibilityRole` getter returns NSAccessibilityUnknownRole until a role is set explicitly. +static NSAccessibilityRole RCTDefaultAccessibilityRoleForElement(NSView *element) +{ + if ([element isKindOfClass:[NSControl class]]) { + NSAccessibilityRole cellRole = ((NSControl *)element).cell.accessibilityRole; + if (cellRole != nil) { + return cellRole; + } + } + return NSAccessibilityUnknownRole; +} + +// Matches Paper (`-[RCTViewManager updateAccessibilityRole:withDefaultView:]`): `role` takes precedence over +// `accessibilityRole`, and the element's default role is used when neither is set. +static NSAccessibilityRole RCTAccessibilityRoleFromViewProps(const ViewProps &props, NSView *element) +{ + if (props.role != Role::None) { + return [RCTConvert accessibilityRoleFromTraits:RCTNSStringFromString(toString(props.role)) useAriaMappings:YES]; + } + if (!props.accessibilityRole.empty()) { + return [RCTConvert accessibilityRoleFromTraits:RCTNSStringFromString(props.accessibilityRole) useAriaMappings:NO]; + } + NSAccessibilityRole traitsRole = + RCTAccessibilityRoleFromTraits(RCTUIAccessibilityTraitsFromAccessibilityTraits(props.accessibilityTraits)); + if (![traitsRole isEqualToString:NSAccessibilityUnknownRole]) { + return traitsRole; + } + return RCTDefaultAccessibilityRoleForElement(element); +} + +// 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(); +} +#endif // macOS] + @implementation RCTViewComponentView { RCTPlatformColor *_backgroundColor; // [macOS] CALayer *_backgroundColorLayer; @@ -487,20 +542,45 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } } +#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. - RCTUIAccessibilityTraits traits = RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits); - self.accessibilityRole = RCTAccessibilityRoleFromTraits(traits); - self.accessibilityEnabled = (traits & RCTUIAccessibilityTraitNotEnabled) == 0; -#endif // macOS] + // `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); } + // `accessibilityState` + // `disabled` and `selected` can also come from the NotEnabled and Selected traits. Only update a state that is, or + // was, provided so that elements without it do not start advertising it. + if (oldViewProps.accessibilityState != newViewProps.accessibilityState || + oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) { + const auto accessibilityState = newViewProps.accessibilityState.value_or(AccessibilityState{}); + const auto traits = RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits); + if (RCTViewPropsProvideAccessibilityEnabled(newViewProps) || + RCTViewPropsProvideAccessibilityEnabled(oldViewProps)) { + self.accessibilityElement.accessibilityEnabled = + !accessibilityState.disabled && (traits & RCTUIAccessibilityTraitNotEnabled) == 0; + } + if (RCTViewPropsProvideAccessibilitySelected(newViewProps) || + RCTViewPropsProvideAccessibilitySelected(oldViewProps)) { + self.accessibilityElement.accessibilitySelected = + accessibilityState.selected || (traits & RCTUIAccessibilityTraitSelected) != 0; + } + if (RCTViewPropsProvideAccessibilityExpanded(newViewProps) || + RCTViewPropsProvideAccessibilityExpanded(oldViewProps)) { + self.accessibilityElement.accessibilityExpanded = accessibilityState.expanded.value_or(false); + } + } +#endif // macOS] + #if !TARGET_OS_OSX // [macOS] // `accessibilityState` if (oldViewProps.accessibilityState != newViewProps.accessibilityState) { @@ -1685,6 +1765,23 @@ - (BOOL)isAccessibilityElement return [super isAccessibilityElement]; } +#if TARGET_OS_OSX // [macOS +- (BOOL)isAccessibilitySelectorAllowed:(SEL)selector +{ + const auto &props = static_cast(*_props); + if (selector == @selector(isAccessibilityEnabled)) { + return RCTViewPropsProvideAccessibilityEnabled(props); + } + if (selector == @selector(isAccessibilitySelected)) { + return RCTViewPropsProvideAccessibilitySelected(props); + } + if (selector == @selector(isAccessibilityExpanded)) { + return RCTViewPropsProvideAccessibilityExpanded(props); + } + return [super isAccessibilitySelectorAllowed:selector]; +} +#endif // macOS] + - (NSString *)accessibilityValue { const auto &props = static_cast(*_props); diff --git a/packages/react-native/React/Views/RCTViewManager.m b/packages/react-native/React/Views/RCTViewManager.m index 8b3e94644d9d..0ec065ec37a9 100644 --- a/packages/react-native/React/Views/RCTViewManager.m +++ b/packages/react-native/React/Views/RCTViewManager.m @@ -353,6 +353,10 @@ - (void) updateAccessibilityRole:(RCTView *)view withDefaultView:(RCTView *)defa NSMutableDictionary *newState = [NSMutableDictionary new]; if (!state) { +#if TARGET_OS_OSX // [macOS + // Removing the prop must clear the previous state, e.g. so a TextInput is re-enabled once `disabled` goes away. + view.reactAccessibilityElement.accessibilityState = nil; +#endif // macOS] return; } diff --git a/packages/rn-tester/RNTester/AppDelegate.mm b/packages/rn-tester/RNTester/AppDelegate.mm index d069bb0980e2..f2fc24a1a0bc 100644 --- a/packages/rn-tester/RNTester/AppDelegate.mm +++ b/packages/rn-tester/RNTester/AppDelegate.mm @@ -76,6 +76,8 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification backing:NSBackingStoreBuffered defer:NO]; self.window.title = @"RNTesterApp"; + // Match RCTAppDelegate so Tab and Shift+Tab move between React Native views. + self.window.autorecalculatesKeyViewLoop = YES; NSDictionary *launchOptions = [notification userInfo]; #endif // macOS] diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 7064fd2395e3..8e08b2cf221e 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -2229,6 +2229,94 @@ function AriaHiddenExample(): React.Node { ); } +// [macOS +// 'none' clears the role, so the element falls back to its default role. +const macOSDynamicRoles: $ReadOnlyArray< + 'menuitem' | 'option' | 'tab' | 'none', +> = ['menuitem', 'option', 'tab', 'none']; + +function MacOSAccessibilityRoleExample(): React.Node { + const [roleIndex, setRoleIndex] = useState(0); + const dynamicRole = macOSDynamicRoles[roleIndex]; + return ( + + + role="menuitem" (AXMenuItem) + + + role="option" (AXStaticText) + + + role="tab" (AXRadioButton) + + + accessibilityRole="textbox" (AXTextField) + + + {`role="${dynamicRole}"`} + + +