diff --git a/.changeset/macos-key-events.md b/.changeset/macos-key-events.md new file mode 100644 index 00000000000..15ee5d6237e --- /dev/null +++ b/.changeset/macos-key-events.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/interactive-hooks': patch +--- + +On react-native-macos 0.78 and later, `useKeyProps`, `useKeyDownProps`, and `useKeyUpProps` emit `keyDownEvents` and `keyUpEvents`, the same as on win32, instead of the deprecated `validKeysDown` and `validKeysUp`. react-native-macos 0.73 and 0.74 still get the legacy props. diff --git a/.changeset/macos-legacy-key-props.md b/.changeset/macos-legacy-key-props.md new file mode 100644 index 00000000000..b2855b66503 --- /dev/null +++ b/.changeset/macos-legacy-key-props.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/adapters': patch +--- + +Mark the macOS view props `passthroughAllKeyEvents`, `validKeysDown`, and `validKeysUp` as deprecated. Only react-native-macos versions earlier than 0.78 support them. diff --git a/packages/utils/adapters/src/platformProps.ts b/packages/utils/adapters/src/platformProps.ts index 860c06058c3..dd53cf70360 100644 --- a/packages/utils/adapters/src/platformProps.ts +++ b/packages/utils/adapters/src/platformProps.ts @@ -252,10 +252,13 @@ export interface ViewAdditionsMacOS { onMouseEnter?: IMouseEventHandler; onMouseLeave?: IMouseEventHandler; onPreferredScrollerStyleDidChange?: (event: NativeSyntheticEvent) => void; + /** @deprecated Only react-native-macos < 0.78 supports this prop. Use `keyDownEvents` and `keyUpEvents`. */ passthroughAllKeyEvents?: boolean; tooltip?: string; // macOS `validKeysDown`/`validKeysUp` are arrays of key names (e.g. "Enter", " "), not key events. + /** @deprecated Only react-native-macos < 0.78 supports this prop. Use `keyDownEvents`. */ validKeysDown?: string[]; + /** @deprecated Only react-native-macos < 0.78 supports this prop. Use `keyUpEvents`. */ validKeysUp?: string[]; } diff --git a/packages/utils/interactive-hooks/src/__tests__/useKeyProps.test.tsx b/packages/utils/interactive-hooks/src/__tests__/useKeyProps.test.tsx index 4843a87414f..cb64f96345f 100644 --- a/packages/utils/interactive-hooks/src/__tests__/useKeyProps.test.tsx +++ b/packages/utils/interactive-hooks/src/__tests__/useKeyProps.test.tsx @@ -1,9 +1,10 @@ import { act } from 'react'; -import { Pressable } from 'react-native'; +import { Platform, Pressable } from 'react-native'; import * as renderer from 'react-test-renderer'; -import { useKeyProps } from '../useKeyProps'; +import { useKeyDownProps, useKeyProps, useKeyUpProps } from '../useKeyProps'; +import type { KeyPressEvent } from '../useKeyProps.types'; import type { PressablePropsExtended } from '../usePressableState.types'; const dummyFunction = () => { @@ -42,3 +43,69 @@ it('useKeyProps called twice', () => { }); expect(component!.toJSON()).toMatchSnapshot(); }); + +describe.each([ + { direction: 'down', useKeyEventProps: useKeyDownProps, onKeyEvent: 'onKeyDown', keyEvents: 'keyDownEvents', validKeys: 'validKeysDown' }, + { direction: 'up', useKeyEventProps: useKeyUpProps, onKeyEvent: 'onKeyUp', keyEvents: 'keyUpEvents', validKeys: 'validKeysUp' }, +] as const)('key $direction props', ({ useKeyEventProps, onKeyEvent, keyEvents, validKeys }) => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // `memoize` caches the props for each set of arguments, so each call uses a new callback. + function getKeyEventProps(os: string, reactNativeMinorVersion: number) { + jest.replaceProperty(Platform, 'OS', os as typeof Platform.OS); + jest.spyOn(Platform, 'constants', 'get').mockReturnValue({ + isTesting: true, + reactNativeVersion: { major: 0, minor: reactNativeMinorVersion, patch: 0 }, + }); + const callback = jest.fn(); + return { callback, props: useKeyEventProps(callback, ' ', 'Enter') }; + } + + it.each([78, 81])(`uses ${keyEvents} on react-native-macos 0.%i`, (minor) => { + expect(getKeyEventProps('macos', minor).props).toStrictEqual({ + [onKeyEvent]: expect.any(Function), + [keyEvents]: [{ key: ' ' }, { key: 'Enter' }], + }); + }); + + it.each([73, 74])(`uses ${validKeys} on react-native-macos 0.%i`, (minor) => { + expect(getKeyEventProps('macos', minor).props).toStrictEqual({ + [onKeyEvent]: expect.any(Function), + [validKeys]: [' ', 'Enter'], + }); + }); + + it.each([74, 81])(`uses ${keyEvents} on win32 0.%i`, (minor) => { + expect(getKeyEventProps('win32', minor).props).toStrictEqual({ + [onKeyEvent]: expect.any(Function), + [keyEvents]: [{ key: ' ' }, { key: 'Enter' }], + }); + }); + + it(`uses only ${onKeyEvent} on react-native-windows`, () => { + expect(getKeyEventProps('windows', 81).props).toStrictEqual({ [onKeyEvent]: expect.any(Function) }); + }); + + it.each(['ios', 'android'])('returns undefined on %s', (os) => { + expect(getKeyEventProps(os, 81).props).toBeUndefined(); + }); + + it(`calls the callback from ${onKeyEvent} only for the listed keys`, () => { + const { callback, props } = getKeyEventProps('macos', 81); + const otherKey = { nativeEvent: { key: 'a' }, stopPropagation: jest.fn() }; + const enter = { nativeEvent: { key: 'Enter' }, stopPropagation: jest.fn() }; + props?.[onKeyEvent]?.(otherKey as unknown as KeyPressEvent); + props?.[onKeyEvent]?.(enter as unknown as KeyPressEvent); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(enter); + expect(otherKey.stopPropagation).not.toHaveBeenCalled(); + expect(enter.stopPropagation).toHaveBeenCalledTimes(1); + }); + + it('returns the same props for the same arguments', () => { + const { callback, props } = getKeyEventProps('macos', 81); + expect(useKeyEventProps(callback, ' ', 'Enter')).toBe(props); + }); +}); diff --git a/packages/utils/interactive-hooks/src/useKeyProps.ts b/packages/utils/interactive-hooks/src/useKeyProps.ts index 6cba4a31f42..0bb290184a7 100644 --- a/packages/utils/interactive-hooks/src/useKeyProps.ts +++ b/packages/utils/interactive-hooks/src/useKeyProps.ts @@ -41,58 +41,55 @@ function keyPressCallback(userCallback?: KeyCallback, ...keys: string[]) { return onKeyEvent; } -function getKeyUpPropsWorker(userCallback: KeyCallback, ...keys: string[]): KeyPressProps { - const keyboardProps = Platform.select({ - ios: undefined, - android: undefined, - macos: { - onKeyUp: keyPressCallback(userCallback, ...keys), - validKeysUp: keys, - }, - windows: { - /** - * https://github.com/microsoft/react-native-windows/issues/11049 - * Windows doesn't filter on `key` but on `code`, which is quite different ('A' vs 'KeyA' or 'GamepadA'). - * While this discrepancy is present, let's not specify `keyUpEvents`. - */ - onKeyUp: keyPressCallback(userCallback, ...keys), - }, - // win32 - default: { - onKeyUp: keyPressCallback(userCallback, ...keys), - keyUpEvents: keys.map((keyCode) => { - return { key: keyCode }; - }), - }, - }); - return keyboardProps; +/** + * react-native-macos 0.78 removed native support for `validKeysDown` and `validKeysUp`. From that version, + * react-native-macos uses `keyDownEvents` and `keyUpEvents`, the same props as win32. + */ +function usesLegacyMacOSKeyProps(): boolean { + return Platform.OS === 'macos' && Platform.constants.reactNativeVersion.minor < 78; } -function getKeyDownPropsWorker(userCallback: KeyCallback, ...keys: string[]): KeyPressProps { - const keyboardProps = Platform.select({ - ios: undefined, - android: undefined, - macos: { - onKeyDown: keyPressCallback(userCallback, ...keys), - validKeysDown: keys, - }, - windows: { - /** - * https://github.com/microsoft/react-native-windows/issues/11049 - * Windows doesn't filter on `key` but on `code`, which is quite different ('A' vs 'KeyA' or 'GamepadA'). - * While this discrepancy is present, let's not specify `keyDownEvents`. - */ - onKeyDown: keyPressCallback(userCallback, ...keys), - }, - // win32 - default: { - onKeyDown: keyPressCallback(userCallback, ...keys), - keyDownEvents: keys.map((keyCode) => { - return { key: keyCode }; - }), - }, - }); - return keyboardProps; +type KeyEventDirection = 'down' | 'up'; + +/** + * Gets the platform-specific props that call `userCallback` when one of `keys` goes down or up. + * The key down and key up hooks share this worker, so both directions use the same platform rules. + * Returns `undefined` on iOS and Android. + */ +function getKeyPropsWorker(direction: KeyEventDirection, userCallback: KeyCallback, keys: string[]): KeyPressProps | undefined { + if (Platform.OS === 'ios' || Platform.OS === 'android') { + return undefined; + } + + const onKeyEvent = keyPressCallback(userCallback, ...keys); + const isKeyDown = direction === 'down'; + + if (Platform.OS === 'windows') { + /** + * https://github.com/microsoft/react-native-windows/issues/11049 + * Windows doesn't filter on `key` but on `code`, which is quite different ('A' vs 'KeyA' or 'GamepadA'). + * While this discrepancy is present, let's not specify `keyDownEvents` or `keyUpEvents`. + */ + return isKeyDown ? { onKeyDown: onKeyEvent } : { onKeyUp: onKeyEvent }; + } + + if (usesLegacyMacOSKeyProps()) { + // react-native-macos 0.73 and 0.74 send JS only the key events that `validKeysDown` and `validKeysUp` list. + // Delete this branch when FURN drops support for react-native-macos < 0.78. + return isKeyDown ? { onKeyDown: onKeyEvent, validKeysDown: keys } : { onKeyUp: onKeyEvent, validKeysUp: keys }; + } + + // win32 and react-native-macos 0.78+ + const keyEvents = keys.map((key) => ({ key })); + return isKeyDown ? { onKeyDown: onKeyEvent, keyDownEvents: keyEvents } : { onKeyUp: onKeyEvent, keyUpEvents: keyEvents }; +} + +function getKeyUpPropsWorker(userCallback: KeyCallback, ...keys: string[]): KeyPressProps | undefined { + return getKeyPropsWorker('up', userCallback, keys); +} + +function getKeyDownPropsWorker(userCallback: KeyCallback, ...keys: string[]): KeyPressProps | undefined { + return getKeyPropsWorker('down', userCallback, keys); } /** diff --git a/packages/utils/interactive-hooks/src/useKeyProps.types.ts b/packages/utils/interactive-hooks/src/useKeyProps.types.ts index af309afeec7..fe6cc32af2b 100644 --- a/packages/utils/interactive-hooks/src/useKeyProps.types.ts +++ b/packages/utils/interactive-hooks/src/useKeyProps.types.ts @@ -3,9 +3,19 @@ export type { KeyEventHandler as KeyCallback, NativeKeyEvent as KeyPressEvent } export type KeyPressProps = { onKeyDown?: KeyEventHandler; - validKeysDown?: string[]; // macOS - keyDownEvents?: any[]; // windows + /** + * The keys that the view handles on key down. Only react-native-macos < 0.78 supports this prop. + * @deprecated Use `keyDownEvents`. react-native-macos 0.78 removed native support for `validKeysDown`. + */ + validKeysDown?: string[]; + /** The key events that the view handles on key down, for example `[{ key: 'Enter' }]`. Used on win32 and react-native-macos 0.78+. */ + keyDownEvents?: any[]; onKeyUp?: KeyEventHandler; - validKeysUp?: string[]; // macOS - keyUpEvents?: any[]; // windows + /** + * The keys that the view handles on key up. Only react-native-macos < 0.78 supports this prop. + * @deprecated Use `keyUpEvents`. react-native-macos 0.78 removed native support for `validKeysUp`. + */ + validKeysUp?: string[]; + /** The key events that the view handles on key up, for example `[{ key: 'Enter' }]`. Used on win32 and react-native-macos 0.78+. */ + keyUpEvents?: any[]; }; diff --git a/references/agents/focus/platform-evidence.md b/references/agents/focus/platform-evidence.md index 7193dd18130..f1783b67bb1 100644 --- a/references/agents/focus/platform-evidence.md +++ b/references/agents/focus/platform-evidence.md @@ -31,10 +31,17 @@ the pinned dependency changes. Installed-package paths are relative to that pack MenuItem focuses on hover as an owner-specific exception. Sources: `packages/utils/interactive-hooks/src/useOnPressWithFocus.ts:14-29`; RNmacOS `RCTViewComponentView.mm:1769-1779`; `packages/components/Menu/src/MenuItem/useMenuItem.ts:68-120`. Version: RNmacOS 0.81.9. Observed: 2026-09-14. -- **C-FOCUS-004:** V1 key helpers prefer key-down on macOS through `validKeysDown` and `validKeysUp`, while RNmacOS - native handling also exposes `keyDownEvents` and `keyUpEvents`. RNmacOS Pressability activates Return and Space on - key-down, and key-up supplies `onPressOut`. Sources: `packages/utils/interactive-hooks/src/useKeyProps.ts:8-95,114-126`; - RNmacOS `RCTViewComponentView.mm:1807-1861`. Version: RNmacOS 0.81.9. Observed: 2026-09-14. +- **C-FOCUS-004:** V1 key helpers prefer key-down on macOS. On RNmacOS 0.78 and later they declare handled keys through + `keyDownEvents` and `keyUpEvents`, the same path as Win32; only RNmacOS 0.73 and 0.74 still get the legacy + `validKeysDown` and `validKeysUp`. The installed native View emits every key event to JS and blocks `super` handling + only for a matching `keyDownEvents` or `keyUpEvents` entry. In the Fabric matcher, a modifier that the entry omits + matches any value, so `{ key: 'Enter' }` also matches Shift+Enter natively; Paper and the JS View matcher treat an + omitted modifier as false. RNmacOS Pressability activates Return and Space on key-down, and key-up supplies + `onPressOut`. Sources: `packages/utils/interactive-hooks/src/useKeyProps.ts:8-93,111-123`; RNmacOS + `RCTViewComponentView.mm:1807-1861`; + `ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/KeyEvent.h:105-111`; + `React/Views/RCTHandledKey.m:104-130`; `Libraries/Components/View/View.js:48-92`; + `Libraries/Pressability/Pressability.js:596-634`. Version: RNmacOS 0.81.9. Observed: 2026-10-15. - **C-FOCUS-005:** The macOS FocusZone translates key events, recalculates the key-view loop, and falls back to mounted descendant order when AppKit collapses a Fabric-backed zone to one key-loop entry. Its Fabric wrapper does not become another first-responder stop; it forwards focus to its content view. Sources: