// @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");
});
});