Site: separate hover, focus and press; still hit areas; one-cell lift; e2e tests
This commit is contained in:
10 files changed
+199
-41
No files matched your search
@@ -1,3 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
test-results
|
||||
playwright-report
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const CELL = 28;
|
||||
const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
||||
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
/** Screen top of the building's first block, in SVG pixels. */
|
||||
const top = (page: Page, name: string) =>
|
||||
page.evaluate((n) => {
|
||||
const svg = document.querySelector<SVGSVGElement>(".stage")!;
|
||||
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
|
||||
return document.querySelector(`.block[data-part="${n}"]`)!.getBoundingClientRect().top / scale;
|
||||
}, name);
|
||||
|
||||
const centre = async (page: Page, name: string) => {
|
||||
const box = await page.locator(`.hit[data-part="${name}"]`).boundingBox();
|
||||
if (!box) throw new Error(`${name} has no hit area`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height * 0.75 };
|
||||
};
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".hit")).toHaveCount(6);
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
test("six links in reading order; reduced motion skips growth", async ({ page }) => {
|
||||
await expect(page.locator(".doors a")).toHaveText(APPS);
|
||||
const anim = await page.locator(".block").first().evaluate((b) => getComputedStyle(b).animationName);
|
||||
expect(anim).toBe("none");
|
||||
});
|
||||
|
||||
test("focus lifts one cell and survives the mouse passing", async ({ page }) => {
|
||||
const rest = (await top(page, "ElyDoc"));
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(".doors a").first()).toBeFocused();
|
||||
expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0);
|
||||
await expect(page.locator(".caption.shown")).toHaveText("ElyDoc");
|
||||
const at = await centre(page, "ElyDoc");
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await page.mouse.move(5, 5);
|
||||
expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0);
|
||||
await expect(page.locator(".caption.shown")).toHaveText("ElyDoc");
|
||||
});
|
||||
|
||||
test("hover at the bottom edge holds still", async ({ page }) => {
|
||||
const box = (await page.locator('.hit[data-part="ElyTable"]').boundingBox())!;
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { flips: number }).flips = 0;
|
||||
const hit = document.querySelector('.hit[data-part="ElyTable"]')!;
|
||||
for (const kind of ["pointerenter", "pointerleave"])
|
||||
hit.addEventListener(kind, () => (window as unknown as { flips: number }).flips++);
|
||||
});
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
|
||||
await page.waitForTimeout(600);
|
||||
expect(await page.evaluate(() => (window as unknown as { flips: number }).flips)).toBe(1);
|
||||
await expect(page.locator(".caption.shown")).toHaveText("ElyTable");
|
||||
});
|
||||
|
||||
test("a primary press sinks one cell; only a click starting on it opens", async ({ page }) => {
|
||||
const rest = (await top(page, "ElyShow"));
|
||||
const at = await centre(page, "ElyShow");
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await page.mouse.down();
|
||||
expect((await top(page, "ElyShow"))).toBeCloseTo(rest, 0);
|
||||
await page.mouse.up();
|
||||
expect(rest - (await top(page, "ElyShow"))).toBeCloseTo(CELL, 0);
|
||||
await expect(page).toHaveURL(/#elyshow$/);
|
||||
|
||||
await page.evaluate(() => (location.hash = ""));
|
||||
await page.mouse.click(at.x, at.y, { button: "right" });
|
||||
await page.mouse.move(5, 5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await page.mouse.up();
|
||||
expect(await page.evaluate(() => location.hash)).toBe("");
|
||||
});
|
||||
|
||||
test("holding Enter sinks the focused building until release", async ({ page }) => {
|
||||
const rest = (await top(page, "ElyDoc"));
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.down("Enter");
|
||||
expect((await top(page, "ElyDoc"))).toBeCloseTo(rest, 0);
|
||||
await expect(page).toHaveURL(/#elydoc$/);
|
||||
await page.keyboard.up("Enter");
|
||||
expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0);
|
||||
});
|
||||
+3
-1
@@ -7,9 +7,11 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "pnpm build && wrangler deploy",
|
||||
"test": "node --test test/*.test.ts"
|
||||
"test": "node --test test/*.test.ts",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.3.4",
|
||||
"wrangler": "4.149.0"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "e2e",
|
||||
fullyParallel: true,
|
||||
use: { baseURL: "http://localhost:47311", viewport: { width: 1280, height: 1000 } },
|
||||
webServer: { command: "pnpm build && pnpm preview --port 47311 --strictPort", port: 47311 },
|
||||
});
|
||||
Generated
+28
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: 1.64.0
|
||||
version: 1.64.0
|
||||
typescript:
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
@@ -401,6 +404,11 @@ packages:
|
||||
'@oxc-project/types@0.153.0':
|
||||
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
|
||||
|
||||
'@playwright/test@1.64.0':
|
||||
resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
|
||||
|
||||
@@ -649,6 +657,16 @@ packages:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
playwright-core@1.64.0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.64.0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.29:
|
||||
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1004,6 +1022,10 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.153.0': {}
|
||||
|
||||
'@playwright/test@1.64.0':
|
||||
dependencies:
|
||||
playwright: 1.64.0
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
dependencies:
|
||||
kleur: 4.1.5
|
||||
@@ -1185,6 +1207,12 @@ snapshots:
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
|
||||
playwright-core@1.64.0: {}
|
||||
|
||||
playwright@1.64.0:
|
||||
dependencies:
|
||||
playwright-core: 1.64.0
|
||||
|
||||
postcss@8.5.29:
|
||||
dependencies:
|
||||
nanoid: 3.3.20
|
||||
|
||||
+3
-2
@@ -36,9 +36,10 @@
|
||||
transition: translate 0.15s ease-out;
|
||||
}
|
||||
|
||||
.block[data-part] {
|
||||
.hit-face {
|
||||
fill: none;
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.block.lifted {
|
||||
|
||||
+1
-1
@@ -35,4 +35,4 @@ svg.classList.add("grow");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
host.append(svg);
|
||||
app.append(title, host);
|
||||
touch(host, parts);
|
||||
touch(host, svg, parts);
|
||||
@@ -29,6 +29,7 @@ h1 {
|
||||
.stage {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.model {
|
||||
|
||||
+58
-37
@@ -1,60 +1,81 @@
|
||||
// Buildings lift on hover or focus and sink when pressed.
|
||||
import { UNIT } from "./iso.ts";
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Links each app's blocks to its region, in reading order. */
|
||||
export function touch(host: HTMLElement, parts: Map<string, SVGGElement[]>): void {
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "caption";
|
||||
caption.setAttribute("aria-hidden", "true");
|
||||
host.append(caption);
|
||||
host.style.setProperty("--step", `${UNIT / 2}px`);
|
||||
export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string, SVGGElement[]>): void {
|
||||
host.style.setProperty("--step", `${UNIT}px`);
|
||||
const nav = document.createElement("nav");
|
||||
nav.className = "doors";
|
||||
host.append(nav);
|
||||
|
||||
for (const [name, blocks] of parts) {
|
||||
const target = `#${name.toLowerCase()}`;
|
||||
const inside = (node: EventTarget | null) =>
|
||||
node instanceof Element && node.closest<SVGGElement>(".block")?.dataset.part === name;
|
||||
const lift = (up: boolean) => {
|
||||
for (const b of blocks) b.classList.toggle("lifted", up);
|
||||
const state = { hover: false, focus: false, press: false };
|
||||
for (const b of blocks) b.dataset.part = name;
|
||||
const hit = area(blocks, name);
|
||||
svg.append(hit);
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "caption";
|
||||
caption.textContent = name;
|
||||
caption.setAttribute("aria-hidden", "true");
|
||||
host.append(caption);
|
||||
|
||||
const show = () => {
|
||||
const up = state.hover || state.focus;
|
||||
for (const b of blocks) {
|
||||
b.classList.toggle("lifted", up);
|
||||
b.classList.toggle("pressed", up && state.press);
|
||||
}
|
||||
caption.classList.toggle("shown", up);
|
||||
if (up) place(caption, host, blocks, name);
|
||||
if (up) place(caption, host, hit);
|
||||
};
|
||||
const press = (down: boolean) => {
|
||||
for (const b of blocks) b.classList.toggle("pressed", down);
|
||||
};
|
||||
const open = () => {
|
||||
press(false);
|
||||
location.hash = target;
|
||||
const set = (change: Partial<typeof state>) => {
|
||||
Object.assign(state, change);
|
||||
show();
|
||||
};
|
||||
|
||||
for (const b of blocks) {
|
||||
b.dataset.part = name;
|
||||
b.addEventListener("pointerenter", () => lift(true));
|
||||
b.addEventListener("pointerleave", (e) => inside(e.relatedTarget) || (press(false), lift(false)));
|
||||
b.addEventListener("pointerdown", () => press(true));
|
||||
b.addEventListener("pointerup", open);
|
||||
}
|
||||
hit.addEventListener("pointerenter", () => set({ hover: true }));
|
||||
hit.addEventListener("pointerleave", () => set({ hover: false, press: false }));
|
||||
hit.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true }));
|
||||
hit.addEventListener("pointerup", () => set({ press: false }));
|
||||
hit.addEventListener("pointercancel", () => set({ press: false }));
|
||||
hit.addEventListener("click", () => (location.hash = target));
|
||||
|
||||
const door = document.createElement("a");
|
||||
door.href = target;
|
||||
door.textContent = name;
|
||||
door.addEventListener("focus", () => lift(true));
|
||||
door.addEventListener("blur", () => (press(false), lift(false)));
|
||||
door.addEventListener("keydown", (e) => e.key === "Enter" && press(true));
|
||||
door.addEventListener("click", () => press(false));
|
||||
door.addEventListener("focus", () => set({ focus: true }));
|
||||
door.addEventListener("blur", () => set({ focus: false, press: false }));
|
||||
door.addEventListener("keydown", (e) => e.key === "Enter" && !e.repeat && set({ press: true }));
|
||||
door.addEventListener("keyup", (e) => e.key === "Enter" && set({ press: false }));
|
||||
nav.append(door);
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts the caption above the building's top. */
|
||||
function place(caption: HTMLElement, host: HTMLElement, blocks: SVGGElement[], name: string) {
|
||||
/** A still hit area over the building, resting and lifted. */
|
||||
function area(blocks: SVGGElement[], name: string): SVGGElement {
|
||||
const hit = document.createElementNS(SVG, "g");
|
||||
hit.setAttribute("class", "hit");
|
||||
hit.dataset.part = name;
|
||||
for (const b of blocks) {
|
||||
const outline = b.querySelector(".outline");
|
||||
if (!outline) throw new Error("block has no outline");
|
||||
for (const dy of [0, -UNIT]) {
|
||||
const face = outline.cloneNode() as SVGPolygonElement;
|
||||
face.setAttribute("class", "hit-face");
|
||||
face.setAttribute("transform", `translate(0 ${dy})`);
|
||||
hit.append(face);
|
||||
}
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** Puts the caption above the lifted building. */
|
||||
function place(caption: HTMLElement, host: HTMLElement, hit: SVGGElement) {
|
||||
const origin = host.getBoundingClientRect();
|
||||
const rects = blocks.map((b) => b.getBoundingClientRect());
|
||||
const left = Math.min(...rects.map((r) => r.left));
|
||||
const right = Math.max(...rects.map((r) => r.right));
|
||||
const top = Math.min(...rects.map((r) => r.top));
|
||||
caption.textContent = name;
|
||||
caption.style.left = `${(left + right) / 2 - origin.left}px`;
|
||||
caption.style.top = `${top - origin.top}px`;
|
||||
const r = hit.getBoundingClientRect();
|
||||
caption.style.left = `${r.left + r.width / 2 - origin.left}px`;
|
||||
caption.style.top = `${r.top - origin.top}px`;
|
||||
}
|
||||
@@ -80,6 +80,8 @@ too_long = []
|
||||
repeated = {}
|
||||
for pattern in PATTERNS:
|
||||
for path in sorted(root.glob(pattern)):
|
||||
if "node_modules" in path.parts:
|
||||
continue
|
||||
if path.suffix in SCANNED:
|
||||
last = None
|
||||
for first, final, said in scan(path.read_text(), SCANNED[path.suffix]):
|
||||
|
||||
Reference in new issue
Block a user