diff --git a/app/components/Compare/PackageSelector.vue b/app/components/Compare/PackageSelector.vue index 6475c9c3e..057e7d64f 100644 --- a/app/components/Compare/PackageSelector.vue +++ b/app/components/Compare/PackageSelector.vue @@ -14,6 +14,11 @@ const maxPackages = computed(() => props.max ?? 4) const inputValue = shallowRef('') const isInputFocused = shallowRef(false) +// Keyboard navigation state +const highlightedIndex = shallowRef(-1) +const listRef = useTemplateRef('listRef') +const PAGE_JUMP = 5 + // Use the shared search composable (supports both npm and Algolia providers) const { searchProvider } = useSearchProvider() const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 }) @@ -54,6 +59,20 @@ const filteredResults = computed(() => { .filter(r => !packages.value.includes(r.name)) }) +// Unified list of navigable items for keyboard navigation +const navigableItems = computed(() => { + const items: { type: 'no-dependency' | 'package'; name: string }[] = [] + if (showNoDependencyOption.value) { + items.push({ type: 'no-dependency', name: NO_DEPENDENCY_ID }) + } + for (const r of filteredResults.value) { + items.push({ type: 'package', name: r.name }) + } + return items +}) + +const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0)) + const numberFormatter = useNumberFormatter() function addPackage(name: string) { @@ -71,6 +90,7 @@ function addPackage(name: string) { packages.value = [...packages.value, name] } inputValue.value = '' + highlightedIndex.value = -1 } function removePackage(name: string) { @@ -78,23 +98,85 @@ function removePackage(name: string) { } function handleKeydown(e: KeyboardEvent) { - const inputValueTrim = inputValue.value.trim() - const hasMatchInPackages = filteredResults.value.find(result => { - return result.name === inputValueTrim - }) - - if (e.key === 'Enter' && inputValueTrim) { - e.preventDefault() - if (showNoDependencyOption.value) { - addPackage(NO_DEPENDENCY_ID) - } else if (hasMatchInPackages) { - addPackage(inputValueTrim) + const items = navigableItems.value + const count = items.length + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + if (count === 0) return + highlightedIndex.value = Math.min(highlightedIndex.value + 1, count - 1) + break + + case 'ArrowUp': + e.preventDefault() + if (count === 0) return + if (highlightedIndex.value > 0) { + highlightedIndex.value-- + } + break + + case 'PageDown': + e.preventDefault() + if (count === 0) return + if (highlightedIndex.value === -1) { + highlightedIndex.value = Math.min(PAGE_JUMP - 1, count - 1) + } else { + highlightedIndex.value = Math.min(highlightedIndex.value + PAGE_JUMP, count - 1) + } + break + + case 'PageUp': + e.preventDefault() + if (count === 0) return + highlightedIndex.value = Math.max(highlightedIndex.value - PAGE_JUMP, 0) + break + + case 'Enter': { + const inputValueTrim = inputValue.value.trim() + if (!inputValueTrim) return + + e.preventDefault() + + // If an item is highlighted, select it + if (highlightedIndex.value >= 0 && highlightedIndex.value < count) { + addPackage(items[highlightedIndex.value]!.name) + return + } + + // Fallback: exact match or easter egg (preserves existing behavior) + if (showNoDependencyOption.value) { + addPackage(NO_DEPENDENCY_ID) + } else { + const hasMatch = filteredResults.value.find(r => r.name === inputValueTrim) + if (hasMatch) { + addPackage(inputValueTrim) + } + } + break } - } else if (e.key === 'Escape') { - inputValue.value = '' + + case 'Escape': + inputValue.value = '' + highlightedIndex.value = -1 + break } } +// Reset highlight when user types +watch(inputValue, () => { + highlightedIndex.value = -1 +}) + +// Scroll highlighted item into view +watch(highlightedIndex, index => { + if (index >= 0 && listRef.value) { + const items = listRef.value.querySelectorAll('[data-navigable]') + const item = items[index] as HTMLElement | undefined + item?.scrollIntoView({ block: 'nearest' }) + } +}) + const { start, stop } = useTimeoutFn(() => { isInputFocused.value = false }, 200) @@ -176,16 +258,18 @@ function handleFocus() { leave-to-class="opacity-0" >