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];
}
Comment on lines +291 to +303

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

}

- (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.

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

// `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]
Comment on lines +259 to +271

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


- (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>

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

#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();
}
Comment on lines +81 to +100

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?

#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]
Comment on lines +525 to +545

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.


#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