Skip to content

Gateway header's theme and language buttons have no accessible name, so screen readers announce both as just "button" #1722

Open

Description

@joshunrau

The gateway header has two controls, the theme toggle and the language toggle, and neither has an accessible name. Each is a <button> whose only content is a lucide icon rendered with aria-hidden="true", and neither carries an aria-label. A screen reader therefore announces both as "button", and a patient who relies on one cannot tell which control switches the page language. For a patient-facing page that is often opened in a language the reader did not choose (the first active language, unless the link carries ?lang=), the language switcher is the control they most need to find. Both components come from shared code, so apps/web (Navbar, Sidebar, login, setup, reset-password) and apps/playground render the same unnamed buttons. #1701 covers other icon-only buttons but not these two.

Where

apps/gateway/src/Root.tsx:110-113:

<div className="flex gap-3">
  <ThemeToggle className="h-9 w-9" />
  <LanguageToggle activeLanguages={props.activeLanguages} triggerClassName="h-9 w-9" />
</div>

The server-rendered markup of / (from render in src/entry-server.tsx) contains, for these two:

<button class="... h-9 w-9"><svg ... class="lucide lucide-sun ..." aria-hidden="true">...</svg><svg ... class="lucide lucide-moon ..." aria-hidden="true">...</svg></button>
<button class="... h-9 w-9" type="button" id="radix-«R57»" aria-haspopup="menu" aria-expanded="false" data-state="closed"><svg ... class="lucide lucide-languages" aria-hidden="true">...</svg></button>

libui's ThemeToggle spreads its remaining props onto the Button, so it accepts an aria-label. libui's LanguageToggle (wrapped by packages/react-core/src/components/LanguageToggle/LanguageToggle.tsx) builds its trigger <Button> from a fixed prop list, so a caller has no way to name it today.

Reproduce

  1. Open / or any assignment link on the gateway with VoiceOver or NVDA running.
  2. Tab to the two buttons at the top right.

Actual: both are announced as "button" with no name. The language button adds "menu pop-up" but still has no name.
Expected: they are announced in the page language as, for example, "Toggle dark mode" and "Change language".

Tests

  • apps/gateway/src/__tests__/Root.test.tsx: it('should name the theme and language controls, so screen-reader users can find the language switch'), asserting screen.getByRole('button', { name: ... }) for each.
  • packages/react-core/src/components/LanguageToggle/__tests__/LanguageToggle.test.tsx: it('should give the trigger an accessible name in the current language').
  • e2e, testing/src/specs/gateway-landing.spec.ts: locate both header controls with getByRole('button', { name }) instead of by test id.

Suggested fix

Pass a translated aria-label to ThemeToggle in Root.tsx. For the language toggle, give react-core's LanguageToggle a default translated trigger label. That needs libui's LanguageToggle to forward an aria-label (or extra trigger props) to its Button, which is a small change in @douglasneuroinformatics/libui. Alternatively, react-core can render its own DropdownMenu trigger. Apply the same labels at the apps/web and apps/playground call sites.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Gatewayapps/gateway (patient-facing app for remote assignments)
    Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another area
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Bumping libui does not fix it. Both toggles are unchanged in 6.17.0 and 6.18.0, the latest release, and libui's main has not touched either file since January.

    Call sites

    Each toggle is rendered in nine places:

    Where the label goes

    The suggested fix passes the theme label at nine call sites across three workspaces, and the next call site can forget it. The default belongs in the component.

    Recommended: fix it in libui. Give ThemeToggle and LanguageToggle a default aria-label from a new key in libui's src/i18n/translations/libui.json, where every key already has en, es and fr. Let LanguageToggle take an override for its trigger, either aria-label or a triggerProps object. libui's own namespace resolves in the gateway and the playground even though both call i18n.init({ translations: {} }) (see packages/react-core/AGENTS.md). react-core's wrapper derives its props from libui's and spreads them (LanguageToggle.tsx:7, :25), so a new trigger prop passes through unchanged. For wording, reuse the outreach site's existing toggle labels (apps/outreach/src/i18n/translations/common.json:6-13) and add Spanish.

    Then raise the catalog floor to that release in a commit of its own. @douglasneuroinformatics/* is in minimumReleaseAgeExclude, so the release can be installed the day it ships. The bump also crosses 6.18.0, which drops forwardRef from libui's components (Button now takes ref as a prop).

    If a libui release is not wanted now, do it in react-core instead. Add a react-core ThemeToggle that wraps libui's with an inline t({ en, es, fr }) label. Have react-core's LanguageToggle render its own DropdownMenu trigger, the way InteractiveContent.tsx:149-154 does. Then move the playground onto both. With activeLanguages={['en', 'fr']} it gets the same English / Français options it hard-codes now. No new dependency is needed.

    Tests

    In addition to the two unit tests in the issue:

    • apps/gateway/src/__tests__/entry-server.test.ts: render({ activeLanguages: ['en', 'fr'], kind: 'landing', language: 'fr' }) should contain both French labels, alongside the existing Bienvenue case. React does not guarantee that hydration patches a mismatched attribute, so the server HTML has to carry the right label.
    • e2e, gateway-landing.spec.ts: testing/AGENTS.md uses roles only where no data-testid exists. So keep getByTestId('language-toggle') and assert toHaveAccessibleName(...) on its button, once in English and again after the existing test picks Français. ThemeToggle has no test id, so page.getByRole('banner').getByRole('button', { name: ... }) is fine for it.

    Related

    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: Gatewayapps/gateway (patient-facing app for remote assignments)Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another areaBugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions