@@ -789,46 +789,42 @@ export const ListBox = forwardRef(function ListBox<T extends object>(
789789 }
790790 } , [ shouldVirtualize , itemsArray , rowVirtualizer ] ) ;
791791
792- // Keep focused item visible when virtualizing, but only for keyboard navigation
793- useEffect ( ( ) => {
794- if ( ! shouldVirtualize ) return ;
792+ // Keep focused item visible, but only for keyboard navigation
793+ useLayoutEffect ( ( ) => {
795794 const focusedKey = listState . selectionManager . focusedKey ;
796- if ( focusedKey != null ) {
797- const idx = itemsArrayRef . current . findIndex (
798- ( it ) => it . key === focusedKey ,
799- ) ;
800- if ( idx !== - 1 ) {
801- // Check if the focused item is actually visible in the current viewport
802- // (not just rendered due to overscan)
803- const scrollElement = scrollRef . current ;
804- if ( scrollElement ) {
805- const scrollTop = scrollElement . scrollTop ;
806- const viewportHeight = scrollElement . clientHeight ;
807- const viewportBottom = scrollTop + viewportHeight ;
808-
809- // Find the virtual item for this index
810- const virtualItems = rowVirtualizer . getVirtualItems ( ) ;
811- const virtualItem = virtualItems . find ( ( item ) => item . index === idx ) ;
812-
813- let isAlreadyVisible = false ;
814- if ( virtualItem ) {
815- const itemTop = virtualItem . start ;
816- const itemBottom = virtualItem . start + virtualItem . size ;
817-
818- // Check if the item is fully visible in the viewport
819- // We should scroll if the item is partially hidden
820- isAlreadyVisible =
821- itemTop >= scrollTop && itemBottom <= viewportBottom ;
822- }
795+ if ( focusedKey == null ) return ;
823796
824- // Only scroll if the item is not already visible AND the focus change was due to keyboard navigation
825- if ( ! isAlreadyVisible && lastFocusSourceRef . current === 'keyboard' ) {
826- rowVirtualizer . scrollToIndex ( idx , { align : 'auto' } ) ;
827- }
828- }
829- }
797+ // Only scroll on keyboard navigation
798+ if ( lastFocusSourceRef . current !== 'keyboard' ) return ;
799+
800+ const scrollElement = scrollRef . current ;
801+ if ( ! scrollElement ) return ;
802+
803+ const itemElement = scrollElement . querySelector (
804+ `[data-key="${ CSS . escape ( String ( focusedKey ) ) } "]` ,
805+ ) as HTMLElement ;
806+ if ( ! itemElement ) return ;
807+
808+ const scrollTop = scrollElement . scrollTop ;
809+ const viewportHeight = scrollElement . clientHeight ;
810+ const viewportBottom = scrollTop + viewportHeight ;
811+
812+ const itemRect = itemElement . getBoundingClientRect ( ) ;
813+ const scrollRect = scrollElement . getBoundingClientRect ( ) ;
814+
815+ // Calculate item position relative to scroll container
816+ const itemTop = itemRect . top - scrollRect . top + scrollTop ;
817+ const itemBottom = itemTop + itemRect . height ;
818+
819+ // Check if the item is fully visible in the viewport
820+ const isAlreadyVisible =
821+ itemTop >= scrollTop && itemBottom <= viewportBottom ;
822+
823+ if ( ! isAlreadyVisible ) {
824+ // Use scrollIntoView with block: 'nearest' to minimize scroll jumps
825+ itemElement . scrollIntoView ( { block : 'nearest' , behavior : 'auto' } ) ;
830826 }
831- } , [ shouldVirtualize , listState . selectionManager . focusedKey , itemsArray ] ) ;
827+ } , [ listState . selectionManager . focusedKey , itemsArray ] ) ;
832828
833829 // Merge React Aria listbox props with custom keyboard props so both sets of
834830 // event handlers (e.g. Arrow navigation *and* our Escape handler) are
@@ -1205,6 +1201,7 @@ function Option({
12051201 ref = { combinedRef }
12061202 qa = { item . props ?. qa }
12071203 id = { `ListBoxItem-${ String ( item . key ) } ` }
1204+ data-key = { String ( item . key ) }
12081205 { ...mergeProps ( filteredOptionProps , hoverProps , {
12091206 onClick : handleOptionClick ,
12101207 onKeyDown,
0 commit comments