diff --git a/app/pages/search.vue b/app/pages/search.vue index b635065aa..d480563b3 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -408,29 +408,68 @@ const exactMatchType = computed<'package' | 'org' | 'user' | null>(() => { const suggestionCount = computed(() => validatedSuggestions.value.length) const totalSelectableCount = computed(() => suggestionCount.value + resultCount.value) +const resultsContainerRef = useTemplateRef('resultsContainerRef') const isVisible = (el: HTMLElement) => el.getClientRects().length > 0 +const focusableElements = shallowRef([]) +let focusableElementsObserver: MutationObserver | null = null +let refreshFocusableElementsFrame: number | null = null /** - * Get all focusable result elements in DOM order (suggestions first, then packages) + * Cache all keyboard-focusable result elements in DOM order. + * DOM order already matches our navigation order: suggestions first, then packages. */ -function getFocusableElements(): HTMLElement[] { - const suggestions = Array.from(document.querySelectorAll('[data-suggestion-index]')) - .filter(isVisible) - .sort((a, b) => { - const aIdx = Number.parseInt(a.dataset.suggestionIndex ?? '0', 10) - const bIdx = Number.parseInt(b.dataset.suggestionIndex ?? '0', 10) - return aIdx - bIdx - }) +function refreshFocusableElements() { + const root = resultsContainerRef.value + if (!root) { + focusableElements.value = [] + return + } - const packages = Array.from(document.querySelectorAll('[data-result-index]')) - .filter(isVisible) - .sort((a, b) => { - const aIdx = Number.parseInt(a.dataset.resultIndex ?? '0', 10) - const bIdx = Number.parseInt(b.dataset.resultIndex ?? '0', 10) - return aIdx - bIdx - }) + focusableElements.value = Array.from( + root.querySelectorAll('[data-suggestion-index], [data-result-index]'), + ).filter(isVisible) +} + +function scheduleFocusableElementsRefresh() { + if (!import.meta.client) return + if (refreshFocusableElementsFrame != null) return - return [...suggestions, ...packages] + refreshFocusableElementsFrame = window.requestAnimationFrame(() => { + refreshFocusableElementsFrame = null + refreshFocusableElements() + }) +} + +function stopObservingFocusableElements() { + focusableElementsObserver?.disconnect() + focusableElementsObserver = null + if (refreshFocusableElementsFrame != null) { + window.cancelAnimationFrame(refreshFocusableElementsFrame) + refreshFocusableElementsFrame = null + } + focusableElements.value = [] +} + +function startObservingFocusableElements() { + stopObservingFocusableElements() + + const root = resultsContainerRef.value + if (!root || !import.meta.client) return + + focusableElementsObserver = new MutationObserver(() => { + scheduleFocusableElementsRefresh() + }) + + focusableElementsObserver.observe(root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'], + }) + + // Perform an initial synchronous refresh so focusableElements is populated + // before any immediate key handling (ArrowUp/ArrowDown) occurs. + refreshFocusableElements() } /** @@ -472,6 +511,32 @@ watch(displayResults, newResults => { } }) +watch(resultsContainerRef, () => { + startObservingFocusableElements() +}) + +watch( + [ + suggestionCount, + resultCount, + viewMode, + paginationMode, + currentPage, + showSelectionView, + isRateLimited, + committedQuery, + ], + () => { + nextTick(() => { + if (!resultsContainerRef.value) { + return + } + scheduleFocusableElementsRefresh() + }) + }, + { flush: 'post' }, +) + /** * Focus the header search input */ @@ -511,7 +576,7 @@ function handleResultsKeydown(e: KeyboardEvent) { if (totalSelectableCount.value <= 0) return - const elements = getFocusableElements() + const elements = focusableElements.value if (elements.length === 0) return const currentIndex = elements.findIndex(el => el === document.activeElement) @@ -552,6 +617,10 @@ function handleResultsKeydown(e: KeyboardEvent) { onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown) +onMounted(() => { + startObservingFocusableElements() +}) + useSeoMeta({ title: () => `${query.value ? $t('search.title_search', { search: query.value }) : $t('search.title_packages')} - npmx`, @@ -669,6 +738,7 @@ watch( ) onBeforeUnmount(() => { + stopObservingFocusableElements() updateLiveRegionMobile.cancel() updateLiveRegionDesktop.cancel() }) @@ -701,7 +771,7 @@ onBeforeUnmount(() => { :view-mode="viewMode" /> -
+