diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m index 75be4b1e3dd181..ed8f3444b755d9 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m @@ -62,6 +62,10 @@ - (instancetype)initWithFrame:(CGRect)frame selector:@selector(boundsDidChange:) name:NSViewBoundsDidChangeNotification object:_scrollView.contentView]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(scrollViewDidScroll:) + name:NSViewBoundsDidChangeNotification + object:_scrollView.contentView]; } return self; @@ -132,6 +136,13 @@ - (void)setTextInputDelegate:(id)textInputDelegate #pragma mark - #pragma mark Scrolling control +#if TARGET_OS_OSX // [macOS +- (void)scrollViewDidScroll:(NSNotification *)notification +{ + [self.textInputDelegate scrollViewDidScroll:_scrollView]; +} +#endif // macOS] + - (BOOL)scrollEnabled { return _scrollView.isScrollEnabled; @@ -181,6 +192,19 @@ - (void)setTextContainerInset:(UIEdgeInsets)textContainerInsets _forwardingTextView.textContainerInsets = textContainerInsets; } +#pragma mark - +#pragma mark Focus ring + +- (BOOL)enableFocusRing +{ + return _scrollView.enableFocusRing; +} + +- (void)setEnableFocusRing:(BOOL)enableFocusRing +{ + _scrollView.enableFocusRing = enableFocusRing; +} + @end #endif // TARGET_OS_OSX diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm index 7fe50d5b24d67a..6aaa1905c0c13d 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm @@ -398,17 +398,17 @@ - (void)textViewDidChangeSelection:(__unused UITextView *)textView [self textViewProbablyDidChangeSelection]; } +#endif // [macOS] + #pragma mark - UIScrollViewDelegate -- (void)scrollViewDidScroll:(UIScrollView *)scrollView +- (void)scrollViewDidScroll:(RCTUIScrollView *)scrollView // [macOS] { if ([_backedTextInputView.textInputDelegate respondsToSelector:@selector(scrollViewDidScroll:)]) { [_backedTextInputView.textInputDelegate scrollViewDidScroll:scrollView]; } } -#endif // [macOS] - #if TARGET_OS_OSX // [macOS #pragma mark - NSTextViewDelegate diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h index 7775ca596bf78c..a57420235dda61 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h +++ b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h @@ -39,6 +39,7 @@ NS_ASSUME_NONNULL_BEGIN #else // [macOS @property (nonatomic, assign) BOOL textWasPasted; @property (nonatomic, readonly) NSResponder *responder; +@property (nonatomic, assign) BOOL enableFocusRing; #endif // macOS] @property (nonatomic, assign, readonly) BOOL dictationRecognizing; @property (nonatomic, assign) UIEdgeInsets textContainerInset; diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.h b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.h index 14735c2cd509eb..ff30d49d7f7f28 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.h +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.h @@ -45,7 +45,7 @@ NS_ASSUME_NONNULL_BEGIN #if !TARGET_OS_OSX // [macOS] @property (nonatomic, assign, getter=isEditable) BOOL editable; #else // [macOS -@property (assign, getter=isEditable) BOOL editable; +@property (atomic, assign, getter=isEditable) BOOL editable; #endif // macOS] @property (nonatomic, getter=isScrollEnabled) BOOL scrollEnabled; @property (nonatomic, strong, nullable) NSString *inputAccessoryViewID; @@ -58,7 +58,7 @@ NS_ASSUME_NONNULL_BEGIN #if TARGET_OS_OSX // [macOS @property (nonatomic, copy, nullable) NSString *text; @property (nonatomic, copy, nullable) NSAttributedString *attributedText; -@property (nonatomic, copy) NSDictionary *defaultTextAttributes; +@property (nonatomic, strong, nullable) NSDictionary *defaultTextAttributes; @property (nullable, nonatomic, copy) NSDictionary *typingAttributes; @property (nonatomic, assign) NSTextAlignment textAlignment; @property (nonatomic, getter=isAutomaticTextReplacementEnabled) BOOL automaticTextReplacementEnabled; diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm index 150f2f3b69a203..4dff1405818ef6 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm @@ -16,10 +16,6 @@ #import #import "RCTLegacyViewManagerInteropCoordinatorAdapter.h" -#if TARGET_OS_OSX // [macOS -#import -#endif // macOS] - using namespace facebook::react; static NSString *const kRCTLegacyInteropChildComponentKey = @"childComponentView"; 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 3a4fa500300a56..4a237ee7f8227c 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -13,11 +13,19 @@ #import #import + +#if !TARGET_OS_OSX // [macOS] #import +#else // [macOS +#include +#include +#endif // macOS] + #import #import #if TARGET_OS_OSX // [macOS #import +#import #endif // macOS] #import "RCTConversions.h" @@ -31,6 +39,11 @@ static const CGFloat kSingleLineKeyboardBottomOffset = 15.0; #endif // [macOS] +#if TARGET_OS_OSX // [macOS +static NSString *kEscapeKeyCode = @"\x1B"; +#endif // macOS] + + using namespace facebook::react; @interface RCTTextInputComponentView () @@ -134,7 +147,10 @@ - (void)didMoveToWindow if (props.autoFocus) { #if !TARGET_OS_OSX // [macOS] [_backedTextInputView becomeFirstResponder]; -#endif // [macOS] +#else // [macOS + NSWindow *window = [_backedTextInputView window]; + [window makeFirstResponder:_backedTextInputView.responder]; +#endif // macOS] [self scrollCursorIntoView]; } _didMoveToWindow = YES; @@ -279,11 +295,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _backedTextInputView.scrollEnabled = newTextInputProps.traits.scrollEnabled; } -#if !TARGET_OS_OSX // [macOS] if (newTextInputProps.traits.secureTextEntry != oldTextInputProps.traits.secureTextEntry) { +#if !TARGET_OS_OSX // [macOS] _backedTextInputView.secureTextEntry = newTextInputProps.traits.secureTextEntry; +#else // [macOS + [self _setSecureTextEntry:newTextInputProps.traits.secureTextEntry]; +#endif // macOS] } +#if !TARGET_OS_OSX // [macOS] if (newTextInputProps.traits.keyboardType != oldTextInputProps.traits.keyboardType) { _backedTextInputView.keyboardType = RCTUIKeyboardTypeFromKeyboardType(newTextInputProps.traits.keyboardType); } @@ -557,6 +577,13 @@ - (void)textInputDidChangeSelection } #if TARGET_OS_OSX // [macOS +- (void)setEnableFocusRing:(BOOL)enableFocusRing { + [super setEnableFocusRing:enableFocusRing]; + if ([_backedTextInputView respondsToSelector:@selector(setEnableFocusRing:)]) { + [_backedTextInputView setEnableFocusRing:enableFocusRing]; + } +} + - (void)automaticSpellingCorrectionDidChange:(BOOL)enabled { if (_eventEmitter) { std::static_pointer_cast(_eventEmitter)->onAutoCorrectChange({.autoCorrectEnabled = static_cast(enabled)}); @@ -577,9 +604,65 @@ - (void)grammarCheckingDidChange:(BOOL)enabled } } -- (void)submitOnKeyDownIfNeeded:(nonnull NSEvent *)event {} +- (void)submitOnKeyDownIfNeeded:(nonnull NSEvent *)event +{ + BOOL shouldSubmit = NO; + NSDictionary *keyEvent = [RCTViewKeyboardEvent bodyFromEvent:event]; + auto const &props = *std::static_pointer_cast(_props); + if (props.traits.submitKeyEvents.empty()) { + shouldSubmit = [keyEvent[@"key"] isEqualToString:@"Enter"] + && ![keyEvent[@"altKey"] boolValue] + && ![keyEvent[@"shiftKey"] boolValue] + && ![keyEvent[@"ctrlKey"] boolValue] + && ![keyEvent[@"metaKey"] boolValue] + && ![keyEvent[@"functionKey"] boolValue]; // Default clearTextOnSubmit key + } else { + NSString *keyValue = keyEvent[@"key"]; + const char *keyCString = [keyValue UTF8String]; + if (keyCString != nullptr) { + std::string_view key(keyCString); + const bool altKey = [keyEvent[@"altKey"] boolValue]; + const bool shiftKey = [keyEvent[@"shiftKey"] boolValue]; + const bool ctrlKey = [keyEvent[@"ctrlKey"] boolValue]; + const bool metaKey = [keyEvent[@"metaKey"] boolValue]; + const bool functionKey = [keyEvent[@"functionKey"] boolValue]; + + shouldSubmit = std::any_of( + props.traits.submitKeyEvents.begin(), + props.traits.submitKeyEvents.end(), + [&](auto const &submitKeyEvent) { + return submitKeyEvent.key == key && submitKeyEvent.altKey == altKey && + submitKeyEvent.shiftKey == shiftKey && submitKeyEvent.ctrlKey == ctrlKey && + submitKeyEvent.metaKey == metaKey && submitKeyEvent.functionKey == functionKey; + }); + } + } + + if (shouldSubmit) { + if (_eventEmitter) { + auto const &textInputEventEmitter = *std::static_pointer_cast(_eventEmitter); + textInputEventEmitter.onSubmitEditing([self _textInputMetrics]); + } -- (void)textInputDidCancel {} + if (props.traits.clearTextOnSubmit) { + _backedTextInputView.attributedText = nil; + [self textInputDidChange]; + } + } +} + +- (void)textInputDidCancel +{ + if (_eventEmitter) { + auto const &textInputEventEmitter = *std::static_pointer_cast(_eventEmitter); + textInputEventEmitter.onKeyPress({ + .text = RCTStringFromNSString(kEscapeKeyCode), + .eventCount = static_cast(_mostRecentEventCount), + }); + } + + [self textInputDidEndEditing]; +} - (NSDragOperation)textInputDraggingEntered:(nonnull id)draggingInfo { if ([draggingInfo.draggingPasteboard availableTypeFromArray:self.registeredDraggedTypes]) { @@ -638,7 +721,11 @@ - (BOOL)textInputShouldHandlePaste:(nonnull id)s - (void)scrollViewDidScroll:(RCTUIScrollView *)scrollView // [macOS] { if (_eventEmitter) { +#if !TARGET_OS_OSX // [macOS] static_cast(*_eventEmitter).onScroll([self _textInputMetrics]); +#else // [macOS + static_cast(*_eventEmitter).onScroll([self _textInputMetricsWithScrollView:scrollView]); +#endif // macOS] } } @@ -838,15 +925,34 @@ - (void)handleInputAccessoryDoneButton #if !TARGET_OS_OSX // [macOS] .contentOffset = RCTPointFromCGPoint(_backedTextInputView.contentOffset), .contentInset = RCTEdgeInsetsFromUIEdgeInsets(_backedTextInputView.contentInset), -#endif // [macOS] +#else // [macOS + .contentOffset = {.x = 0, .y = 0}, + .contentInset = EdgeInsets{}, +#endif // macOS] .contentSize = RCTSizeFromCGSize(_backedTextInputView.contentSize), .layoutMeasurement = RCTSizeFromCGSize(_backedTextInputView.bounds.size), -#if !TARGET_OS_OSX // [macOS] - .zoomScale = _backedTextInputView.zoomScale, -#endif // [macOS] + .zoomScale = 1, }; } +#if TARGET_OS_OSX // [macOS +- (TextInputEventEmitter::Metrics)_textInputMetricsWithScrollView:(RCTUIScrollView *)scrollView +{ + TextInputEventEmitter::Metrics metrics = [self _textInputMetrics]; + + if (scrollView) { + metrics.contentOffset = RCTPointFromCGPoint(scrollView.contentOffset); + metrics.contentInset = RCTEdgeInsetsFromUIEdgeInsets(scrollView.contentInset); + metrics.contentSize = RCTSizeFromCGSize(scrollView.contentSize); + metrics.layoutMeasurement = RCTSizeFromCGSize(scrollView.bounds.size); + metrics.zoomScale = scrollView.zoomScale ?: 1; + } + + return metrics; +} +#endif // macOS] + + - (void)_updateState { if (!_state) { @@ -893,6 +999,13 @@ - (void)_restoreTextSelection - (void)_setAttributedString:(NSAttributedString *)attributedString { +#if TARGET_OS_OSX // [macOS + // When the text view displays temporary content (e.g. completions, accents), do not update the attributed string. + if (_backedTextInputView.hasMarkedText) { + return; + } +#endif // macOS] + if ([self _textOf:attributedString equals:_backedTextInputView.attributedText]) { return; } @@ -1001,6 +1114,27 @@ - (void)_setShowSoftInputOnFocus:(BOOL)showSoftInputOnFocus } #endif // macOS] +#if TARGET_OS_OSX // [macOS +- (void)_setSecureTextEntry:(BOOL)secureTextEntry +{ + [_backedTextInputView removeFromSuperview]; + RCTPlatformView *backedTextInputView = secureTextEntry ? [RCTUISecureTextField new] : [RCTUITextField new]; + backedTextInputView.frame = _backedTextInputView.frame; + RCTCopyBackedTextInput(_backedTextInputView, backedTextInputView); + + // Copy the text field specific properties if we came from a single line input before the switch + if ([_backedTextInputView isKindOfClass:[RCTUITextField class]]) { + RCTUITextField *previousTextField = (RCTUITextField *)_backedTextInputView; + RCTUITextField *newTextField = (RCTUITextField *)backedTextInputView; + newTextField.textAlignment = previousTextField.textAlignment; + newTextField.text = previousTextField.text; + } + + _backedTextInputView = backedTextInputView; + [self addSubview:_backedTextInputView]; +} +#endif // macOS] + - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText { // When the dictation is running we can't update the attributed text on the backed up text view diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h index 619bb3164408a7..5628dfa338d8e1 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h @@ -30,9 +30,7 @@ UITextAutocapitalizationType RCTUITextAutocapitalizationTypeFromAutocapitalizati UIKeyboardAppearance RCTUIKeyboardAppearanceFromKeyboardAppearance( facebook::react::KeyboardAppearance keyboardAppearance); -#endif // [macOS] -#if !TARGET_OS_OSX // [macOS] UITextSpellCheckingType RCTUITextSpellCheckingTypeFromOptionalBool(std::optional spellCheck); UITextFieldViewMode RCTUITextFieldViewModeFromTextInputAccessoryVisibilityMode( 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 18a62f52dbf637..68e767250e8545 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm @@ -27,6 +27,15 @@ void RCTCopyBackedTextInput( toTextInput.placeholder = fromTextInput.placeholder; toTextInput.placeholderColor = fromTextInput.placeholderColor; toTextInput.textContainerInset = fromTextInput.textContainerInset; + +#if TARGET_OS_OSX // [macOS + toTextInput.accessibilityElement = fromTextInput.accessibilityElement; + toTextInput.accessibilityHelp = fromTextInput.accessibilityHelp; + toTextInput.accessibilityIdentifier = fromTextInput.accessibilityIdentifier; + toTextInput.accessibilityLabel = fromTextInput.accessibilityLabel; + toTextInput.accessibilityRole = fromTextInput.accessibilityRole; + toTextInput.autoresizingMask = fromTextInput.autoresizingMask; +#endif // macOS] #if TARGET_OS_IOS // [macOS] [visionOS] toTextInput.inputAccessoryView = fromTextInput.inputAccessoryView; #endif // [macOS] [visionOS] @@ -94,9 +103,7 @@ UIKeyboardAppearance RCTUIKeyboardAppearanceFromKeyboardAppearance(KeyboardAppea return UIKeyboardAppearanceDark; } } -#endif // [macOS] -#if !TARGET_OS_OSX // [macOS] UITextSpellCheckingType RCTUITextSpellCheckingTypeFromOptionalBool(std::optional spellCheck) { return spellCheck.has_value() ? (*spellCheck ? UITextSpellCheckingTypeYes : UITextSpellCheckingTypeNo) diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/primitives.h b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/primitives.h index a6d703106d8967..92a13cf4c157cc 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/primitives.h +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/primitives.h @@ -89,6 +89,16 @@ enum class PastedTypesType { Image, String, }; + +class SubmitKeyEvent final { + public: + std::string key{}; + bool altKey{false}; + bool shiftKey{false}; + bool ctrlKey{false}; + bool metaKey{false}; + bool functionKey{false}; +}; #endif // macOS] /* @@ -231,7 +241,7 @@ class TextInputTraits final { #if TARGET_OS_OSX // [macOS /* * Can be empty (`null` in JavaScript) which means `default`. - * maOS + * macOS * Default value: `empty` (`null`). */ std::optional grammarCheck{}; @@ -242,6 +252,20 @@ class TextInputTraits final { * Default value: `empty list` */ std::vector pastedTypes{}; + + /* + * List of key combinations that should submit. + * macOS-only + * Default value: `empty list` applies as 'Enter' key. + */ + std::vector submitKeyEvents{}; + + /* + * When set to `true`, the text will be cleared after the submit. + * macOS-only + * Default value: `false` + */ + bool clearTextOnSubmit{false}; #endif // macOS] }; diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/propsConversions.h b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/propsConversions.h index 0cb3bfc1ac3c10..47c96c8dac13f0 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/propsConversions.h +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/ios/react/renderer/components/iostextinput/propsConversions.h @@ -194,4 +194,50 @@ inline void fromRawValue( LOG(ERROR) << "Unsupported Selection type"; } } + +#if TARGET_OS_OSX // [macOS +inline void fromRawValue( + const PropsParserContext &context, + const RawValue& value, + SubmitKeyEvent& result) { + auto map = (std::unordered_map)value; + + auto tmp_key = map.find("key"); + if (tmp_key != map.end()) { + fromRawValue(context, tmp_key->second, result.key); + } + auto tmp_altKey = map.find("altKey"); + if (tmp_altKey != map.end()) { + fromRawValue(context, tmp_altKey->second, result.altKey); + } + auto tmp_shiftKey = map.find("shiftKey"); + if (tmp_shiftKey != map.end()) { + fromRawValue(context, tmp_shiftKey->second, result.shiftKey); + } + auto tmp_ctrlKey = map.find("ctrlKey"); + if (tmp_ctrlKey != map.end()) { + fromRawValue(context, tmp_ctrlKey->second, result.ctrlKey); + } + auto tmp_metaKey = map.find("metaKey"); + if (tmp_metaKey != map.end()) { + fromRawValue(context, tmp_metaKey->second, result.metaKey); + } + auto tmp_functionKey = map.find("functionKey"); + if (tmp_functionKey != map.end()) { + fromRawValue(context, tmp_functionKey->second, result.functionKey); + } +} + +inline void fromRawValue( + const PropsParserContext& context, + const RawValue& value, + std::vector& result) { + auto items = (std::vector)value; + for (const auto &item : items) { + SubmitKeyEvent newItem; + fromRawValue(context, item, newItem); + result.emplace_back(newItem); + } +} +#endif // macOS] } // namespace facebook::react