Skip to content

Commit 33c79ca

Browse files
layershifterclaude
andcommitted
fix(react-menu): Escape in an open Menu does not trigger tabster actions
A Menu nested in a tabster groupper/modalizer (e.g. a focusMode Card, or a list item whose actions popover shares a modalizer scope with the item) moves focus to the groupper root when Escape closes the menu, instead of restoring focus to the trigger. The MenuPopover keydown handler calls event.preventDefault(), but tabster listens for keydown in the capture phase on the document, so it can't be stopped from the popover handler and still runs its own Escape behaviour (escaping the parent groupper). This is the same problem fixed for react-combobox in microsoft#36275. Fix: add the tabster `focusable.ignoreKeydown: { Escape: true }` attribute on the MenuPopover. The popover only renders while the menu is open, so the attribute is only ever present on an open menu (and Escape still works normally elsewhere) — no need to gate it on the open state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 56b12c6 commit 33c79ca

3 files changed

Lines changed: 29 additions & 4 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "fix: Escape in an open Menu does not trigger tabster actions",
4+
"packageName": "@fluentui/react-menu",
5+
"email": "olfedias@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.test.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,16 @@ describe('useMenuPopover_unstable', () => {
140140
expect((result.current.root as Record<string, unknown>)['data-testid']).toBe('popover');
141141
});
142142

143-
it('spreads restoreFocusSource attributes onto root', () => {
143+
it('spreads restoreFocusSource and ignoreKeydown attributes onto root', () => {
144144
const { wrapper } = makeWrapper();
145145

146146
const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper });
147147

148-
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBe('{"restorer":{"type":1}}');
148+
// tabster is told to ignore Escape so the menu (which closes itself on Escape)
149+
// doesn't also escape a parent groupper/modalizer
150+
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBe(
151+
'{"restorer":{"type":1},"focusable":{"ignoreKeydown":{"Escape":true}}}',
152+
);
149153
});
150154
});
151155

packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import { ArrowLeft, Tab, ArrowRight, Escape } from '@fluentui/keyboard-keys';
44
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
55
import { useMotionForwardedRef } from '@fluentui/react-motion';
6-
import { useRestoreFocusSource } from '@fluentui/react-tabster';
6+
import {
7+
useRestoreFocusSource,
8+
useTabsterAttributes,
9+
useMergedTabsterAttributes_unstable,
10+
} from '@fluentui/react-tabster';
711
import { getIntrinsicElementProps, useEventCallback, useMergedRefs, slot, useTimeout } from '@fluentui/react-utilities';
812
import * as React from 'react';
913

@@ -129,13 +133,23 @@ export const useMenuPopoverBase_unstable = (props: MenuPopoverProps, ref: React.
129133
*/
130134
export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref<HTMLElement>): MenuPopoverState => {
131135
const restoreFocusSourceAttributes = useRestoreFocusSource();
136+
137+
// Opt the menu's popover out of tabster's Escape handling. The menu closes itself on
138+
// Escape; without this tabster would also act on Escape (e.g. escaping a parent
139+
// groupper/modalizer) and move focus away from the trigger instead of restoring it.
140+
const ignoreEscapeKeyAttribute = useTabsterAttributes({
141+
focusable: { ignoreKeydown: { Escape: true } },
142+
});
143+
144+
const tabsterAttributes = useMergedTabsterAttributes_unstable(restoreFocusSourceAttributes, ignoreEscapeKeyAttribute);
145+
132146
const motionRef = useMotionForwardedRef();
133147
const baseState = useMenuPopoverBase_unstable(props, ref);
134148

135149
return {
136150
...baseState,
137151
root: {
138-
...restoreFocusSourceAttributes,
152+
...tabsterAttributes,
139153
...baseState.root,
140154
ref: useMergedRefs(baseState.root.ref, motionRef) as React.Ref<HTMLDivElement>,
141155
},

0 commit comments

Comments
 (0)