@@ -10,7 +10,9 @@ const props = defineProps<{
1010}>()
1111
1212const isOpen = ref (false )
13- const dropdownRef = ref <HTMLElement >()
13+ const dropdownRef = useTemplateRef (' dropdownRef' )
14+ const listboxRef = useTemplateRef (' listboxRef' )
15+ const focusedIndex = ref (- 1 )
1416
1517onClickOutside (dropdownRef , () => {
1618 isOpen .value = false
@@ -19,10 +21,19 @@ onClickOutside(dropdownRef, () => {
1921/** Maximum number of versions to show in dropdown */
2022const 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 */
2334const 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