@@ -43,6 +43,8 @@ function getColor(provider: string): string {
4343// Dropdown state
4444const isOpen = ref (false )
4545const dropdownRef = ref <HTMLElement >()
46+ const menuRef = ref <HTMLElement >()
47+ const focusedIndex = ref (- 1 )
4648
4749onClickOutside (dropdownRef , () => {
4850 isOpen .value = false
@@ -52,12 +54,64 @@ onClickOutside(dropdownRef, () => {
5254const hasSingleLink = computed (() => props .links .length === 1 )
5355const hasMultipleLinks = computed (() => props .links .length > 1 )
5456const firstLink = computed (() => props .links [0 ])
57+
58+ function closeDropdown() {
59+ isOpen .value = false
60+ focusedIndex .value = - 1
61+ }
62+
63+ function handleKeydown(event : KeyboardEvent ) {
64+ if (! isOpen .value ) {
65+ if (event .key === ' ArrowDown' || event .key === ' Enter' || event .key === ' ' ) {
66+ event .preventDefault ()
67+ isOpen .value = true
68+ focusedIndex .value = 0
69+ nextTick (() => focusMenuItem (0 ))
70+ }
71+ return
72+ }
73+
74+ switch (event .key ) {
75+ case ' Escape' :
76+ event .preventDefault ()
77+ closeDropdown ()
78+ break
79+ case ' ArrowDown' :
80+ event .preventDefault ()
81+ focusedIndex .value = (focusedIndex .value + 1 ) % props .links .length
82+ focusMenuItem (focusedIndex .value )
83+ break
84+ case ' ArrowUp' :
85+ event .preventDefault ()
86+ focusedIndex .value = focusedIndex .value <= 0 ? props .links .length - 1 : focusedIndex .value - 1
87+ focusMenuItem (focusedIndex .value )
88+ break
89+ case ' Home' :
90+ event .preventDefault ()
91+ focusedIndex .value = 0
92+ focusMenuItem (0 )
93+ break
94+ case ' End' :
95+ event .preventDefault ()
96+ focusedIndex .value = props .links .length - 1
97+ focusMenuItem (props .links .length - 1 )
98+ break
99+ case ' Tab' :
100+ closeDropdown ()
101+ break
102+ }
103+ }
104+
105+ function focusMenuItem(index : number ) {
106+ const items = menuRef .value ?.querySelectorAll <HTMLElement >(' [role="menuitem"]' )
107+ items ?.[index ]?.focus ()
108+ }
55109 </script >
56110
57111<template >
58112 <section v-if =" links.length > 0" aria-labelledby =" playgrounds-heading" >
59113 <h2 id =" playgrounds-heading" class =" text-xs text-fg-subtle uppercase tracking-wider mb-3" >
60- Playgrounds
114+ Try it out
61115 </h2 >
62116
63117 <div ref =" dropdownRef" class =" relative" >
@@ -67,7 +121,7 @@ const firstLink = computed(() => props.links[0])
67121 :href =" firstLink.url"
68122 target =" _blank"
69123 rel =" noopener noreferrer"
70- class =" w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated transition-colors duration-200"
124+ class =" w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200"
71125 >
72126 <span
73127 :class =" [getIcon(firstLink.provider), getColor(firstLink.provider), 'w-4 h-4 shrink-0']"
@@ -81,40 +135,47 @@ const firstLink = computed(() => props.links[0])
81135 <button
82136 v-if =" hasMultipleLinks"
83137 type =" button"
84- class =" w-full flex items-center justify-between gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated transition-colors duration-200"
138+ aria-haspopup =" true"
139+ :aria-expanded =" isOpen"
140+ class =" w-full flex items-center justify-between gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200"
85141 @click =" isOpen = !isOpen"
142+ @keydown =" handleKeydown"
86143 >
87144 <span class =" flex items-center gap-2" >
88145 <span class =" i-carbon-play w-4 h-4 shrink-0 text-fg-muted" aria-hidden =" true" />
89146 <span class =" text-fg-muted" >{{ links.length }} Playgrounds</span >
90147 </span >
91148 <span
92- class =" i-carbon-chevron-down w-3 h-3 text-fg-subtle transition-transform duration-200"
149+ class =" i-carbon-chevron-down w-3 h-3 text-fg-subtle transition-transform duration-200 motion-reduce:transition-none "
93150 :class =" { 'rotate-180': isOpen }"
94151 aria-hidden =" true"
95152 />
96153 </button >
97154
98155 <!-- Dropdown menu -->
99156 <Transition
100- enter-active-class =" transition duration-150 ease-out"
101- enter-from-class =" opacity-0 scale-95"
157+ enter-active-class =" transition duration-150 ease-out motion-reduce:transition-none "
158+ enter-from-class =" opacity-0 scale-95 motion-reduce:scale-100 "
102159 enter-to-class =" opacity-100 scale-100"
103- leave-active-class =" transition duration-100 ease-in"
160+ leave-active-class =" transition duration-100 ease-in motion-reduce:transition-none "
104161 leave-from-class =" opacity-100 scale-100"
105- leave-to-class =" opacity-0 scale-95"
162+ leave-to-class =" opacity-0 scale-95 motion-reduce:scale-100 "
106163 >
107164 <div
108165 v-if =" isOpen && hasMultipleLinks"
166+ ref =" menuRef"
167+ role =" menu"
109168 class =" absolute top-full left-0 right-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 overflow-visible"
169+ @keydown =" handleKeydown"
110170 >
111171 <AppTooltip v-for =" link in links" :key =" link.url" :text =" link.providerName" class =" block" >
112172 <a
113173 :href =" link.url"
114174 target =" _blank"
115175 rel =" noopener noreferrer"
116- class =" flex items-center gap-2 px-3 py-2 text-sm font-mono text-fg-muted hover:text-fg hover:bg-bg-muted transition-colors duration-150"
117- @click =" isOpen = false"
176+ role =" menuitem"
177+ class =" flex items-center gap-2 px-3 py-2 text-sm font-mono text-fg-muted hover:text-fg hover:bg-bg-muted focus-visible:outline-none focus-visible:text-fg focus-visible:bg-bg-muted transition-colors duration-150"
178+ @click =" closeDropdown"
118179 >
119180 <span
120181 :class =" [getIcon(link.provider), getColor(link.provider), 'w-4 h-4 shrink-0']"
0 commit comments