fix(admin): keep a sideways-scrolling table from widening the page on a phone

The login overview's IP button gained screen-reader text, which is
absolutely positioned. Inside a scroll container that is not positioned,
its containing block lies outside the container, so the span escaped the
container's clipping and every row panned past the edge widened the page:
the admin page scrolled sideways at 390 px. Every overflow-x-auto wrapper
around a table is now relative (the admin sections, the settings token
tables and the Coach result table), and a guard holds that.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 22:52:48 +02:00
1 parent dda4c57f5a
commit 294be53883
13 files changed
+86 -16

No files matched your search

@@ -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("<table") || !text.includes("overflow-x-auto")) return [];
const lines = text.split("\n");
const out: string[] = [];
lines.forEach((line, index) => {
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("<table") && !/\brelative\b/.test(line)) {
out.push(`${rel}:${index + 1}`);
}
});
return out;
}
describe("sideways-scrolling table wrappers", () => {
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("<table") && text.includes("overflow-x-auto");
});
expect(wrappers.length).toBeGreaterThan(8);
});
it("are positioned, so what they scroll cannot widen the page", () => {
const found = files.flatMap((rel) =>
offenders(rel, readSourceText(join(SRC, rel))),
);
expect(found).toEqual([]);
});
it("catches the shape it guards against", () => {
const bad = ['<div className="overflow-x-auto">', " <table>"].join("\n");
expect(offenders("x.tsx", bad)).toEqual(["x.tsx:1"]);
const good = ['<div className="relative overflow-x-auto">', "<table>"].join(
"\n",
);
expect(offenders("x.tsx", good)).toEqual([]);
});
});
@@ -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("</div>");
@@ -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(
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
/<div class="relative overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
);
expect(wrapper).not.toBeNull();
expect(wrapper![0]).not.toContain("app-log-preview-summary");
@@ -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 `</div>` closing the wrapper.
@@ -114,7 +116,7 @@ describe("LoginOverviewSection — pagination lives outside the scroll wrapper",
// same depth — naively by grabbing up to `</table></div>`, which
// matches the structure produced by the current implementation.
const wrapper = html.match(
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
/<div class="relative overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
);
expect(wrapper).not.toBeNull();
expect(wrapper![0]).not.toContain("admin.section.auditLog.next");
+1 -1
View File
@@ -117,7 +117,7 @@ export function AiQualitySection() {
title={t("admin.aiQuality.empty")}
/>
) : (
<div className="overflow-x-auto">
<div className="relative overflow-x-auto">
<table className="w-full text-sm" data-slot="ai-quality-table">
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
@@ -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. */}
<div className="hidden overflow-x-auto md:block">
<div className="relative hidden overflow-x-auto md:block">
<table className="w-full table-fixed text-sm">
<colgroup>
<col className="w-[18%]" />
@@ -279,7 +279,7 @@ export function AppLogPreviewSection() {
// the table is scrolled horizontally. The refresh control was
// already in the header above the table wrapper.
<>
<div className="overflow-x-auto">
<div className="relative overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
+1 -1
View File
@@ -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. */}
<div className="hidden overflow-x-auto md:block">
<div className="relative hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
@@ -112,7 +112,7 @@ export function CoachFeedbackSection() {
title={t("admin.coachFeedback.empty")}
/>
) : (
<div className="overflow-x-auto">
<div className="relative overflow-x-auto">
<table className="w-full text-sm" data-slot="coach-feedback-table">
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
+1 -1
View File
@@ -457,7 +457,7 @@ export function ColumnCoverage({
sideways, and a keyboard user has to be able to reach it
(axe scrollable-region-focusable). */
<div
className="overflow-x-auto"
className="relative overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
@@ -404,7 +404,7 @@ export function LoginOverviewSection() {
// back to its starting offset. The CSV-export button was
// already in the toolbar row above the table wrapper.
<>
<div className="overflow-x-auto">
<div className="relative overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
@@ -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. */}
<div className="hidden overflow-x-auto md:block">
<div className="relative hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
@@ -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",
)}
>
+3 -3
View File
@@ -352,7 +352,7 @@ function ApiEndpointsCard() {
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
@@ -514,7 +514,7 @@ function ApiTokensCard() {
{t("settings.activeTokensTitle")}
</p>
{/* Desktop table — verbatim layout for md+, hidden on phones. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[860px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
@@ -695,7 +695,7 @@ function ApiTokensCard() {
{showRevokedTokens && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">