Skip to content

Commit 2fbd464

Browse files
committed
[menu] Fold focusItem into list navigation
1 parent 974094c commit 2fbd464

3 files changed

Lines changed: 143 additions & 76 deletions

File tree

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

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ export interface UseListNavigationProps {
134134
* @default true
135135
*/
136136
focusItemOnHover?: boolean | undefined;
137+
/**
138+
* A pending imperative item focus request to resolve against the list.
139+
* @default null
140+
*/
141+
pendingFocusItem?: UseListNavigationFocusItem | null | undefined;
142+
/**
143+
* Callback fired when a pending imperative item focus request has been consumed.
144+
*/
145+
onPendingFocusItemChange?: ((pendingFocusItem: null) => void) | undefined;
137146
/**
138147
* Whether pressing an arrow key on the navigation's main axis opens the
139148
* floating element.
@@ -223,6 +232,8 @@ export interface UseListNavigationProps {
223232
externalTree?: FloatingTreeStore | undefined;
224233
}
225234

235+
export type UseListNavigationFocusItem = 'first' | 'last' | 'none';
236+
226237
/**
227238
* Adds arrow key-based navigation of a list of items, either using real DOM
228239
* focus or virtual focus.
@@ -245,6 +256,8 @@ export function useListNavigation(
245256
virtual = false,
246257
focusItemOnOpen = 'auto',
247258
focusItemOnHover = true,
259+
pendingFocusItem = null,
260+
onPendingFocusItemChange,
248261
openOnArrowKeyDown = true,
249262
disabledIndices = undefined,
250263
orientation = 'vertical',
@@ -298,6 +311,10 @@ export function useListNavigation(
298311
onNavigateProp(indexRef.current === -1 ? null : indexRef.current, event);
299312
});
300313

314+
const clearPendingFocusItem = useStableCallback(() => {
315+
onPendingFocusItemChange?.(null);
316+
});
317+
301318
const previousOnNavigateRef = React.useRef(onNavigate);
302319
const previousMountedRef = React.useRef(!!floatingElement);
303320
const previousOpenRef = React.useRef(open);
@@ -311,7 +328,6 @@ export function useListNavigation(
311328
const resetOnPointerLeaveRef = useValueAsRef(resetOnPointerLeave);
312329

313330
const focusFrame = useAnimationFrame();
314-
const waitForListPopulatedFrame = useAnimationFrame();
315331

316332
const focusItem = useStableCallback(() => {
317333
function runFocus(item: HTMLElement) {
@@ -391,21 +407,70 @@ export function useListNavigation(
391407
// open.
392408
useIsoLayoutEffect(() => {
393409
if (!enabled) {
394-
return;
410+
return undefined;
395411
}
396412
if (!open) {
397413
forceSyncFocusRef.current = false;
398-
return;
414+
return undefined;
399415
}
400416
if (!floatingElement) {
401-
return;
417+
return undefined;
418+
}
419+
420+
const resolveFocusItem = (
421+
target: Exclude<UseListNavigationFocusItem, 'none'>,
422+
onDone?: () => void,
423+
) => {
424+
let cancelled = false;
425+
426+
const waitForListPopulated = () => {
427+
if (cancelled) {
428+
return;
429+
}
430+
431+
if (listRef.current[0] == null) {
432+
onDone?.();
433+
return;
434+
}
435+
436+
indexRef.current = target === 'last' ? getMaxListIndex(listRef) : getMinListIndex(listRef);
437+
keyRef.current = null;
438+
onNavigate();
439+
onDone?.();
440+
};
441+
442+
if (listRef.current[0] == null) {
443+
// Some composed items register after their indexes are assigned by
444+
// CompositeList, which can land one layout-effect flush after the
445+
// popup opens. Retry once before clearing the pending request.
446+
queueMicrotask(waitForListPopulated);
447+
} else {
448+
waitForListPopulated();
449+
}
450+
451+
return () => {
452+
cancelled = true;
453+
};
454+
};
455+
456+
if (pendingFocusItem != null) {
457+
forceSyncFocusRef.current = false;
458+
459+
if (pendingFocusItem === 'none') {
460+
indexRef.current = -1;
461+
onNavigate();
462+
clearPendingFocusItem();
463+
return undefined;
464+
}
465+
466+
return resolveFocusItem(pendingFocusItem, clearPendingFocusItem);
402467
}
403468

404469
if (activeIndex == null) {
405470
forceSyncFocusRef.current = false;
406471

407472
if (selectedIndexRef.current != null) {
408-
return;
473+
return undefined;
409474
}
410475

411476
// Reset while the floating element was open (e.g. the list changed).
@@ -420,52 +485,36 @@ export function useListNavigation(
420485
focusItemOnOpenRef.current &&
421486
(keyRef.current != null || (focusItemOnOpenRef.current === true && keyRef.current == null))
422487
) {
423-
let runs = 0;
424-
const waitForListPopulated = () => {
425-
if (listRef.current[0] == null) {
426-
// Avoid letting the browser paint if possible on the first try,
427-
// otherwise use rAF. Don't try more than twice, since something
428-
// is wrong otherwise.
429-
if (runs < 2) {
430-
const scheduler = runs
431-
? (callback: () => void) => waitForListPopulatedFrame.request(callback)
432-
: queueMicrotask;
433-
scheduler(waitForListPopulated);
434-
}
435-
runs += 1;
436-
} else {
437-
// initially focus the first non-disabled item
438-
indexRef.current =
439-
keyRef.current == null ||
440-
isMainOrientationToEndKey(keyRef.current, orientation, rtl) ||
441-
nested
442-
? getMinListIndex(listRef)
443-
: getMaxListIndex(listRef);
444-
keyRef.current = null;
445-
onNavigate();
446-
}
447-
};
448-
449-
waitForListPopulated();
488+
const target =
489+
keyRef.current == null ||
490+
isMainOrientationToEndKey(keyRef.current, orientation, rtl) ||
491+
nested
492+
? 'first'
493+
: 'last';
494+
495+
return resolveFocusItem(target);
450496
}
451497
} else if (!isIndexOutOfListBounds(listRef.current, activeIndex)) {
452498
indexRef.current = activeIndex;
453499
focusItem();
454500
forceScrollIntoViewRef.current = false;
455501
}
502+
503+
return undefined;
456504
}, [
457505
enabled,
458506
open,
459507
floatingElement,
460508
activeIndex,
509+
pendingFocusItem,
461510
selectedIndexRef,
462511
nested,
463512
listRef,
464513
orientation,
465514
rtl,
466515
onNavigate,
516+
clearPendingFocusItem,
467517
focusItem,
468-
waitForListPopulatedFrame,
469518
]);
470519

471520
// Ensure the parent floating element has focus when a nested child closes

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@mui/internal-test-utils';
1111
import { DirectionProvider } from '@base-ui/react/direction-provider';
1212
import { useRefWithInit } from '@base-ui/utils/useRefWithInit';
13+
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
1314
import { Menu } from '@base-ui/react/menu';
1415
import { Dialog } from '@base-ui/react/dialog';
1516
import { AlertDialog } from '@base-ui/react/alert-dialog';
@@ -1453,6 +1454,60 @@ describe('<Menu.Root />', () => {
14531454
expect(item).toHaveAttribute('tabindex', '-1');
14541455
});
14551456
});
1457+
1458+
it('waits for composed items that register after a layout effect', async () => {
1459+
const actionsRef: React.RefObject<Menu.Root.Actions | null> = { current: null };
1460+
1461+
function DelayedItem(props: { children: React.ReactNode; testId: string }) {
1462+
const [mounted, setMounted] = React.useState(false);
1463+
useIsoLayoutEffect(() => {
1464+
setMounted(true);
1465+
}, []);
1466+
1467+
if (!mounted) {
1468+
return null;
1469+
}
1470+
1471+
return <Menu.Item data-testid={props.testId}>{props.children}</Menu.Item>;
1472+
}
1473+
1474+
function App() {
1475+
const [open, setOpen] = React.useState(false);
1476+
return (
1477+
<div>
1478+
<button
1479+
type="button"
1480+
onClick={() => {
1481+
setOpen(true);
1482+
actionsRef.current?.focusItem('first');
1483+
}}
1484+
>
1485+
external
1486+
</button>
1487+
<TestMenu
1488+
rootProps={{ open, onOpenChange: setOpen, actionsRef }}
1489+
popupProps={{
1490+
children: (
1491+
<React.Fragment>
1492+
<DelayedItem testId="delayed-1">One</DelayedItem>
1493+
<DelayedItem testId="delayed-2">Two</DelayedItem>
1494+
</React.Fragment>
1495+
),
1496+
}}
1497+
/>
1498+
</div>
1499+
);
1500+
}
1501+
1502+
const { user } = await render(<App />);
1503+
1504+
await user.click(screen.getByRole('button', { name: 'external' }));
1505+
1506+
const firstItem = await screen.findByTestId('delayed-1');
1507+
await waitFor(() => {
1508+
expect(firstItem).toHaveFocus();
1509+
});
1510+
});
14561511
});
14571512
});
14581513

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

Lines changed: 6 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { useStableCallback } from '@base-ui/utils/useStableCallback';
55
import { useId } from '@base-ui/utils/useId';
66
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
77
import { useOnFirstRender } from '@base-ui/utils/useOnFirstRender';
8-
import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame';
98
import { EMPTY_ARRAY, EMPTY_OBJECT } from '@base-ui/utils/empty';
109
import { fastComponent } from '@base-ui/utils/fastHooks';
1110
import {
@@ -17,7 +16,6 @@ import {
1716
useTypeahead,
1817
useSyncedFloatingRootContext,
1918
} from '../../floating-ui-react';
20-
import { getMaxListIndex, getMinListIndex } from '../../floating-ui-react/utils/composite';
2119
import { MenuRootContext, useMenuRootContext } from './MenuRootContext';
2220
import { MenubarContext, useMenubarContext } from '../../menubar/MenubarContext';
2321
import { TYPEAHEAD_RESET_MS } from '../../internals/constants';
@@ -421,6 +419,10 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot
421419
});
422420

423421
const direction = useDirection();
422+
const pendingFocusItem = store.useState('pendingFocusItem');
423+
const clearPendingFocusItem = useStableCallback(() => {
424+
store.set('pendingFocusItem', null);
425+
});
424426

425427
const listNavigation = useListNavigation(floatingRootContext, {
426428
enabled: !disabled,
@@ -436,49 +438,10 @@ export const MenuRoot = fastComponent(function MenuRoot<Payload>(props: MenuRoot
436438
openOnArrowKeyDown: parent.type !== 'context-menu',
437439
externalTree: nested ? floatingTreeRoot : undefined,
438440
focusItemOnHover: highlightItemOnHover,
441+
pendingFocusItem,
442+
onPendingFocusItemChange: clearPendingFocusItem,
439443
});
440444

441-
const pendingFocusItem = store.useState('pendingFocusItem');
442-
const focusItemFrame = useAnimationFrame();
443-
444-
useIsoLayoutEffect(() => {
445-
if (pendingFocusItem == null || !open) {
446-
return undefined;
447-
}
448-
449-
if (pendingFocusItem === 'none') {
450-
store.update({ activeIndex: null, pendingFocusItem: null });
451-
return undefined;
452-
}
453-
454-
let cancelled = false;
455-
let runs = 0;
456-
const elements = store.context.itemDomElements;
457-
458-
const resolve = () => {
459-
if (cancelled) {
460-
return;
461-
}
462-
if (elements.current[0] == null) {
463-
if (runs < 2) {
464-
runs += 1;
465-
focusItemFrame.request(resolve);
466-
}
467-
return;
468-
}
469-
const index =
470-
pendingFocusItem === 'last' ? getMaxListIndex(elements) : getMinListIndex(elements);
471-
store.update({ activeIndex: index, pendingFocusItem: null });
472-
};
473-
474-
resolve();
475-
476-
return () => {
477-
cancelled = true;
478-
focusItemFrame.cancel();
479-
};
480-
}, [open, positionerElement, pendingFocusItem, store, focusItemFrame]);
481-
482445
const onTyping = React.useCallback(
483446
(nextTyping: boolean) => {
484447
store.context.typingRef.current = nextTyping;

0 commit comments

Comments
 (0)