Skip to content
Open
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/menuitem-hover-focus-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fluentui-react-native/menu': patch
---

Fix `MenuItem` showing a focus ring when focus moves to it from mouse hover on win32. The hover-initiated focus is now tracked with a ref instead of stale pressable state.
64 changes: 64 additions & 0 deletions packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as React from 'react';
import { act } from 'react';
import * as renderer from 'react-test-renderer';

import type { MenuItemInfo } from './MenuItem.types';
import { useMenuItem } from './useMenuItem';

describe('useMenuItem focus ring (win32)', () => {
let info: MenuItemInfo;
const Probe = () => {
info = useMenuItem({ componentRef: React.useRef({ focus: jest.fn(), blur: jest.fn() } as any) });
return null;
};

beforeEach(() => {
act(() => {
renderer.create(<Probe />);
});
});

it('keeps the focus ring hidden when hover moves focus before React re-renders', () => {
// Simulate the native focus event firing with the handlers from the same render as onHoverIn.
const { onHoverIn, onFocus } = info.props;
act(() => {
onHoverIn({} as any);
onFocus({} as any);
});
expect(info.props.enableFocusRing).toBe(false);
});

it('shows the focus ring for a keyboard focus after a hover focus', () => {
act(() => {
info.props.onHoverIn({} as any);
});
act(() => {
info.props.onFocus({} as any);
});
expect(info.props.enableFocusRing).toBe(false);

act(() => {
info.props.onBlur({} as any);
});
act(() => {
info.props.onHoverOut({} as any);
});
act(() => {
info.props.onFocus({} as any);
});
expect(info.props.enableFocusRing).toBe(true);
});

it('shows the focus ring for a keyboard focus after a hover that never focused the item', () => {
// Hover in and out before the hover focus lands, so no focus event comes from the hover.
const { onHoverIn, onHoverOut } = info.props;
act(() => {
onHoverIn({} as any);
onHoverOut({} as any);
});
act(() => {
info.props.onFocus({} as any);
});
expect(info.props.enableFocusRing).toBe(true);
});
});
21 changes: 20 additions & 1 deletion packages/components/Menu/src/MenuItem/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,38 @@ export const useMenuItem = (props: MenuItemProps): MenuItemInfo => {
useHoverFocusEffect(pressable.state.hovered, componentRef);

const [enableFocusRing, setEnableFocusRing] = React.useState(!pressable.state.hovered);
// Tracked in a ref rather than state: the focus caused by hovering can fire before React re-renders, so state read in
// onFocus would still be the pre-hover value.
const isHoverFocus = React.useRef(false);

const onHoverIn = React.useCallback(
(e) => {
pressable.props.onHoverIn(e);
// when it's a hover focus, set enableFocusRing explicitly to false
if (!pressable.state.focused) {
isHoverFocus.current = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Cancel pending hover-focus intent when the hover ends without focus

This ref is cleared only by a subsequent onFocus, not by onHoverOut or cancellation of the focus request. If hover-in and hover-out are processed before a commit, useHoverFocusEffect sees the final hovered=false and never requests focus, but isHoverFocus.current remains true. The next genuine keyboard onFocus then consumes the stale flag and leaves enableFocusRing=false. The same stale-intent issue occurs when the imperative focus request does not deliver a focus event. Please clear or otherwise invalidate pending hover intent when that hover/request ends, and extend the new hook tests with hover-in, hover-out, then keyboard focus without an intervening hover-focus event. The existing second test always supplies that event, so it misses this path.

setEnableFocusRing(false);
}
},
[pressable],
);

const onHoverOut = React.useCallback(
(e) => {
pressable.props.onHoverOut(e);
// the hover ended, so any focus the hover asked for may never arrive; don't let it eat the next real focus
isHoverFocus.current = false;
},
[pressable],
);

const onFocus = React.useCallback(
(e) => {
pressable.props.onFocus(e);
// when it's not a hover focus, set enableFocusRing explicitly to true
if (!pressable.state.focused) {
if (isHoverFocus.current) {
isHoverFocus.current = false;
} else {
setEnableFocusRing(true);
}
},
Expand All @@ -101,6 +116,10 @@ export const useMenuItem = (props: MenuItemProps): MenuItemInfo => {
macos: pressable.props.onHoverIn,
default: onHoverIn, // win32
}),
onHoverOut: Platform.select({
macos: pressable.props.onHoverOut,
default: onHoverOut, // win32
}),
onFocus: Platform.select({
macos: pressable.props.onFocus,
default: onFocus, // win32
Expand Down
Loading