From ec48636ba874b7b2161e51edbfd9dfeb622562d6 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 6 Mar 2026 22:19:13 +0700 Subject: [PATCH] fix(site): WCAG 2.1 AA accessibility remediation for core frontend flows (#22673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is several accessibility improvements researched by Mux. Manually tested and these changes should be mostly harmless. ## Summary Targeted WCAG 2.1 AA accessibility remediation across core frontend user flows: login, dashboard navigation, audit interactions, settings, and workspace parameter inputs. ### Changes #### Navigation, keyboard & focus visibility (WCAG 2.4.1, 2.1.1, 2.4.7, 1.4.11) - **DashboardLayout**: Added "Skip to main content" link (visually hidden, visible on focus) with `#main-content` target on the main outlet container. - **AuditLogRow**: Expanded keyboard handler so both Enter and Space toggle expandable audit details (with `preventDefault` on Space to prevent scroll). - **model-selector**: Removed `focus:ring-0 focus-visible:ring-0` from `SelectTrigger` to restore the default visible focus indicator. #### Forms & input assistance (WCAG 3.3.1, 3.3.2, 1.3.5) - **PasswordSignInForm**: Wired `aria-invalid` and `aria-describedby` on email/password inputs, pointing to stable-ID error elements (`signin-email-error`, `signin-password-error`). - **AccountForm**: Added `autoComplete="name"` to the Name field. #### Name/role/value & status messages (WCAG 1.1.1, 4.1.2, 4.1.3, 1.3.1) - **PortForwardButton**: Added `aria-label="Delete shared port"` to the icon-only delete button. - **DynamicParameter**: Replaced mouse-only peek-and-hold reveal with a persistent keyboard-accessible toggle. Added dynamic `aria-label` ("Show value"/"Hide value") and `aria-pressed`. - **Tabs**: Removed incorrect `role="tablist"` (these are route navigation links, not ARIA tabs). Added `aria-current="page"` on the active `TabLink`. - **Loader**: Wrapped spinner in `role="status" aria-live="polite"` container with an `aria-label` for screen reader announcements. - **Alert**: Changed `AlertTitle` from `

` to `

` to avoid multiple page-level headings. ### Testing - **9 Vitest test files** (22 tests) — all new or extended to cover the a11y changes. - **1 Jest test file** (38 tests) — DynamicParameter tests updated for toggle semantics + keyboard activation. - `pnpm lint:types` ✅ - `pnpm check` (Biome lint + format) ✅ ### Files changed | File | Change | |------|--------| | `site/src/modules/dashboard/DashboardLayout.tsx` | Skip link + `#main-content` id | | `site/src/modules/dashboard/DashboardLayout.test.tsx` | Skip link assertions | | `site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx` | Space + Enter keyboard handling | | `site/src/pages/AuditPage/AuditPage.test.tsx` | Keyboard toggle tests | | `site/src/components/ai-elements/model-selector.tsx` | Remove focus ring suppression | | `site/src/components/ai-elements/model-selector.test.tsx` | **New** — focus ring assertion | | `site/src/pages/LoginPage/PasswordSignInForm.tsx` | aria-invalid + aria-describedby | | `site/src/pages/LoginPage/LoginPage.test.tsx` | Error association tests | | `site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx` | autoComplete="name" | | `site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx` | Autocomplete assertion | | `site/src/modules/resources/PortForwardButton.tsx` | aria-label on delete button | | `site/src/modules/resources/PortForwardButton.test.tsx` | **New** — accessible name test | | `site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx` | Keyboard toggle + ARIA | | `site/src/modules/workspaces/DynamicParameter/DynamicParameter.jest.tsx` | Toggle semantics tests | | `site/src/components/Tabs/Tabs.tsx` | Remove tablist role, add aria-current | | `site/src/components/Tabs/Tabs.test.tsx` | **New** — tablist/aria-current tests | | `site/src/components/Loader/Loader.tsx` | role="status" + aria-live | | `site/src/components/Loader/Loader.test.tsx` | **New** — status semantics tests | | `site/src/components/Alert/Alert.tsx` | h1 → h2 | | `site/src/components/Alert/Alert.test.tsx` | **New** — heading level test | --- site/src/components/Alert/Alert.test.tsx | 20 +++++++ site/src/components/Alert/Alert.tsx | 4 +- site/src/components/Loader/Loader.test.tsx | 19 ++++++ site/src/components/Loader/Loader.tsx | 10 +++- site/src/components/Tabs/Tabs.test.tsx | 40 +++++++++++++ site/src/components/Tabs/Tabs.tsx | 7 +-- .../ai-elements/model-selector.test.tsx | 26 ++++++++ .../components/ai-elements/model-selector.tsx | 2 +- .../dashboard/DashboardLayout.test.tsx | 43 ++++++++++++- .../src/modules/dashboard/DashboardLayout.tsx | 22 ++++++- .../resources/PortForwardButton.test.tsx | 31 ++++++++++ .../modules/resources/PortForwardButton.tsx | 1 + .../DynamicParameter.jest.tsx | 45 ++++++++++++-- .../DynamicParameter/DynamicParameter.tsx | 12 ++-- .../AuditPage/AuditLogRow/AuditLogRow.tsx | 3 +- site/src/pages/AuditPage/AuditPage.test.tsx | 60 ++++++++++++++++++- site/src/pages/LoginPage/LoginPage.test.tsx | 42 +++++++++++++ .../pages/LoginPage/PasswordSignInForm.tsx | 16 ++++- .../TemplateVersionEditorPage.test.tsx | 2 +- .../AccountPage/AccountForm.test.tsx | 30 ++++++++-- .../AccountPage/AccountForm.tsx | 1 + 21 files changed, 401 insertions(+), 35 deletions(-) create mode 100644 site/src/components/Alert/Alert.test.tsx create mode 100644 site/src/components/Loader/Loader.test.tsx create mode 100644 site/src/components/Tabs/Tabs.test.tsx create mode 100644 site/src/components/ai-elements/model-selector.test.tsx create mode 100644 site/src/modules/resources/PortForwardButton.test.tsx diff --git a/site/src/components/Alert/Alert.test.tsx b/site/src/components/Alert/Alert.test.tsx new file mode 100644 index 0000000000..ff52cc6197 --- /dev/null +++ b/site/src/components/Alert/Alert.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { Alert, AlertDescription, AlertTitle } from "./Alert"; + +describe("AlertTitle", () => { + it("renders as an h2 heading", () => { + render( + + Deployment warning + Something needs your attention. + , + ); + + expect( + screen.getByRole("heading", { level: 2, name: "Deployment warning" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("heading", { level: 1, name: "Deployment warning" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/site/src/components/Alert/Alert.tsx b/site/src/components/Alert/Alert.tsx index faf16ddc66..62d5329e11 100644 --- a/site/src/components/Alert/Alert.tsx +++ b/site/src/components/Alert/Alert.tsx @@ -135,9 +135,9 @@ export const AlertDescription: React.FC = ({ ); }; -export const AlertTitle: React.FC> = ({ +export const AlertTitle: React.FC> = ({ className, ...props }) => { - return

; + return

; }; diff --git a/site/src/components/Loader/Loader.test.tsx b/site/src/components/Loader/Loader.test.tsx new file mode 100644 index 0000000000..40af194659 --- /dev/null +++ b/site/src/components/Loader/Loader.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { Loader } from "./Loader"; + +describe("Loader", () => { + it("announces loading status politely", () => { + render(); + + expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); + expect(screen.getByLabelText("Loading")).toBeInTheDocument(); + }); + + it("applies custom spinner labels when provided", () => { + render(); + + expect( + screen.getByLabelText("Loading workspace resources"), + ).toBeInTheDocument(); + }); +}); diff --git a/site/src/components/Loader/Loader.tsx b/site/src/components/Loader/Loader.tsx index f46bf7a38c..2688d94b58 100644 --- a/site/src/components/Loader/Loader.tsx +++ b/site/src/components/Loader/Loader.tsx @@ -14,21 +14,25 @@ interface LoaderProps extends HTMLAttributes { export const Loader: FC = ({ fullscreen, size = "lg", - label = "Loading...", + label, className, ...attrs }) => { + const resolvedLabel = label ?? "Loading"; + return (
- +
); }; diff --git a/site/src/components/Tabs/Tabs.test.tsx b/site/src/components/Tabs/Tabs.test.tsx new file mode 100644 index 0000000000..b0bb195005 --- /dev/null +++ b/site/src/components/Tabs/Tabs.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { TabLink, Tabs, TabsList } from "./Tabs"; + +const renderTabs = (active = "overview") => { + render( + + + + + Overview + + + Settings + + + + , + ); +}; + +describe("Tabs", () => { + it("does not expose tablist semantics for link navigation", () => { + renderTabs(); + + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + }); + + it("marks only the active tab link as the current page", () => { + renderTabs("overview"); + + expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "Settings" })).not.toHaveAttribute( + "aria-current", + ); + }); +}); diff --git a/site/src/components/Tabs/Tabs.tsx b/site/src/components/Tabs/Tabs.tsx index ec823d0afe..de4a1d3581 100644 --- a/site/src/components/Tabs/Tabs.tsx +++ b/site/src/components/Tabs/Tabs.tsx @@ -96,11 +96,7 @@ export const TabsList: FC = ({ className, ...props }) => { return (
-
+
= ({ return ( { + render( + , + ); + + const trigger = screen.getByRole("combobox"); + + expect(trigger.className).not.toContain("focus:ring-0"); + expect(trigger.className).not.toContain("focus-visible:ring-0"); +}); diff --git a/site/src/components/ai-elements/model-selector.tsx b/site/src/components/ai-elements/model-selector.tsx index 5f7d2f5148..bc2f8dfcce 100644 --- a/site/src/components/ai-elements/model-selector.tsx +++ b/site/src/components/ai-elements/model-selector.tsx @@ -93,7 +93,7 @@ export const ModelSelector: FC = ({