[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 package manager select dropdown (#379)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Jonathan Yeong
Daniel Roe
and committed by
GitHub
6dd57506 0e8acc5b

+261 -152
+19 -15
app/components/ConnectorModal.vue
··· 125 125 > 126 126 <span class="text-fg-subtle">$</span> 127 127 <span class="text-fg-subtle ms-2">{{ executeNpmxConnectorCommand }}</span> 128 - <button 129 - type="button" 130 - :aria-label=" 131 - copied ? $t('connector.modal.copied') : $t('connector.modal.copy_command') 132 - " 133 - class="ms-auto text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 134 - @click="copyCommand" 135 - > 136 - <span v-if="!copied" class="i-carbon:copy block w-5 h-5" aria-hidden="true" /> 137 - <span 138 - v-else 139 - class="i-carbon:checkmark block w-5 h-5 text-green-500" 140 - aria-hidden="true" 141 - /> 142 - </button> 128 + <div class="ms-auto flex items-center gap-2"> 129 + <PackageManagerSelect /> 130 + 131 + <button 132 + type="button" 133 + :aria-label=" 134 + copied ? $t('connector.modal.copied') : $t('connector.modal.copy_command') 135 + " 136 + class="ms-auto text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 137 + @click="copyCommand" 138 + > 139 + <span v-if="!copied" class="i-carbon:copy block w-5 h-5" aria-hidden="true" /> 140 + <span 141 + v-else 142 + class="i-carbon:checkmark block w-5 h-5 text-green-500" 143 + aria-hidden="true" 144 + /> 145 + </button> 146 + </div> 143 147 </div> 144 148 145 149 <p class="text-sm text-fg-muted">{{ $t('connector.modal.paste_token') }}</p>
+192
app/components/PackageManagerSelect.vue
··· 1 + <script setup lang="ts"> 2 + import { onClickOutside, useEventListener } from '@vueuse/core' 3 + 4 + const selectedPM = useSelectedPackageManager() 5 + 6 + const listRef = useTemplateRef('listRef') 7 + const triggerRef = useTemplateRef('triggerRef') 8 + const isOpen = shallowRef(false) 9 + const highlightedIndex = shallowRef(-1) 10 + 11 + const dropdownPosition = shallowRef<{ top: number; left: number } | null>(null) 12 + 13 + function 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 + 21 + useEventListener('scroll', close, true) 22 + 23 + // Generate unique ID for accessibility 24 + const inputId = useId() 25 + const listboxId = `${inputId}-listbox` 26 + 27 + function 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 + 43 + function close() { 44 + isOpen.value = false 45 + highlightedIndex.value = -1 46 + } 47 + 48 + function select(id: PackageManagerId) { 49 + selectedPM.value = id 50 + close() 51 + triggerRef.value?.focus() 52 + } 53 + 54 + // Check for reduced motion preference 55 + const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') 56 + 57 + onClickOutside(listRef, close, { ignore: [triggerRef] }) 58 + function 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="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-none focus-visible:ring-2 focus-visible:ring-fg/50 hover:text-fg" 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-carbon: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="flex items-center gap-2 px-3 py-1.5 font-mono text-xs cursor-pointer 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-carbon:checkmark 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>
-97
app/components/PackageManagerTabs.vue
··· 1 - <script setup lang="ts"> 2 - const selectedPM = useSelectedPackageManager() 3 - 4 - const tablistNavigationKeys = new Set(['ArrowRight', 'ArrowLeft', 'Home', 'End']) 5 - 6 - function onTabListKeydown(event: KeyboardEvent) { 7 - if (!tablistNavigationKeys.has(event.key)) return 8 - const tablist = event.currentTarget as HTMLElement | null 9 - if (!tablist) return 10 - 11 - const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]')) 12 - const count = Math.min(tabs.length, packageManagers.length) 13 - if (!count) return 14 - 15 - event.preventDefault() 16 - 17 - let activeIndex = packageManagers.findIndex(pm => pm.id === selectedPM.value) 18 - if (activeIndex < 0) activeIndex = 0 19 - 20 - let nextIndex = activeIndex 21 - if (event.key === 'ArrowRight') nextIndex = (activeIndex + 1) % count 22 - if (event.key === 'ArrowLeft') nextIndex = (activeIndex - 1 + count) % count 23 - if (event.key === 'Home') nextIndex = 0 24 - if (event.key === 'End') nextIndex = count - 1 25 - 26 - const nextTab = tabs[nextIndex] 27 - const nextId = packageManagers[nextIndex]?.id 28 - if (nextId && nextId !== selectedPM.value) { 29 - selectedPM.value = nextId 30 - } 31 - 32 - nextTick(() => nextTab?.focus()) 33 - } 34 - </script> 35 - 36 - <template> 37 - <div 38 - class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md overflow-x-auto" 39 - role="tablist" 40 - :aria-label="$t('package.get_started.pm_label')" 41 - @keydown="onTabListKeydown" 42 - > 43 - <button 44 - v-for="pm in packageManagers" 45 - :key="pm.id" 46 - :id="`pm-tab-${pm.id}`" 47 - role="tab" 48 - :data-pm-tab="pm.id" 49 - :aria-selected="selectedPM === pm.id" 50 - :aria-controls="`pm-panel-${pm.id}`" 51 - :tabindex="selectedPM === pm.id ? 0 : -1" 52 - type="button" 53 - class="pm-tab px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 inline-flex items-center gap-1.5 hover:text-fg" 54 - @click="selectedPM = pm.id" 55 - > 56 - <span class="inline-block h-3 w-3" :class="pm.icon" aria-hidden="true" /> 57 - {{ pm.label }} 58 - </button> 59 - </div> 60 - </template> 61 - 62 - <style> 63 - /* 64 - * Package manager tab styling based on data-pm attribute on <html>. 65 - * Selected tab gets highlighted background and border. 66 - */ 67 - 68 - [data-pm-tab] { 69 - --pm-tab-bg: transparent; 70 - --pm-tab-border: transparent; 71 - --pm-tab-shadow: none; 72 - background: var(--pm-tab-bg); 73 - border-color: var(--pm-tab-border); 74 - box-shadow: var(--pm-tab-shadow); 75 - color: var(--fg-subtle); 76 - } 77 - 78 - :root[data-pm='npm'] [data-pm-tab='npm'], 79 - :root[data-pm='pnpm'] [data-pm-tab='pnpm'], 80 - :root[data-pm='yarn'] [data-pm-tab='yarn'], 81 - :root[data-pm='bun'] [data-pm-tab='bun'], 82 - :root[data-pm='deno'] [data-pm-tab='deno'], 83 - :root[data-pm='vlt'] [data-pm-tab='vlt'] { 84 - --pm-tab-bg: var(--bg); 85 - --pm-tab-border: var(--border); 86 - --pm-tab-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 87 - color: var(--fg); 88 - } 89 - 90 - /* Fallback: when no data-pm is set, npm is selected by default */ 91 - :root:not([data-pm]) [data-pm-tab='npm'] { 92 - --pm-tab-bg: var(--bg); 93 - --pm-tab-border: var(--border); 94 - --pm-tab-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 95 - color: var(--fg); 96 - } 97 - </style>
+8 -5
app/pages/[...package].vue
··· 798 798 <h2 id="run-heading" class="text-xs text-fg-subtle uppercase tracking-wider"> 799 799 {{ $t('package.run.title') }} 800 800 </h2> 801 - <!-- Package manager tabs --> 802 - <PackageManagerTabs /> 801 + <!-- Package manager dropdown --> 802 + <PackageManagerSelect /> 803 803 </div> 804 804 <div 805 805 role="tabpanel" ··· 832 832 /> 833 833 </a> 834 834 </h2> 835 - <!-- Package manager tabs --> 836 - <PackageManagerTabs /> 835 + <!-- Package manager dropdown --> 836 + <PackageManagerSelect /> 837 837 </div> 838 838 <div 839 839 role="tabpanel" ··· 1071 1071 grid-area: header; 1072 1072 overflow-x: hidden; 1073 1073 } 1074 + 1074 1075 .area-install { 1075 1076 grid-area: install; 1076 - overflow-x: hidden; 1077 1077 } 1078 + 1078 1079 .area-vulns { 1079 1080 grid-area: vulns; 1080 1081 overflow-x: hidden; 1081 1082 } 1083 + 1082 1084 .area-readme { 1083 1085 grid-area: readme; 1084 1086 overflow-x: hidden; 1085 1087 } 1088 + 1086 1089 .area-sidebar { 1087 1090 grid-area: sidebar; 1088 1091 }
+9
test/nuxt/components.spec.ts
··· 95 95 import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue' 96 96 import PackageDeprecatedTree from '~/components/PackageDeprecatedTree.vue' 97 97 import DependencyPathPopup from '~/components/DependencyPathPopup.vue' 98 + import PackageManagerSelect from '~/components/PackageManagerSelect.vue' 98 99 99 100 describe('component accessibility audits', () => { 100 101 describe('DateTime', () => { ··· 1289 1290 path: ['root@1.0.0', 'dep-a@1.0.0', 'dep-b@2.0.0', 'dep-c@3.0.0', 'vulnerable-pkg@4.0.0'], 1290 1291 }, 1291 1292 }) 1293 + const results = await runAxe(component) 1294 + expect(results.violations).toEqual([]) 1295 + }) 1296 + }) 1297 + 1298 + describe('PackageManagerSelect', () => { 1299 + it('should have no accessibility violations', async () => { 1300 + const component = await mountSuspended(PackageManagerSelect) 1292 1301 const results = await runAxe(component) 1293 1302 expect(results.violations).toEqual([]) 1294 1303 })
+33
tests/package-manager-select.spec.ts
··· 1 + import { expect, test } from '@nuxt/test-utils/playwright' 2 + 3 + test.describe('Package Page', () => { 4 + test('/vue → package manager select dropdown works', async ({ page, goto }) => { 5 + await goto('/vue', { waitUntil: 'domcontentloaded' }) 6 + 7 + const packageManagerButton = page.locator('button[aria-haspopup="listbox"]').first() 8 + await expect(packageManagerButton).toBeVisible() 9 + 10 + // Open dropdown 11 + await packageManagerButton.click() 12 + const packageManagerDropdown = page.locator('[role="listbox"]') 13 + await expect(packageManagerDropdown).toBeVisible() 14 + 15 + // Arrow keys navigate the listbox 16 + await packageManagerButton.press('ArrowDown') 17 + const firstDescendant = await packageManagerDropdown.getAttribute('aria-activedescendant') 18 + await packageManagerButton.press('ArrowDown') 19 + const secondDescendant = await packageManagerDropdown.getAttribute('aria-activedescendant') 20 + expect(secondDescendant).not.toBe(firstDescendant) 21 + 22 + // Escape closes dropdown and returns focus 23 + await packageManagerButton.press('Escape') 24 + await expect(packageManagerDropdown).not.toBeVisible() 25 + await expect(packageManagerButton).toBeFocused() 26 + 27 + // Enter selects option and closes dropdown 28 + await packageManagerButton.click() 29 + await packageManagerButton.press('ArrowDown') 30 + await packageManagerButton.press('Enter') 31 + await expect(packageManagerDropdown).not.toBeVisible() 32 + }) 33 + })
-35
tests/package-manager-tabs.spec.ts
··· 1 - import { expect, test } from '@nuxt/test-utils/playwright' 2 - 3 - test.describe('Package Page', () => { 4 - test('/vue → package manager tabs use roving tabindex', async ({ page, goto }) => { 5 - await goto('/vue', { waitUntil: 'domcontentloaded' }) 6 - 7 - const tablist = page.locator('[role="tablist"]').first() 8 - await expect(tablist).toBeVisible() 9 - 10 - const tabs = tablist.locator('[role="tab"]') 11 - const tabCount = await tabs.count() 12 - expect(tabCount).toBeGreaterThan(1) 13 - 14 - const firstTab = tabs.first() 15 - await firstTab.focus() 16 - await expect(firstTab).toBeFocused() 17 - 18 - await page.keyboard.press('ArrowRight') 19 - 20 - const secondTab = tabs.nth(1) 21 - await expect(secondTab).toBeFocused() 22 - await expect(secondTab).toHaveAttribute('aria-selected', 'true') 23 - await expect(secondTab).toHaveAttribute('tabindex', '0') 24 - await expect(firstTab).toHaveAttribute('tabindex', '-1') 25 - 26 - const tabpanel = page.locator('[role="tabpanel"]').first() 27 - const controls = await secondTab.getAttribute('aria-controls') 28 - const panelId = await tabpanel.getAttribute('id') 29 - expect(controls).toBe(panelId) 30 - 31 - const labelledBy = await tabpanel.getAttribute('aria-labelledby') 32 - const tabId = await secondTab.getAttribute('id') 33 - expect(labelledBy).toBe(tabId) 34 - }) 35 - })