Skip to content

Commit aa83d88

Browse files
committed
[menu] Allow onKeyDown handlers to be called at Group level
1 parent c54f640 commit aa83d88

File tree

3 files changed

+83
-5
lines changed

3 files changed

+83
-5
lines changed

packages/react/src/floating-ui-react/hooks/useTypeahead.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@ import * as React from 'react';
22
import { useStableCallback } from '@base-ui/utils/useStableCallback';
33
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
44
import { useTimeout } from '@base-ui/utils/useTimeout';
5-
import { contains, stopEvent } from '../utils';
5+
import { contains } from '../utils';
66

77
import type { ElementProps, FloatingContext, FloatingRootContext } from '../types';
88
import { EMPTY_ARRAY } from '../../utils/constants';
99

10+
// Track handled native events without mutating them so ancestors can skip typeahead.
11+
const handledTypeaheadEvents = new WeakSet<Event>();
12+
13+
function isTypeaheadEventHandled(event: React.KeyboardEvent) {
14+
return handledTypeaheadEvents.has(event.nativeEvent);
15+
}
16+
17+
function markTypeaheadEventHandled(event: React.KeyboardEvent) {
18+
handledTypeaheadEvents.add(event.nativeEvent);
19+
}
20+
1021
export interface UseTypeaheadProps {
1122
/**
1223
* A ref which contains an array of strings whose indices match the HTML
@@ -45,6 +56,11 @@ export interface UseTypeaheadProps {
4556
* @default 750
4657
*/
4758
resetMs?: number | undefined;
59+
/**
60+
* Whether to stop event propagation after typeahead handles the key.
61+
* @default true
62+
*/
63+
stopPropagation?: boolean | undefined;
4864
/**
4965
* An array of keys to ignore when typing.
5066
* @default []
@@ -77,6 +93,7 @@ export function useTypeahead(
7793
enabled = true,
7894
findMatch = null,
7995
resetMs = 750,
96+
stopPropagation = true,
8097
ignoreKeys = EMPTY_ARRAY,
8198
selectedIndex = null,
8299
} = props;
@@ -118,7 +135,24 @@ export function useTypeahead(
118135
}
119136
});
120137

138+
function handleTypeaheadEvent(event: React.KeyboardEvent) {
139+
markTypeaheadEventHandled(event);
140+
event.preventDefault();
141+
142+
if (stopPropagation) {
143+
event.stopPropagation();
144+
}
145+
}
146+
121147
const onKeyDown = useStableCallback((event: React.KeyboardEvent) => {
148+
// Allow bubbling for group handlers, but avoid parent typeahead re-running.
149+
if (isTypeaheadEventHandled(event)) {
150+
if (stopPropagation) {
151+
event.stopPropagation();
152+
}
153+
return;
154+
}
155+
122156
function getMatchingIndex(
123157
list: Array<string | null>,
124158
orderedList: Array<string | null>,
@@ -139,7 +173,7 @@ export function useTypeahead(
139173
if (getMatchingIndex(listContent, listContent, stringRef.current) === -1) {
140174
setTypingChange(false);
141175
} else if (event.key === ' ') {
142-
stopEvent(event);
176+
handleTypeaheadEvent(event);
143177
}
144178
}
145179

@@ -157,7 +191,7 @@ export function useTypeahead(
157191
}
158192

159193
if (open && event.key !== ' ') {
160-
stopEvent(event);
194+
handleTypeaheadEvent(event);
161195
setTypingChange(true);
162196
}
163197

packages/react/src/menu/group/MenuGroup.test.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { screen } from '@mui/internal-test-utils';
2-
import { expect } from 'chai';
1+
import { flushMicrotasks, screen } from '@mui/internal-test-utils';
2+
import { expect, vi } from 'vitest';
33
import { Menu } from '@base-ui/react/menu';
44
import { createRenderer, describeConformance } from '#test-utils';
55

@@ -15,4 +15,45 @@ describe('<Menu.Group />', () => {
1515
await render(<Menu.Group />);
1616
expect(screen.getByRole('group')).toBeVisible();
1717
});
18+
19+
it('calls the group keydown handler without triggering parent typeahead in an open submenu', async () => {
20+
const handleKeyDown = vi.fn();
21+
22+
const { user } = await render(
23+
<Menu.Root open>
24+
<Menu.Portal>
25+
<Menu.Positioner>
26+
<Menu.Popup>
27+
<Menu.Group onKeyDown={handleKeyDown}>
28+
<Menu.Item>Apple</Menu.Item>
29+
<Menu.Item>Banana</Menu.Item>
30+
<Menu.SubmenuRoot open>
31+
<Menu.SubmenuTrigger>More</Menu.SubmenuTrigger>
32+
<Menu.Portal>
33+
<Menu.Positioner>
34+
<Menu.Popup>
35+
<Menu.Item closeOnClick={false}>Sub item</Menu.Item>
36+
</Menu.Popup>
37+
</Menu.Positioner>
38+
</Menu.Portal>
39+
</Menu.SubmenuRoot>
40+
</Menu.Group>
41+
</Menu.Popup>
42+
</Menu.Positioner>
43+
</Menu.Portal>
44+
</Menu.Root>,
45+
);
46+
47+
await flushMicrotasks();
48+
49+
const parentMatchItem = screen.getByRole('menuitem', { name: 'Banana' });
50+
const subItem = screen.getByRole('menuitem', { name: 'Sub item' });
51+
expect(parentMatchItem).not.toHaveAttribute('data-highlighted');
52+
53+
await user.click(subItem);
54+
await user.keyboard('b');
55+
56+
expect(handleKeyDown).toHaveBeenCalled();
57+
expect(parentMatchItem).not.toHaveAttribute('data-highlighted');
58+
});
1859
});

packages/react/src/menu/root/MenuRoot.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,9 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot
470470
listRef: store.context.itemLabels,
471471
activeIndex,
472472
resetMs: TYPEAHEAD_RESET_MS,
473+
// Let keydown bubble in nested menus so group handlers can run, but still mark the
474+
// event so parent menus skip typeahead.
475+
stopPropagation: parent.type !== 'menu' && parent.type !== 'nested-context-menu',
473476
onMatch: (index) => {
474477
if (open && index !== activeIndex) {
475478
store.set('activeIndex', index);

0 commit comments

Comments
 (0)