fix(site): WCAG 2.1 AA remediation — landmarks, semantics, and a11y tooling (#22746)

## Summary

Targeted WCAG 2.1 AA accessibility remediation — continuation of #22673
— addressing remaining semantic, landmark, and tooling gaps identified
in the frontend accessibility review.

### Changes

#### Document semantics (WCAG 3.1.1)
- **`site/index.html`**: Added `<html lang="en">` root wrapper so screen
readers and browser features correctly identify the document language.

#### Landmark & bypass (WCAG 1.3.1, 2.4.1)
- **`DashboardLayout.tsx`**: Replaced `<div id="main-content">` with
`<main id="main-content">` so assistive technology exposes a proper main
landmark and the skip link targets a semantic region.

#### Table header relationships (WCAG 1.3.1)
- **`Table.tsx`**: `TableHead` now renders `scope="col"` by default
(overridable via prop), giving data cells an explicit header
relationship.

#### Semantic interactive controls (WCAG 2.1.1, 4.1.2)
- **`AuditLogRow.tsx`**: Replaced `<div role="button" tabIndex={0}>`
with native `<button type="button">`, removing the manual keyboard
handler (native button provides Enter/Space for free).
- **`Autocomplete.tsx`**: Replaced clear `<span role="button"
tabIndex={0}>` with native `<button type="button" aria-label="Clear
selection">`.

#### Reduced motion (WCAG 2.3.3 best practice)
- **`index.css`**: Added global `@media (prefers-reduced-motion:
reduce)` block that suppresses non-essential animations and transitions.

#### Accessibility regression tooling
- **Storybook**: Added `@storybook/addon-a11y` (version-matched to
existing Storybook 10.x).
- **vitest-axe**: Added `vitest-axe` with setup wiring and an exemplar
`Table.axe.test.tsx` that runs axe-core assertions in vitest.

### Test plan

- 12 new/updated tests pass across 5 test files:
  - `DashboardLayout.test.tsx` — main landmark + skip link behavior
  - `Table.test.tsx` — scope default + override
  - `Table.axe.test.tsx` — axe-core violation scan
  - `AuditPage.test.tsx` — keyboard toggle with native button
  - `Autocomplete.test.tsx` — clear control semantics
- `pnpm lint` clean (biome, TypeScript, circular deps)
- Manual keyboard traversal: skip link → main content, audit row toggle,
autocomplete clear
This commit is contained in:
Jaayden Halko
2026-03-13 10:47:53 +00:00
committed by GitHub
parent 6104a000d1
commit 4e2640e506
23 changed files with 146 additions and 56 deletions
+1
View File
@@ -3,6 +3,7 @@ export default {
addons: [
"@chromatic-com/storybook",
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-links",
"@storybook/addon-themes",
+2
View File
@@ -10,6 +10,7 @@
.########+ -########. #########+ ########## #### .#### ###########
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Coder</title>
@@ -61,3 +62,4 @@
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>
+1
View File
@@ -134,6 +134,7 @@
"@chromatic-com/storybook": "5.0.1",
"@octokit/types": "12.6.0",
"@playwright/test": "1.50.1",
"@storybook/addon-a11y": "10.2.10",
"@storybook/addon-docs": "10.2.10",
"@storybook/addon-links": "10.2.10",
"@storybook/addon-themes": "10.2.10",
+20
View File
@@ -311,6 +311,9 @@ importers:
'@playwright/test':
specifier: 1.50.1
version: 1.50.1
'@storybook/addon-a11y':
specifier: 10.2.10
version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))
'@storybook/addon-docs':
specifier: 10.2.10
version: 10.2.10(@types/react@19.2.7)(esbuild@0.25.12)(rollup@4.53.3)(storybook@10.2.10(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(vite@7.2.6(@types/node@20.19.25)(jiti@1.21.7)(yaml@2.7.0))
@@ -2313,6 +2316,11 @@ packages:
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz}
'@storybook/addon-a11y@10.2.10':
resolution: {integrity: sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q==, tarball: https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.10.tgz}
peerDependencies:
storybook: ^10.2.10
'@storybook/addon-docs@10.2.10':
resolution: {integrity: sha512-2wIYtdvZIzPbQ5194M5Igpy8faNbQ135nuO5ZaZ2VuttqGr+IJcGnDP42zYwbAsGs28G8ohpkbSgIzVyJWUhPQ==, tarball: https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.10.tgz}
peerDependencies:
@@ -3036,6 +3044,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==, tarball: https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz}
engines: {node: '>= 0.4'}
axe-core@4.11.1:
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==, tarball: https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz}
engines: {node: '>=4'}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==, tarball: https://registry.npmjs.org/axios/-/axios-1.13.2.tgz}
@@ -8653,6 +8665,12 @@ snapshots:
'@standard-schema/spec@1.0.0': {}
'@storybook/addon-a11y@10.2.10(storybook@10.2.10(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))':
dependencies:
'@storybook/global': 5.0.0
axe-core: 4.11.1
storybook: 10.2.10(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@storybook/addon-docs@10.2.10(@types/react@19.2.7)(esbuild@0.25.12)(rollup@4.53.3)(storybook@10.2.10(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(vite@7.2.6(@types/node@20.19.25)(jiti@1.21.7)(yaml@2.7.0))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.2)
@@ -9414,6 +9432,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.0.0
axe-core@4.11.1: {}
axios@1.13.2:
dependencies:
follow-redirects: 1.15.11
@@ -3,7 +3,7 @@ import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { Check } from "lucide-react";
import { useState } from "react";
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
import { Autocomplete } from "./Autocomplete";
const meta: Meta<typeof Autocomplete> = {
@@ -222,13 +222,22 @@ export const SearchAndFilter: Story = {
};
export const ClearSelection: Story = {
render: function ClearSelectionStory() {
args: {
onChange: fn<(value: unknown) => void>(),
},
render: function ClearSelectionStory(args) {
const [value, setValue] = useState<SimpleOption | null>(simpleOptions[0]);
const handleChange = (newValue: SimpleOption | null) => {
args.onChange(newValue);
setValue(newValue);
};
return (
<div className="w-80">
<Autocomplete
{...args}
value={value}
onChange={setValue}
onChange={handleChange}
options={simpleOptions}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
@@ -237,13 +246,23 @@ export const ClearSelection: Story = {
</div>
);
},
play: async ({ canvasElement }) => {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("button", { name: /mango/i });
expect(trigger).toHaveTextContent("Mango");
const clearButton = canvas.getByRole("button", { name: "Clear selection" });
const onChangeSpy = args.onChange as ReturnType<
typeof fn<(value: unknown) => void>
>;
onChangeSpy.mockClear();
const clearButton = canvas.getByLabelText("Clear selection");
expect(clearButton).toHaveAttribute("role", "button");
expect(clearButton).toHaveAttribute("tabindex", "0");
expect(clearButton.tagName).toBe("SPAN");
await userEvent.click(clearButton);
await waitFor(() => expect(onChangeSpy).toHaveBeenCalledWith(null));
await waitFor(() =>
expect(
@@ -181,7 +181,7 @@ export function Autocomplete<TOption>({
handleClear(e);
}
}}
className="flex items-center justify-center size-5 rounded hover:bg-surface-secondary transition-colors"
className="flex items-center justify-center size-5 rounded hover:bg-surface-secondary transition-colors cursor-pointer"
aria-label="Clear selection"
>
<X className="size-4 text-content-secondary hover:text-content-primary" />
@@ -23,7 +23,7 @@ export const FullPageForm: FC<FullPageFormProps> = ({
{detail && <PageHeaderSubtitle>{detail}</PageHeaderSubtitle>}
</PageHeader>
<main>{children}</main>
<div>{children}</div>
</Margins>
);
};
@@ -35,7 +35,7 @@ export const FullPageHorizontalForm: FC<FullPageHorizontalFormProps> = ({
{detail && <PageHeaderSubtitle>{detail}</PageHeaderSubtitle>}
</PageHeader>
<main>{children}</main>
<div>{children}</div>
</Margins>
);
};
+27 -1
View File
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
Table,
TableBody,
@@ -87,4 +88,29 @@ const meta: Meta<typeof Table> = {
export default meta;
type Story = StoryObj<typeof Table>;
export const Default: Story = {};
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const invoiceHeader = canvas.getByRole("columnheader", { name: "Invoice" });
expect(invoiceHeader).toHaveAttribute("scope", "col");
},
};
export const ScopeOverride: Story = {
args: {
children: (
<TableHeader>
<TableRow>
<TableHead scope="row">Invoice</TableHead>
</TableRow>
</TableHeader>
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const invoiceHeader = canvas.getByRole("rowheader", { name: "Invoice" });
expect(invoiceHeader).toHaveAttribute("scope", "row");
},
};
+2
View File
@@ -106,6 +106,7 @@ export const TableRow: React.FC<TableRowProps> = ({
export const TableHead: React.FC<React.ComponentPropsWithRef<"th">> = ({
className,
scope = "col",
...props
}) => {
return (
@@ -115,6 +116,7 @@ export const TableHead: React.FC<React.ComponentPropsWithRef<"th">> = ({
"[&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
scope={scope}
{...props}
/>
);
+11
View File
@@ -217,3 +217,14 @@
@apply text-xs font-mono font-medium;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@@ -34,9 +34,10 @@ test("renders a skip link before navigation content", async () => {
name: "Skip to main content",
});
const navigation = screen.getAllByRole("navigation")[0];
const mainContent = document.getElementById("main-content");
const mainContent = screen.getByRole("main");
expect(skipToContentLink).toHaveAttribute("href", "#main-content");
expect(mainContent).toHaveAttribute("id", "main-content");
expect(mainContent).toHaveAttribute("tabindex", "-1");
expect(
skipToContentLink.compareDocumentPosition(navigation) &
@@ -39,7 +39,7 @@ export const DashboardLayout: FC = () => {
</a>
<Navbar />
<div
<main
id="main-content"
tabIndex={-1}
className={cn(
@@ -50,7 +50,7 @@ export const DashboardLayout: FC = () => {
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</div>
</main>
<DeploymentBanner />
@@ -54,11 +54,11 @@ const DeploymentSettingsLayout: FC = () => {
<section className="px-10 max-w-screen-2xl mx-auto">
<div className="flex flex-row gap-28 py-10">
<DeploymentSidebar />
<main css={{ flexGrow: 1 }}>
<div css={{ flexGrow: 1 }}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
</section>
</div>
@@ -7,11 +7,11 @@ const OrganizationSidebarLayout: FC = () => {
return (
<div className="flex flex-row flex-1 min-h-0 w-full">
<OrganizationSidebar />
<main className="flex flex-col items-center flex-1 min-h-0 h-full overflow-y-auto w-full px-10 pt-10">
<div className="flex flex-col items-center flex-1 min-h-0 h-full overflow-y-auto w-full px-10 pt-10">
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
);
};
@@ -15,9 +15,10 @@ import {
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { InfoIcon, NetworkIcon } from "lucide-react";
import { type FC, useState } from "react";
import { type FC, type KeyboardEvent, useState } from "react";
import { Link as RouterLink } from "react-router";
import userAgentParser from "ua-parser-js";
import { cn } from "utils/cn";
import { buildReasonLabels } from "utils/workspace";
import { AuditLogDescription } from "./AuditLogDescription/AuditLogDescription";
import { AuditLogDiff } from "./AuditLogDiff/AuditLogDiff";
@@ -75,16 +76,22 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
<TableCell className="!p-0 border-0 border-b text-base">
<Collapsible open={isDiffOpen} onOpenChange={setIsDiffOpen}>
<div
className="flex flex-row items-center gap-4 py-4 px-8"
tabIndex={0}
role="button"
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
}}
className={cn(
"flex flex-row items-center gap-4 py-4 px-8",
shouldDisplayDiff && "cursor-pointer",
)}
{...(shouldDisplayDiff && {
tabIndex: 0,
role: "button",
"aria-expanded": isDiffOpen,
onClick: toggle,
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
},
})}
>
<div className="flex flex-row items-center gap-4 flex-1">
<div className="flex flex-row items-center gap-4 w-full">
+18 -18
View File
@@ -8,13 +8,7 @@ import {
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import {
createEvent,
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import type { AuditLogsRequest } from "api/typesGenerated";
@@ -91,47 +85,53 @@ describe("AuditPage", () => {
audit_logs: [MockAuditLog],
count: 1,
});
const user = userEvent.setup();
await renderPage();
const row = screen.getByTestId(`audit-log-row-${MockAuditLog.id}`);
const expandableRowButton = within(row).getByRole("button");
expect(expandableRowButton).toHaveAttribute("role", "button");
expect(expandableRowButton).toHaveAttribute("tabindex", "0");
expect(expandableRowButton.tagName).toBe("DIV");
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
fireEvent.keyDown(expandableRowButton, { key: "Enter" });
expandableRowButton.focus();
await user.keyboard("{Enter}");
expect(screen.getAllByText(/ttl:/i)).toHaveLength(2);
fireEvent.keyDown(expandableRowButton, { key: "Enter" });
await user.keyboard("{Enter}");
await waitFor(() => {
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
});
});
it("toggles an expandable audit row with Space and prevents default", async () => {
it("toggles an expandable audit row with Space", async () => {
vi.spyOn(API, "getAuditLogs").mockResolvedValue({
audit_logs: [MockAuditLog],
count: 1,
});
const user = userEvent.setup();
await renderPage();
const row = screen.getByTestId(`audit-log-row-${MockAuditLog.id}`);
const expandableRowButton = within(row).getByRole("button");
const spaceEvent = createEvent.keyDown(expandableRowButton, {
key: " ",
code: "Space",
});
const preventDefaultSpy = vi.spyOn(spaceEvent, "preventDefault");
fireEvent(expandableRowButton, spaceEvent);
expect(expandableRowButton).toHaveAttribute("role", "button");
expect(expandableRowButton).toHaveAttribute("tabindex", "0");
expect(expandableRowButton.tagName).toBe("DIV");
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
expandableRowButton.focus();
await user.keyboard(" ");
expect(preventDefaultSpy).toHaveBeenCalled();
expect(screen.getAllByText(/ttl:/i)).toHaveLength(2);
fireEvent.keyDown(expandableRowButton, { key: " " });
await user.keyboard(" ");
await waitFor(() => {
expect(screen.queryByText(/ttl:/i)).not.toBeInTheDocument();
+1 -1
View File
@@ -83,7 +83,7 @@ export const HealthyDot: FC<HealthyDotProps> = ({ severity }) => {
export const Main: FC<HTMLAttributes<HTMLDivElement>> = (props) => {
return (
<main
<div
css={{
padding: `0 ${CONTENT_PADDING}px ${CONTENT_PADDING}px`,
display: "flex",
@@ -21,7 +21,7 @@ const CreateOrganizationPage: FC = () => {
const error = createOrganizationMutation.error;
return (
<main className="py-7">
<div className="py-7">
<RequirePermission isFeatureVisible={permissions.createOrganization}>
<CreateOrganizationPageView
error={error}
@@ -38,7 +38,7 @@ const CreateOrganizationPage: FC = () => {
}}
/>
</RequirePermission>
</main>
</div>
);
};
+2 -2
View File
@@ -162,7 +162,7 @@ const TasksPage: FC = () => {
<PageHeaderSubtitle>Automate tasks with AI</PageHeaderSubtitle>
</PageHeader>
<main className="pb-8">
<div className="pb-8">
<TaskPrompt
templates={aiTemplatesQuery.data}
error={aiTemplatesQuery.error}
@@ -295,7 +295,7 @@ const TasksPage: FC = () => {
/>
</section>
)}
</main>
</div>
<BatchDeleteConfirmation
open={isDeleteDialogOpen}
@@ -66,9 +66,9 @@ export const TemplateSettingsLayout: FC = () => {
>
<Sidebar template={templateQuery.data} />
<Suspense fallback={<Loader />}>
<main css={{ width: "100%" }}>
<div className="w-full">
<Outlet />
</main>
</div>
</Suspense>
</TemplateSettings.Provider>
)}
+2 -2
View File
@@ -17,9 +17,9 @@ const Layout: FC = () => {
<div className="flex flex-row gap-12 py-12">
<Sidebar user={me} />
<Suspense fallback={<Loader />}>
<main className="w-full max-w-full">
<div className="w-full max-w-full">
<Outlet />
</main>
</div>
</Suspense>
</div>
</Margins>
@@ -73,9 +73,9 @@ export const WorkspaceSettingsLayout: FC = () => {
>
<Sidebar />
<Suspense fallback={<Loader />}>
<main css={{ width: "100%" }}>
<div className="w-full">
<Outlet />
</main>
</div>
</Suspense>
</WorkspaceSettings.Provider>
)