Skip to content

Commit 6dd5750

Browse files
feat: add package manager select dropdown (#379)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 0e8acc5 commit 6dd5750

7 files changed

Lines changed: 261 additions & 152 deletions

File tree

app/components/ConnectorModal.vue

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -125,21 +125,25 @@ watch(open, isOpen => {
125125
>
126126
<span class="text-fg-subtle">$</span>
127127
<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>
143147
</div>
144148

145149
<p class="text-sm text-fg-muted">{{ $t('connector.modal.paste_token') }}</p>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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>

app/components/PackageManagerTabs.vue

Lines changed: 0 additions & 97 deletions
This file was deleted.

app/pages/[...package].vue

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -798,8 +798,8 @@ function handleClick(event: MouseEvent) {
798798
<h2 id="run-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
799799
{{ $t('package.run.title') }}
800800
</h2>
801-
<!-- Package manager tabs -->
802-
<PackageManagerTabs />
801+
<!-- Package manager dropdown -->
802+
<PackageManagerSelect />
803803
</div>
804804
<div
805805
role="tabpanel"
@@ -832,8 +832,8 @@ function handleClick(event: MouseEvent) {
832832
/>
833833
</a>
834834
</h2>
835-
<!-- Package manager tabs -->
836-
<PackageManagerTabs />
835+
<!-- Package manager dropdown -->
836+
<PackageManagerSelect />
837837
</div>
838838
<div
839839
role="tabpanel"
@@ -1071,18 +1071,21 @@ function handleClick(event: MouseEvent) {
10711071
grid-area: header;
10721072
overflow-x: hidden;
10731073
}
1074+
10741075
.area-install {
10751076
grid-area: install;
1076-
overflow-x: hidden;
10771077
}
1078+
10781079
.area-vulns {
10791080
grid-area: vulns;
10801081
overflow-x: hidden;
10811082
}
1083+
10821084
.area-readme {
10831085
grid-area: readme;
10841086
overflow-x: hidden;
10851087
}
1088+
10861089
.area-sidebar {
10871090
grid-area: sidebar;
10881091
}

test/nuxt/components.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import ViewModeToggle from '~/components/ViewModeToggle.vue'
9595
import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue'
9696
import PackageDeprecatedTree from '~/components/PackageDeprecatedTree.vue'
9797
import DependencyPathPopup from '~/components/DependencyPathPopup.vue'
98+
import PackageManagerSelect from '~/components/PackageManagerSelect.vue'
9899

99100
describe('component accessibility audits', () => {
100101
describe('DateTime', () => {
@@ -1293,4 +1294,12 @@ describe('component accessibility audits', () => {
12931294
expect(results.violations).toEqual([])
12941295
})
12951296
})
1297+
1298+
describe('PackageManagerSelect', () => {
1299+
it('should have no accessibility violations', async () => {
1300+
const component = await mountSuspended(PackageManagerSelect)
1301+
const results = await runAxe(component)
1302+
expect(results.violations).toEqual([])
1303+
})
1304+
})
12961305
})

0 commit comments

Comments
 (0)