diff --git a/site/.storybook/main.ts b/site/.storybook/main.ts index 00d97a2458..78ceb1d99b 100644 --- a/site/.storybook/main.ts +++ b/site/.storybook/main.ts @@ -3,6 +3,7 @@ export default { addons: [ "@chromatic-com/storybook", + "@storybook/addon-a11y", "@storybook/addon-docs", "@storybook/addon-links", "@storybook/addon-themes", diff --git a/site/index.html b/site/index.html index 2473634d76..5b3098e222 100644 --- a/site/index.html +++ b/site/index.html @@ -10,6 +10,7 @@ .########+ -########. #########+ ########## #### .#### ########### --> + Coder @@ -61,3 +62,4 @@
+ diff --git a/site/package.json b/site/package.json index 4c61f3bec1..008ba1fe5d 100644 --- a/site/package.json +++ b/site/package.json @@ -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", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index ef4b49a382..5e8faa0716 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -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 diff --git a/site/src/components/Autocomplete/Autocomplete.stories.tsx b/site/src/components/Autocomplete/Autocomplete.stories.tsx index 9639e8a89b..4d19a5bd5c 100644 --- a/site/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/site/src/components/Autocomplete/Autocomplete.stories.tsx @@ -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 = { @@ -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(simpleOptions[0]); + const handleChange = (newValue: SimpleOption | null) => { + args.onChange(newValue); + setValue(newValue); + }; + return (
opt.id} getOptionLabel={(opt) => opt.name} @@ -237,13 +246,23 @@ export const ClearSelection: Story = {
); }, - 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( diff --git a/site/src/components/Autocomplete/Autocomplete.tsx b/site/src/components/Autocomplete/Autocomplete.tsx index 61cd05535b..025a1dc88d 100644 --- a/site/src/components/Autocomplete/Autocomplete.tsx +++ b/site/src/components/Autocomplete/Autocomplete.tsx @@ -181,7 +181,7 @@ export function Autocomplete({ 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" > diff --git a/site/src/components/FullPageForm/FullPageForm.tsx b/site/src/components/FullPageForm/FullPageForm.tsx index df068a637d..7606353ab5 100644 --- a/site/src/components/FullPageForm/FullPageForm.tsx +++ b/site/src/components/FullPageForm/FullPageForm.tsx @@ -23,7 +23,7 @@ export const FullPageForm: FC = ({ {detail && {detail}} -
{children}
+
{children}
); }; diff --git a/site/src/components/FullPageForm/FullPageHorizontalForm.tsx b/site/src/components/FullPageForm/FullPageHorizontalForm.tsx index 7be86788a7..1919a4cbe6 100644 --- a/site/src/components/FullPageForm/FullPageHorizontalForm.tsx +++ b/site/src/components/FullPageForm/FullPageHorizontalForm.tsx @@ -35,7 +35,7 @@ export const FullPageHorizontalForm: FC = ({ {detail && {detail}} -
{children}
+
{children}
); }; diff --git a/site/src/components/Table/Table.stories.tsx b/site/src/components/Table/Table.stories.tsx index ee9369c8a3..459ad0f115 100644 --- a/site/src/components/Table/Table.stories.tsx +++ b/site/src/components/Table/Table.stories.tsx @@ -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 = { export default meta; type Story = StoryObj; -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: ( + + + Invoice + + + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const invoiceHeader = canvas.getByRole("rowheader", { name: "Invoice" }); + + expect(invoiceHeader).toHaveAttribute("scope", "row"); + }, +}; diff --git a/site/src/components/Table/Table.tsx b/site/src/components/Table/Table.tsx index 84eda7fcbc..897e0f249b 100644 --- a/site/src/components/Table/Table.tsx +++ b/site/src/components/Table/Table.tsx @@ -106,6 +106,7 @@ export const TableRow: React.FC = ({ export const TableHead: React.FC> = ({ className, + scope = "col", ...props }) => { return ( @@ -115,6 +116,7 @@ export const TableHead: React.FC> = ({ "[&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className, )} + scope={scope} {...props} /> ); diff --git a/site/src/index.css b/site/src/index.css index 053869765a..baa0d92d28 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -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; + } +} diff --git a/site/src/modules/dashboard/DashboardLayout.test.tsx b/site/src/modules/dashboard/DashboardLayout.test.tsx index 3aaadb4155..0653bc3595 100644 --- a/site/src/modules/dashboard/DashboardLayout.test.tsx +++ b/site/src/modules/dashboard/DashboardLayout.test.tsx @@ -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) & diff --git a/site/src/modules/dashboard/DashboardLayout.tsx b/site/src/modules/dashboard/DashboardLayout.tsx index 9cc0f9d493..82dbb39d3c 100644 --- a/site/src/modules/dashboard/DashboardLayout.tsx +++ b/site/src/modules/dashboard/DashboardLayout.tsx @@ -39,7 +39,7 @@ export const DashboardLayout: FC = () => { -
{ }> -
+ diff --git a/site/src/modules/management/DeploymentSettingsLayout.tsx b/site/src/modules/management/DeploymentSettingsLayout.tsx index da14a24753..d52e52d253 100644 --- a/site/src/modules/management/DeploymentSettingsLayout.tsx +++ b/site/src/modules/management/DeploymentSettingsLayout.tsx @@ -54,11 +54,11 @@ const DeploymentSettingsLayout: FC = () => {
-
+
}> -
+
diff --git a/site/src/modules/management/OrganizationSidebarLayout.tsx b/site/src/modules/management/OrganizationSidebarLayout.tsx index 6113235be3..ee8803686e 100644 --- a/site/src/modules/management/OrganizationSidebarLayout.tsx +++ b/site/src/modules/management/OrganizationSidebarLayout.tsx @@ -7,11 +7,11 @@ const OrganizationSidebarLayout: FC = () => { return (
-
+
}> -
+
); }; diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx index 946e21aad0..a49b04394d 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx @@ -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 = ({
{ - 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) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(); + } + }, + })} >
diff --git a/site/src/pages/AuditPage/AuditPage.test.tsx b/site/src/pages/AuditPage/AuditPage.test.tsx index 04be04aa11..7f0b0cebe2 100644 --- a/site/src/pages/AuditPage/AuditPage.test.tsx +++ b/site/src/pages/AuditPage/AuditPage.test.tsx @@ -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(); diff --git a/site/src/pages/HealthPage/Content.tsx b/site/src/pages/HealthPage/Content.tsx index db3cdd70c5..a4a833f805 100644 --- a/site/src/pages/HealthPage/Content.tsx +++ b/site/src/pages/HealthPage/Content.tsx @@ -83,7 +83,7 @@ export const HealthyDot: FC = ({ severity }) => { export const Main: FC> = (props) => { return ( -
{ const error = createOrganizationMutation.error; return ( -
+
{ }} /> -
+
); }; diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 18510fd005..e6092cdfb5 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -162,7 +162,7 @@ const TasksPage: FC = () => { Automate tasks with AI -
+
{ /> )} -
+
{ > }> -
+
-
+
)} diff --git a/site/src/pages/UserSettingsPage/Layout.tsx b/site/src/pages/UserSettingsPage/Layout.tsx index b953b4909e..85d9b36926 100644 --- a/site/src/pages/UserSettingsPage/Layout.tsx +++ b/site/src/pages/UserSettingsPage/Layout.tsx @@ -17,9 +17,9 @@ const Layout: FC = () => {
}> -
+
-
+
diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx index 6f8a0921ef..a65883c3e9 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx @@ -73,9 +73,9 @@ export const WorkspaceSettingsLayout: FC = () => { > }> -
+
-
+
)