Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Draft] Add support for keyDownEvents and keyUpEvents #1615

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 21 additions & 2 deletions Libraries/Components/Pressable/Pressable.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,17 @@ type Props = $ReadOnly<{|
onBlur?: ?(event: BlurEvent) => void,

/**
* Called after a key down event is detected.
* Fired when a key is pressed. If validKeysDown is set, only those keys will fire this event.
*
* @platform macos
*/
onKeyDown?: ?(event: KeyEvent) => void,

/**
* Called after a key up event is detected.
* Array of keyboard events whose natiev handling should be supressed. Use with `onKeyDown`
* to handle a keyboard event purely in JS.
*
* @platform macos
*/
onKeyUp?: ?(event: KeyEvent) => void,

Expand All @@ -208,6 +213,20 @@ type Props = $ReadOnly<{|
*/
validKeysUp?: ?Array<string | HandledKeyboardEvent>,

/**
* Array of keyboard events who should have their default native behavior prevented.
*
* @platform macos
*/
keyUpEvents?: ?$ReadOnlyArray<HandledKeyboardEvent>,

/**
* Array of keyboard events who should have their default native behavior prevented.
*
* @platform macos
*/
keyUpEvents?: ?$ReadOnlyArray<HandledKeyboardEvent>,

/**
* Specifies whether the view should receive the mouse down event when the
* containing window is in the background.
Expand Down
2 changes: 2 additions & 0 deletions Libraries/Components/View/ReactNativeViewAttributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const UIView = {
passthroughAllKeyEvents: true,
validKeysDown: true,
validKeysUp: true,
keyDownEvents: true,
keyUpEvents: true,
draggedTypes: true,
// macOS]
};
Expand Down
9 changes: 9 additions & 0 deletions Libraries/Components/View/ViewPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,15 @@ type IOSViewProps = $ReadOnly<{|
|}>;

// [macOS
type HandledKeyboardEvent = $ReadOnly<{|
altKey?: ?boolean,
ctrlKey?: ?boolean,
metaKey?: ?boolean,
shiftKey?: ?boolean,
code: string,
handledEventPhase?: number,
|}>;

type MacOSViewProps = $ReadOnly<{|
/**
* Fired when a file is dragged into the view via the mouse.
Expand Down
21 changes: 7 additions & 14 deletions Libraries/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -1197,24 +1197,17 @@ export default class VirtualizedList extends StateSafePureComponent<
this.props.onPreferredScrollerStyleDidChange;
const invertedDidChange = this.props.onInvertedDidChange;

const isFirstRowSelected =
this.state.selectedRowIndex === this.state.cellsAroundViewport.first;
const isLastRowSelected =
this.state.selectedRowIndex === this.state.cellsAroundViewport.last;

// Don't pass in ArrowUp/ArrowDown at the top/bottom of the list so that keyboard event can bubble
let _validKeysDown = ['Home', 'End'];
if (!isFirstRowSelected) {
_validKeysDown.push('ArrowUp');
}
if (!isLastRowSelected) {
_validKeysDown.push('ArrowDown');
}
let _keyDownEvents = [
{key: 'Home'},
{key: 'End'},
{key: 'ArrowDown'},
{key: 'ArrowUp'},
];

const keyboardNavigationProps = {
focusable: true,
validKeysDown: _validKeysDown,
onKeyDown: this._handleKeyDown,
keyDownEvents: _keyDownEvents,
};

// macOS]
Expand Down
29 changes: 23 additions & 6 deletions Libraries/NativeComponent/BaseViewConfig.macos.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,24 @@ import type {PartialViewConfigWithoutName} from './PlatformBaseViewConfig';
/* $FlowFixMe allow macOS to share iOS file */
import PlatformBaseViewConfigIos from './BaseViewConfig.ios';
import {ConditionallyIgnoredEventHandlers} from './ViewConfigIgnore';
import ReactNativeFeatureFlags from '../ReactNative/ReactNativeFeatureFlags';

const bubblingEventTypes = {
...PlatformBaseViewConfigIos.bubblingEventTypes,
...(ReactNativeFeatureFlags.enableCrossPlatformKeyboardEventAPI() && {
topKeyDown: {
phasedRegistrationNames: {
captured: 'onKeyDownCapture',
bubbled: 'onKeyDown',
},
},
topKeyUp: {
phasedRegistrationNames: {
captured: 'onKeyUpCapture',
bubbled: 'onKeyUp',
},
},
}),
};

const directEventTypes = {
Expand All @@ -31,18 +46,20 @@ const directEventTypes = {
topDrop: {
registrationName: 'onDrop',
},
topKeyUp: {
registrationName: 'onKeyUp',
},
topKeyDown: {
registrationName: 'onKeyDown',
},
topMouseEnter: {
registrationName: 'onMouseEnter',
},
topMouseLeave: {
registrationName: 'onMouseLeave',
},
...(!ReactNativeFeatureFlags.enableCrossPlatformKeyboardEventAPI() && {
topKeyUp: {
registrationName: 'onKeyUp',
},
topKeyDown: {
registrationName: 'onKeyDown',
},
}),
};

const validAttributesForNonEventProps = {
Expand Down
4 changes: 4 additions & 0 deletions Libraries/ReactNative/ReactNativeFeatureFlags.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export type FeatureFlags = {|
* components that are implemented in C++.
*/
enableCppComponents: () => boolean,

// [macOS]
enableCrossPlatformKeyboardEventAPI: () => boolean,
|};

const ReactNativeFeatureFlags: FeatureFlags = {
Expand All @@ -52,6 +55,7 @@ const ReactNativeFeatureFlags: FeatureFlags = {
animatedShouldDebounceQueueFlush: () => false,
animatedShouldUseSingleOp: () => false,
enableCppComponents: () => false,
enableCrossPlatformKeyboardEventAPI: () => false, // [macOS]
};

module.exports = ReactNativeFeatureFlags;
7 changes: 7 additions & 0 deletions React/Base/RCTConstants.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,10 @@ RCT_EXTERN void RCTSetMemoryPressureUnloadLevel(int value);
*/
RCT_EXTERN BOOL RCTGetParseUnhandledJSErrorStackNatively(void);
RCT_EXTERN void RCTSetParseUnhandledJSErrorStackNatively(BOOL value);

/**
* Align to the Keyboard API spec and use `key(Up|Down)Events` instead of `validKeys(Up|Down)`
* https://github.com/microsoft/react-native-windows/blob/main/vnext/proposals/active/keyboard-reconcile-desktop.md
*/
RCT_EXTERN BOOL RCTGetEnableCrossPlatformKeyboardEventAPI(void);
RCT_EXTERN void RCTSetEnableCrossPlatformKeyboardEventAPI(BOOL value);
15 changes: 15 additions & 0 deletions React/Base/RCTConstants.m
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,18 @@ void RCTSetParseUnhandledJSErrorStackNatively(BOOL value)
{
RCTParseUnhandledJSErrorStackNatively = value;
}

/**
* TODO
*/
static BOOL RCTEnableCrossPlatformKeyboardEventAPI = NO;

BOOL RCTGetEnableCrossPlatformKeyboardEventAPI()
{
return RCTEnableCrossPlatformKeyboardEventAPI;
}

void RCTSetEnableCrossPlatformKeyboardEventAPI(BOOL value)
{
RCTEnableCrossPlatformKeyboardEventAPI = value;
}
24 changes: 19 additions & 5 deletions React/Views/RCTView.h
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,29 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait;
@property (nonatomic, copy) RCTDirectEventBlock onDrop;

// Keyboarding events
// NOTE does not properly work with single line text inputs (most key downs). This is because those are
// presumably handled by the window's field editor. To make it work, we'd need to look into providing
// a custom field editor for NSTextField controls.

@property (nonatomic, assign) BOOL passthroughAllKeyEvents;
@property (nonatomic, copy) RCTDirectEventBlock onKeyDown;
@property (nonatomic, copy) RCTDirectEventBlock onKeyUp;
@property (nonatomic, copy) NSArray<RCTHandledKey*> *validKeysDown;
@property (nonatomic, copy) NSArray<RCTHandledKey*> *validKeysUp;

@property (nonatomic, copy) NSArray<RCTHandledKey *> *keyDownEvents;
@property (nonatomic, copy) NSArray<RCTHandledKey *> *keyUpEvents;

/**
* Note: does not properly work with single line text inputs (most key downs). This is because those are
* presumably handled by the window's field editor. To make it work, we'd need to look into providing
* a custom field editor for NSTextField controls.
*/

/**
* Note 2: `RCTDirectEventBlock` and `RCTBubblingEventBlock` are both typedefs for the same thing. The distinction is actually made in
* the JS ViewConfig. So it's safe to specify `onKeyDown/onKeyUp` as RCTBubblingEventBlock here, even though they are direct events
* when used in conjunction with `validKeysDown/validKeysUp
*/

@property (nonatomic, copy) RCTBubblingEventBlock onKeyDown;
@property (nonatomic, copy) RCTBubblingEventBlock onKeyUp;

// Shadow Properties
@property (nonatomic, strong) NSColor *shadowColor;
@property (nonatomic, assign) CGFloat shadowOpacity;
Expand Down
97 changes: 73 additions & 24 deletions React/Views/RCTView.m
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
#import "RCTViewKeyboardEvent.h"
#if TARGET_OS_OSX // [macOS
#import "RCTTextView.h"
#import <React/RCTSinglelineTextInputView.h>
#import <React/RCTMultilineTextInputView.h>
#import <React/RCTUITextField.h>

#endif // macOS]

RCT_MOCK_DEF(RCTView, RCTContentInsets);
Expand Down Expand Up @@ -1710,62 +1714,107 @@ - (BOOL)performDragOperation:(id <NSDraggingInfo>)sender
return dict;
}

- (RCTViewKeyboardEvent*)keyboardEvent:(NSEvent*)event shouldBlock:(BOOL *)shouldBlock {
/// Use the legacy validKeysDown/validKeysUp/passthroughAllKeyEvents API.
/// Returns a BOOL of whether to prevent the default behavior.
/// the NSEvent is handled natively normally ( A.K.A: sent up the NSResponder chain ) unless `validKeysDown` or `validKeysUp`
/// contains the event. This prevents the default native behavior and sends the event to JS as a direct event.
/// The prop `passthroughAllKeyEvents` can be used to pass through all keyboard events to JS unconditionally. It does not, however,
/// have any effect on whether the default native behavior is prevented.
- (BOOL)handleKeyboardEventLegacy:(NSEvent *)event {
BOOL keyDown = event.type == NSEventTypeKeyDown;
NSArray<RCTHandledKey *> *validKeys = keyDown ? self.validKeysDown : self.validKeysUp;

NSArray<RCTHandledKey *> *keyEventsToBlock = keyDown ? [self validKeysDown] : [self validKeysUp];
// If the view is focusable and the component didn't explicity set the validKeysDown or validKeysUp,
// allow enter/return and spacebar key events to mimic the behavior of native controls.
if (self.focusable && validKeys == nil) {
validKeys = @[
if ([self focusable] && keyEventsToBlock == nil) {
keyEventsToBlock = @[
[[RCTHandledKey alloc] initWithKey:@"Enter"],
[[RCTHandledKey alloc] initWithKey:@" "]
];
}

// If a view specifies a key, it will always be removed from the responder chain (i.e. "handled")
*shouldBlock = [RCTHandledKey event:event matchesFilter:validKeys];
BOOL eventMatchesFilter = [RCTHandledKey event:event matchesFilter:keyEventsToBlock];

// If an event isn't being removed from the queue, but was requested to "passthrough" by a view,
BOOL shouldDispatchEvent = eventMatchesFilter;
// If an event isn't having it's native behavior prevented, but was requested to "passthrough" by a view,
// we want to be sure we dispatch it only once for that view. See note for GetEventDispatchStateDictionary.
if ([self passthroughAllKeyEvents] && !*shouldBlock) {
if ([self passthroughAllKeyEvents] && !eventMatchesFilter) {
NSNumber *tag = [self reactTag];
NSMutableDictionary<NSNumber *, NSNumber *> *dict = GetEventDispatchStateDictionary(event);

if ([dict[tag] boolValue]) {
return nil;
}
shouldDispatchEvent = NO;
} else {
shouldDispatchEvent = YES;
dict[tag] = @YES;
}
}

dict[tag] = @YES;
if (shouldDispatchEvent) {
RCTViewKeyboardEvent *keyboardEvent = [RCTViewKeyboardEvent keyEventFromEvent:event reactTag:self.reactTag];
[_eventDispatcher sendEvent:keyboardEvent];
}

// Don't pass events we don't care about
if (![self passthroughAllKeyEvents] && !*shouldBlock) {
return nil;
return eventMatchesFilter;
}

/// Use the cross platform `keyDownEvents/keyUpEvents` API to handle keyboard events:
/// All keyboard events are sent to JS unconditionally, and are bubbling events. The NSEvent is handled natively normally
/// ( A.K.A: sent up the NSResponder chain ) unless `keyFownEvents` or `keyUpEvents` is specified.
- (BOOL)handleKeyboardEventModern:(NSEvent*)event {
BOOL keyDown = event.type == NSEventTypeKeyDown;
NSArray<RCTHandledKey *> *keyEventsToBlock = keyDown ? [self keyDownEvents] : [self keyUpEvents];


// To ensure we only dispatch one keyboard event to JS, only dispatch it if we are the first responder.
BOOL isFirstResponder = self == [[self window] firstResponder];
if ([self isKindOfClass:[RCTSinglelineTextInputView class]]) {
isFirstResponder = [(RCTUITextField *)[(RCTSinglelineTextInputView *)self backedTextInputView] currentEditor] == [[self window] firstResponder];
}
if ([self isKindOfClass:[RCTMultilineTextInputView class]]) {
isFirstResponder = [(RCTSinglelineTextInputView *)self backedTextInputView] == [[self window] firstResponder];
}
if (isFirstResponder) {
RCTViewKeyboardEvent *keyboardEvent = [RCTViewKeyboardEvent keyEventFromEvent:event reactTag:self.reactTag];
[_eventDispatcher sendEvent:keyboardEvent];
}

BOOL eventMatchesFilter = [RCTHandledKey event:event matchesFilter:keyEventsToBlock];

return [RCTViewKeyboardEvent keyEventFromEvent:event reactTag:self.reactTag];
return eventMatchesFilter;
}

/// This method will dispatch the keyboard event to JS if needed, based on the current Feature Flag.
/// Returns: a boolean indicating whether we prevent the default native behavior (aka: calling super ).
- (BOOL)handleKeyboardEvent:(NSEvent *)event {
if (event.type == NSEventTypeKeyDown ? self.onKeyDown : self.onKeyUp) {
BOOL shouldBlock = YES;
RCTViewKeyboardEvent *keyboardEvent = [self keyboardEvent:event shouldBlock:&shouldBlock];
if (keyboardEvent) {
[_eventDispatcher sendEvent:keyboardEvent];
return shouldBlock;
}
BOOL shouldUseCrossPlatformKeyboardEventAPI = RCTGetEnableCrossPlatformKeyboardEventAPI();
BOOL shouldPreventNativeBehavior = NO;

if (shouldUseCrossPlatformKeyboardEventAPI) {
shouldPreventNativeBehavior = [self handleKeyboardEventModern:event];
} else {
shouldPreventNativeBehavior = [self handleKeyboardEventLegacy:event];
}
return NO;

return shouldPreventNativeBehavior;
}

- (void)keyDown:(NSEvent *)event {
// Ignore "dead keys" (key press that waits for another key to make a character)
if (!event.charactersIgnoringModifiers.length) {
[super keyDown:event];
}

if (![self handleKeyboardEvent:event]) {
[super keyDown:event];
}
}

- (void)keyUp:(NSEvent *)event {
// Ignore "dead keys" (key press that waits for another key to make a character)
if (!event.charactersIgnoringModifiers.length) {
[super keyUp:event];
}

if (![self handleKeyboardEvent:event]) {
[super keyUp:event];
}
Expand Down
11 changes: 7 additions & 4 deletions React/Views/RCTViewKeyboardEvent.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
#import <Foundation/Foundation.h>
#import <React/RCTComponentEvent.h>

#if TARGET_OS_OSX // [macOS

@interface RCTViewKeyboardEvent : RCTComponentEvent

#if TARGET_OS_OSX // [macOS
+ (NSDictionary *)bodyFromEvent:(NSEvent *)event;
+ (NSString *)keyFromEvent:(NSEvent *)event;
+ (instancetype)keyEventFromEvent:(NSEvent *)event reactTag:(NSNumber *)reactTag;
#endif // macOS]
+ (NSString *)keyFromEvent:(NSEvent *)event;
+ (NSDictionary *)bodyFromEvent:(NSEvent *)event;


@end

#endif // macOS]