From 33c79ca84a9d79fdb73b22951b7c03f165d730dd Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 16 Jun 2026 10:57:14 +0200 Subject: [PATCH] fix(react-menu): Escape in an open Menu does not trigger tabster actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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) --- ...u-ca4cf597-e985-4c1d-aa1d-c300ae041879.json | 7 +++++++ .../MenuPopover/useMenuPopover.test.tsx | 8 ++++++-- .../components/MenuPopover/useMenuPopover.ts | 18 ++++++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 change/@fluentui-react-menu-ca4cf597-e985-4c1d-aa1d-c300ae041879.json diff --git a/change/@fluentui-react-menu-ca4cf597-e985-4c1d-aa1d-c300ae041879.json b/change/@fluentui-react-menu-ca4cf597-e985-4c1d-aa1d-c300ae041879.json new file mode 100644 index 00000000000000..43c62f452008dc --- /dev/null +++ b/change/@fluentui-react-menu-ca4cf597-e985-4c1d-aa1d-c300ae041879.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: Escape in an open Menu does not trigger tabster actions", + "packageName": "@fluentui/react-menu", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.test.tsx b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.test.tsx index 178ff21ca3081b..6dcc89c06c7d9e 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.test.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.test.tsx @@ -140,12 +140,16 @@ describe('useMenuPopover_unstable', () => { expect((result.current.root as Record)['data-testid']).toBe('popover'); }); - it('spreads restoreFocusSource attributes onto root', () => { + it('spreads restoreFocusSource and ignoreKeydown attributes onto root', () => { const { wrapper } = makeWrapper(); const { result } = renderHook(() => useMenuPopover_unstable({}, null), { wrapper }); - expect((result.current.root as Record)['data-tabster']).toBe('{"restorer":{"type":1}}'); + // tabster is told to ignore Escape so the menu (which closes itself on Escape) + // doesn't also escape a parent groupper/modalizer + expect((result.current.root as Record)['data-tabster']).toBe( + '{"restorer":{"type":1},"focusable":{"ignoreKeydown":{"Escape":true}}}', + ); }); }); diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts index 5fed05c1f79f7c..be8ce713a916e8 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts @@ -3,7 +3,11 @@ import { ArrowLeft, Tab, ArrowRight, Escape } from '@fluentui/keyboard-keys'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { useMotionForwardedRef } from '@fluentui/react-motion'; -import { useRestoreFocusSource } from '@fluentui/react-tabster'; +import { + useRestoreFocusSource, + useTabsterAttributes, + useMergedTabsterAttributes_unstable, +} from '@fluentui/react-tabster'; import { getIntrinsicElementProps, useEventCallback, useMergedRefs, slot, useTimeout } from '@fluentui/react-utilities'; import * as React from 'react'; @@ -129,13 +133,23 @@ export const useMenuPopoverBase_unstable = (props: MenuPopoverProps, ref: React. */ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { const restoreFocusSourceAttributes = useRestoreFocusSource(); + + // Opt the menu's popover out of tabster's Escape handling. The menu closes itself on + // Escape; without this tabster would also act on Escape (e.g. escaping a parent + // groupper/modalizer) and move focus away from the trigger instead of restoring it. + const ignoreEscapeKeyAttribute = useTabsterAttributes({ + focusable: { ignoreKeydown: { Escape: true } }, + }); + + const tabsterAttributes = useMergedTabsterAttributes_unstable(restoreFocusSourceAttributes, ignoreEscapeKeyAttribute); + const motionRef = useMotionForwardedRef(); const baseState = useMenuPopoverBase_unstable(props, ref); return { ...baseState, root: { - ...restoreFocusSourceAttributes, + ...tabsterAttributes, ...baseState.root, ref: useMergedRefs(baseState.root.ref, motionRef) as React.Ref, },