Skip to content

Commit ed0d7b6

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 3a4c729 commit ed0d7b6

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
@@ -800,6 +800,7 @@ function handleClick(event: MouseEvent) {
800800
</h2>
801801
<!-- Package manager tabs -->
802802
<PackageManagerTabs />
803+
<PackageManagerSelect />
803804
</div>
804805
<div
805806
role="tabpanel"
@@ -834,6 +835,7 @@ function handleClick(event: MouseEvent) {
834835
</h2>
835836
<!-- Package manager tabs -->
836837
<PackageManagerTabs />
838+
<PackageManagerSelect />
837839
</div>
838840
<div
839841
role="tabpanel"
@@ -1071,18 +1073,22 @@ function handleClick(event: MouseEvent) {
10711073
grid-area: header;
10721074
overflow-x: hidden;
10731075
}
1076+
10741077
.area-install {
10751078
grid-area: install;
10761079
overflow-x: hidden;
10771080
}
1081+
10781082
.area-vulns {
10791083
grid-area: vulns;
10801084
overflow-x: hidden;
10811085
}
1086+
10821087
.area-readme {
10831088
grid-area: readme;
10841089
overflow-x: hidden;
10851090
}
1091+
10861092
.area-sidebar {
10871093
grid-area: sidebar;
10881094
}

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)