Repository navigation
Description
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
- 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.
- Open View Current Subject → Assignments for a subject with an assignment, and try to reach a row with Tab: it is skipped.
- Open Manage Users and Tab to "Add User", then press Enter: nothing happens until a second Tab reaches the inner link.
- 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: replaceshould open the navigation sheet from the outline button as wellwithit('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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). #1825 does not fix any of it. On its branch the stray trigger is atNavbar.tsx:56-58and the sidebar toggles are atSidebar.tsx:125-132.Corrections to the suggested fix
Dropping
ring-0is not enough in dark mode. libui sets--ringtoslate-800under[data-mode='dark'](src/tailwind/globals.css:163in 6.16.1, unchanged in 6.18.0). The sidebar isbg-slate-900in both themes, so the restored ring would have 1.2:1 contrast against it. Usefocus-visible:ring-slate-400instead (6.8:1). The sidebarThemeToggleatapps/web/src/components/Sidebar/Sidebar.tsx:177needs the same class. It has no override, so its ring is already invisible in dark mode.Language picker: delete
focus-visible:outline-noneand keep the browser's outline. Aring-ringreplacement would be slate-800 on the slate-900 dark background too.Assignment rows: do not copy
ActionCard.role="button"on a<tr>replaces itsrowrole and breaks the table for screen readers. Wrap the title in a<button type="button">and keep the row'sonClickfor 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 (relativeon the<tr>,after:absolute after:inset-0on the button). WebKit ignoredposition: relativeon table rows until a fix in April 2026 (bug 240961). libui'sTableRootwraps the table in arelativediv, so in older Safari every overlay covers the whole table and the last row gets every click.Return focus when the sheet closes.
AssignmentSliderhas noSheet.Trigger, and Radix's modalonCloseAutoFocusfocuses onlycontext.triggerRef(@radix-ui/react-dialog1.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 passonCloseAutoFocus={(event) => { event.preventDefault(); openerRef.current?.focus(); }}toSheet.Content.The stray "Open" trigger also takes focus when the mobile sheet closes. Both
Sheet.Triggers write their node into the sametriggerRef(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'saria-labelin the change that deletes it.Elsewhere
No other
<Button>inapps/web,apps/gatewayorpackages/react-corewraps a<Link>withoutasChild. One related problem has no issue yet. The dark--ringgives every libui focus ring 1.2:1 contrast on the dark--backgroundin web, gateway and playground. That includes the sidebarGroupSwitchertrigger (apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx:66-73, libui'sfocus:ring-ring). The fix belongs in libui, or once inpackages/react-core/src/globals.css, which all three apps import. If that lands first, the sidebar only needs thering-0removals. 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 needswaitFor, because Radix restores focus in asetTimeout.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 focusesnavbar-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 withpage.goto.PHONE_VIEWPORTspec: open and close the menu by keyboard, then assert there is a single trigger and that it has focus.user-dropup-trigger, thelanguage-togglebutton and the theme toggle. Assert the ring's 1px spread and its colour. The spread alone is not enough:ring-0still sets a zero-spreadbox-shadow, and theThemeTogglealready has a 1px ring that is only the wrong colour.enableLanguageSelect. Inplayground.spec.ts, open a share link to an interactive instrument withlanguage: ['en', 'fr']andcontent.enableLanguageSelect: true. Tab to a language button and assertoutline-styleis notnone.#1693 and #1757 also edit
assignments.tsx, and #1693 rewrites the same cancel step insubject-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.