Skip to content

fix(a11y): use a roving tabindex for connect menu#1849

Draft
knowler wants to merge 3 commits intomainfrom
knowler/connect-menu-correct-behaviour
Draft

fix(a11y): use a roving tabindex for connect menu#1849
knowler wants to merge 3 commits intomainfrom
knowler/connect-menu-correct-behaviour

Conversation

@knowler
Copy link
Member

@knowler knowler commented Mar 2, 2026

A menu (composite) widget is navigated with arrow keys as opposed to the Tab key. This means we need to implement a roving tabindex for our connect menu.

Here is the expected behaviour:

  • When a user activates the “connect” menu button, it should open the menu and focus the first item.
  • When the user presses the down arrow key, it navigates to the next menu item.
  • When the user presses the up arrow key, it navigates to the previous menu item.
  • When the user presses the Home or End keys, they navigate to the first or last menu item respectively (optional).
  • When a user presses Escape, tabs, or clicks away from the menu, the menu should close. In the first case, the focus should return to the menu button.

Since role=menu has an implicit aria-orientation=vertical property, I’m just implementing the up/down arrow keys. It could be worth just doing up/left and down/right in all cases as the direction isn’t often exposed to users.

I’ve done my best with the Vue and TypeScript code, however, I imagine I’m doing a few things a bit strange, so feel free to edit or make suggestions.

@vercel
Copy link

vercel bot commented Mar 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 2, 2026 10:39pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 2, 2026 10:39pm
npmx-lunaria Ignored Ignored Mar 2, 2026 10:39pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ed91478 and 1b4003b.

📒 Files selected for processing (1)
  • test/e2e/connector.spec.ts

📝 Walkthrough

Walkthrough

Adds roving tabindex and keyboard navigation to the AccountMenu dropdown: introduces refs for the menu button and menu container, initialises and manages focus on the first menu item when opened, implements ArrowDown/ArrowUp/Home/End navigation with circular traversal and a safe index wrap helper, handles Escape to focus the menu button before closing, closes the menu when focus leaves the menu region, converts menu item buttons to tabindex="-1", and updates aria-haspopup to "menu".

Possibly related PRs

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly explains the implementation of roving tabindex for a WAI-ARIA menu widget, detailing expected behaviour and implementation approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch knowler/connect-menu-correct-behaviour

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 929e2e5 and 64c22f8.

📒 Files selected for processing (1)
  • app/components/Header/AccountMenu.client.vue

@codecov
Copy link

codecov bot commented Mar 2, 2026

Codecov Report

❌ Patch coverage is 12.50000% with 35 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/Header/AccountMenu.client.vue 12.50% 29 Missing and 6 partials ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
app/components/Header/AccountMenu.client.vue (1)

61-70: ⚠️ Potential issue | 🟡 Minor

Avoid hard throws in focus/index edge paths; fail gracefully instead

Line 66, Line 100, and Line 123 throw for recoverable UI states. In keyboard/focus handlers this can surface runtime errors instead of safely closing or no-oping the menu. Also, ensure the “active item not found” path is handled explicitly before dereferencing the current item.

Suggested patch
 watch(menuRef, () => {
   if (!menuRef.value) return
   // Set up focus for the first menu item
-  const firstMenuItem = menuRef.value.querySelector('[role="menuitem"]') as HTMLButtonElement
+  const firstMenuItem = menuRef.value.querySelector<HTMLElement>('[role="menuitem"]')
   if (!firstMenuItem) {
-    throw new Error('Cannot find a menuitem to focus')
+    isOpen.value = false
+    return
   }
   firstMenuItem.tabIndex = 0
   firstMenuItem.focus()
 })
@@
 function onMenuKeyDown(event: KeyboardEvent) {
   const menu = event.currentTarget as HTMLElement
   if (!menu) return
 
   // Collect the menu items (i.e. focusable candidates)
   const menuItems: HTMLElement[] = Array.from(menu.querySelectorAll('[role="menuitem"]'))
+  if (menuItems.length === 0) {
+    isOpen.value = false
+    return
+  }
   // Find the current item
-  let currentIndex = menuItems.findIndex(menuItem => menuItem.tabIndex !== -1)
+  let currentIndex = menuItems.findIndex(menuItem => menuItem.tabIndex === 0)
+  if (currentIndex < 0) currentIndex = 0
   const currentMenuItem = menuItems.at(currentIndex)
   if (!currentMenuItem) {
-    throw new Error(`Missing menuitem at index ${currentIndex}`)
+    return
   }
@@
   const menuItemToFocus = menuItems.at(currentIndex)
   if (!menuItemToFocus) {
-    throw new RangeError(`currentIndex (${currentIndex}) outside of range of menu items`)
+    return
   }

As per coding guidelines: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index."

#!/bin/bash
# Verify the current focus/index edge handling in AccountMenu handlers
rg -n -C3 'watch\(menuRef|onMenuKeyDown|findIndex\(|throw new Error|throw new RangeError' app/components/Header/AccountMenu.client.vue

Also applies to: 95-124


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 64c22f8 and ed91478.

📒 Files selected for processing (1)
  • app/components/Header/AccountMenu.client.vue

@userquin
Copy link
Member

userquin commented Mar 2, 2026

Looks like using mouse click at connect doesn't add "focus" styles to first item on chrome, works pressing enter.

@knowler
Copy link
Member Author

knowler commented Mar 2, 2026

@userquin I wouldn’t expect the focus styles to appear on mouse click since focus styles only appear for focus visible.

I did notice an issue that if you click the menu button while it’s open, since the menu is checking for loss of focus to close itself, it will cause the menu to immediately open again.

@userquin
Copy link
Member

userquin commented Mar 2, 2026

I mean, the first item in the list without focus styles (tabindex=0) when using mouse click, I'm not talking about focusing the "selector/menu". Mouse click and pressing enter should have the same behavior (? ), I'm just asking.

pressing enter
first item focused when pressing enter

@knowler
Copy link
Member Author

knowler commented Mar 2, 2026

@userquin :focus-visible styles are only applied based on the heuristics the browser decides. If you use a keyboard the browser will display them, if you use a mouse the browser won’t. The value of tabindex is not relevant for mouse/touch users in this situation—it’s purely for keyboard users and assistive tech. This is the case for any control. When you open a model with a click/touch, you won’t see a focus ring around the dismiss button even though that’s where the initial focus is set.

Copy link
Member

@serhalp serhalp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This LGTM and seems to work well.

This is a wild amount of boilerplate for basic a11y though. We should be using a headless component library or something, right??? https://reka-ui.com/?

@knowler
Copy link
Member Author

knowler commented Mar 3, 2026

I might eventually make a roving tabindex composable since that's a very common pattern.


Please no one merge until I fix the light dismiss double trigger issue.

@serhalp serhalp added this to the Alpha announcement milestone Mar 3, 2026
@knowler knowler marked this pull request as draft March 3, 2026 04:16
@knowler
Copy link
Member Author

knowler commented Mar 3, 2026

I’m making this one a draft. I won’t have time to finish it before I turn in for the night.

There are a few problems here:

  1. When the menu is open and the user clicks the menu button again, it causes the popup to dismiss and then reopen. To me that’s enough to block this from merging.
  2. Upon further reflection, I think we also need to implement a focus trap for this. Luckily, there is already one included in @vueuse/integrations, but I think there might need to be a bit of refactoring to get it working.

My code is also a bit messy and trying to add the focus trap on top only makes it messier. The roving tabindex bit is pretty self-contained (i.e. the onMenuKeyDown function), but handling the light dismiss is kind of all over the place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants