Skip to content

Commit 4e3db5c

Browse files
committed
feat: add package manager select dropdown
This replaces the tab list so it's extra clear to the user what package manager they've selected. It also lets use use it in the connector modal to be explicit on the package manager choice.
1 parent 228de60 commit 4e3db5c

4 files changed

Lines changed: 184 additions & 15 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="ml-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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<script setup lang="ts">
2+
import { onClickOutside } 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+
// Generate unique ID for accessibility
12+
const inputId = useId()
13+
const listboxId = `${inputId}-listbox`
14+
15+
const pm = computed(() => {
16+
return packageManagers.find(p => p.id === selectedPM.value) ?? packageManagers[0]
17+
})
18+
19+
function toggle() {
20+
if (isOpen.value) {
21+
close()
22+
} else {
23+
isOpen.value = true
24+
highlightedIndex.value = packageManagers.findIndex(pm => pm.id === selectedPM.value)
25+
}
26+
}
27+
28+
function close() {
29+
isOpen.value = false
30+
highlightedIndex.value = -1
31+
}
32+
33+
function select(id: PackageManagerId) {
34+
selectedPM.value = id
35+
close()
36+
triggerRef.value?.focus()
37+
}
38+
39+
// Check for reduced motion preference
40+
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
41+
42+
onClickOutside(listRef, close, { ignore: [triggerRef] })
43+
function handleKeydown(event: KeyboardEvent) {
44+
if (!isOpen.value) return
45+
46+
switch (event.key) {
47+
case 'ArrowDown':
48+
event.preventDefault()
49+
highlightedIndex.value = (highlightedIndex.value + 1) % packageManagers.length
50+
break
51+
case 'ArrowUp':
52+
event.preventDefault()
53+
highlightedIndex.value =
54+
highlightedIndex.value <= 0 ? packageManagers.length - 1 : highlightedIndex.value - 1
55+
break
56+
case 'Enter': {
57+
event.preventDefault()
58+
const pm = packageManagers[highlightedIndex.value]
59+
if (pm) {
60+
select(pm.id)
61+
}
62+
break
63+
}
64+
case 'Escape':
65+
close()
66+
triggerRef.value?.focus()
67+
break
68+
}
69+
}
70+
</script>
71+
72+
<template>
73+
<div class="relative">
74+
<button
75+
ref="triggerRef"
76+
type="button"
77+
class="inline-flex items-center gap-1 px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 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"
78+
:aria-expanded="isOpen"
79+
aria-haspopup="listbox"
80+
:aria-label="$t('settings.package_manager')"
81+
:aria-controls="listboxId"
82+
@click="toggle"
83+
@keydown="handleKeydown"
84+
>
85+
<ClientOnly>
86+
<span class="inline-block h-3 w-3" :class="pm.icon" aria-hidden="true" />
87+
<span>{{ pm.label }}</span>
88+
<template #fallback>
89+
<span class="inline-block h-3 w-3 i-simple-icons:npm" aria-hidden="true" />
90+
<span>npm</span>
91+
</template>
92+
</ClientOnly>
93+
<span
94+
class="i-carbon-chevron-down w-3 h-3"
95+
:class="[
96+
{ 'rotate-180': isOpen },
97+
prefersReducedMotion ? '' : 'transition-transform duration-200',
98+
]"
99+
aria-hidden="true"
100+
/>
101+
</button>
102+
103+
<!-- Dropdown menu -->
104+
<Transition
105+
:enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'"
106+
:enter-from-class="prefersReducedMotion ? '' : 'opacity-0'"
107+
enter-to-class="opacity-100"
108+
:leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'"
109+
leave-from-class="opacity-100"
110+
:leave-to-class="prefersReducedMotion ? '' : 'opacity-0'"
111+
>
112+
<ul
113+
v-if="isOpen"
114+
:id="listboxId"
115+
ref="listRef"
116+
role="listbox"
117+
:aria-activedescendant="
118+
highlightedIndex >= 0
119+
? `${listboxId}-${packageManagers[highlightedIndex]?.id}`
120+
: undefined
121+
"
122+
:aria-label="$t('settings.package_manager')"
123+
class="absolute right-0 top-full mt-1 min-w-28 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 overflow-hidden py-1"
124+
>
125+
<li
126+
v-for="(pm, index) in packageManagers"
127+
:id="`${listboxId}-${pm.id}`"
128+
:key="pm.id"
129+
role="option"
130+
:aria-selected="selectedPM === pm.id"
131+
class="flex items-center gap-2 px-3 py-1.5 font-mono text-xs cursor-pointer transition-colors duration-150"
132+
:class="[
133+
selectedPM === pm.id ? 'text-fg' : 'text-fg-subtle',
134+
highlightedIndex === index ? 'bg-bg-subtle' : 'hover:bg-bg-subtle',
135+
]"
136+
@click="select(pm.id)"
137+
@mouseenter="highlightedIndex = index"
138+
>
139+
<span class="inline-block h-3 w-3 shrink-0" :class="pm.icon" aria-hidden="true" />
140+
<span>{{ pm.label }}</span>
141+
<span
142+
v-if="selectedPM === pm.id"
143+
class="i-carbon-checkmark w-3 h-3 text-accent ml-auto shrink-0"
144+
aria-hidden="true"
145+
/>
146+
</li>
147+
</ul>
148+
</Transition>
149+
</div>
150+
</template>

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,7 @@ function handleClick(event: MouseEvent) {
846846
</h2>
847847
<!-- Package manager tabs -->
848848
<PackageManagerTabs />
849+
<PackageManagerSelect />
849850
</div>
850851
<div
851852
role="tabpanel"
@@ -880,6 +881,7 @@ function handleClick(event: MouseEvent) {
880881
</h2>
881882
<!-- Package manager tabs -->
882883
<PackageManagerTabs />
884+
<PackageManagerSelect />
883885
</div>
884886
<div
885887
role="tabpanel"
@@ -1117,18 +1119,22 @@ function handleClick(event: MouseEvent) {
11171119
grid-area: header;
11181120
overflow-x: hidden;
11191121
}
1122+
11201123
.area-install {
11211124
grid-area: install;
11221125
overflow-x: hidden;
11231126
}
1127+
11241128
.area-vulns {
11251129
grid-area: vulns;
11261130
overflow-x: hidden;
11271131
}
1132+
11281133
.area-readme {
11291134
grid-area: readme;
11301135
overflow-x: hidden;
11311136
}
1137+
11321138
.area-sidebar {
11331139
grid-area: sidebar;
11341140
}

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)