Skip to content

Commit 364626e

Browse files
committed
perf(ui): cache search keyboard navigation targets
1 parent 791ce70 commit 364626e

File tree

1 file changed

+84
-19
lines changed

1 file changed

+84
-19
lines changed

app/pages/search.vue

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -408,29 +408,62 @@ const exactMatchType = computed<'package' | 'org' | 'user' | null>(() => {
408408
const suggestionCount = computed(() => validatedSuggestions.value.length)
409409
const totalSelectableCount = computed(() => suggestionCount.value + resultCount.value)
410410
411+
const resultsContainerRef = useTemplateRef<HTMLElement>('resultsContainerRef')
411412
const 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
553609
onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown)
554610
611+
onMounted(() => {
612+
startObservingFocusableElements()
613+
})
614+
555615
useSeoMeta({
556616
title: () =>
557617
`${query.value ? $t('search.title_search', { search: query.value }) : $t('search.title_packages')} - npmx`,
@@ -669,6 +729,11 @@ watch(
669729
)
670730
671731
onBeforeUnmount(() => {
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

Comments
 (0)