mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user