Skip to content

Commit 64c22f8

Browse files
committed
fix(a11y): use a roving tabindex for connect menu
1 parent e939665 commit 64c22f8

File tree

1 file changed

+85
-2
lines changed

1 file changed

+85
-2
lines changed

app/components/Header/AccountMenu.client.vue

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const {
1313
1414
const { user: atprotoUser } = useAtproto()
1515
16+
const menuButtonRef = useTemplateRef('menuButtonRef')
17+
const menuRef = useTemplateRef('menuRef')
18+
1619
const isOpen = shallowRef(false)
1720
1821
/** Check if connected to at least one service */
@@ -32,6 +35,7 @@ onClickOutside(accountMenuRef, () => {
3235
3336
useEventListener('keydown', event => {
3437
if (event.key === 'Escape' && isOpen.value) {
38+
menuButtonRef.value?.focus()
3539
isOpen.value = false
3640
}
3741
})
@@ -53,14 +57,82 @@ function openAuthModal() {
5357
authModal.open()
5458
}
5559
}
60+
61+
watch(menuRef, () => {
62+
if (!menuRef.value) return
63+
// Set up focus for the first menu item
64+
const firstMenuItem = menuRef.value.querySelector('[role="menuitem"]') as HTMLButtonElement
65+
firstMenuItem.tabIndex = 0
66+
firstMenuItem.focus()
67+
})
68+
69+
const menuItemNavKeys = {
70+
next: 'ArrowDown',
71+
prev: 'ArrowUp',
72+
start: 'Home',
73+
end: 'End',
74+
}
75+
76+
function onMenuBlurWithin() {
77+
requestAnimationFrame(() => {
78+
if (!menuRef.value?.contains(document.activeElement)) {
79+
isOpen.value = false
80+
}
81+
})
82+
}
83+
84+
/**
85+
* Use a roving tabindex for the menu widget
86+
*/
87+
function onMenuKeyDown(event: KeyboardEvent) {
88+
const menu = event.currentTarget as HTMLElement
89+
if (!menu) return
90+
91+
// Collect the menu items (i.e. focusable candidates)
92+
const menuItems: HTMLElement[] = Array.from(menu.querySelectorAll('[role="menuitem"]'))
93+
// Find the current item
94+
let currentIndex = menuItems.findIndex(menuItem => menuItem.tabIndex !== -1)
95+
let currentMenuItem = menuItems.at(currentIndex)!
96+
97+
switch (event.key) {
98+
case menuItemNavKeys.prev:
99+
currentIndex = mod(currentIndex - 1, menuItems.length)
100+
break
101+
case menuItemNavKeys.next:
102+
currentIndex = mod(currentIndex + 1, menuItems.length)
103+
break
104+
case menuItemNavKeys.start:
105+
currentIndex = 0
106+
break
107+
case menuItemNavKeys.end:
108+
currentIndex = menuItems.length - 1
109+
break
110+
default:
111+
// Ignore all other keys
112+
return
113+
}
114+
115+
event.preventDefault()
116+
117+
currentMenuItem.tabIndex = -1
118+
// Update and focus the new current item
119+
currentMenuItem = menuItems.at(currentIndex)!
120+
currentMenuItem.tabIndex = 0
121+
currentMenuItem.focus()
122+
}
123+
124+
function mod(n: number, m: number): number {
125+
return ((n % m) + m) % m
126+
}
56127
</script>
57128

58129
<template>
59130
<div ref="accountMenuRef" class="relative flex min-w-28 justify-end">
60131
<ButtonBase
132+
ref="menuButtonRef"
61133
type="button"
62134
:aria-expanded="isOpen"
63-
aria-haspopup="true"
135+
aria-haspopup="menu"
64136
@click="isOpen = !isOpen"
65137
class="border-none"
66138
>
@@ -135,7 +207,14 @@ function openAuthModal() {
135207
enter-from-class="opacity-0 translate-y-1"
136208
leave-to-class="opacity-0 translate-y-1"
137209
>
138-
<div v-if="isOpen" class="absolute inset-ie-0 top-full pt-2 w-72 z-50" role="menu">
210+
<div
211+
v-if="isOpen"
212+
class="absolute inset-ie-0 top-full pt-2 w-72 z-50"
213+
ref="menuRef"
214+
role="menu"
215+
@blur.capture="onMenuBlurWithin"
216+
@keydown="onMenuKeyDown"
217+
>
139218
<div
140219
class="bg-bg-subtle/80 backdrop-blur-sm border border-border-subtle rounded-lg shadow-lg shadow-bg-elevated/50 overflow-hidden px-1"
141220
>
@@ -145,6 +224,7 @@ function openAuthModal() {
145224
<ButtonBase
146225
v-if="isNpmConnected && npmUser"
147226
role="menuitem"
227+
tabindex="-1"
148228
class="w-full text-start gap-x-3 border-none"
149229
@click="openConnectorModal"
150230
out
@@ -188,6 +268,7 @@ function openAuthModal() {
188268
<ButtonBase
189269
v-if="atprotoUser"
190270
role="menuitem"
271+
tabindex="-1"
191272
class="w-full text-start gap-x-3 border-none"
192273
@click="openAuthModal"
193274
>
@@ -225,6 +306,7 @@ function openAuthModal() {
225306
<ButtonBase
226307
v-if="!isNpmConnected"
227308
role="menuitem"
309+
tabindex="-1"
228310
class="w-full text-start gap-x-3 border-none"
229311
@click="openConnectorModal"
230312
>
@@ -251,6 +333,7 @@ function openAuthModal() {
251333
<ButtonBase
252334
v-if="!atprotoUser"
253335
role="menuitem"
336+
tabindex="-1"
254337
class="w-full text-start gap-x-3 border-none"
255338
@click="openAuthModal"
256339
>

0 commit comments

Comments
 (0)