[READ-ONLY] a fast, modern browser for the npm registry
at main 192 lines 5.8 kB view raw
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>