Skip to content

refactor(ui): Improve UserButton accessibility#8325

Open
alexcarpenter wants to merge 3 commits intomainfrom
carp/user-4842-improve-userbutton-a11y
Open

refactor(ui): Improve UserButton accessibility#8325
alexcarpenter wants to merge 3 commits intomainfrom
carp/user-4842-improve-userbutton-a11y

Conversation

@alexcarpenter
Copy link
Copy Markdown
Member

@alexcarpenter alexcarpenter commented Apr 15, 2026

Description

The UserButton popover was updated from role="menu" with role="menuitem" children to role="dialog" with role="group" sections. Focus management was fixed using floating-ui's
interaction system (useInteractions, useClick, useRole, useDismiss) instead of manual onClick/aria-* wiring.

Why dialog, not menu

The WAI-ARIA menu role is for a list of differing actions — like a right-click context menu or a menubar dropdown. Every child must be a menuitem, menuitemcheckbox, or
menuitemradio. The UserButton popover contains:

  • A user profile preview (not an action)
  • Account management buttons
  • Session switcher cards
  • A sign-out section
  • A branded footer with links

This is mixed content, not a flat action list. Forcing menuitem on non-action elements (previews, footers) creates a misleading experience for screen reader users — they hear
"menu with 8 items" when only 3 are actionable. The dialog role correctly communicates that this is a container with varied, grouped content.

Key implementation details

  • initialFocus={popoverRef} — Focuses the dialog container on open (screen reader announces "Account panel dialog"), not the first interactive child. Tab then moves to the first
    action.
  • order={['content']} — Excludes the trigger from the popover's Tab cycle.
  • useInteractions — Properly composes useClick, useDismiss, useRole through floating-ui instead of manual onClick={toggle} and hardcoded aria-* attributes.
  • role="group" + aria-label on action sections — Groups related actions ("Account actions", "Active sessions") for screen reader navigation.
  • role={undefined} on individual actions — Removes the inherited menuitem role since they're no longer inside a menu.
  • Identity-first trigger label — "First Last - Open account panel" instead of generic "Open user menu", so screen readers announce who the button belongs to

Fixes USER-4842

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 15, 2026

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

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Apr 23, 2026 5:15pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 15, 2026

🦋 Changeset detected

Latest commit: 73355ba

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@clerk/ui Patch
@clerk/shared Patch
@clerk/localizations Patch
@clerk/astro Patch
@clerk/chrome-extension Patch
@clerk/react Patch
@clerk/vue Patch
@clerk/backend Patch
@clerk/clerk-js Patch
@clerk/expo-passkeys Patch
@clerk/expo Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/hono Patch
@clerk/msw Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@alexcarpenter alexcarpenter marked this pull request as ready for review April 23, 2026 17:14
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

