Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ @implementation RCTUITextView {
NSArray<NSString *> *_acceptDragAndDropTypes;
#if TARGET_OS_OSX // [macOS
BOOL _enableFocusRing;
BOOL _enabled;
NSArray<NSPasteboardType> *_readablePasteboardTypes;
#endif // macOS]
}
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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<NSString *, id> *)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<NSAttributedStringKey, id> *)defaultTextAttributes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString *, id> *)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
{
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native/React/Base/RCTConvert.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
#endif

#if TARGET_OS_OSX // [macOS
#import <React/RCTConvert.h>
#import <React/RCTCursor.h>
#import <React/RCTViewKeyboardEvent.h>
#import <React/RCTUtils.h>
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1685,6 +1765,23 @@ - (BOOL)isAccessibilityElement
return [super isAccessibilityElement];
}

#if TARGET_OS_OSX // [macOS
- (BOOL)isAccessibilitySelectorAllowed:(SEL)selector
{
const auto &props = static_cast<const ViewProps &>(*_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<const ViewProps &>(*_props);
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native/React/Views/RCTViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,10 @@ - (void) updateAccessibilityRole:(RCTView *)view withDefaultView:(RCTView *)defa
NSMutableDictionary<NSString *, id> *newState = [NSMutableDictionary<NSString *, id> 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;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/rn-tester/RNTester/AppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
Loading
Loading