Files
siftlode/frontend/src/components/ui/ClampedText.dom.test.tsx
T
peter 77638f7a0b fix(player): a link that opens the cut text is scrolled back into view
Focus had scrolled the clamped box itself; opened, the link dropped by that much, out of a scrolling panel's sight. DESIGN states where Close sits in the stacked layout; a stale test comment goes.
2026-10-09 23:46:40 +02:00

125 lines
5.2 KiB
TypeScript

// @vitest-environment jsdom
//
// A clamped text offers More only when it is actually cut (built for the person page, shared with
// the collection page, where a one-line summary carried a More that did nothing).
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ClampedText } from "./ClampedText";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
const view = (text: string) => (
<ClampedText text={text} lines={3} moreLabel="More" lessLabel="Less" />
);
describe("ClampedText", () => {
it("offers no toggle for a text that fits", () => {
render(view("Short."));
expect(screen.queryByText("More")).toBeNull(); // jsdom: nothing is cut
});
it("offers More for a cut text, and Less once open", () => {
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
try {
render(view("A long text.\n\nOn and on."));
const p = screen.getByText(/A long text/);
expect(p.className).toContain("line-clamp-3");
fireEvent.click(screen.getByText("More"));
expect(screen.getByText("Less").getAttribute("aria-expanded")).toBe("true");
expect(p.className).not.toContain("line-clamp-3");
} finally {
tall.mockRestore();
box.mockRestore();
}
});
it("opens when focus reaches a link the cut hides, and not for one in sight", () => {
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
try {
render(
<ClampedText text="t" lines={3} moreLabel="More" lessLabel="Less">
<a href="#a">seen</a>
<a href="#b">cut</a>
</ClampedText>,
);
const clamp = screen.getByText("seen").parentElement!;
vi.spyOn(clamp, "getBoundingClientRect").mockReturnValue(rect(100, 160));
vi.spyOn(screen.getByText("seen"), "getBoundingClientRect").mockReturnValue(rect(100, 120));
act(() => screen.getByText("seen").focus());
expect(screen.getByText("More")).toBeTruthy();
// A Tab onto a link below the cut. The browser scrolls the clamped box to show it (measured in
// Chrome: 45 px), so on screen the link sits inside the box: only the text's own top tells.
clamp.scrollTop = 240;
vi.spyOn(screen.getByText("cut"), "getBoundingClientRect").mockReturnValue(rect(130, 150));
// Opened, the link drops back to its place in the text, as far as the box had scrolled: in a
// scrolling panel that is out of sight, so it is brought back into view.
const reveal = vi.fn();
screen.getByText("cut").scrollIntoView = reveal;
act(() => screen.getByText("cut").focus());
expect(screen.getByText("Less").getAttribute("aria-expanded")).toBe("true");
expect(reveal).toHaveBeenCalledWith({ block: "nearest" });
} finally {
tall.mockRestore();
box.mockRestore();
}
});
it("starts another text collapsed: a reused view opening the next item", () => {
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
try {
const { rerender } = render(view("The first show's long summary."));
fireEvent.click(screen.getByText("More"));
rerender(view("The next show's long summary."));
expect(screen.getByText(/next show/).className).toContain("line-clamp-3");
expect(screen.getByText("More").getAttribute("aria-expanded")).toBe("false");
} finally {
tall.mockRestore();
box.mockRestore();
}
});
it("measures again when its box changes: the window or the text size moved the cut", () => {
const seen: ResizeObserverCallback[] = []; // the callbacks of observers watching something
const real = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
constructor(private cb: ResizeObserverCallback) {}
observe() {
seen.push(this.cb);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
let height = 60;
const tall = vi
.spyOn(HTMLElement.prototype, "scrollHeight", "get")
.mockImplementation(() => height);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
try {
render(view("A text that fits, until the reader raises the text size."));
expect(screen.queryByText("More")).toBeNull();
height = 400; // the text size went up: the same text now runs past three lines
act(() => seen[seen.length - 1]!([], {} as ResizeObserver));
expect(screen.getByText("More")).toBeTruthy();
} finally {
tall.mockRestore();
box.mockRestore();
globalThis.ResizeObserver = real;
}
});
});