diff --git a/packages/ui/src/lib/components/AppShell/AppShellParts.svelte.test.ts b/packages/ui/src/lib/components/AppShell/AppShellParts.svelte.test.ts index fb7249ca..829ba02c 100644 --- a/packages/ui/src/lib/components/AppShell/AppShellParts.svelte.test.ts +++ b/packages/ui/src/lib/components/AppShell/AppShellParts.svelte.test.ts @@ -5,6 +5,7 @@ import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; import { settled } from "../../../../tests/settled"; +import { touchTarget } from "../../../../tests/touch-target"; import { VIEWPORTS } from "../../../../tests/viewports"; import BottomNav from "./BottomNav.svelte"; import MobileHeader from "./MobileHeader.svelte"; @@ -274,7 +275,7 @@ describe("BottomNav", () => { .getByRole("button", { name: "Application" }) .element() .getBoundingClientRect(); - expect(rect.height).toBeGreaterThanOrEqual(44); + expect(rect.height).toBeGreaterThanOrEqual(touchTarget()); }); }); diff --git a/packages/ui/src/lib/components/Carousel/Carousel.svelte.test.ts b/packages/ui/src/lib/components/Carousel/Carousel.svelte.test.ts index 9e115373..2e966a9d 100644 --- a/packages/ui/src/lib/components/Carousel/Carousel.svelte.test.ts +++ b/packages/ui/src/lib/components/Carousel/Carousel.svelte.test.ts @@ -3,6 +3,7 @@ import { cdp, page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; import { q } from "../../../../tests/dom"; +import { touchTarget } from "../../../../tests/touch-target"; import { VIEWPORTS } from "../../../../tests/viewports"; import Harness from "./CarouselHarness.test.svelte"; @@ -247,7 +248,7 @@ describe("Carousel scroller and touch (#464)", () => { for (const sel of [".ca-carousel__nav", ".ca-carousel__play", ".ca-carousel__dot"]) { const el = q(screen.container, sel); const [w, h] = hit(el); - expect(Math.min(w, h), `${sel} ${w}x${h}`).toBeGreaterThanOrEqual(44); + expect(Math.min(w, h), `${sel} ${w}x${h}`).toBeGreaterThanOrEqual(touchTarget()); } } finally { await cdp().send("Emulation.setTouchEmulationEnabled", { enabled: false }); diff --git a/packages/ui/src/lib/components/ChipGroup/ChipGroup.svelte.test.ts b/packages/ui/src/lib/components/ChipGroup/ChipGroup.svelte.test.ts index 80598ca4..f713cad1 100644 --- a/packages/ui/src/lib/components/ChipGroup/ChipGroup.svelte.test.ts +++ b/packages/ui/src/lib/components/ChipGroup/ChipGroup.svelte.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; +import { touchTarget } from "../../../../tests/touch-target"; import ChipGroupHarness from "./ChipGroupHarness.test.svelte"; const radio = (screen: Awaited>, name: string) => @@ -125,7 +126,7 @@ describe("ChipGroup", () => { const screen = await render(ChipGroupHarness, { initial: "1" }); const group = screen.getByRole("radiogroup").element() as HTMLElement; for (const label of group.querySelectorAll(".ca-chip-group__chip")) { - expect(label.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + expect(label.getBoundingClientRect().height).toBeGreaterThanOrEqual(touchTarget()); } expect(group.scrollWidth).toBeLessThanOrEqual(group.clientWidth); const root = document.documentElement; diff --git a/packages/ui/src/lib/components/ConversationItem/ConversationItem.svelte.test.ts b/packages/ui/src/lib/components/ConversationItem/ConversationItem.svelte.test.ts index 658e6401..d2028bfb 100644 --- a/packages/ui/src/lib/components/ConversationItem/ConversationItem.svelte.test.ts +++ b/packages/ui/src/lib/components/ConversationItem/ConversationItem.svelte.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { userEvent } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; +import { touchTarget } from "../../../../tests/touch-target"; import Harness from "./ConversationItem.test.svelte"; const item = (c: Element) => c.querySelector(".ca-conversation") as HTMLElement; @@ -94,9 +95,13 @@ describe("ConversationItem", () => { it("keeps a 44px minimum height for the whole row", async () => { const screen = await render(Harness, { props: { preview: "Hi" } }); - expect(item(screen.container).getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + expect(item(screen.container).getBoundingClientRect().height).toBeGreaterThanOrEqual( + touchTarget() + ); const link = await render(Harness, { props: { href: "/x" } }); - expect(item(link.container).getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + expect(item(link.container).getBoundingClientRect().height).toBeGreaterThanOrEqual( + touchTarget() + ); }); it("calls onclick on click", async () => { diff --git a/packages/ui/src/lib/components/Dialog/DialogTouch.svelte.test.ts b/packages/ui/src/lib/components/Dialog/DialogTouch.svelte.test.ts index f881b0f1..56acecb9 100644 --- a/packages/ui/src/lib/components/Dialog/DialogTouch.svelte.test.ts +++ b/packages/ui/src/lib/components/Dialog/DialogTouch.svelte.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { cdp, page } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { q } from "../../../../tests/dom"; +import { touchTarget } from "../../../../tests/touch-target"; import { VIEWPORTS } from "../../../../tests/viewports"; import Harness from "./DialogTouch.test.svelte"; @@ -21,7 +22,7 @@ describe("Dialog close button under a coarse pointer", () => { const overlay = a.content !== "none" && a.position === "absolute"; const w = Math.max(r.width, overlay ? Number.parseFloat(a.width) || 0 : 0); const h = Math.max(r.height, overlay ? Number.parseFloat(a.height) || 0 : 0); - expect(Math.min(w, h), `close ${w}x${h}`).toBeGreaterThanOrEqual(44); + expect(Math.min(w, h), `close ${w}x${h}`).toBeGreaterThanOrEqual(touchTarget()); } finally { await cdp().send("Emulation.setTouchEmulationEnabled", { enabled: false }); } diff --git a/packages/ui/src/lib/components/Docs/DocsProse.svelte.test.ts b/packages/ui/src/lib/components/Docs/DocsProse.svelte.test.ts index dcaaeb12..af444e8e 100644 --- a/packages/ui/src/lib/components/Docs/DocsProse.svelte.test.ts +++ b/packages/ui/src/lib/components/Docs/DocsProse.svelte.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; +import { touchTarget } from "../../../../tests/touch-target"; import { setLocale } from "../../i18n/index.js"; import DocsHarness from "./DocsHarness.test.svelte"; @@ -51,7 +52,7 @@ describe("CardGrid / LinkCard", () => { it("link cards are at least 44px tall (touch target)", async () => { const screen = await render(DocsHarness, { kind: "cardgrid" }); for (const a of screen.getByRole("link").elements()) { - expect(a.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + expect(a.getBoundingClientRect().height).toBeGreaterThanOrEqual(touchTarget()); } }); }); diff --git a/packages/ui/src/lib/components/Docs/Releases.svelte.test.ts b/packages/ui/src/lib/components/Docs/Releases.svelte.test.ts index 115119cf..f5cd8551 100644 --- a/packages/ui/src/lib/components/Docs/Releases.svelte.test.ts +++ b/packages/ui/src/lib/components/Docs/Releases.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; +import { touchTarget } from "../../../../tests/touch-target"; import Releases from "./Releases.test.svelte"; const css = (el: Element, prop: string) => getComputedStyle(el).getPropertyValue(prop); @@ -25,7 +26,7 @@ describe("ca-release (changelog markup inside .ca-prose)", () => { expect(css(meta, "display")).toBe("flex"); expect(css(meta, "flex-wrap")).toBe("wrap"); for (const a of meta.querySelectorAll("a")) { - expect(a.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + expect(a.getBoundingClientRect().height).toBeGreaterThanOrEqual(touchTarget()); } }); @@ -35,7 +36,7 @@ describe("ca-release (changelog markup inside .ca-prose)", () => { expect(css(list, "display")).toBe("flex"); expect(css(list, "list-style-type")).toBe("none"); for (const a of screen.container.querySelectorAll(".ca-release-jump a")) { - expect(a.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + expect(a.getBoundingClientRect().height).toBeGreaterThanOrEqual(touchTarget()); } }); diff --git a/packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts b/packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts index d28d93cc..c8ad78d4 100644 --- a/packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts +++ b/packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; +import { touchTarget } from "../../../../tests/touch-target"; import { VIEWPORTS } from "../../../../tests/viewports"; import type { KanbanMove } from "./Kanban.svelte"; import Harness from "./Kanban.test.svelte"; @@ -274,7 +275,7 @@ describe("Kanban mobile (390px)", () => { const tabs = screen.getByRole("tab"); expect(tabs.elements()).toHaveLength(5); for (const t of tabs.elements()) - expect(t.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + expect(t.getBoundingClientRect().height).toBeGreaterThanOrEqual(touchTarget()); await expect .element(screen.getByRole("tab", { name: /New/ })) .toHaveAttribute("aria-selected", "true"); @@ -317,7 +318,7 @@ describe("Kanban mobile (390px)", () => { .element(screen.getByRole("tab", { name: /New/ })) .toHaveAttribute("aria-selected", "true"); const btn = screen.getByRole("button", { name: "Move Ana Souza to…" }); - expect(btn.element().getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + expect(btn.element().getBoundingClientRect().height).toBeGreaterThanOrEqual(touchTarget()); await btn.click(); await screen.getByRole("menuitem", { name: "Won" }).click(); await waitFor(() => moves.length === 1); diff --git a/packages/ui/src/lib/components/MediaGrid/MediaGrid.svelte.test.ts b/packages/ui/src/lib/components/MediaGrid/MediaGrid.svelte.test.ts index 6ff43a5c..a9366c00 100644 --- a/packages/ui/src/lib/components/MediaGrid/MediaGrid.svelte.test.ts +++ b/packages/ui/src/lib/components/MediaGrid/MediaGrid.svelte.test.ts @@ -3,6 +3,7 @@ import { page } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; import { settled } from "../../../../tests/settled"; +import { touchTarget } from "../../../../tests/touch-target"; import type { MediaGridItem } from "./MediaGrid.svelte"; import Harness from "./MediaGrid.test.svelte"; @@ -68,8 +69,8 @@ describe("MediaGrid", () => { const screen = await render(Harness, { props: { items: images(4) } }); for (const t of tiles(screen.container)) { const r = t.getBoundingClientRect(); - expect(r.width).toBeGreaterThanOrEqual(44); - expect(r.height).toBeGreaterThanOrEqual(44); + expect(r.width).toBeGreaterThanOrEqual(touchTarget()); + expect(r.height).toBeGreaterThanOrEqual(touchTarget()); } }); diff --git a/packages/ui/src/lib/components/Menubar/Menubar.svelte.test.ts b/packages/ui/src/lib/components/Menubar/Menubar.svelte.test.ts index d650bade..dff7129b 100644 --- a/packages/ui/src/lib/components/Menubar/Menubar.svelte.test.ts +++ b/packages/ui/src/lib/components/Menubar/Menubar.svelte.test.ts @@ -3,6 +3,7 @@ import { cdp, page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; import { settled } from "../../../../tests/settled"; +import { touchTarget } from "../../../../tests/touch-target"; import { VIEWPORTS } from "../../../../tests/viewports"; import Harness from "./Menubar.test.svelte"; @@ -193,7 +194,9 @@ describe("Menubar touch (#464)", () => { const overlay = a.content !== "none" && a.position === "absolute"; const w = Math.max(r.width, overlay ? Number.parseFloat(a.width) || 0 : 0); const h = Math.max(r.height, overlay ? Number.parseFloat(a.height) || 0 : 0); - expect(Math.min(w, h), `${el.textContent?.trim()} ${w}x${h}`).toBeGreaterThanOrEqual(44); + expect(Math.min(w, h), `${el.textContent?.trim()} ${w}x${h}`).toBeGreaterThanOrEqual( + touchTarget() + ); } } finally { await cdp().send("Emulation.setTouchEmulationEnabled", { enabled: false }); diff --git a/packages/ui/src/lib/components/RecordList/RecordList.svelte.test.ts b/packages/ui/src/lib/components/RecordList/RecordList.svelte.test.ts index f82a164c..40d61ff8 100644 --- a/packages/ui/src/lib/components/RecordList/RecordList.svelte.test.ts +++ b/packages/ui/src/lib/components/RecordList/RecordList.svelte.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; import { q } from "../../../../tests/dom"; +import { touchTarget } from "../../../../tests/touch-target"; import Harness from "./RecordListHarness.test.svelte"; /** True when the element is painted (not display:none, not visually hidden to a 1px box). */ @@ -72,8 +73,8 @@ describe("RecordList (wide)", () => { it("the actions cell is at least 44px wide and tall", async () => { const screen = await render(Harness, { width: 900 }); const box = q(screen.container, ".ca-record-row__actions").getBoundingClientRect(); - expect(box.width).toBeGreaterThanOrEqual(44); - expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(touchTarget()); + expect(box.height).toBeGreaterThanOrEqual(touchTarget()); }); it("has no axe violations", async () => { @@ -118,8 +119,8 @@ describe("RecordList (narrow container)", () => { it("the actions cell stays a 44px target", async () => { const screen = await render(Harness, { width: 360 }); const box = q(screen.container, ".ca-record-row__actions").getBoundingClientRect(); - expect(box.width).toBeGreaterThanOrEqual(44); - expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(touchTarget()); + expect(box.height).toBeGreaterThanOrEqual(touchTarget()); }); it("nothing overflows the container", async () => { diff --git a/packages/ui/src/lib/components/Table/TableDense.svelte.test.ts b/packages/ui/src/lib/components/Table/TableDense.svelte.test.ts index dcd3ebf6..75b31b3b 100644 --- a/packages/ui/src/lib/components/Table/TableDense.svelte.test.ts +++ b/packages/ui/src/lib/components/Table/TableDense.svelte.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { cdp, page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; +import { touchTarget } from "../../../../tests/touch-target"; import { VIEWPORTS } from "../../../../tests/viewports"; import Harness from "./TableDense.test.svelte"; @@ -172,7 +173,7 @@ describe("SimpleTable touch targets (#360)", () => { const el = document.querySelector(sel) as HTMLElement; expect(el, sel).not.toBeNull(); const [w, h] = hit(el); - expect(Math.min(w, h), `${sel} ${w}x${h}`).toBeGreaterThanOrEqual(44); + expect(Math.min(w, h), `${sel} ${w}x${h}`).toBeGreaterThanOrEqual(touchTarget()); } } finally { await cdp().send("Emulation.setTouchEmulationEnabled", { enabled: false }); diff --git a/packages/ui/src/lib/components/ToggleGroup/SegmentedControl.svelte.test.ts b/packages/ui/src/lib/components/ToggleGroup/SegmentedControl.svelte.test.ts index e2ade264..bf3ff210 100644 --- a/packages/ui/src/lib/components/ToggleGroup/SegmentedControl.svelte.test.ts +++ b/packages/ui/src/lib/components/ToggleGroup/SegmentedControl.svelte.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { cdp, userEvent } from "vitest/browser"; import { render } from "vitest-browser-svelte"; import { checkA11y } from "../../../../tests/a11y"; +import { touchTarget } from "../../../../tests/touch-target"; import { reducedMotion } from "../../stores/reduced-motion.svelte"; import SegmentedControlHarness from "./SegmentedControlHarness.test.svelte"; import SegmentedControlNarrow from "./SegmentedControlNarrow.test.svelte"; @@ -83,8 +84,8 @@ describe("SegmentedControl", () => { expect(matchMedia("(pointer: coarse)").matches).toBe(true); for (const item of screen.container.querySelectorAll(".ca-seg__item")) { const r = item.getBoundingClientRect(); - expect(r.width, "item width").toBeGreaterThanOrEqual(44); - expect(r.height, "item height").toBeGreaterThanOrEqual(44); + expect(r.width, "item width").toBeGreaterThanOrEqual(touchTarget()); + expect(r.height, "item height").toBeGreaterThanOrEqual(touchTarget()); } } finally { await cdp().send("Emulation.setTouchEmulationEnabled", { enabled: false }); diff --git a/packages/ui/tests/touch-target-source.test.ts b/packages/ui/tests/touch-target-source.test.ts new file mode 100644 index 00000000..3ef1380f --- /dev/null +++ b/packages/ui/tests/touch-target-source.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +/** + * #650: `--ca-touch-target` (tokens.css) is the one definition of the minimum hit area, but 26 assertions + * repeated `44` by hand, so changing the token would have left tests asserting the old size. A test that + * checks a hit area reads the token (`touchTarget()` in tests/touch-target.ts, `touchTarget(page)` in + * site/tests/touch-target.ts) instead of a literal. + */ +const ROOT = join(import.meta.dir, "../../.."); +const TESTS = /(\.svelte\.test\.ts|\.spec\.ts|layout-audit\.ts)$/; + +function* files(dir: string): Generator { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) { + if ( + !["node_modules", "dist", "__screenshots__", "__traces__", ".svelte-kit"].includes(e.name) + ) + yield* files(p); + } else if (TESTS.test(e.name)) yield p; + } +} + +/** A hit-area size compared against a bare 44 (or the 43.5 tolerance the audit used). */ +const LITERAL = + /(toBeGreaterThanOrEqual|toBeGreaterThan|toBeLessThan)\(\s*(44|43\.5)\s*\)|<\s*43\.5|touch target < 44px/; + +describe("hit-area tests read --ca-touch-target (#650)", () => { + it("no test compares a size with a literal 44", () => { + const hits: string[] = []; + for (const root of ["packages/ui/src", "site/tests"]) + for (const f of files(join(ROOT, root))) + readFileSync(f, "utf8") + .split("\n") + .forEach((l, i) => { + if (LITERAL.test(l)) hits.push(`${relative(ROOT, f)}:${i + 1}`); + }); + expect(hits).toEqual([]); + }); + + it("the token the tests read is 44px today", () => { + const css = readFileSync(join(ROOT, "packages/ui/src/lib/styles/tokens.css"), "utf8"); + expect(css).toMatch(/--ca-touch-target:\s*44px;/); + }); +}); diff --git a/packages/ui/tests/touch-target.ts b/packages/ui/tests/touch-target.ts new file mode 100644 index 00000000..1a78621d --- /dev/null +++ b/packages/ui/tests/touch-target.ts @@ -0,0 +1,14 @@ +/** + * The minimum hit area in px: `--ca-touch-target` (styles/tokens.css), resolved by the browser. A test that + * asserts a touch target compares with this, never with a literal 44 (#650). Browser tests only: the setup + * file loads global.css, which defines the token. + */ +export function touchTarget(): number { + const probe = document.createElement("div"); + probe.style.cssText = "position:absolute;visibility:hidden;width:var(--ca-touch-target)"; + document.body.append(probe); + const px = probe.getBoundingClientRect().width; + probe.remove(); + if (!(px > 0)) throw new Error("--ca-touch-target did not resolve: is global.css loaded?"); + return px; +} diff --git a/site/tests/layout-audit.ts b/site/tests/layout-audit.ts index 9b49f56b..e511bd1b 100644 --- a/site/tests/layout-audit.ts +++ b/site/tests/layout-audit.ts @@ -1,5 +1,7 @@ import type { Page } from "@playwright/test"; +import { touchTarget } from "./touch-target"; + /** * Layout integrity on every prerendered page (desktop + mobile projects): * - the page never scrolls sideways (wide content scrolls in its own region); @@ -102,8 +104,9 @@ export async function audit(page: Page, isMobile: boolean): Promise { return out.slice(0, 10); }, INTENTIONAL); if (isMobile) { - // Touch targets: every visible control in the content is at least 44×44 (WCAG 2.5.5 / HIG). - const small = await page.evaluate(() => { + // Touch targets: every visible control in the content is at least --ca-touch-target square (WCAG 2.5.5 / HIG). + const min = await touchTarget(page); + const small = await page.evaluate((min) => { // Effective hit area: the element, its expanded ::after overlay, or (for a bare text // input) the field box that receives the tap. const hitArea = (e: Element): [number, number] => { @@ -133,15 +136,15 @@ export async function audit(page: Page, isMobile: boolean): Promise { if (s.visibility === "hidden" || s.display === "none" || Number(s.opacity) < 0.05) return false; const [w, h] = hitArea(e); - return Math.min(w, h) < 43.5; + return Math.min(w, h) < min - 0.5; }) .slice(0, 8) .map((e) => { const [w, h] = hitArea(e); return `${e.tagName.toLowerCase()}.${e.classList[0] ?? ""} ${Math.round(w)}×${Math.round(h)}`; }); - }); - issues.push(...small.map((x) => `touch target < 44px: ${x}`)); + }, min); + issues.push(...small.map((x) => `touch target < ${min}px: ${x}`)); } return issues; } diff --git a/site/tests/mobile.webkit.spec.ts b/site/tests/mobile.webkit.spec.ts index cb50fcd9..9e0b3ae2 100644 --- a/site/tests/mobile.webkit.spec.ts +++ b/site/tests/mobile.webkit.spec.ts @@ -1,4 +1,5 @@ import { expect, gotoReady, test } from "./fixtures"; +import { touchTarget } from "./touch-target"; // Runs only in the `iphone` project (WebKit, iPhone 15): iOS viewport, safe areas, zoom-on-focus. @@ -35,13 +36,14 @@ test("text-entry controls are at least 16px so iOS does not zoom on focus", asyn } }); -test("buttons meet the 44px touch target", async ({ page }) => { +test("buttons meet --ca-touch-target", async ({ page }) => { await gotoReady(page, "/templates/app/dashboard/"); const heights = await page .locator(".ca-btn:visible") .evaluateAll((els) => els.map((el) => el.getBoundingClientRect().height)); expect(heights.length).toBeGreaterThan(0); - for (const h of heights) expect(h).toBeGreaterThanOrEqual(44); + const min = await touchTarget(page); + for (const h of heights) expect(h).toBeGreaterThanOrEqual(min); }); test("theme-color follows the opaque chrome at the top edge", async ({ page }) => { diff --git a/site/tests/responsive.spec.ts b/site/tests/responsive.spec.ts index a7e85487..ae8e9b26 100644 --- a/site/tests/responsive.spec.ts +++ b/site/tests/responsive.spec.ts @@ -1,4 +1,5 @@ import { expect, gotoReady, test } from "./fixtures"; +import { touchTarget } from "./touch-target"; test.describe("responsive (mobile)", () => { test.skip(({ isMobile }) => !isMobile, "mobile-only"); @@ -34,19 +35,20 @@ test.describe("responsive (mobile)", () => { expect(cols).toBe(1); }); - test("touch targets are at least 44px", async ({ page }) => { + test("touch targets meet --ca-touch-target", async ({ page }) => { await gotoReady(page, "/docs/inputs/button/"); + const min = await touchTarget(page); const lg = page.locator(".ca-btn--lg").first(); await expect(lg).toBeVisible(); - expect((await lg.boundingBox())?.height).toBeGreaterThanOrEqual(44); + expect((await lg.boundingBox())?.height).toBeGreaterThanOrEqual(min); const icon = page.locator(".ca-icon-btn, .ca-btn--icon").first(); - if (await icon.count()) expect((await icon.boundingBox())?.height).toBeGreaterThanOrEqual(44); + if (await icon.count()) expect((await icon.boundingBox())?.height).toBeGreaterThanOrEqual(min); await gotoReady(page, "/docs/navigation/tabs/"); const tab = page.getByRole("tab").first(); await expect(tab).toBeVisible(); - expect((await tab.boundingBox())?.height).toBeGreaterThanOrEqual(44); + expect((await tab.boundingBox())?.height).toBeGreaterThanOrEqual(min); }); test("TabsList scrolls on its own with the active tab in view", async ({ page }) => { diff --git a/site/tests/touch-target.ts b/site/tests/touch-target.ts new file mode 100644 index 00000000..87ddf7a0 --- /dev/null +++ b/site/tests/touch-target.ts @@ -0,0 +1,16 @@ +import type { Page } from "@playwright/test"; + +/** + * The minimum hit area in px of the page under test: `--ca-touch-target`, resolved by the browser. Specs compare + * with this, never with a literal 44 (#650). + */ +export const touchTarget = (page: Page): Promise => + page.evaluate(() => { + const probe = document.createElement("div"); + probe.style.cssText = "position:absolute;visibility:hidden;width:var(--ca-touch-target)"; + document.body.append(probe); + const px = probe.getBoundingClientRect().width; + probe.remove(); + if (!(px > 0)) throw new Error("--ca-touch-target did not resolve on this page"); + return px; + });