diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.js b/packages/react-native/Libraries/Components/Pressable/Pressable.js index ece22fb83dcc..72266bd2fa7e 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.js +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.js @@ -381,7 +381,14 @@ function Pressable({ // [macOS acceptsFirstMouse: acceptsFirstMouse !== false && !disabled, enableFocusRing: enableFocusRing !== false && !disabled, - keyDownEvents: keyDownEvents ?? [{key: ' '}, {key: 'Enter'}], + keyDownEvents: + keyDownEvents ?? + // $FlowFixMe[unclear-type] Legacy props not in type definitions + (((props: any).validKeysDown: mixed) == null && + // $FlowFixMe[unclear-type] + ((props: any).passthroughAllKeyEvents: mixed) !== true + ? [{key: ' '}, {key: 'Enter'}] + : undefined), mouseDownCanMoveWindow: false, // macOS] }; diff --git a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js index 1c24dfa93f82..253c2a49e4ed 100644 --- a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -52,6 +52,18 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = { captured: 'onSubmitEditingCapture', }, }, + topKeyDown: { + phasedRegistrationNames: { + bubbled: 'onKeyDown', + captured: 'onKeyDownCapture', + }, + }, + topKeyUp: { + phasedRegistrationNames: { + bubbled: 'onKeyUp', + captured: 'onKeyUpCapture', + }, + }, topTouchCancel: { phasedRegistrationNames: { bubbled: 'onTouchCancel', @@ -173,6 +185,8 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = { clearTextOnSubmit: true, grammarCheck: true, hideVerticalScrollIndicator: true, + keyDownEvents: true, + keyUpEvents: true, pastedTypes: true, submitKeyEvents: true, tooltip: true, @@ -191,6 +205,8 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = { onAutoCorrectChange: true, onSpellCheckChange: true, onGrammarCheckChange: true, + onKeyDown: true, + onKeyUp: true, // macOS] }), disableKeyboardShortcuts: true, diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 610d16504866..3531ac22fd20 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -61,6 +61,12 @@ import StyleSheet, {type TextStyleProp} from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; import TextAncestorContext from '../../Text/TextAncestorContext'; import Platform from '../../Utilities/Platform'; +// [macOS +import processLegacyKeyProps, { + hasLegacyKeyProps, + stripLegacyKeyProps, +} from '../../Utilities/normalizeLegacyHandledKeyEvents'; +// macOS] import useMergeRefs from '../../Utilities/useMergeRefs'; import TextInputState from './TextInputState'; import invariant from 'invariant'; @@ -386,6 +392,23 @@ function useTextInputStateSynchronization({ * */ function InternalTextInput(props: TextInputProps): React.Node { + // [macOS Legacy keyboard event compat — to remove, delete this block and its import + const usingLegacyKeyboardProps = hasLegacyKeyProps(props); + // $FlowFixMe[unclear-type] + const effectiveProps: any = usingLegacyKeyboardProps ? ({...props}: any) : props; + if (usingLegacyKeyboardProps) { + stripLegacyKeyProps(effectiveProps); + const legacy = processLegacyKeyProps(props); + effectiveProps.keyDownEvents = legacy.keyDownEvents; + effectiveProps.keyUpEvents = legacy.keyUpEvents; + if (legacy.onKeyDown != null) { + effectiveProps.onKeyDown = legacy.onKeyDown; + } + if (legacy.onKeyUp != null) { + effectiveProps.onKeyUp = legacy.onKeyUp; + } + } + // macOS] const { 'aria-busy': ariaBusy, 'aria-checked': ariaChecked, @@ -400,7 +423,7 @@ function InternalTextInput(props: TextInputProps): React.Node { selectionHandleColor, cursorColor, ...otherProps - } = props; + } = effectiveProps; const inputRef = useRef(null); @@ -582,7 +605,7 @@ function InternalTextInput(props: TextInputProps): React.Node { // [macOS const _onKeyDown = (event: KeyEvent) => { - const keyDownEvents = props.keyDownEvents; + const keyDownEvents = effectiveProps.keyDownEvents; if (keyDownEvents != null && !event.isPropagationStopped()) { const isHandled = keyDownEvents.some( ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { @@ -599,11 +622,11 @@ function InternalTextInput(props: TextInputProps): React.Node { event.stopPropagation(); } } - props.onKeyDown?.(event); + effectiveProps.onKeyDown?.(event); }; const _onKeyUp = (event: KeyEvent) => { - const keyUpEvents = props.keyUpEvents; + const keyUpEvents = effectiveProps.keyUpEvents; if (keyUpEvents != null && !event.isPropagationStopped()) { const isHandled = keyUpEvents.some( ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { @@ -620,7 +643,7 @@ function InternalTextInput(props: TextInputProps): React.Node { event.stopPropagation(); } } - props.onKeyUp?.(event); + effectiveProps.onKeyUp?.(event); }; // macOS] diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index a3049473a04c..7f8616010109 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -13,6 +13,12 @@ import type {ViewProps} from './ViewPropTypes'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import TextAncestorContext from '../../Text/TextAncestorContext'; +// [macOS +import processLegacyKeyProps, { + hasLegacyKeyProps, + stripLegacyKeyProps, +} from '../../Utilities/normalizeLegacyHandledKeyEvents'; +// macOS] import ViewNativeComponent from './ViewNativeComponent'; import * as React from 'react'; import {use} from 'react'; @@ -30,11 +36,29 @@ export default component View( ) { const hasTextAncestor = use(TextAncestorContext); + // [macOS Legacy keyboard event compat — to remove, delete this block and its import + const usingLegacyKeyboardProps = hasLegacyKeyProps(props); + // $FlowFixMe[unclear-type] + const effectiveProps: any = usingLegacyKeyboardProps ? ({...props}: any) : props; + if (usingLegacyKeyboardProps) { + stripLegacyKeyProps(effectiveProps); + const legacy = processLegacyKeyProps(props); + effectiveProps.keyDownEvents = legacy.keyDownEvents; + effectiveProps.keyUpEvents = legacy.keyUpEvents; + if (legacy.onKeyDown != null) { + effectiveProps.onKeyDown = legacy.onKeyDown; + } + if (legacy.onKeyUp != null) { + effectiveProps.onKeyUp = legacy.onKeyUp; + } + } + // macOS] + let actualView; // [macOS const _onKeyDown = (event: KeyEvent) => { - const keyDownEvents = props.keyDownEvents; + const keyDownEvents = effectiveProps.keyDownEvents; if (keyDownEvents != null && !event.isPropagationStopped()) { const isHandled = keyDownEvents.some( ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { @@ -51,11 +75,11 @@ export default component View( event.stopPropagation(); } } - props.onKeyDown?.(event); + effectiveProps.onKeyDown?.(event); }; const _onKeyUp = (event: KeyEvent) => { - const keyUpEvents = props.keyUpEvents; + const keyUpEvents = effectiveProps.keyUpEvents; if (keyUpEvents != null && !event.isPropagationStopped()) { const isHandled = keyUpEvents.some( ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => { @@ -72,7 +96,7 @@ export default component View( event.stopPropagation(); } } - props.onKeyUp?.(event); + effectiveProps.onKeyUp?.(event); }; // macOS] @@ -96,7 +120,7 @@ export default component View( id, tabIndex, ...otherProps - } = props; + } = effectiveProps; // Since we destructured props, we can now treat it as mutable const processedProps = otherProps as {...ViewProps}; @@ -195,7 +219,7 @@ export default component View( nativeID, tabIndex, ...otherProps - } = props; + } = effectiveProps; const _accessibilityLabelledBy = ariaLabelledBy?.split(/\s*,\s*/g) ?? accessibilityLabelledBy; diff --git a/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js new file mode 100644 index 000000000000..9a6a6e8a5a9b --- /dev/null +++ b/packages/react-native/Libraries/Utilities/normalizeLegacyHandledKeyEvents.js @@ -0,0 +1,167 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +// [macOS] +// Legacy validKeysDown/validKeysUp/passthroughAllKeyEvents compat layer. +// When removing legacy support, delete this file and its call sites. + +import type {HandledKeyEvent, KeyEvent} from '../Types/CoreEventTypes'; + +type LegacyHandledKeyEvent = string | HandledKeyEvent; + +function expandKey(entry: LegacyHandledKeyEvent): Array { + if (typeof entry !== 'string') { + return [entry]; + } + const out: Array = []; + const bools: Array = [false, true]; + for (const metaKey of bools) { + for (const ctrlKey of bools) { + for (const altKey of bools) { + for (const shiftKey of bools) { + out.push({altKey, ctrlKey, key: entry, metaKey, shiftKey}); + } + } + } + } + return out; +} + +function normalize( + legacy: ?$ReadOnlyArray, +): void | Array { + if (legacy == null) { + return undefined; + } + const result: Array = []; + for (const entry of legacy) { + result.push(...expandKey(entry)); + } + return result; +} + +function matchesEvent( + events: $ReadOnlyArray, + event: KeyEvent, +): boolean { + return events.some( + ({key, metaKey, ctrlKey, altKey, shiftKey}: HandledKeyEvent) => + event.nativeEvent.key === key && + Boolean(metaKey) === event.nativeEvent.metaKey && + Boolean(ctrlKey) === event.nativeEvent.ctrlKey && + Boolean(altKey) === event.nativeEvent.altKey && + Boolean(shiftKey) === event.nativeEvent.shiftKey, + ); +} + +export type LegacyKeyResult = { + keyDownEvents: void | Array, + keyUpEvents: void | Array, + onKeyDown: void | ((event: KeyEvent) => void), + onKeyUp: void | ((event: KeyEvent) => void), +}; + +/** + * Returns true if the props contain legacy key props that need processing. + */ +export function hasLegacyKeyProps(props: mixed): boolean { + // $FlowFixMe[unclear-type] + const p = (props: any); + return ( + p.validKeysDown != null || + p.validKeysUp != null || + p.passthroughAllKeyEvents != null + ); +} + +/** + * Strips legacy props from a props object (mutates). + */ +export function stripLegacyKeyProps(props: {+[string]: mixed}): void { + // $FlowFixMe[unclear-type] + const p = (props: any); + delete p.validKeysDown; + delete p.validKeysUp; + delete p.passthroughAllKeyEvents; +} + +/** + * Processes legacy validKeysDown/validKeysUp/passthroughAllKeyEvents props + * and returns the equivalent modern keyDownEvents/keyUpEvents and wrapped + * onKeyDown/onKeyUp handlers. + * + * Usage in component: + * if (hasLegacyKeyProps(props)) { + * const legacy = processLegacyKeyProps(props); + * // use legacy.keyDownEvents, legacy.onKeyDown, etc. + * } + */ +export default function processLegacyKeyProps( + // $FlowFixMe[unclear-type] + props: any, +): LegacyKeyResult { + const validKeysDown: ?$ReadOnlyArray = + props.validKeysDown; + const validKeysUp: ?$ReadOnlyArray = + props.validKeysUp; + const passthroughAllKeyEvents: ?boolean = props.passthroughAllKeyEvents; + + const hasModernKeyDown = props.keyDownEvents != null; + const hasModernKeyUp = props.keyUpEvents != null; + const legacyPassthrough = + passthroughAllKeyEvents === true && !hasModernKeyDown; + + const gateKeyDown = + !hasModernKeyDown && validKeysDown != null && !legacyPassthrough; + const gateKeyUp = + !hasModernKeyUp && validKeysUp != null && !legacyPassthrough; + + const normalizedDown = + props.keyDownEvents ?? normalize(validKeysDown); + const normalizedUp = + props.keyUpEvents ?? normalize(validKeysUp); + + const keyDownEvents = legacyPassthrough ? undefined : normalizedDown; + const keyUpEvents = legacyPassthrough ? undefined : normalizedUp; + + const onKeyDown = + props.onKeyDown != null + ? (event: KeyEvent) => { + let isHandled = false; + if (normalizedDown != null && !event.isPropagationStopped()) { + isHandled = matchesEvent(normalizedDown, event); + if (isHandled && hasModernKeyDown) { + event.stopPropagation(); + } + } + if (!gateKeyDown || isHandled) { + props.onKeyDown?.(event); + } + } + : undefined; + + const onKeyUp = + props.onKeyUp != null + ? (event: KeyEvent) => { + let isHandled = false; + if (normalizedUp != null && !event.isPropagationStopped()) { + isHandled = matchesEvent(normalizedUp, event); + if (isHandled && hasModernKeyUp) { + event.stopPropagation(); + } + } + if (!gateKeyUp || isHandled) { + props.onKeyUp?.(event); + } + } + : undefined; + + return {keyDownEvents, keyUpEvents, onKeyDown, onKeyUp}; +} diff --git a/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js b/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js index 6a5024e836b4..fea414741b00 100644 --- a/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js +++ b/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js @@ -293,6 +293,101 @@ function KeyboardEventExample(): React.Node { ); } +/** + * Tests legacy validKeysDown/validKeysUp compat layer. + * Exercises the JS shim that translates legacy props to modern keyDownEvents. + */ +function LegacyValidKeysExample(): React.Node { + const ref1 = React.useRef | null>(null); + const ref2 = React.useRef | null>(null); + const ref3 = React.useRef | null>(null); + const [eventLog, setEventLog] = React.useState>([]); + + function appendEvent(eventName: string, source?: string) { + const limit = 12; + setEventLog((current: Array) => { + const prefix = source != null ? `${source}: ` : ''; + return [`${prefix}${eventName}`].concat(current.slice(0, limit - 1)); + }); + } + + return ( + + + These components use the legacy validKeysDown / validKeysUp props. The JS + compat layer converts them to modern keyDownEvents / keyUpEvents under + the hood. + + + + + validKeysDown: ['a', 'b', 'Enter'] (string format) + + {/* $FlowFixMe[prop-missing] Legacy props not in type definitions */} + ref1.current?.focus()} + onKeyDown={(event: KeyEvent) => { + appendEvent(`keyDown: ${formatKeyEvent(event)}`, 'String keys'); + }}> + + Click to focus — press 'a', 'b', or Enter + + + + + + + validKeysDown: [Cmd+s, Ctrl+z] (object format) + + {/* $FlowFixMe[prop-missing] Legacy props not in type definitions */} + ref2.current?.focus()} + onKeyDown={(event: KeyEvent) => { + appendEvent(`keyDown: ${formatKeyEvent(event)}`, 'Modifier keys'); + }}> + + Click to focus — press Cmd+S or Ctrl+Z + + + + + + + passthroughAllKeyEvents + validKeysDown: ['Enter'] + + {/* $FlowFixMe[prop-missing] Legacy props not in type definitions */} + ref3.current?.focus()} + onKeyDown={(event: KeyEvent) => { + appendEvent(`keyDown: ${formatKeyEvent(event)}`, 'Passthrough'); + }}> + + Click to focus — ALL keys should log + + + + + setEventLog([])} /> + + ); +} + const styles = StyleSheet.create({ eventLogBox: { padding: 10, @@ -468,4 +563,10 @@ exports.examples = [ return ; }, }, + { + title: 'Legacy validKeysDown / validKeysUp compat', + render: function (): React.Node { + return ; + }, + }, ];