// @vitest-environment jsdom /** * Expandable — the "cap a long block vertically + make it expandable" * wrapper (user request: long pasted text/code shouldn't stretch the page). * * Contract under test: * - SHORT content renders bare: no max-height, no overflow, no button. * - LONG content (measured height > cap) is capped (max-height + vertical * scroll) and shows an Expand button. * - Expand → full height (cap removed, button becomes "Collapse"); the * content itself is never destroyed/re-mounted, just un-clipped. * - `liftCap` forces the cap OFF (used by inline text blocks while the * block is the active editing target, so a live caret is never clipped). * - `useMeasuredHeight` reports the element's scrollHeight. * * jsdom has no layout (scrollHeight is always 0) and no ResizeObserver, so * both are stubbed: a fake ResizeObserver that records its callback (so we * can trigger a "resize"), and a per-element scrollHeight we set before * firing the callback. This exercises the real capping logic * (height > cap → cap) deterministically without a real browser. */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act, useRef, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Expandable, useMeasuredHeight } from "../expandable"; // --- ResizeObserver stub ----------------------------------------------------- let roCallback: ResizeObserverCallback | null = null; class FakeResizeObserver { observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); constructor(cb: ResizeObserverCallback) { roCallback = cb; } } (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeResizeObserver; /** Set an element's scrollHeight (jsdom has no layout, so it defaults to 0). */ function setScrollHeight(el: Element, v: number) { Object.defineProperty(el, "scrollHeight", { value: v, configurable: true, enumerable: true }); } /** Fire the recorded ResizeObserver callback (simulates a resize). */ function fireResize() { if (roCallback) act(() => roCallback!([] as never, {} as never)); } let container: HTMLElement | null = null; let root: Root | null = null; function render(node: ReactNode) { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); act(() => { root!.render(node); }); } afterEach(() => { act(() => root?.unmount()); container?.remove(); container = null; root = null; roCallback = null; vi.clearAllMocks(); }); describe("Expandable", () => { it("renders SHORT content bare (no cap, no scroll, no button)", () => { render(
short
); const box = container!.querySelector('[data-testid="expandable-content"]') as HTMLElement; setScrollHeight(box, 80); fireResize(); // 80 < 300 → not long expect(box.style.maxHeight, "short content must not be capped").toBe(""); expect(box.style.overflowY).not.toBe("auto"); expect(container!.querySelector('[data-testid="expandable-toggle"]'), "no button for short").toBeNull(); }); it("caps LONG content and shows an Expand button", () => { render(
long code
); const box = container!.querySelector('[data-testid="expandable-content"]') as HTMLElement; setScrollHeight(box, 900); fireResize(); // 900 > 300 → long expect(box.style.maxHeight, "long content must be capped").toBe("300px"); expect(box.style.overflowY, "capped content scrolls").toBe("auto"); const btn = container!.querySelector('[data-testid="expandable-toggle"]') as HTMLButtonElement; expect(btn, "expand button present").toBeTruthy(); expect(btn!.textContent).toContain("Expand"); expect(btn!.textContent).toContain("42"); // line count shown expect(btn!.getAttribute("aria-expanded")).toBe("false"); }); it("Expand lifts the cap (full height, button → Collapse); Collapse restores it", () => { render(
long
); const box = container!.querySelector('[data-testid="expandable-content"]') as HTMLElement; setScrollHeight(box, 900); fireResize(); let btn = container!.querySelector('[data-testid="expandable-toggle"]') as HTMLButtonElement; act(() => btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); // Expanded: cap removed. expect(box.style.maxHeight, "expanded → full height").toBe(""); expect(box.style.overflowY).not.toBe("auto"); btn = container!.querySelector('[data-testid="expandable-toggle"]') as HTMLButtonElement; expect(btn!.textContent).toContain("Collapse"); expect(btn!.getAttribute("aria-expanded")).toBe("true"); // Collapse restores the cap. act(() => btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(box.style.maxHeight).toBe("300px"); expect((container!.querySelector('[data-testid="expandable-toggle"]') as HTMLButtonElement).textContent).toContain("Expand"); }); it("liftCap forces the cap OFF even for long content (live editing target)", () => { render(
long
); const box = container!.querySelector('[data-testid="expandable-content"]') as HTMLElement; setScrollHeight(box, 900); fireResize(); expect(box.style.maxHeight, "liftCap → no cap").toBe(""); expect(box.style.overflowY).not.toBe("auto"); expect(container!.querySelector('[data-testid="expandable-toggle"]'), "no button when lifted").toBeNull(); }); it("useMeasuredHeight reports the element's scrollHeight", () => { function Harness() { const ref = useRef(null); const h = useMeasuredHeight(ref); return (
{h}
); } render(); const box = container!.querySelector('[data-testid="h"]') as HTMLElement; expect(box.textContent).toBe("0"); // pre-measure setScrollHeight(box, 420); fireResize(); expect(box.textContent).toBe("420"); }); });