Skip to content

Commit f794bb6

Browse files
authored
Merge pull request #6453 from nghetienhiep/fix/issue-6179
fix(ui): adopt TableScroll + caption + scope=col a11y pattern across 8 tables
2 parents 4d6d016 + 8dff90f commit f794bb6

13 files changed

Lines changed: 324 additions & 89 deletions

apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
1+
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33

44
// Mock the API layer so the component never touches the network.
@@ -58,6 +58,21 @@ describe("ActivationPreview", () => {
5858
);
5959
});
6060

61+
it("wraps the sample table in a keyboard-focusable, labelled scroll region with a caption and column-scoped headers (#794 a11y pattern)", async () => {
62+
apiFetch.mockResolvedValue({ ok: true, data: BASE_PREVIEW });
63+
render(<ActivationPreview reviewability={REVIEWABILITY} />);
64+
await waitFor(() => expect(screen.getByText("Add cursor pagination")).toBeTruthy());
65+
const region = screen.getByRole("region", { name: "Advisory preview sample PRs" });
66+
// A bare overflow-hidden div is not a tab stop; TableScroll makes it one (WCAG 2.1.1).
67+
expect(region.tabIndex).toBe(0);
68+
expect(region.className).toContain("overflow-x-auto");
69+
const table = screen.getByRole("table", {
70+
name: "Sample pull requests with their title, severity, and finding count.",
71+
});
72+
expect(within(table).getByRole("columnheader", { name: "PR" })).toBeTruthy();
73+
expect(within(table).getByRole("columnheader", { name: "Findings" })).toBeTruthy();
74+
});
75+
6176
it("renders an error state with the failure message when the preview fails to load", async () => {
6277
apiFetch.mockResolvedValue({ ok: false, message: "503 Service Unavailable" });
6378
render(<ActivationPreview reviewability={REVIEWABILITY} />);

apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CheckCircle2, Loader2, Rocket } from "lucide-react";
22
import { useCallback, useEffect, useMemo, useState } from "react";
33

44
import { StatusPill, type Status } from "@/components/site/control-primitives";
5+
import { TableScroll } from "@/components/site/data-table";
56
import { StateBoundary } from "@/components/site/state-views";
67
import { apiFetch } from "@/lib/api/request";
78
import { getApiOrigin } from "@/lib/api/origin";
@@ -250,14 +251,25 @@ function ActivationPreviewBody({
250251
) : null}
251252

252253
{preview.samples.length > 0 ? (
253-
<div className="overflow-hidden rounded-token border-hairline">
254+
<TableScroll className="rounded-token border-hairline" label="Advisory preview sample PRs">
254255
<table className="w-full text-left text-token-xs">
256+
<caption className="sr-only">
257+
Sample pull requests with their title, severity, and finding count.
258+
</caption>
255259
<thead className="border-b-hairline font-mono uppercase tracking-wider text-muted-foreground">
256260
<tr>
257-
<th className="px-3 py-2 font-normal">PR</th>
258-
<th className="px-3 py-2 font-normal">Title</th>
259-
<th className="px-3 py-2 font-normal">Severity</th>
260-
<th className="px-3 py-2 font-normal">Findings</th>
261+
<th scope="col" className="px-3 py-2 font-normal">
262+
PR
263+
</th>
264+
<th scope="col" className="px-3 py-2 font-normal">
265+
Title
266+
</th>
267+
<th scope="col" className="px-3 py-2 font-normal">
268+
Severity
269+
</th>
270+
<th scope="col" className="px-3 py-2 font-normal">
271+
Findings
272+
</th>
261273
</tr>
262274
</thead>
263275
<tbody>
@@ -275,7 +287,7 @@ function ActivationPreviewBody({
275287
))}
276288
</tbody>
277289
</table>
278-
</div>
290+
</TableScroll>
279291
) : null}
280292

281293
<div className="flex flex-wrap items-center gap-3">

apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router";
33
import { Check, Copy, Download, History, Loader2, RefreshCw } from "lucide-react";
44

