Site: separate hover, focus and press; still hit areas; one-cell lift; e2e tests

This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 23:47:27 -04:00
1 parent 0f62a36182
commit 29e4e7f70f
10 files changed
+199 -41

No files matched your search

+3 -2
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -29,6 +29,7 @@ h1 {
.stage {
max-width: 100%;
height: auto;
overflow: visible;
}
.model {
+58 -37
View File
@@ -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`;
}