forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import { onClickOutside, useEventListener } from '@vueuse/core'
3
4const selectedPM = useSelectedPackageManager()
5
6const listRef = useTemplateRef('listRef')
7const triggerRef = useTemplateRef('triggerRef')
8const isOpen = shallowRef(false)
9const highlightedIndex = shallowRef(-1)
10
11const dropdownPosition = shallowRef<{ top: number; left: number } | null>(null)
12
13function getDropdownStyle(): Record<string, string> {
14 if (!dropdownPosition.value) return {}
15 return {
16 top: `${dropdownPosition.value.top}px`,
17 left: `${dropdownPosition.value.left}px`,
18 }
19}
20
21useEventListener('scroll', close, true)
22
23// Generate unique ID for accessibility
24const inputId = useId()
25const listboxId = `${inputId}-listbox`
26
27function toggle() {
28 if (isOpen.value) {
29 close()
30 } else {
31 if (triggerRef.value) {
32 const rect = triggerRef.value.getBoundingClientRect()
33 dropdownPosition.value = {
34 top: rect.bottom + 4,
35 left: rect.left,
36 }
37 }
38 isOpen.value = true
39 highlightedIndex.value = packageManagers.findIndex(pm => pm.id === selectedPM.value)
40 }
41}
42
43function close() {
44 isOpen.value = false
45 highlightedIndex.value = -1
46}
47
48function select(id: PackageManagerId) {
49 selectedPM.value = id
50 close()
51 triggerRef.value?.focus()
52}
53
54// Check for reduced motion preference
55const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
56
57onClickOutside(listRef, close, { ignore: [triggerRef] })
58function handleKeydown(event: KeyboardEvent) {
59 if (!isOpen.value) return
60
61 switch (event.key) {
62 case 'ArrowDown':
63 event.preventDefault()
64 highlightedIndex.value = (highlightedIndex.value + 1) % packageManagers.length
65 break
66 case 'ArrowUp':
67 event.preventDefault()
68 highlightedIndex.value =
69 highlightedIndex.value <= 0 ? packageManagers.length - 1 : highlightedIndex.value - 1
70 break
71 case 'Enter': {
72 event.preventDefault()
73 const pm = packageManagers[highlightedIndex.value]
74 if (pm) {
75 select(pm.id)
76 }
77 break
78 }
79 case 'Escape':
80 close()
81 triggerRef.value?.focus()
82 break
83 }
84}
85</script>
86
87<template>
88 <button
89 ref="triggerRef"
90 type="button"
91 class="cursor-pointer flex items-center gap-1.5 px-2 py-2 font-mono text-xs text-fg-muted bg-bg-subtle border border-border-subtle border-solid rounded-md transition-colors duration-150 hover:(text-fg border-border-hover) active:scale-95 focus:border-border-hover focus-visible:outline-accent/70"
92 :aria-expanded="isOpen"
93 aria-haspopup="listbox"
94 :aria-label="$t('package.get_started.pm_label')"
95 :aria-controls="listboxId"
96 @click="toggle"
97 @keydown="handleKeydown"
98 >
99 <template v-for="pmOption in packageManagers" :key="pmOption.id">
100 <span
101 class="inline-block h-3 w-3 pm-select-content"
102 :class="pmOption.icon"
103 :data-pm-select="pmOption.id"
104 aria-hidden="true"
105 />
106 <span
107 class="pm-select-content"
108 :data-pm-select="pmOption.id"
109 :aria-hidden="pmOption.id !== selectedPM"
110 >{{ pmOption.label }}</span
111 >
112 </template>
113 <span
114 class="i-lucide:chevron-down w-3 h-3"
115 :class="[
116 { 'rotate-180': isOpen },
117 prefersReducedMotion ? '' : 'transition-transform duration-200',
118 ]"
119 aria-hidden="true"
120 />
121 </button>
122
123 <!-- Dropdown menu (teleported to body to avoid clipping) -->
124 <Teleport to="body">
125 <Transition
126 :enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'"
127 :enter-from-class="prefersReducedMotion ? '' : 'opacity-0'"
128 enter-to-class="opacity-100"
129 :leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'"
130 leave-from-class="opacity-100"
131 :leave-to-class="prefersReducedMotion ? '' : 'opacity-0'"
132 >
133 <ul
134 v-if="isOpen"
135 :id="listboxId"
136 ref="listRef"
137 role="listbox"
138 :aria-activedescendant="
139 highlightedIndex >= 0
140 ? `${listboxId}-${packageManagers[highlightedIndex]?.id}`
141 : undefined
142 "
143 :aria-label="$t('package.get_started.pm_label')"
144 :style="getDropdownStyle()"
145 class="fixed bg-bg-subtle border border-border rounded-md shadow-lg z-50"
146 >
147 <li
148 v-for="(pm, index) in packageManagers"
149 :id="`${listboxId}-${pm.id}`"
150 :key="pm.id"
151 role="option"
152 :aria-selected="selectedPM === pm.id"
153 class="cursor-pointer flex items-center gap-2 px-3 py-1.5 font-mono text-xs transition-colors duration-150"
154 :class="[
155 selectedPM === pm.id ? 'text-fg' : 'text-fg-subtle',
156 highlightedIndex === index ? 'bg-bg-elevated' : 'hover:bg-bg-elevated',
157 ]"
158 @click="select(pm.id)"
159 @mouseenter="highlightedIndex = index"
160 >
161 <span class="inline-block h-3 w-3" :class="pm.icon" aria-hidden="true" />
162 <span>{{ pm.label }}</span>
163 <span
164 v-if="selectedPM === pm.id"
165 class="i-lucide:check w-3 h-3 text-accent ms-auto"
166 aria-hidden="true"
167 />
168 </li>
169 </ul>
170 </Transition>
171 </Teleport>
172</template>
173
174<style>
175:root[data-pm] .pm-select-content {
176 display: none;
177}
178
179:root[data-pm='npm'] [data-pm-select='npm'],
180:root[data-pm='pnpm'] [data-pm-select='pnpm'],
181:root[data-pm='yarn'] [data-pm-select='yarn'],
182:root[data-pm='bun'] [data-pm-select='bun'],
183:root[data-pm='deno'] [data-pm-select='deno'],
184:root[data-pm='vlt'] [data-pm-select='vlt'] {
185 display: inline-block;
186}
187
188/* Fallback: when no data-pm is set, npm is selected by default */
189:root:not([data-pm]) .pm-select-content:not([data-pm-select='npm']) {
190 display: none;
191}
192</style>