55
import { KeyValueGrid, StatusPill, type Status } from "@/components/site/control-primitives";
6+
import { TableScroll } from "@/components/site/data-table";
67
import { McpVersionBadge } from "@/components/site/mcp-version-badge";
78
import { StatCard } from "@/components/site/primitives";
89
import { RefreshMeta } from "@/components/site/refresh-meta";
@@ -364,42 +365,54 @@ export function MinerPanel() {
364365
<p className="mt-1 text-token-xs text-muted-foreground">
365366
Where to spend time, and where not to.
366367
</p>
367-
<table className="mt-4 w-full text-left text-token-sm">
368-
<thead>
369-
<tr className="border-b-hairline font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
370-
<th className="py-2 pr-3 font-normal">Repo</th>
371-
<th className="py-2 pr-3 font-normal">Lane</th>
372-
<th className="py-2 font-normal">Why</th>
373-
</tr>
374-
</thead>
375-
<tbody>
376-
{data.repoFit.map((repo, index) => {
377-
const lane = repo.lane ?? "pursue";
378-
return (
379-
<tr
380-
key={`${repo.repoFullName ?? index}`}
381-
className="border-b-hairline last:border-b-0 transition-colors hover:bg-muted/40"
382-
>
383-
<td className="py-2 pr-3 align-top">
384-
<div className="break-all font-mono text-token-xs text-foreground/90">
385-
{repo.repoFullName ?? "repo pending"}
386-
</div>
387-
<RecommendationChangeInline change={repo.change} />
388-
</td>
389-
<td className="py-2 pr-3 align-top">
390-
<StatusPill status={LANE_TONE[lane] ?? "info"}>{lane}</StatusPill>
391-
</td>
392-
<td className="py-2 align-top text-token-xs text-muted-foreground">
393-
{repo.why ??
394-
repo.rationale ??
395-
repo.recommendation ??
396-
"No rationale recorded."}
397-
</td>
398-
</tr>
399-
);
400-
})}
401-
</tbody>
402-
</table>
368+
<TableScroll className="mt-4" label="Repo fit">
369+
<table className="w-full text-left text-token-sm">
370+
<caption className="sr-only">
371+
Repositories with their recommended lane and rationale for where to spend
372+
time.
373+
</caption>
374+
<thead>
375+
<tr className="border-b-hairline font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
376+
<th scope="col" className="py-2 pr-3 font-normal">
377+
Repo
378+
</th>
379+
<th scope="col" className="py-2 pr-3 font-normal">
380+
Lane
381+
</th>
382+
<th scope="col" className="py-2 font-normal">
383+
Why
384+
</th>
385+
</tr>
386+
</thead>
387+
<tbody>
388+
{data.repoFit.map((repo, index) => {
389+
const lane = repo.lane ?? "pursue";
390+
return (
391+
<tr
392+
key={`${repo.repoFullName ?? index}`}
393+
className="border-b-hairline last:border-b-0 transition-colors hover:bg-muted/40"
394+
>
395+
<td className="py-2 pr-3 align-top">
396+
<div className="break-all font-mono text-token-xs text-foreground/90">
397+
{repo.repoFullName ?? "repo pending"}
398+
</div>
399+
<RecommendationChangeInline change={repo.change} />
400+
</td>
401+
<td className="py-2 pr-3 align-top">
402+
<StatusPill status={LANE_TONE[lane] ?? "info"}>{lane}</StatusPill>
403+
</td>
404+
<td className="py-2 align-top text-token-xs text-muted-foreground">
405+
{repo.why ??
406+
repo.rationale ??
407+
repo.recommendation ??
408+
"No rationale recorded."}
409+
</td>
410+
</tr>
411+
);
412+
})}
413+
</tbody>
414+
</table>
415+
</TableScroll>
403416
</div>
404417
</section>
405418
</div>

apps/loopover-ui/src/components/site/audit-feed.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
1+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33

44
const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
@@ -116,6 +116,20 @@ describe("AuditFeed", () => {
116116
);
117117
});
118118

