Skip to content

Commit 814b5cc

Browse files
authored
feat: roving tabindex for PackageManagerTabs (#406)
1 parent 2136df0 commit 814b5cc

3 files changed

Lines changed: 98 additions & 13 deletions

File tree

app/components/PackageManagerTabs.vue

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,55 @@
11
<script setup lang="ts">
22
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+
}
334
</script>
435

536
<template>
637
<div
738
class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md overflow-x-auto"
839
role="tablist"
940
:aria-label="$t('package.get_started.pm_label')"
41+
@keydown="onTabListKeydown"
1042
>
1143
<button
1244
v-for="pm in packageManagers"
1345
:key="pm.id"
46+
:id="`pm-tab-${pm.id}`"
1447
role="tab"
1548
:data-pm-tab="pm.id"
1649
:aria-selected="selectedPM === pm.id"
50+
:aria-controls="`pm-panel-${pm.id}`"
51+
:tabindex="selectedPM === pm.id ? 0 : -1"
52+
type="button"
1753
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"
1854
@click="selectedPM = pm.id"
1955
>

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

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ definePageMeta({
1414
const router = useRouter()
1515
1616
const { packageName, requestedVersion, orgName } = usePackageRoute()
17+
const selectedPM = useSelectedPackageManager()
18+
const activePmId = computed(() => selectedPM.value ?? 'npm')
1719
1820
if (import.meta.server) {
1921
assertValidPackageName(packageName.value)
@@ -845,11 +847,17 @@ function handleClick(event: MouseEvent) {
845847
<!-- Package manager tabs -->
846848
<PackageManagerTabs />
847849
</div>
848-
<ExecuteCommandTerminal
849-
:package-name="pkg.name"
850-
:jsr-info="jsrInfo"
851-
:is-create-package="isCreatePkg"
852-
/>
850+
<div
851+
role="tabpanel"
852+
:id="`pm-panel-${activePmId}`"
853+
:aria-labelledby="`pm-tab-${activePmId}`"
854+
>
855+
<ExecuteCommandTerminal
856+
:package-name="pkg.name"
857+
:jsr-info="jsrInfo"
858+
:is-create-package="isCreatePkg"
859+
/>
860+
</div>
853861
</section>
854862

855863
<!-- Regular packages: Install command with optional run command -->
@@ -873,14 +881,20 @@ function handleClick(event: MouseEvent) {
873881
<!-- Package manager tabs -->
874882
<PackageManagerTabs />
875883
</div>
876-
<InstallCommandTerminal
877-
:package-name="pkg.name"
878-
:requested-version="requestedVersion"
879-
:jsr-info="jsrInfo"
880-
:types-package-name="typesPackageName"
881-
:executable-info="executableInfo"
882-
:create-package-info="createPackageInfo"
883-
/>
884+
<div
885+
role="tabpanel"
886+
:id="`pm-panel-${activePmId}`"
887+
:aria-labelledby="`pm-tab-${activePmId}`"
888+
>
889+
<InstallCommandTerminal
890+
:package-name="pkg.name"
891+
:requested-version="requestedVersion"
892+
:jsr-info="jsrInfo"
893+
:types-package-name="typesPackageName"
894+
:executable-info="executableInfo"
895+
:create-package-info="createPackageInfo"
896+
/>
897+
</div>
884898
</section>
885899

886900
<div class="area-vulns space-y-6">

tests/package-manager-tabs.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
})

0 commit comments

Comments
 (0)