@@ -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
0 commit comments