[READ-ONLY] a fast, modern browser for the npm registry
at main 322 lines 10 kB view raw
1<script setup lang="ts"> 2import { NO_DEPENDENCY_ID } from '~/composables/usePackageComparison' 3 4const packages = defineModel<string[]>({ required: true }) 5 6const props = defineProps<{ 7 /** Maximum number of packages allowed */ 8 max?: number 9}>() 10 11const maxPackages = computed(() => props.max ?? 4) 12 13// Input state 14const inputValue = shallowRef('') 15const isInputFocused = shallowRef(false) 16 17// Keyboard navigation state 18const highlightedIndex = shallowRef(-1) 19const listRef = useTemplateRef('listRef') 20const PAGE_JUMP = 5 21 22// Use the shared search composable (supports both npm and Algolia providers) 23const { searchProvider } = useSearchProvider() 24const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 }) 25 26const isSearching = computed(() => status.value === 'pending') 27 28// Trigger strings for "What Would James Do?" typeahead Easter egg 29// Intentionally not localized 30const EASTER_EGG_TRIGGERS = new Set([ 31 'no dep', 32 'none', 33 'vanilla', 34 'diy', 35 'zero', 36 'nothing', 37 '0', 38 "don't", 39 'native', 40 'use the platform', 41]) 42 43// Check if "no dependency" option should show in typeahead 44const showNoDependencyOption = computed(() => { 45 if (packages.value.includes(NO_DEPENDENCY_ID)) return false 46 const input = inputValue.value.toLowerCase().trim() 47 if (!input) return false 48 return EASTER_EGG_TRIGGERS.has(input) 49}) 50 51// Filter out already selected packages 52const filteredResults = computed(() => { 53 if (!searchData.value?.objects) return [] 54 return searchData.value.objects 55 .map(o => ({ 56 name: o.package.name, 57 description: o.package.description, 58 })) 59 .filter(r => !packages.value.includes(r.name)) 60}) 61 62// Unified list of navigable items for keyboard navigation 63const 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 74const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0)) 75 76const numberFormatter = useNumberFormatter() 77 78function addPackage(name: string) { 79 if (packages.value.length >= maxPackages.value) return 80 if (packages.value.includes(name)) return 81 82 // Keep NO_DEPENDENCY_ID always last 83 if (name === NO_DEPENDENCY_ID) { 84 packages.value = [...packages.value, name] 85 } else if (packages.value.includes(NO_DEPENDENCY_ID)) { 86 // Insert before the no-dep entry 87 const withoutNoDep = packages.value.filter(p => p !== NO_DEPENDENCY_ID) 88 packages.value = [...withoutNoDep, name, NO_DEPENDENCY_ID] 89 } else { 90 packages.value = [...packages.value, name] 91 } 92 inputValue.value = '' 93 highlightedIndex.value = -1 94} 95 96function removePackage(name: string) { 97 packages.value = packages.value.filter(p => p !== name) 98} 99 100function handleKeydown(e: KeyboardEvent) { 101 const items = navigableItems.value 102 const count = items.length 103 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 157 } 158 159 case 'Escape': 160 inputValue.value = '' 161 highlightedIndex.value = -1 162 break 163 } 164} 165 166// Reset highlight when user types 167watch(inputValue, () => { 168 highlightedIndex.value = -1 169}) 170 171// Scroll highlighted item into view 172watch(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}) 179 180const { start, stop } = useTimeoutFn(() => { 181 isInputFocused.value = false 182}, 200) 183 184function handleBlur() { 185 start() 186} 187 188function handleFocus() { 189 stop() 190 isInputFocused.value = true 191} 192</script> 193 194<template> 195 <div class="space-y-3"> 196 <!-- Selected packages --> 197 <div v-if="packages.length > 0" class="flex flex-wrap gap-2"> 198 <TagStatic v-for="pkg in packages" :key="pkg"> 199 <!-- No dependency display --> 200 <template v-if="pkg === NO_DEPENDENCY_ID"> 201 <span class="text-sm text-accent italic flex items-center gap-1.5"> 202 <span class="i-lucide:leaf w-3.5 h-3.5" aria-hidden="true" /> 203 {{ $t('compare.no_dependency.label') }} 204 </span> 205 </template> 206 <LinkBase v-else :to="packageRoute(pkg)" class="text-sm"> 207 {{ pkg }} 208 </LinkBase> 209 <ButtonBase 210 size="small" 211 :aria-label=" 212 $t('compare.selector.remove_package', { 213 package: pkg === NO_DEPENDENCY_ID ? $t('compare.no_dependency.label') : pkg, 214 }) 215 " 216 @click="removePackage(pkg)" 217 classicon="i-lucide:x" 218 /> 219 </TagStatic> 220 </div> 221 222 <!-- Add package input --> 223 <div v-if="packages.length < maxPackages" class="relative"> 224 <div class="relative group flex items-center"> 225 <label for="package-search" class="sr-only"> 226 {{ $t('compare.selector.search_label') }} 227 </label> 228 <span 229 class="absolute inset-is-3 text-fg-subtle font-mono text-md pointer-events-none transition-colors duration-200 motion-reduce:transition-none [.group:hover:not(:focus-within)_&]:text-fg/80 group-focus-within:text-accent z-1" 230 > 231 / 232 </span> 233 <InputBase 234 id="package-search" 235 v-model="inputValue" 236 type="text" 237 :placeholder=" 238 packages.length === 0 239 ? $t('compare.selector.search_first') 240 : $t('compare.selector.search_add') 241 " 242 no-correct 243 size="medium" 244 class="w-full min-w-25 ps-7" 245 aria-autocomplete="list" 246 @focus="handleFocus" 247 @blur="handleBlur" 248 @keydown="handleKeydown" 249 /> 250 </div> 251 252 <!-- Search results dropdown --> 253 <Transition 254 enter-active-class="transition-opacity duration-150" 255 enter-from-class="opacity-0" 256 leave-active-class="transition-opacity duration-100" 257 leave-from-class="opacity-100" 258 leave-to-class="opacity-0" 259 > 260 <div 261 v-if="isInputFocused && (navigableItems.length > 0 || isSearching)" 262 ref="listRef" 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" 264 > 265 <!-- No dependency option (easter egg with James) --> 266 <ButtonBase 267 v-if="showNoDependencyOption" 268 data-navigable 269 class="block w-full text-start" 270 :class="highlightedIndex === 0 ? '!bg-accent/15' : ''" 271 :aria-label="$t('compare.no_dependency.add_column')" 272 @mouseenter="highlightedIndex = 0" 273 @click="addPackage(NO_DEPENDENCY_ID)" 274 > 275 <span class="text-sm text-accent italic flex items-center gap-2"> 276 <span class="i-lucide:leaf w-4 h-4" aria-hidden="true" /> 277 {{ $t('compare.no_dependency.typeahead_title') }} 278 </span> 279 <span class="text-xs text-fg-muted truncate mt-0.5"> 280 {{ $t('compare.no_dependency.typeahead_description') }} 281 </span> 282 </ButtonBase> 283 284 <div 285 v-if="isSearching && navigableItems.length === 0" 286 class="px-4 py-3 text-sm text-fg-muted" 287 > 288 {{ $t('compare.selector.searching') }} 289 </div> 290 <ButtonBase 291 v-for="(result, index) in filteredResults" 292 :key="result.name" 293 data-navigable 294 class="block w-full text-start" 295 :class="highlightedIndex === index + resultIndexOffset ? '!bg-accent/15' : ''" 296 @mouseenter="highlightedIndex = index + resultIndexOffset" 297 @click="addPackage(result.name)" 298 > 299 <span class="font-mono text-sm text-fg block">{{ result.name }}</span> 300 <span 301 v-if="result.description" 302 class="text-xs text-fg-muted truncate mt-0.5 w-full block" 303 > 304 {{ result.description }} 305 </span> 306 </ButtonBase> 307 </div> 308 </Transition> 309 </div> 310 311 <!-- Hint --> 312 <p class="text-xs text-fg-subtle"> 313 {{ 314 $t('compare.selector.packages_selected', { 315 count: numberFormatter.format(packages.length), 316 max: numberFormatter.format(maxPackages), 317 }) 318 }} 319 <span v-if="packages.length < 2">{{ $t('compare.selector.add_hint') }}</span> 320 </p> 321 </div> 322</template>