import { expect, test, type Page } from "@playwright/test"; /** * A separator's role, name, orientation, and range. Chromium reports `valuenow` as the AX node's * top-level `value`, not as one of its `properties` (unlike `valuemin`/`valuemax`/`orientation`), * so this reads the raw tree directly rather than through `testing/ax.ts`'s flattened `AxNode`. */ async function gutterAx(page: Page, name: string) { const cdp = await page.context().newCDPSession(page); try { const { nodes } = await cdp.send("Accessibility.getFullAXTree"); const node = nodes.find((n) => n.role?.value === "separator" && n.name?.value === name); if (!node) return undefined; const props = Object.fromEntries((node.properties ?? []).map((p) => [p.name, p.value.value])); return { orientation: props.orientation as string | undefined, valuemin: props.valuemin as number | undefined, valuemax: props.valuemax as number | undefined, valuenow: node.value?.value as number | undefined, }; } finally { await cdp.detach(); } } async function valueNow(page: Page, name: string): Promise { return (await gutterAx(page, name))!.valuenow!; } /** A gutter inside a split's own shadow root, by index in document order. */ function gutter(page: Page, hostId: string, index = 0) { return page.evaluateHandle( ({ hostId, index }) => document.getElementById(hostId)!.shadowRoot!.querySelectorAll("nib-gutter")[index]!, { hostId, index }, ); } async function focusGutter(page: Page, hostId: string, index = 0) { const handle = await gutter(page, hostId, index); await handle.evaluate((el: HTMLElement) => el.focus()); return handle; } test("gutters expose role, orientation, and value bounds to assistive tech", async ({ page }) => { await page.goto("/split.html"); const node = await gutterAx(page, "Resize Explorer"); expect(node).toBeTruthy(); expect(node!.orientation).toBe("vertical"); expect(node!.valuenow).toBe(50); // Explorer and Editor are equal panels, so they split their pair evenly expect(node!.valuemin!).toBeLessThan(node!.valuenow!); expect(node!.valuemax!).toBeGreaterThan(node!.valuenow!); }); test("a column split reports horizontal orientation", async ({ page }) => { await page.goto("/split.html"); const node = await gutterAx(page, "Resize Preview"); expect(node?.orientation).toBe("horizontal"); }); test("ArrowRight and ArrowLeft step the row gutter by 10px, and 50px with Shift", async ({ page }) => { await page.goto("/split.html"); await focusGutter(page, "outer", 0); const start = await valueNow(page, "Resize Explorer"); await page.keyboard.press("ArrowRight"); const afterRight = await valueNow(page, "Resize Explorer"); expect(afterRight).toBeGreaterThan(start); await page.keyboard.press("ArrowLeft"); await page.keyboard.press("ArrowLeft"); const afterLeft = await valueNow(page, "Resize Explorer"); expect(afterLeft).toBeLessThan(afterRight); const beforeShift = afterLeft; await page.keyboard.press("Shift+ArrowRight"); const afterShift = await valueNow(page, "Resize Explorer"); expect(afterShift - beforeShift).toBeGreaterThan(afterRight - start); }); test("ArrowUp and ArrowDown step the column gutter", async ({ page }) => { await page.goto("/split.html"); await focusGutter(page, "inner", 0); const start = await valueNow(page, "Resize Preview"); await page.keyboard.press("ArrowDown"); expect(await valueNow(page, "Resize Preview")).toBeGreaterThan(start); await page.keyboard.press("ArrowUp"); await page.keyboard.press("ArrowUp"); expect(await valueNow(page, "Resize Preview")).toBeLessThan(start); }); test("Home and End move the primary panel to its minimum and maximum", async ({ page }) => { await page.goto("/split.html"); await focusGutter(page, "outer", 0); await page.keyboard.press("Home"); const atMin = await gutterAx(page, "Resize Explorer"); expect(atMin!.valuenow).toBe(atMin!.valuemin); await page.keyboard.press("End"); const atMax = await gutterAx(page, "Resize Explorer"); expect(atMax!.valuenow).toBe(atMax!.valuemax); }); test("Enter collapses the primary panel to its minimum, and restores it on a second press", async ({ page }) => { await page.goto("/split.html"); await focusGutter(page, "outer", 0); const original = await valueNow(page, "Resize Explorer"); await page.keyboard.press("Enter"); const collapsed = await gutterAx(page, "Resize Explorer"); expect(collapsed!.valuenow).toBe(collapsed!.valuemin); await page.keyboard.press("Enter"); expect(await valueNow(page, "Resize Explorer")).toBe(original); }); test("each keyboard step fires a committed resize", async ({ page }) => { await page.goto("/split.html"); await focusGutter(page, "outer", 0); await expect(page.locator("#log")).toHaveText(""); await page.keyboard.press("ArrowRight"); await expect(page.locator("#log")).not.toHaveText(""); }); test("dragging a gutter clamps to the minimum size on both sides", async ({ page }) => { await page.goto("/split.html"); const toward = async (dx: number) => { const handle = await gutter(page, "outer", 0); const box = await handle.evaluate((el: HTMLElement) => { const rect = el.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; }); await page.mouse.move(box.x, box.y); await page.mouse.down(); await page.mouse.move(box.x + dx, box.y, { steps: 10 }); await page.mouse.up(); }; await toward(-2000); const minSide = await gutterAx(page, "Resize Explorer"); expect(minSide!.valuenow).toBe(minSide!.valuemin); await toward(4000); const maxSide = await gutterAx(page, "Resize Explorer"); expect(maxSide!.valuenow).toBe(maxSide!.valuemax); }); test("gutters appear and disappear as panels are added or removed", async ({ page }) => { await page.goto("/split.html"); const count = () => page.evaluate(() => document.getElementById("outer")!.shadowRoot!.querySelectorAll("nib-gutter").length); expect(await count()).toBe(2); await page.evaluate(() => { const panel = document.createElement("nib-panel"); (panel as any).label = "Extra"; document.getElementById("outer")!.appendChild(panel); }); await expect.poll(count).toBe(3); await page.evaluate(() => document.getElementById("outer")!.lastElementChild!.remove()); await expect.poll(count).toBe(2); }); test("sizes normalizes its input and falls back to equal weights on a length mismatch", async ({ page }) => { await page.goto("/split.html"); const result = await page.evaluate(() => { const outer = document.getElementById("outer") as any; outer.sizes = [1, 1, 2]; const normalized = [...outer.sizes]; outer.sizes = [1, 1]; // only 2 values for 3 panels const fallback = [...outer.sizes]; return { normalized, fallback }; }); expect(result.normalized.map((n: number) => Math.round(n * 100))).toEqual([25, 25, 50]); for (const weight of result.fallback) expect(Math.abs(weight - 1 / 3)).toBeLessThan(0.01); }); test("a nested column split sits inside the row split's middle panel", async ({ page }) => { await page.goto("/split.html"); const axes = await page.evaluate(() => [ (document.getElementById("outer") as any).axis, (document.getElementById("inner") as any).axis, ]); expect(axes).toEqual(["row", "column"]); }); test("a gutter is a hairline at rest with a 24px pointer target, and shows a thick bar while active", async ({ page }) => { await page.goto("/split.html"); const gutter = page.locator("#outer nib-gutter").first(); const bar = () => gutter.evaluate((el) => getComputedStyle(el, "::after").visibility); const box = (await gutter.boundingBox())!; expect(box.width).toBe(1); expect(await bar()).toBe("hidden"); // 11px from the line is inside the 24px target, over the neighbouring panel. await page.mouse.move(box.x - 11, box.y + box.height / 2); expect(await gutter.evaluate((el) => el.matches(":hover"))).toBe(true); expect(await bar()).toBe("visible"); expect(await gutter.evaluate((el) => getComputedStyle(el, "::after").width)).toBe("4px"); await page.mouse.down(); await page.mouse.move(box.x + 300, box.y + box.height / 2, { steps: 4 }); expect(await gutter.evaluate((el) => el.matches(":state(dragging)"))).toBe(true); expect(await bar()).toBe("visible"); await page.mouse.up(); expect(await gutter.evaluate((el) => el.matches(":state(dragging)"))).toBe(false); await page.mouse.move(0, 0); expect(await bar()).toBe("hidden"); // 13px away is outside the target. const moved = (await gutter.boundingBox())!; await page.mouse.move(moved.x - 13, moved.y + moved.height / 2); expect(await gutter.evaluate((el) => el.matches(":hover"))).toBe(false); }); test("a panel holding a nested split along the same axis keeps room for every nested panel", async ({ page }) => { await page.goto("/split.html"); await page.setViewportSize({ width: 800, height: 400 }); await page.evaluate(() => { // The fixture's demo styles border every panel, which would eat into the nested split's width. // Vite injects the tokens as