[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add keyboard navigation to compare page package search (#1377)

Co-authored-by: Salma Alam-Naylor <52798353+whitep4nth3r@users.noreply.github.com>

authored by

abeer0
Salma Alam-Naylor
and committed by
GitHub
c28f5148 eac8537f

+107 -17
+107 -17
app/components/Compare/PackageSelector.vue
··· 14 14 const inputValue = shallowRef('') 15 15 const isInputFocused = shallowRef(false) 16 16 17 + // Keyboard navigation state 18 + const highlightedIndex = shallowRef(-1) 19 + const listRef = useTemplateRef('listRef') 20 + const PAGE_JUMP = 5 21 + 17 22 // Use the shared search composable (supports both npm and Algolia providers) 18 23 const { searchProvider } = useSearchProvider() 19 24 const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 }) ··· 54 59 .filter(r => !packages.value.includes(r.name)) 55 60 }) 56 61 62 + // Unified list of navigable items for keyboard navigation 63 + const navigableItems = computed(() => { 64 + const items: { type: 'no-dependency' | 'package'; name: string }[] = [] 65 + if (showNoDependencyOption.value) { 66 + items.push({ type: 'no-dependency', name: NO_DEPENDENCY_ID }) 67 + } 68 + for (const r of filteredResults.value) { 69 + items.push({ type: 'package', name: r.name }) 70 + } 71 + return items 72 + }) 73 + 74 + const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0)) 75 + 57 76 const numberFormatter = useNumberFormatter() 58 77 59 78 function addPackage(name: string) { ··· 71 90 packages.value = [...packages.value, name] 72 91 } 73 92 inputValue.value = '' 93 + highlightedIndex.value = -1 74 94 } 75 95 76 96 function removePackage(name: string) { ··· 78 98 } 79 99 80 100 function handleKeydown(e: KeyboardEvent) { 81 - const inputValueTrim = inputValue.value.trim() 82 - const hasMatchInPackages = filteredResults.value.find(result => { 83 - return result.name === inputValueTrim 84 - }) 101 + const items = navigableItems.value 102 + const count = items.length 85 103 86 - if (e.key === 'Enter' && inputValueTrim) { 87 - e.preventDefault() 88 - if (showNoDependencyOption.value) { 89 - addPackage(NO_DEPENDENCY_ID) 90 - } else if (hasMatchInPackages) { 91 - addPackage(inputValueTrim) 104 + switch (e.key) { 105 + case 'ArrowDown': 106 + e.preventDefault() 107 + if (count === 0) return 108 + highlightedIndex.value = Math.min(highlightedIndex.value + 1, count - 1) 109 + break 110 + 111 + case 'ArrowUp': 112 + e.preventDefault() 113 + if (count === 0) return 114 + if (highlightedIndex.value > 0) { 115 + highlightedIndex.value-- 116 + } 117 + break 118 + 119 + case 'PageDown': 120 + e.preventDefault() 121 + if (count === 0) return 122 + if (highlightedIndex.value === -1) { 123 + highlightedIndex.value = Math.min(PAGE_JUMP - 1, count - 1) 124 + } else { 125 + highlightedIndex.value = Math.min(highlightedIndex.value + PAGE_JUMP, count - 1) 126 + } 127 + break 128 + 129 + case 'PageUp': 130 + e.preventDefault() 131 + if (count === 0) return 132 + highlightedIndex.value = Math.max(highlightedIndex.value - PAGE_JUMP, 0) 133 + break 134 + 135 + case 'Enter': { 136 + const inputValueTrim = inputValue.value.trim() 137 + if (!inputValueTrim) return 138 + 139 + e.preventDefault() 140 + 141 + // If an item is highlighted, select it 142 + if (highlightedIndex.value >= 0 && highlightedIndex.value < count) { 143 + addPackage(items[highlightedIndex.value]!.name) 144 + return 145 + } 146 + 147 + // Fallback: exact match or easter egg (preserves existing behavior) 148 + if (showNoDependencyOption.value) { 149 + addPackage(NO_DEPENDENCY_ID) 150 + } else { 151 + const hasMatch = filteredResults.value.find(r => r.name === inputValueTrim) 152 + if (hasMatch) { 153 + addPackage(inputValueTrim) 154 + } 155 + } 156 + break 92 157 } 93 - } else if (e.key === 'Escape') { 94 - inputValue.value = '' 158 + 159 + case 'Escape': 160 + inputValue.value = '' 161 + highlightedIndex.value = -1 162 + break 95 163 } 96 164 } 165 + 166 + // Reset highlight when user types 167 + watch(inputValue, () => { 168 + highlightedIndex.value = -1 169 + }) 170 + 171 + // Scroll highlighted item into view 172 + watch(highlightedIndex, index => { 173 + if (index >= 0 && listRef.value) { 174 + const items = listRef.value.querySelectorAll('[data-navigable]') 175 + const item = items[index] as HTMLElement | undefined 176 + item?.scrollIntoView({ block: 'nearest' }) 177 + } 178 + }) 97 179 98 180 const { start, stop } = useTimeoutFn(() => { 99 181 isInputFocused.value = false ··· 176 258 leave-to-class="opacity-0" 177 259 > 178 260 <div 179 - v-if=" 180 - isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption) 181 - " 261 + v-if="isInputFocused && (navigableItems.length > 0 || isSearching)" 262 + ref="listRef" 182 263 class="absolute top-full inset-x-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto" 183 264 > 184 265 <!-- No dependency option (easter egg with James) --> 185 266 <ButtonBase 186 267 v-if="showNoDependencyOption" 268 + data-navigable 187 269 class="block w-full text-start" 270 + :class="highlightedIndex === 0 ? '!bg-accent/15' : ''" 188 271 :aria-label="$t('compare.no_dependency.add_column')" 272 + @mouseenter="highlightedIndex = 0" 189 273 @click="addPackage(NO_DEPENDENCY_ID)" 190 274 > 191 275 <span class="text-sm text-accent italic flex items-center gap-2"> ··· 197 281 </span> 198 282 </ButtonBase> 199 283 200 - <div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted"> 284 + <div 285 + v-if="isSearching && navigableItems.length === 0" 286 + class="px-4 py-3 text-sm text-fg-muted" 287 + > 201 288 {{ $t('compare.selector.searching') }} 202 289 </div> 203 290 <ButtonBase 204 - v-for="result in filteredResults" 291 + v-for="(result, index) in filteredResults" 205 292 :key="result.name" 293 + data-navigable 206 294 class="block w-full text-start" 295 + :class="highlightedIndex === index + resultIndexOffset ? '!bg-accent/15' : ''" 296 + @mouseenter="highlightedIndex = index + resultIndexOffset" 207 297 @click="addPackage(result.name)" 208 298 > 209 299 <span class="font-mono text-sm text-fg block">{{ result.name }}</span>