119+
it("wraps the audit table in a keyboard-focusable, labelled scroll region with a caption and column-scoped headers (#794 a11y pattern)", async () => {
120+
render(<AuditFeed />);
121+
await screen.findByText("repo-owner/owned-repo");
122+
const region = screen.getByRole("region", { name: "Skipped PR audit" });
123+
// A bare overflow-x-auto div is not a tab stop; TableScroll makes it one (WCAG 2.1.1).
124+
expect(region.tabIndex).toBe(0);
125+
expect(region.className).toContain("overflow-x-auto");
126+
const table = screen.getByRole("table", {
127+
name: "Skipped pull requests with the time, repository, pull request, skip reason, and remediation for each.",
128+
});
129+
expect(within(table).getByRole("columnheader", { name: "Time" })).toBeTruthy();
130+
expect(within(table).getByRole("columnheader", { name: "Remediation" })).toBeTruthy();
131+
});
132+
119133
it("shows an empty state when the audit export has no items", async () => {
120134
apiFetch.mockResolvedValue({ ok: true, data: { ...SAMPLE, items: [] } });
121135
render(<AuditFeed />);

apps/loopover-ui/src/components/site/audit-feed.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type SkippedPrAuditReason,
1515
} from "@/components/site/audit-feed-model";
1616
import { BoundaryBadge, StatusPill } from "@/components/site/control-primitives";
17+
import { TableScroll } from "@/components/site/data-table";
1718
import {
1819
EmptyState,
1920
ErrorState,
@@ -184,15 +185,32 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
184185
onReset={resetFilters}
185186
/>
186187

187-
<div className="overflow-x-auto rounded-token border border-border bg-transparent">
188+
<TableScroll
189+
className="rounded-token border border-border bg-transparent"
190+
label="Skipped PR audit"
191+
>
188192
<table className="w-full min-w-[760px] text-left text-token-sm">
193+
<caption className="sr-only">
194+
Skipped pull requests with the time, repository, pull request, skip reason, and
195+
remediation for each.
196+
</caption>
189197
<thead className="border-b border-border text-token-xs uppercase text-muted-foreground">
190198
<tr>
191-
<th className="px-4 py-3 font-medium">Time</th>
192-
<th className="px-4 py-3 font-medium">Repository</th>
193-
<th className="px-4 py-3 font-medium">Pull request</th>
194-
<th className="px-4 py-3 font-medium">Reason</th>
195-
<th className="px-4 py-3 font-medium">Remediation</th>
199+
<th scope="col" className="px-4 py-3 font-medium">
200+
Time
201+
</th>
202+
<th scope="col" className="px-4 py-3 font-medium">
203+
Repository
204+
</th>
205+
<th scope="col" className="px-4 py-3 font-medium">
206+
Pull request
207+
</th>
208+
<th scope="col" className="px-4 py-3 font-medium">
209+
Reason
210+
</th>
211+
<th scope="col" className="px-4 py-3 font-medium">
212+
Remediation
213+
</th>
196214
</tr>
197215
</thead>
198216
<tbody>
@@ -228,7 +246,7 @@ export function AuditFeed({ enabled = true }: AuditFeedProps) {
228246
))}
229247
</tbody>
230248
</table>
231-
</div>
249+
</TableScroll>
232250

