Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/macos-key-events.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/macos-legacy-key-props.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/utils/adapters/src/platformProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,13 @@ export interface ViewAdditionsMacOS {
onMouseEnter?: IMouseEventHandler;
onMouseLeave?: IMouseEventHandler;
onPreferredScrollerStyleDidChange?: (event: NativeSyntheticEvent<object>) => 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[];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down Expand Up @@ -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);
});
});
97 changes: 47 additions & 50 deletions packages/utils/interactive-hooks/src/useKeyProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
18 changes: 14 additions & 4 deletions packages/utils/interactive-hooks/src/useKeyProps.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
15 changes: 11 additions & 4 deletions references/agents/focus/platform-evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading