1
2
22(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
23import { act, useRef, type ReactNode } from "react";
24import { createRoot, type Root } from "react-dom/client";
25import { afterEach, describe, expect, it, vi } from "vitest";
26
27import { Expandable, useMeasuredHeight } from "../expandable";
28
29
30let roCallback: ResizeObserverCallback | null = null;
31class FakeResizeObserver {
32 observe = vi.fn();
33 unobserve = vi.fn();
34 disconnect = vi.fn();
35 constructor(cb: ResizeObserverCallback) {
36 roCallback = cb;
37 }
38}
39(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeResizeObserver;
40
41
42function setScrollHeight(el: Element, v: number) {
43 Object.defineProperty(el, "scrollHeight", { value: v, configurable: true, enumerable: true });
44}
45
46function fireResize() {
47 if (roCallback) act(() => roCallback!([] as never, {} as never));
48}
49
50let container: HTMLElement | null = null;
51let root: Root | null = null;
52
53function render(node: ReactNode) {
54 container = document.createElement("div");
55 document.body.appendChild(container);
56 root = createRoot(container);
57 act(() => {
58 root!.render(node);
59 });
60}
61
62afterEach(() => {
63 act(() => root?.unmount());
64 container?.remove();
65 container = null;
66 root = null;
67 roCallback = null;
68 vi.clearAllMocks();
69});
70
71describe("Expandable", () => {
72 it("renders SHORT content bare (no cap, no scroll, no button)", () => {
73 render(
74 <Expandable cap={300} label="text block">
75 <div className="inner">short</div>
76 </Expandable>
77 );
78 const box = container!.querySelector('[data-testid="expandable-content"]') as HTMLElement;
79 setScrollHeight(box, 80);
80 fireResize();
81 expect(box.style.maxHeight, "short content must not be capped").toBe("");
82 expect(box.style.overflowY).not.toBe("auto");
83 expect(container!.querySelector('[data-testid="expandable-toggle"]'), "no button for short").toBeNull();
84 });
85
86 it("caps LONG content and shows an Expand button", () => {
87 render(
88 <Expandable cap={300} label="code block" lines={42}>
89 <div className="inner">long code</div>
90 </Expandable>
91 );
92 const box = container!.querySelector('[data-testid="expandable-content"]') as HTMLElement;
93 setScrollHeight(box, 900);
94 fireResize();
95 expect(box.style.maxHeight, "long content must be capped").toBe("300px");
96 expect(box.style.overflowY, "capped content scrolls").toBe("auto");
97 const btn = container!.querySelector('[data-testid="expandable-toggle"]') as HTMLButtonElement;
98 expect(btn, "expand button present").toBeTruthy();
99 expect(btn!.textContent).toContain("Expand");
100 expect(btn!.textContent).toContain("42");
101 expect(btn!.getAttribute("aria-expanded")).toBe("false");
102 });
103
104 it("Expand lifts the cap (full height, button → Collapse); Collapse restores it", () => {
105 render(
106 <Expandable cap={300} label="text block">
107 <div className="inner">long</div>
108 </Expandable>
109 );
110 const box = container!.querySelector('[data-testid="expandable-content"]') as HTMLElement;
111 setScrollHeight(box, 900);
112 fireResize();
113 let btn = container!.querySelector('[data-testid="expandable-toggle"]') as HTMLButtonElement;
114 act(() => btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
115
116 expect(box.style.maxHeight, "expanded → full height").toBe("");
117 expect(box.style.overflowY).not.toBe("auto");
118 btn = container!.querySelector('[data-testid="expandable-toggle"]') as HTMLButtonElement;
119 expect(btn!.textContent).toContain("Collapse");
120 expect(btn!.getAttribute("aria-expanded")).toBe("true");
121
122 act(() => btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
123 expect(box.style.maxHeight).toBe("300px");
124 expect((container!.querySelector('[data-testid="expandable-toggle"]') as HTMLButtonElement).textContent).toContain("Expand");
125 });
126
127 it("liftCap forces the cap OFF even for long content (live editing target)", () => {
128 render(
129 <Expandable cap={300} label="text block" liftCap>
130 <div className="inner">long</div>
131 </Expandable>
132 );
133 const box = container!.querySelector('[data-testid="expandable-content"]') as HTMLElement;
134 setScrollHeight(box, 900);
135 fireResize();
136 expect(box.style.maxHeight, "liftCap → no cap").toBe("");
137 expect(box.style.overflowY).not.toBe("auto");
138 expect(container!.querySelector('[data-testid="expandable-toggle"]'), "no button when lifted").toBeNull();
139 });
140
141 it("useMeasuredHeight reports the element's scrollHeight", () => {
142 function Harness() {
143 const ref = useRef<HTMLDivElement>(null);
144 const h = useMeasuredHeight(ref);
145 return (
146 <div ref={ref} data-testid="h">
147 {h}
148 </div>
149 );
150 }
151 render(<Harness />);
152 const box = container!.querySelector('[data-testid="h"]') as HTMLElement;
153 expect(box.textContent).toBe("0");
154 setScrollHeight(box, 420);
155 fireResize();
156 expect(box.textContent).toBe("420");
157 });
158});
159