@@ -408,29 +408,62 @@ const exactMatchType = computed<'package' | 'org' | 'user' | null>(() => {
408408const suggestionCount = computed (() => validatedSuggestions .value .length )
409409const totalSelectableCount = computed (() => suggestionCount .value + resultCount .value )
410410
411+ const resultsContainerRef = useTemplateRef <HTMLElement >(' resultsContainerRef' )
411412const isVisible = (el : HTMLElement ) => el .getClientRects ().length > 0
413+ const focusableElements = shallowRef <HTMLElement []>([])
414+ let focusableElementsObserver: MutationObserver | null = null
415+ let refreshFocusableElementsFrame: number | null = null
412416
413417/**
414- * Get all focusable result elements in DOM order (suggestions first, then packages)
418+ * Cache all keyboard-focusable result elements in DOM order.
419+ * DOM order already matches our navigation order: suggestions first, then packages.
415420 */
416- function getFocusableElements(): HTMLElement [] {
417- const suggestions = Array .from (document .querySelectorAll <HTMLElement >(' [data-suggestion-index]' ))
418- .filter (isVisible )
419- .sort ((a , b ) => {
420- const aIdx = Number .parseInt (a .dataset .suggestionIndex ?? ' 0' , 10 )
421- const bIdx = Number .parseInt (b .dataset .suggestionIndex ?? ' 0' , 10 )
422- return aIdx - bIdx
423- })
421+ function refreshFocusableElements() {
422+ const root = resultsContainerRef .value
423+ if (! root ) {
424+ focusableElements .value = []
425+ return
426+ }
424427
425- const packages = Array .from (document .querySelectorAll <HTMLElement >(' [data-result-index]' ))
426- .filter (isVisible )
427- .sort ((a , b ) => {
428- const aIdx = Number .parseInt (a .dataset .resultIndex ?? ' 0' , 10 )
429- const bIdx = Number .parseInt (b .dataset .resultIndex ?? ' 0' , 10 )
430- return aIdx - bIdx
431- })
428+ focusableElements .value = Array .from (
429+ root .querySelectorAll <HTMLElement >(' [data-suggestion-index], [data-result-index]' ),
430+ ).filter (isVisible )
431+ }
432+
433+ function scheduleFocusableElementsRefresh() {
434+ if (! import .meta .client ) return
435+ if (refreshFocusableElementsFrame != null ) return
436+
437+ refreshFocusableElementsFrame = window .requestAnimationFrame (() => {
438+ refreshFocusableElementsFrame = null
439+ refreshFocusableElements ()
440+ })
441+ }
442+
443+ function stopObservingFocusableElements() {
444+ focusableElementsObserver ?.disconnect ()
445+ focusableElementsObserver = null
446+ focusableElements .value = []
447+ }
448+
449+ function startObservingFocusableElements() {
450+ stopObservingFocusableElements ()
451+
452+ const root = resultsContainerRef .value
453+ if (! root || ! import .meta .client ) return
454+
455+ focusableElementsObserver = new MutationObserver (() => {
456+ scheduleFocusableElementsRefresh ()
457+ })
458+
459+ focusableElementsObserver .observe (root , {
460+ childList: true ,
461+ subtree: true ,
462+ attributes: true ,
463+ attributeFilter: [' class' , ' style' , ' hidden' , ' aria-hidden' ],
464+ })
432465
433- return [ ... suggestions , ... packages ]
466+ scheduleFocusableElementsRefresh ()
434467}
435468
436469/**
@@ -472,6 +505,29 @@ watch(displayResults, newResults => {
472505 }
473506})
474507
508+ watch (resultsContainerRef , () => {
509+ startObservingFocusableElements ()
510+ })
511+
512+ watch (
513+ [
514+ suggestionCount ,
515+ resultCount ,
516+ viewMode ,
517+ paginationMode ,
518+ currentPage ,
519+ showSelectionView ,
520+ isRateLimited ,
521+ committedQuery ,
522+ ],
523+ () => {
524+ nextTick (() => {
525+ scheduleFocusableElementsRefresh ()
526+ })
527+ },
528+ { flush: ' post' },
529+ )
530+
475531/**
476532 * Focus the header search input
477533 */
@@ -511,7 +567,7 @@ function handleResultsKeydown(e: KeyboardEvent) {
511567
512568 if (totalSelectableCount .value <= 0 ) return
513569
514- const elements = getFocusableElements ()
570+ const elements = focusableElements . value
515571 if (elements .length === 0 ) return
516572
517573 const currentIndex = elements .findIndex (el => el === document .activeElement )
@@ -552,6 +608,10 @@ function handleResultsKeydown(e: KeyboardEvent) {
552608
553609onKeyDown ([' ArrowDown' , ' ArrowUp' , ' Enter' ], handleResultsKeydown )
554610
611+ onMounted (() => {
612+ startObservingFocusableElements ()
613+ })
614+
555615useSeoMeta ({
556616 title : () =>
557617 ` ${query .value ? $t (' search.title_search' , { search: query .value }) : $t (' search.title_packages' )} - npmx ` ,
@@ -669,6 +729,11 @@ watch(
669729)
670730
671731onBeforeUnmount (() => {
732+ stopObservingFocusableElements ()
733+ if (refreshFocusableElementsFrame != null ) {
734+ window .cancelAnimationFrame (refreshFocusableElementsFrame )
735+ refreshFocusableElementsFrame = null
736+ }
672737 updateLiveRegionMobile .cancel ()
673738 updateLiveRegionDesktop .cancel ()
674739})
@@ -701,7 +766,7 @@ onBeforeUnmount(() => {
701766 :view-mode =" viewMode"
702767 />
703768
704- <section v-else-if =" committedQuery" class =" results-layout" >
769+ <section v-else-if =" committedQuery" ref = " resultsContainerRef " class =" results-layout" >
705770 <LoadingSpinner v-if =" showSearching" :text =" $t('search.searching')" />
706771
707772 <div
0 commit comments