233251
<div className="flex flex-wrap items-center gap-3">
234252
{data.hasMore && limit < MAX_LIMIT ? (

apps/loopover-ui/src/components/site/check-run-readiness-table.test.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from "@testing-library/react";
1+
import { render, screen, within } from "@testing-library/react";
22
import { describe, expect, it } from "vitest";
33

44
import { CheckRunReadinessTable } from "@/components/site/check-run-readiness-table";
@@ -55,6 +55,19 @@ describe("CheckRunReadinessTable", () => {
5555
expect(screen.getByText("Partial")).toBeTruthy();
5656
});
5757

58+
it("wraps the table in a keyboard-focusable, labelled scroll region with a caption and column-scoped headers (#794 a11y pattern)", () => {
59+
render(<CheckRunReadinessTable detailLevel="standard" readiness={SAMPLE} />);
60+
const region = screen.getByRole("region", { name: "Context check readiness signals" });
61+
// A bare overflow-hidden/overflow-x-auto div is not a tab stop; TableScroll makes it one.
62+
expect(region.tabIndex).toBe(0);
63+
expect(region.className).toContain("overflow-x-auto");
64+
const table = screen.getByRole("table", {
65+
name: "Readiness signals with their band, evidence, and recommended action.",
66+
});
67+
expect(within(table).getByRole("columnheader", { name: "Signal" })).toBeTruthy();
68+
expect(within(table).getByRole("columnheader", { name: "Action" })).toBeTruthy();
69+
});
70+
5871
it("hides the table at minimal detail level", () => {
5972
render(<CheckRunReadinessTable detailLevel="minimal" readiness={SAMPLE} />);
6073
expect(screen.queryByText("Context check readiness")).toBeNull();

apps/loopover-ui/src/components/site/check-run-readiness-table.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { StatusPill, type Status } from "@/components/site/control-primitives";
2+
import { TableScroll } from "@/components/site/data-table";
23
import {
34
COMPONENT_BAND_LABEL,
45
READINESS_BAND_LABEL,
@@ -57,14 +58,28 @@ export function CheckRunReadinessTable({
5758
</StatusPill>
5859
</div>
5960

60-
<div className="overflow-hidden rounded-token border-hairline">
61+
<TableScroll
62+
className="rounded-token border-hairline"
63+
label="Context check readiness signals"
64+
>
6165
<table className="w-full text-left text-token-xs">
66+
<caption className="sr-only">
67+
Readiness signals with their band, evidence, and recommended action.
68+
</caption>
6269
<thead className="border-b-hairline font-mono uppercase tracking-wider text-muted-foreground">
6370
<tr>
64-
<th className="px-3 py-2 font-normal">Signal</th>
65-
<th className="px-3 py-2 font-normal">Band</th>
66-
<th className="px-3 py-2 font-normal">Evidence</th>
67-
<th className="hidden px-3 py-2 font-normal lg:table-cell">Action</th>
71+
<th scope="col" className="px-3 py-2 font-normal">
72+
Signal
73+
</th>
74+
<th scope="col" className="px-3 py-2 font-normal">
75+
Band
76+
</th>
77+
<th scope="col" className="px-3 py-2 font-normal">
78+
Evidence
79+
</th>
80+
<th scope="col" className="hidden px-3 py-2 font-normal lg:table-cell">
81+
Action
82+
</th>
6883
</tr>
6984
</thead>
7085
<tbody>
@@ -84,7 +99,7 @@ export function CheckRunReadinessTable({
8499
))}
85100
</tbody>
86101
</table>
87-
</div>
102+
</TableScroll>
88103
</section>
89104
);
90105
}

apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
1+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33

44
const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
@@ -123,6 +123,20 @@ describe("DeadLetterQueuePanel", () => {
123123
expect(dashCells.length).toBeGreaterThan(0);
124124
});
125125

126+
it("wraps the queue table in a keyboard-focusable, labelled scroll region with a caption and column-scoped headers (#794 a11y pattern)", async () => {
127+
render(<DeadLetterQueuePanel />);
128+
await screen.findByText("github-webhook");
129+
const region = screen.getByRole("region", { name: "Dead letter queue" });
130+
// A bare overflow-x-auto div is not a tab stop; TableScroll makes it one (WCAG 2.1.1).
131+
expect(region.tabIndex).toBe(0);
132+
expect(region.className).toContain("overflow-x-auto");
133+
const table = screen.getByRole("table", {
134+
name: "Failed background jobs with their ID, type, attempt count, last error, timestamps, and retry actions.",
135+
});
136+
expect(within(table).getByRole("columnheader", { name: "Job ID" })).toBeTruthy();
137+
expect(within(table).getByRole("columnheader", { name: "Actions" })).toBeTruthy();
138+
});
139+
126140
it("shows an empty state when the queue has no dead-letter jobs", async () => {
127141
apiFetch.mockResolvedValue({ ok: true, data: { ...SAMPLE_PAGE, total: 0, items: [] } });
128142
render(<DeadLetterQueuePanel />);

0 commit comments

Comments
 (0)