Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -274,7 +275,7 @@ describe("BottomNav", () => {
.getByRole("button", { name: "Application" })
.element()
.getBoundingClientRect();
expect(rect.height).toBeGreaterThanOrEqual(44);
expect(rect.height).toBeGreaterThanOrEqual(touchTarget());
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof render>>, name: string) =>
Expand Down Expand Up @@ -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<HTMLElement>(".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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 });
}
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/lib/components/Docs/DocsProse.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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());
}
});
});
5 changes: 3 additions & 2 deletions packages/ui/src/lib/components/Docs/Releases.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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());
}
});

Expand All @@ -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());
}
});

Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/components/Kanban/Kanban.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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());
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
47 changes: 47 additions & 0 deletions packages/ui/tests/touch-target-source.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;/);
});
});
14 changes: 14 additions & 0 deletions packages/ui/tests/touch-target.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 8 additions & 5 deletions site/tests/layout-audit.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -102,8 +104,9 @@ export async function audit(page: Page, isMobile: boolean): Promise<string[]> {
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] => {
Expand Down Expand Up @@ -133,15 +136,15 @@ export async function audit(page: Page, isMobile: boolean): Promise<string[]> {
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;
}
6 changes: 4 additions & 2 deletions site/tests/mobile.webkit.spec.ts
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading