@@ -13,6 +13,9 @@ const {
1313
1414const { user : atprotoUser } = useAtproto ()
1515
16+ const menuButtonRef = useTemplateRef (' menuButtonRef' )
17+ const menuRef = useTemplateRef (' menuRef' )
18+
1619const isOpen = shallowRef (false )
1720
1821/** Check if connected to at least one service */
@@ -32,6 +35,7 @@ onClickOutside(accountMenuRef, () => {
3235
3336useEventListener (' 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