Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248import { 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<number> { 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 <style data-vite-dev-id>, so those stay. for (const style of document.querySelectorAll("style:not([data-vite-dev-id])")) style.remove(); document.body.replaceChildren(); const outer = document.createElement("nib-split"); outer.id = "nest"; outer.style.cssText = "position: fixed; inset: 0"; const left = document.createElement("nib-panel"); const right = document.createElement("nib-panel"); const inner = document.createElement("nib-split"); inner.id = "inner"; for (const name of ["a", "b"]) { const panel = document.createElement("nib-panel"); panel.id = name; inner.append(panel); } right.append(inner); outer.append(left, right); document.body.append(outer); }); const gutter = page.locator("#nest nib-gutter").first(); const box = (await gutter.boundingBox())!; await page.mouse.move(box.x, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(790, box.y + box.height / 2, { steps: 5 }); await page.mouse.up(); const geometry = await page.evaluate(() => { const rect = (id: string) => document.getElementById(id)!.getBoundingClientRect(); return { inner: rect("inner"), a: rect("a"), b: rect("b") }; }); // The drag did move the gutter: the nested side shrank from half the width. expect(geometry.inner.width).toBeLessThan(380); // Two 160px minimums plus a hairline gutter, and nothing spills past the nested split's edge. expect(geometry.inner.width).toBeGreaterThanOrEqual(321); expect(geometry.a.width).toBeGreaterThanOrEqual(160); expect(geometry.b.width).toBeGreaterThanOrEqual(160); expect(geometry.b.right).toBeLessThanOrEqual(geometry.inner.right + 0.5);});