diff --git a/src/__tests__/table-scroll-container-guard.test.ts b/src/__tests__/table-scroll-container-guard.test.ts new file mode 100644 index 000000000..f5470e2da --- /dev/null +++ b/src/__tests__/table-scroll-container-guard.test.ts @@ -0,0 +1,66 @@ +/** + * A sideways-scrolling table wrapper is a positioned box. + * + * Screen-reader text (`sr-only`) is absolutely positioned. Inside a scroll + * container that is not positioned, its containing block is an ancestor + * outside the container, so it escapes the container's clipping, and a cell + * panned past the phone's edge widens the whole page: the admin login + * overview scrolled sideways at 390 px once its IP button gained screen-reader + * text. Every `overflow-x-auto` wrapper around a table therefore carries + * `relative`, which makes it the containing block of what it scrolls. + */ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { readSourceText, walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** `file:line` of each table wrapper that scrolls without being positioned. */ +function offenders(rel: string, text: string): string[] { + if (!text.includes(" { + if (!/"[^"]*\boverflow-x-auto\b[^"]*"/.test(line)) return; + if (/^\s*(\*|\/\/)/.test(line)) return; + const window = lines.slice(index, index + 8).join("\n"); + if (window.includes(" { + const files = walkSourceFiles(SRC, { floor: 1500 }).filter( + (rel) => + rel.endsWith(".tsx") && + !rel.includes("__tests__") && + !rel.startsWith("generated/"), + ); + + it("reads the wrappers it claims to check", () => { + const wrappers = files.filter((rel) => { + const text = readSourceText(join(SRC, rel)); + return text.includes(" { + const found = files.flatMap((rel) => + offenders(rel, readSourceText(join(SRC, rel))), + ); + expect(found).toEqual([]); + }); + + it("catches the shape it guards against", () => { + const bad = ['
', " "].join("\n"); + expect(offenders("x.tsx", bad)).toEqual(["x.tsx:1"]); + const good = ['
', "
"].join( + "\n", + ); + expect(offenders("x.tsx", good)).toEqual([]); + }); +}); diff --git a/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx b/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx index 9968dc244..49d9a679f 100644 --- a/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx +++ b/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx @@ -83,7 +83,9 @@ describe("AppLogPreviewSection — summary lives outside the scroll wrapper", () const summaryIdx = html.indexOf('data-testid="app-log-preview-summary"'); expect(summaryIdx).toBeGreaterThan(-1); const before = html.slice(0, summaryIdx); - const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"'); + const lastScrollOpen = before.lastIndexOf( + 'class="relative overflow-x-auto"', + ); expect(lastScrollOpen).toBeGreaterThan(-1); const tail = before.slice(lastScrollOpen); expect(tail).toContain(""); @@ -92,7 +94,7 @@ describe("AppLogPreviewSection — summary lives outside the scroll wrapper", () it("does not nest the summary copy inside the scroll wrapper", () => { const html = render(); const wrapper = html.match( - /
]*>[\s\S]*?<\/table><\/div>/, + /
]*>[\s\S]*?<\/table><\/div>/, ); expect(wrapper).not.toBeNull(); expect(wrapper![0]).not.toContain("app-log-preview-summary"); diff --git a/src/components/admin/__tests__/login-overview-pagination-position.test.tsx b/src/components/admin/__tests__/login-overview-pagination-position.test.tsx index c37af0740..f1af9b3ac 100644 --- a/src/components/admin/__tests__/login-overview-pagination-position.test.tsx +++ b/src/components/admin/__tests__/login-overview-pagination-position.test.tsx @@ -98,7 +98,9 @@ describe("LoginOverviewSection — pagination lives outside the scroll wrapper", ); expect(paginationIdx).toBeGreaterThan(-1); const before = html.slice(0, paginationIdx); - const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"'); + const lastScrollOpen = before.lastIndexOf( + 'class="relative overflow-x-auto"', + ); expect(lastScrollOpen).toBeGreaterThan(-1); // Between the scroll-wrapper opening and the pagination opening // there must be at least one `
` closing the wrapper. @@ -114,7 +116,7 @@ describe("LoginOverviewSection — pagination lives outside the scroll wrapper", // same depth — naively by grabbing up to `
`, which // matches the structure produced by the current implementation. const wrapper = html.match( - /
]*>[\s\S]*?<\/table><\/div>/, + /
]*>[\s\S]*?<\/table><\/div>/, ); expect(wrapper).not.toBeNull(); expect(wrapper![0]).not.toContain("admin.section.auditLog.next"); diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index 97ee96201..bbef90f84 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -117,7 +117,7 @@ export function AiQualitySection() { title={t("admin.aiQuality.empty")} /> ) : ( -
+
diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 4989fcbc4..e0696fa27 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -185,7 +185,7 @@ export function ApiTokenOverviewSection() { inner `overflow-x-auto` is a belt-and-suspenders guard for medium viewports with extra-long permission lists. */} -
+
diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 974d3dfbf..4507f4595 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -279,7 +279,7 @@ export function AppLogPreviewSection() { // the table is scrolled horizontally. The refresh control was // already in the header above the table wrapper. <> -
+
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 2f3ab6759..059bb8242 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -1286,7 +1286,7 @@ export function BackupsSection() { {/* Wide layout: the canonical table. Hidden below `md`, where the five columns push Download / Restore off-screen behind a horizontal scroll; the card list below takes over there. */} -
+
diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 09ac117bc..3eb6659bb 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -112,7 +112,7 @@ export function CoachFeedbackSection() { title={t("admin.coachFeedback.empty")} /> ) : ( -
+
diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 4ba20e22b..bbea2f6f8 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -457,7 +457,7 @@ export function ColumnCoverage({ sideways, and a keyboard user has to be able to reach it (axe scrollable-region-focusable). */
-
+
diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 5ae5c41fb..325beee72 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -348,7 +348,7 @@ export function UserManagementSection() { `md:block hidden` swaps it out for the card-list below at `< md`. Hiding the table-only wrapper (instead of just the cells) saves DOM weight on mobile too. */} -
+
diff --git a/src/components/insights/coach-panel/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx index 812adb1e2..4b00bc70b 100644 --- a/src/components/insights/coach-panel/result-table.tsx +++ b/src/components/insights/coach-panel/result-table.tsx @@ -179,7 +179,7 @@ export function CoachResultTable({ tabIndex={0} aria-labelledby={titleId} className={cn( - "focus-visible:ring-input-focus overflow-x-auto rounded-b-lg outline-none focus-visible:ring-2", + "focus-visible:ring-input-focus relative overflow-x-auto rounded-b-lg outline-none focus-visible:ring-2", expanded && "max-h-96 overflow-y-auto overscroll-contain", )} > diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index c3e1e73e9..4279624c7 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -352,7 +352,7 @@ function ApiEndpointsCard() { /> {/* Desktop table — verbatim layout for md+. */} -
+
@@ -514,7 +514,7 @@ function ApiTokensCard() { {t("settings.activeTokensTitle")}

{/* Desktop table — verbatim layout for md+, hidden on phones. */} -
+
@@ -695,7 +695,7 @@ function ApiTokensCard() { {showRevokedTokens && ( <> {/* Desktop table — verbatim layout for md+. */} -
+