Skip to content

Commit 6ece1eb

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 e4181fc commit 6ece1eb

4 files changed

Lines changed: 194 additions & 69 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: 16 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -926,29 +926,8 @@ defineOgImageComponent('Package', {
926926
<h2 id="run-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
927927
{{ $t('package.run.title') }}
928928
</h2>
929-
<!-- Package manager tabs -->
930-
<div
931-
class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md"
932-
role="tablist"
933-
aria-label="Package manager"
934-
>
935-
<button
936-
v-for="pm in packageManagers"
937-
:key="pm.id"
938-
role="tab"
939-
:aria-selected="isMounted && selectedPM === pm.id"
940-
class="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"
941-
:class="
942-
isMounted && selectedPM === pm.id
943-
? 'bg-bg shadow text-fg border-border'
944-
: 'text-fg-subtle hover:text-fg border-transparent'
945-
"
946-
@click="selectedPM = pm.id"
947-
>
948-
<span class="inline-block h-3 w-3" :class="pm.icon" aria-hidden="true" />
949-
{{ pm.label }}
950-
</button>
951-
</div>
929+
<!-- Package manager dropdown -->
930+
<PackageManagerSelect />
952931
</div>
953932
<div class="relative group">
954933
<!-- Terminal-style execute command -->
@@ -962,8 +941,8 @@ defineOgImageComponent('Package', {
962941
<!-- Execute command -->
963942
<div class="flex items-center gap-2 group/executecmd">
964943
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
965-
<code class="font-mono text-sm"
966-
><ClientOnly
944+
<code class="font-mono text-sm">
945+
<ClientOnly
967946
><span
968947
v-for="(part, i) in executeCommandParts"
969948
:key="i"
@@ -973,8 +952,8 @@ defineOgImageComponent('Package', {
973952
><span class="text-fg">npx</span
974953
><span class="text-fg-muted"> {{ pkg.name }}</span></template
975954
></ClientOnly
976-
></code
977-
>
955+
>
956+
</code>
978957
<button
979958
type="button"
980959
class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/executecmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@@ -1011,29 +990,8 @@ defineOgImageComponent('Package', {
1011990
/>
1012991
</a>
1013992
</h2>
1014-
<!-- Package manager tabs -->
1015-
<div
1016-
class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md overflow-x-auto"
1017-
role="tablist"
1018-
:aria-label="$t('package.get_started.pm_label')"
1019-
>
1020-
<button
1021-
v-for="pm in packageManagers"
1022-
:key="pm.id"
1023-
role="tab"
1024-
:aria-selected="isMounted && selectedPM === pm.id"
1025-
class="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"
1026-
:class="
1027-
isMounted && selectedPM === pm.id
1028-
? 'bg-bg shadow text-fg border-border'
1029-
: 'text-fg-subtle hover:text-fg border-transparent'
1030-
"
1031-
@click="selectedPM = pm.id"
1032-
>
1033-
<span class="inline-block h-3 w-3" :class="pm.icon" aria-hidden="true" />
1034-
{{ pm.label }}
1035-
</button>
1036-
</div>
993+
<!-- Package manager dropdown -->
994+
<PackageManagerSelect />
1037995
</div>
1038996
<div class="relative group">
1039997
<!-- Terminal-style install command -->
@@ -1047,8 +1005,8 @@ defineOgImageComponent('Package', {
10471005
<!-- Install command -->
10481006
<div class="flex items-center gap-2 group/installcmd min-w-0">
10491007
<span class="text-fg-subtle font-mono text-sm select-none shrink-0">$</span>
1050-
<code class="font-mono text-sm min-w-0"
1051-
><ClientOnly
1008+
<code class="font-mono text-sm min-w-0">
1009+
<ClientOnly
10521010
><span
10531011
v-for="(part, i) in installCommandParts"
10541012
:key="i"
@@ -1058,8 +1016,8 @@ defineOgImageComponent('Package', {
10581016
><span class="text-fg">npm</span
10591017
><span class="text-fg-muted"> install {{ pkg.name }}</span></template
10601018
></ClientOnly
1061-
></code
1062-
>
1019+
>
1020+
</code>
10631021
<button
10641022
type="button"
10651023
class="px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/installcmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@@ -1410,18 +1368,22 @@ defineOgImageComponent('Package', {
14101368
grid-area: header;
14111369
overflow-x: hidden;
14121370
}
1371+
14131372
.area-install {
14141373
grid-area: install;
14151374
overflow-x: hidden;
14161375
}
1376+
14171377
.area-vulns {
14181378
grid-area: vulns;
14191379
overflow-x: hidden;
14201380
}
1381+
14211382
.area-readme {
14221383
grid-area: readme;
14231384
overflow-x: hidden;
14241385
}
1386+
14251387
.area-sidebar {
14261388
grid-area: sidebar;
14271389
}

test/nuxt/components.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import PaginationControls from '~/components/PaginationControls.vue'
9494
import ViewModeToggle from '~/components/ViewModeToggle.vue'
9595
import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue'
9696
import DependencyPathPopup from '~/components/DependencyPathPopup.vue'
97+
import PackageManagerSelect from '~/components/PackageManagerSelect.vue'
9798

9899
describe('component accessibility audits', () => {
99100
describe('DateTime', () => {
@@ -1279,4 +1280,12 @@ describe('component accessibility audits', () => {
12791280
expect(results.violations).toEqual([])
12801281
})
12811282
})
1283+
1284+
describe('PackageManagerSelect', () => {
1285+
it('should have no accessibility violations', async () => {
1286+
const component = await mountSuspended(PackageManagerSelect)
1287+
const results = await runAxe(component)
1288+
expect(results.violations).toEqual([])
1289+
})
1290+
})
12821291
})

0 commit comments

Comments
 (0)