Skip to content

Commit 58dbc53

Browse files
committed
fix: some a11y issues
1 parent 0299f87 commit 58dbc53

1 file changed

Lines changed: 95 additions & 15 deletions

File tree

app/components/DocsVersionSelector.vue

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ const props = defineProps<{
1010
}>()
1111
1212
const isOpen = ref(false)
13-
const dropdownRef = ref<HTMLElement>()
13+
const dropdownRef = useTemplateRef('dropdownRef')
14+
const listboxRef = useTemplateRef('listboxRef')
15+
const focusedIndex = ref(-1)
1416
1517
onClickOutside(dropdownRef, () => {
1618
isOpen.value = false
@@ -19,10 +21,19 @@ onClickOutside(dropdownRef, () => {
1921
/** Maximum number of versions to show in dropdown */
2022
const MAX_VERSIONS = 10
2123
24+
/** Safe version comparison that falls back to string comparison on error */
25+
function safeCompareVersions(a: string, b: string): number {
26+
try {
27+
return compareVersions(a, b)
28+
} catch {
29+
return a.localeCompare(b)
30+
}
31+
}
32+
2233
/** Get sorted list of recent versions with their tags */
2334
const recentVersions = computed(() => {
2435
const versionList = Object.keys(props.versions)
25-
.sort((a, b) => compareVersions(b, a))
36+
.sort((a, b) => safeCompareVersions(b, a))
2637
.slice(0, MAX_VERSIONS)
2738
2839
// Create a map of version -> tags
@@ -49,11 +60,72 @@ function getDocsUrl(version: string): string {
4960
return `/docs/${props.packageName}/v/${version}`
5061
}
5162
52-
function handleKeydown(event: KeyboardEvent) {
63+
function handleButtonKeydown(event: KeyboardEvent) {
5364
if (event.key === 'Escape') {
5465
isOpen.value = false
66+
} else if (event.key === 'ArrowDown' && !isOpen.value) {
67+
event.preventDefault()
68+
isOpen.value = true
69+
focusedIndex.value = 0
70+
}
71+
}
72+
73+
function handleListboxKeydown(event: KeyboardEvent) {
74+
const items = recentVersions.value
75+
76+
switch (event.key) {
77+
case 'Escape':
78+
isOpen.value = false
79+
break
80+
case 'ArrowDown':
81+
event.preventDefault()
82+
focusedIndex.value = Math.min(focusedIndex.value + 1, items.length - 1)
83+
scrollToFocused()
84+
break
85+
case 'ArrowUp':
86+
event.preventDefault()
87+
focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
88+
scrollToFocused()
89+
break
90+
case 'Home':
91+
event.preventDefault()
92+
focusedIndex.value = 0
93+
scrollToFocused()
94+
break
95+
case 'End':
96+
event.preventDefault()
97+
focusedIndex.value = items.length - 1
98+
scrollToFocused()
99+
break
100+
case 'Enter':
101+
case ' ':
102+
event.preventDefault()
103+
if (focusedIndex.value >= 0 && focusedIndex.value < items.length) {
104+
navigateToVersion(items[focusedIndex.value]!.version)
105+
}
106+
break
55107
}
56108
}
109+
110+
function scrollToFocused() {
111+
nextTick(() => {
112+
const focused = listboxRef.value?.querySelector('[data-focused="true"]')
113+
focused?.scrollIntoView({ block: 'nearest' })
114+
})
115+
}
116+
117+
function navigateToVersion(version: string) {
118+
isOpen.value = false
119+
navigateTo(getDocsUrl(version))
120+
}
121+
122+
// Reset focused index when dropdown opens
123+
watch(isOpen, open => {
124+
if (open) {
125+
const currentIdx = recentVersions.value.findIndex(v => v.isCurrent)
126+
focusedIndex.value = currentIdx >= 0 ? currentIdx : 0
127+
}
128+
})
57129
</script>
58130

59131
<template>
@@ -62,9 +134,9 @@ function handleKeydown(event: KeyboardEvent) {
62134
type="button"
63135
aria-haspopup="listbox"
64136
:aria-expanded="isOpen"
65-
class="flex items-center gap-1.5 text-fg-subtle font-mono text-sm hover:text-fg transition-colors"
137+
class="flex items-center gap-1.5 text-fg-subtle font-mono text-sm hover:text-fg transition-[color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg rounded"
66138
@click="isOpen = !isOpen"
67-
@keydown="handleKeydown"
139+
@keydown="handleButtonKeydown"
68140
>
69141
<span>{{ currentVersion }}</span>
70142
<span
@@ -74,36 +146,44 @@ function handleKeydown(event: KeyboardEvent) {
74146
latest
75147
</span>
76148
<span
77-
class="i-carbon-chevron-down w-3.5 h-3.5 transition-transform duration-200"
149+
class="i-carbon-chevron-down w-3.5 h-3.5 transition-[transform] duration-200 motion-reduce:transition-none"
78150
:class="{ 'rotate-180': isOpen }"
79151
aria-hidden="true"
80152
/>
81153
</button>
82154

83155
<Transition
84-
enter-active-class="transition duration-150 ease-out"
156+
enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
85157
enter-from-class="opacity-0 scale-95"
86158
enter-to-class="opacity-100 scale-100"
87-
leave-active-class="transition duration-100 ease-in"
159+
leave-active-class="transition-[opacity,transform] duration-100 ease-in motion-reduce:transition-none"
88160
leave-from-class="opacity-100 scale-100"
89161
leave-to-class="opacity-0 scale-95"
90162
>
91163
<div
92164
v-if="isOpen"
165+
ref="listboxRef"
93166
role="listbox"
94-
:aria-activedescendant="`version-${currentVersion}`"
95-
class="absolute top-full left-0 mt-2 min-w-[180px] bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 max-h-[300px] overflow-y-auto"
96-
@keydown="handleKeydown"
167+
tabindex="0"
168+
:aria-activedescendant="
169+
focusedIndex >= 0 ? `version-${recentVersions[focusedIndex]?.version}` : undefined
170+
"
171+
class="absolute top-full left-0 mt-2 min-w-[180px] bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 max-h-[300px] overflow-y-auto overscroll-contain focus-visible:outline-none"
172+
@keydown="handleListboxKeydown"
97173
>
98174
<NuxtLink
99-
v-for="{ version, tags, isCurrent } in recentVersions"
175+
v-for="({ version, tags, isCurrent }, index) in recentVersions"
100176
:id="`version-${version}`"
101177
:key="version"
102178
:to="getDocsUrl(version)"
103179
role="option"
104180
:aria-selected="isCurrent"
105-
class="flex items-center justify-between gap-3 px-3 py-2 text-sm font-mono hover:bg-bg-muted transition-colors"
106-
:class="isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-muted'"
181+
:data-focused="index === focusedIndex"
182+
class="flex items-center justify-between gap-3 px-3 py-2 text-sm font-mono hover:bg-bg-muted transition-[color,background-color] focus-visible:outline-none focus-visible:bg-bg-muted"
183+
:class="[
184+
isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-muted',
185+
index === focusedIndex ? 'bg-bg-muted' : '',
186+
]"
107187
@click="isOpen = false"
108188
>
109189
<span class="truncate">{{ version }}</span>
@@ -129,7 +209,7 @@ function handleKeydown(event: KeyboardEvent) {
129209
>
130210
<NuxtLink
131211
:to="`/${packageName}`"
132-
class="text-xs text-fg-subtle hover:text-fg transition-colors"
212+
class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg"
133213
@click="isOpen = false"
134214
>
135215
View all {{ Object.keys(versions).length }} versions

0 commit comments

Comments
 (0)