forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>