This pull request updates the UserButton component's accessibility architecture by replacing the menu-based pattern (with role="menu" and role="menuitem") with a dialog-based pattern (role="dialog" with grouped actions). The changes include localized labels for the popover and action groups, identity-first trigger text that displays the user's name, and enhanced focus management leveraging Floating UI's interaction system. The Popover element gains modal behavior support, and the usePopover hook is extended with role interactions and prop getter functions. Comprehensive tests verify the new dialog semantics and focus trapping behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main objective of the changeset: improving UserButton accessibility through role restructuring and focus management updates.
Description check ✅ Passed The description is directly related to the changeset, providing detailed rationale, implementation details, and context for the accessibility improvements made across multiple components.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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
Copy Markdown
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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx`:
- Around line 243-266: The current test "traps focus within the popover when
open" only asserts the trigger loses focus on Tab, which can pass even if focus
moves outside the dialog; update the loop to assert that the dialog element
(retrieved as dialog = screen.getByRole('dialog', { name: 'Account panel' }))
contains the active element after each await userEvent.tab() call instead of
checking expect(trigger).not.toHaveFocus(); i.e., replace the per-iteration
assertion with a containment check like dialog.contains(document.activeElement)
(using the existing dialog variable and userEvent.tab()) so each Tab verifies
focus remains inside the popover.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 6d4f3cc8-0b08-4506-8719-c6ce29033b7f

📥 Commits

Reviewing files that changed from the base of the PR and between e73d266 and 73355ba.

📒 Files selected for processing (10)
  • .changeset/user-button-a11y.md
  • packages/localizations/src/en-US.ts
  • packages/shared/src/types/localization.ts
  • packages/ui/src/components/UserButton/SessionActions.tsx
  • packages/ui/src/components/UserButton/UserButtonPopover.tsx
  • packages/ui/src/components/UserButton/UserButtonTrigger.tsx
  • packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx
  • packages/ui/src/components/UserButton/index.tsx
  • packages/ui/src/elements/Popover.tsx
  • packages/ui/src/hooks/usePopover.ts

Comment on lines +243 to +266
it('traps focus within the popover when open', async () => {
const { wrapper } = await createFixtures(f => {
f.withUser({
first_name: 'First',
last_name: 'Last',
username: 'username1',
email_addresses: ['test@clerk.com'],
});
});

const { getByRole, userEvent } = render(<UserButton />, { wrapper });
const trigger = getByRole('button', { name: accountPanelButtonName('First Last') });
await userEvent.click(trigger);

const dialog = screen.getByRole('dialog', { name: 'Account panel' });
expect(dialog).toBeInTheDocument();

for (let i = 0; i < 10; i++) {
await userEvent.tab();
expect(trigger).not.toHaveFocus();
}

expect(screen.getByRole('dialog', { name: 'Account panel' })).toBeInTheDocument();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Assert focus remains inside the dialog.

This test can still pass if Tab moves focus to document.body or any other outside element, because it only checks that the trigger is not focused. Since focus trapping is core to this change, assert the active element is contained by the dialog on each tab.

Proposed fix
       for (let i = 0; i < 10; i++) {
         await userEvent.tab();
+        expect(dialog).toContainElement(document.activeElement as HTMLElement);
         expect(trigger).not.toHaveFocus();
       }

As per coding guidelines, “Test component behavior, not implementation details” and “Implement proper focus traps in React components.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('traps focus within the popover when open', async () => {
const { wrapper } = await createFixtures(f => {
f.withUser({
first_name: 'First',
last_name: 'Last',
username: 'username1',
email_addresses: ['test@clerk.com'],
});
});
const { getByRole, userEvent } = render(<UserButton />, { wrapper });
const trigger = getByRole('button', { name: accountPanelButtonName('First Last') });
await userEvent.click(trigger);
const dialog = screen.getByRole('dialog', { name: 'Account panel' });
expect(dialog).toBeInTheDocument();
for (let i = 0; i < 10; i++) {
await userEvent.tab();
expect(trigger).not.toHaveFocus();
}
expect(screen.getByRole('dialog', { name: 'Account panel' })).toBeInTheDocument();
});
it('traps focus within the popover when open', async () => {
const { wrapper } = await createFixtures(f => {
f.withUser({
first_name: 'First',
last_name: 'Last',
username: 'username1',
email_addresses: ['test@clerk.com'],
});
});
const { getByRole, userEvent } = render(<UserButton />, { wrapper });
const trigger = getByRole('button', { name: accountPanelButtonName('First Last') });
await userEvent.click(trigger);
const dialog = screen.getByRole('dialog', { name: 'Account panel' });
expect(dialog).toBeInTheDocument();
for (let i = 0; i < 10; i++) {
await userEvent.tab();
expect(dialog).toContainElement(document.activeElement as HTMLElement);
expect(trigger).not.toHaveFocus();
}
expect(screen.getByRole('dialog', { name: 'Account panel' })).toBeInTheDocument();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx` around
lines 243 - 266, The current test "traps focus within the popover when open"
only asserts the trigger loses focus on Tab, which can pass even if focus moves
outside the dialog; update the loop to assert that the dialog element (retrieved
as dialog = screen.getByRole('dialog', { name: 'Account panel' })) contains the
active element after each await userEvent.tab() call instead of checking
expect(trigger).not.toHaveFocus(); i.e., replace the per-iteration assertion
with a containment check like dialog.contains(document.activeElement) (using the
existing dialog variable and userEvent.tab()) so each Tab verifies focus remains
inside the popover.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant