Skip to content

Sidebar menus and the instrument language picker hide keyboard focus, and assignment rows, Add User and a hidden Open button fail keyboard users #1730

Open

Description

@joshunrau

Several controls in apps/web and packages/react-core fail keyboard-only users. On some the focus indicator has been removed, so the user cannot see where focus is. One can only be operated with a mouse. One takes a tab stop and does nothing, and one is focusable but hidden behind the mobile top bar. Each is a one-line fix, and together they cover the main navigation, a subject's assignment list, user management and the participant-facing language picker.

Where

Focus ring removed, nothing replaces it. libui's Button draws its focus indicator with focus-visible:ring-1, and these call sites override it to ring-0 (tailwind-merge keeps the later class) or add outline-none with no ring:

  • apps/web/src/components/UserDropup/UserDropup.tsx:24-27: the sidebar user-menu trigger (className="flex items-center gap-1.5 focus-visible:ring-0").
  • apps/web/src/components/Sidebar/Sidebar.tsx:170-176: the sidebar language toggle (triggerClassName="... focus-visible:ring-0").
  • packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx:198-199: the "Select Language" buttons shown before an interactive instrument starts, including to participants in the gateway (focus-visible:outline-none, no ring).

Mouse-only rows. apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx:128-134: the only way to open an assignment (to copy its link, see its QR code, email it or cancel it) is a click handler on a <tr> with no tabIndex, role or key handler:

<Table.Row
  className="cursor-pointer"
  data-state={selectedId === assignment.id ? 'selected' : undefined}
  data-testid="assignment-row"
  key={assignment.id}
  onClick={() => onSelection(assignment)}
>

A button that does nothing. apps/web/src/routes/_app/admin/users/index.tsx:153-163: "Add User" nests a <Link> inside a <Button> without asChild. The outer <button> takes the first tab stop, and pressing Enter on it does nothing; clicking its padding outside the text does nothing either. admin/groups/index.tsx:140 does the same thing correctly with asChild.

<Button variant="outline">
  <Link to="/admin/users/create">

A hidden button in the tab order. apps/web/src/components/Navbar/Navbar.tsx:58-60 renders a second sheet trigger, an outline "Open" button, in normal flow inside Layout's absolute md:hidden wrapper. That puts it at the top-left of the page underneath the fixed z-10 top bar, so it is not visibly reachable, yet it is focusable and announced as "Open". The hamburger at Navbar.tsx:47-54 already opens the same sheet.

Reproduce

  1. Using only the keyboard on a desktop-width window, Tab to the username at the bottom of the sidebar, then to the language toggle beside it: neither shows a focus indicator.
  2. Open View Current Subject → Assignments for a subject with an assignment, and try to reach a row with Tab: it is skipped.
  3. Open Manage Users and Tab to "Add User", then press Enter: nothing happens until a second Tab reaches the inner link.
  4. In a phone-width window, Tab from the page top: after the hamburger, focus moves to an invisible "Open" button.

Expected: every focused control shows a visible indicator, assignment rows can be opened with the keyboard, "Add User" works on the first Enter, and there is no hidden focusable button.

Tests

  • apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/assignments.test.tsx: it('should open an assignment from the keyboard, so the link can be reached without a mouse').
  • apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx: it('should render Add User as a single link, so the first Enter navigates').
  • apps/web/src/components/Navbar/__tests__/Navbar.test.tsx: replace should open the navigation sheet from the outline button as well with it('should render a single menu trigger').
  • testing/src/specs/subject-detail.spec.ts: focus an assignment row and press Enter, then assert the sheet opens.

Suggested fix

Drop the focus-visible:ring-0 and outline-none overrides, or replace them with a ring visible on the dark sidebar (e.g. focus-visible:ring-slate-400). Make the assignment title cell a <button>, or give the row tabIndex={0} and an Enter/Space handler as ActionCard in group/remote-assignments.tsx:29-46 does. Add asChild to the Add User Button. Delete the stray Sheet.Trigger at Navbar.tsx:58-60.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Webapps/web (clinician-facing React SPA)
    Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another area
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). #1825 does not fix any of it. On its branch the stray trigger is at Navbar.tsx:56-58 and the sidebar toggles are at Sidebar.tsx:125-132.

    Corrections to the suggested fix

    Dropping ring-0 is not enough in dark mode. libui sets --ring to slate-800 under [data-mode='dark'] (src/tailwind/globals.css:163 in 6.16.1, unchanged in 6.18.0). The sidebar is bg-slate-900 in both themes, so the restored ring would have 1.2:1 contrast against it. Use focus-visible:ring-slate-400 instead (6.8:1). The sidebar ThemeToggle at apps/web/src/components/Sidebar/Sidebar.tsx:177 needs the same class. It has no override, so its ring is already invisible in dark mode.

    Language picker: delete focus-visible:outline-none and keep the browser's outline. A ring-ring replacement would be slate-800 on the slate-900 dark background too.

    Assignment rows: do not copy ActionCard. role="button" on a <tr> replaces its row role and breaks the table for screen readers. Wrap the title in a <button type="button"> and keep the row's onClick for mouse users. Enter and Space on a button dispatch a click, and that click bubbles to the row, so one handler covers both. Do not stretch the button over the row (relative on the <tr>, after:absolute after:inset-0 on the button). WebKit ignored position: relative on table rows until a fix in April 2026 (bug 240961). libui's TableRoot wraps the table in a relative div, so in older Safari every overlay covers the whole table and the last row gets every click.

    Return focus when the sheet closes. AssignmentSlider has no Sheet.Trigger, and Radix's modal onCloseAutoFocus focuses only context.triggerRef (@radix-ui/react-dialog 1.1.15, dist/index.mjs:146-149). So after Escape, the close button or Cancel, focus falls to <body>. Put a ref on the selected row's title button and pass onCloseAutoFocus={(event) => { event.preventDefault(); openerRef.current?.focus(); }} to Sheet.Content.

    The stray "Open" trigger also takes focus when the mobile sheet closes. Both Sheet.Triggers write their node into the same triggerRef (dist/index.mjs:63). The outline one is attached last, so Radix refocuses the hidden button and not the hamburger. It is also the only trigger with an accessible name today (#1701 covers the hamburger), so add the hamburger's aria-label in the change that deletes it.

    Elsewhere

    No other <Button> in apps/web, apps/gateway or packages/react-core wraps a <Link> without asChild. One related problem has no issue yet. The dark --ring gives every libui focus ring 1.2:1 contrast on the dark --background in web, gateway and playground. That includes the sidebar GroupSwitcher trigger (apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx:66-73, libui's focus:ring-ring). The fix belongs in libui, or once in packages/react-core/src/globals.css, which all three apps import. If that lands first, the sidebar only needs the ring-0 removals. It should get its own issue.

    Tests

    happy-dom loads no Tailwind CSS, and it does not turn Enter on a button into a click. Rings and key activation therefore have to be tested in e2e.

    • assignments.test.tsx: the title renders as a button named after the instrument, and closing the sheet focuses it again. This needs waitFor, because Radix restores focus in a setTimeout.
    • admin/users/__tests__/index.test.tsx: queryByRole('button', { name: 'Add User' }) is null. Today it finds the outer <button>.
    • Navbar.test.tsx: closing the sheet focuses navbar-menu-trigger.
    • subject-detail.spec.ts: after Escape, the title button has focus again.
    • admin-management.spec.ts: Enter on Add User reaches /admin/users/create. Every test there today goes there with page.goto.
    • A PHONE_VIEWPORT spec: open and close the menu by keyboard, then assert there is a single trigger and that it has focus.
    • Sidebar, in both themes: Tab to user-dropup-trigger, the language-toggle button and the theme toggle. Assert the ring's 1px spread and its colour. The spread alone is not enough: ring-0 still sets a zero-spread box-shadow, and the ThemeToggle already has a 1px ring that is only the wrong colour.
    • Language picker: no built-in instrument sets enableLanguageSelect. In playground.spec.ts, open a share link to an interactive instrument with language: ['en', 'fr'] and content.enableLanguageSelect: true. Tab to a language button and assert outline-style is not none.

    #1693 and #1757 also edit assignments.tsx, and #1693 rewrites the same cancel step in subject-detail.spec.ts, so land #1693 first. #1722 adds labels to the same sidebar toggles.

    Triage note written with Claude Code. Code references point at main @ d86740e.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another areaArea: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions