From c7cc818d64d3125b1eb164aa68b45d3e3fd8f642 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Thu, 2 Jul 2026 15:11:20 +0000 Subject: [PATCH] fix(menu): wire useTypeahead into ContextMenu + NavHeadingMenu (menus-11) --- .changeset/typeahead-context-nav-menus.md | 6 ++++ .../core/src/ContextMenu/ContextMenu.test.tsx | 16 +++++++++ packages/core/src/ContextMenu/ContextMenu.tsx | 34 ++++++++++++++++--- packages/core/src/NavMenu/NavHeadingMenu.tsx | 31 +++++++++++++++-- 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 .changeset/typeahead-context-nav-menus.md diff --git a/.changeset/typeahead-context-nav-menus.md b/.changeset/typeahead-context-nav-menus.md new file mode 100644 index 000000000000..13a9b01a48b3 --- /dev/null +++ b/.changeset/typeahead-context-nav-menus.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] ContextMenu and NavMenu heading menus now support first-character typeahead (type a letter to jump to the matching item), via the shared `useTypeahead` hook — matching DropdownMenu. MoreMenu inherits it through DropdownMenu (#3343). +@cixzhang diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index b83e37a91881..38fdf22d7fdd 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -60,6 +60,22 @@ describe('ContextMenu', () => { expect(screen.getByRole('menu', {hidden: true})).toBeInTheDocument(); }); + it('typeahead focuses the matching menu item (menus-11)', () => { + render( + +
Right-click me
+
, + ); + fireEvent.contextMenu(screen.getByText('Right-click me')); + const menu = screen.getByRole('menu', {hidden: true}); + fireEvent.keyDown(menu, {key: 'p'}); + expect( + screen.getByRole('menuitem', {name: 'Paste', hidden: true}), + ).toHaveFocus(); + }); + it('opens menu on right-click', () => { render( diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index 156086d55ccb..2b3101e2b684 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -42,6 +42,7 @@ import { type DropdownMenuContextValue, } from '../DropdownMenu/DropdownMenuContext'; import {useListFocus} from '../hooks/useListFocus'; +import {useTypeahead} from '../hooks/useTypeahead'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; import { colorVars, @@ -138,9 +139,7 @@ interface ContextMenuCompoundProps extends ContextMenuBaseProps { menuContent: ReactNode; } -export type ContextMenuProps = - | ContextMenuDataProps - | ContextMenuCompoundProps; +export type ContextMenuProps = ContextMenuDataProps | ContextMenuCompoundProps; // ============================================================================= // ContextMenu @@ -212,12 +211,35 @@ export function ContextMenu({ listRef, handleKeyDown: listNavKeyDown, focusFirst, + focusItem, } = useListFocus({ itemSelector: '[role="menuitem"]:not([aria-disabled="true"])', wrap: false, onEscape: closeMenu, }); + // First-character typeahead over the enabled menu items (menus-11). + const getMenuItems = useCallback( + (): HTMLElement[] => + listRef.current + ? Array.from( + listRef.current.querySelectorAll( + '[role="menuitem"]:not([aria-disabled="true"])', + ), + ) + : [], + [listRef], + ); + const typeahead = useTypeahead({ + getItemLabels: () => getMenuItems().map(el => el.textContent), + onMatch: focusItem, + getCurrentIndex: () => + getMenuItems().findIndex( + el => + el === document.activeElement || el.contains(document.activeElement), + ), + }); + // Dismiss on any click outside the menu. We use popover="manual" (not // "auto") because the native light-dismiss treats the mouseup from the // opening right-click as a dismiss event. Handling it ourselves via @@ -248,9 +270,13 @@ export function ContextMenu({ } return; } + if (typeahead.onKeyDown(e)) { + e.preventDefault(); + return; + } listNavKeyDown(e); }, - [listNavKeyDown], + [listNavKeyDown, typeahead], ); const handleContextMenu = useCallback( diff --git a/packages/core/src/NavMenu/NavHeadingMenu.tsx b/packages/core/src/NavMenu/NavHeadingMenu.tsx index 154d277fb168..98235ce08a8d 100644 --- a/packages/core/src/NavMenu/NavHeadingMenu.tsx +++ b/packages/core/src/NavMenu/NavHeadingMenu.tsx @@ -18,6 +18,7 @@ import {spacingVars} from '../theme/tokens.stylex'; import {mergeProps, mergeRefs} from '../utils'; import type {BaseProps} from '../BaseProps'; import {useListFocus} from '../hooks/useListFocus'; +import {useTypeahead} from '../hooks/useTypeahead'; import {themeProps} from '../utils/themeProps'; import { NavHeadingMenuContext, @@ -98,10 +99,32 @@ export function NavHeadingMenu({ const closeCtx = useNavHeadingCloseContext(); const closeMenu = closeCtx?.closeMenu; - const {listRef, handleKeyDown} = useListFocus({ + const {listRef, handleKeyDown, focusItem} = useListFocus({ onEscape: closeMenu, }); + // First-character typeahead over the menu items (menus-11). + const getMenuItems = useCallback( + (): HTMLElement[] => + listRef.current + ? Array.from( + listRef.current.querySelectorAll('[role="menuitem"]'), + ) + : [], + [listRef], + ); + const typeahead = useTypeahead({ + getItemLabels: () => getMenuItems().map(el => el.textContent), + onMatch: focusItem, + getCurrentIndex: () => + getMenuItems().findIndex( + el => + el === document.activeElement || el.contains(document.activeElement), + ), + isDisabled: index => + getMenuItems()[index]?.getAttribute('aria-disabled') === 'true', + }); + // Extend useListFocus with Enter/Space activation. Items rendered without an // `href` are `
` elements, which have no native keyboard // activation — without this, Enter/Space on a focused onClick-only item does @@ -116,9 +139,13 @@ export function NavHeadingMenu({ return; } } + if (typeahead.onKeyDown(e)) { + e.preventDefault(); + return; + } handleKeyDown(e); }, - [handleKeyDown], + [handleKeyDown, typeahead], ); const ctx = useMemo(