411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { TaskMessageScroller } from "./TaskMessageScroller";
|
|
import { TaskChatScrollNavigation } from "./scroll-navigation";
|
|
|
|
const PILL_SELECTOR = 'button[aria-label="Scroll to latest"]';
|
|
|
|
/**
|
|
* jsdom has no layout, so scroll geometry is faked per element: scrollHeight /
|
|
* clientHeight are pinned and scrollTop is backed by a plain variable.
|
|
*/
|
|
function fakeGeometry(el: HTMLElement, { scrollHeight = 1000, clientHeight = 400 } = {}) {
|
|
let scrollTop = 0;
|
|
Object.defineProperty(el, "scrollHeight", { value: scrollHeight, configurable: true });
|
|
Object.defineProperty(el, "clientHeight", { value: clientHeight, configurable: true });
|
|
Object.defineProperty(el, "scrollTop", {
|
|
get: () => scrollTop,
|
|
set: (v: number) => {
|
|
scrollTop = v;
|
|
},
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
function fakeResizableGeometry(
|
|
el: HTMLElement,
|
|
{ scrollHeight = 1000, clientHeight = 400 } = {},
|
|
) {
|
|
let currentClientHeight = clientHeight;
|
|
let scrollTop = 0;
|
|
Object.defineProperty(el, "scrollHeight", {
|
|
value: scrollHeight,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(el, "clientHeight", {
|
|
get: () => currentClientHeight,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(el, "scrollTop", {
|
|
get: () => scrollTop,
|
|
set: (value: number) => {
|
|
scrollTop = value;
|
|
},
|
|
configurable: true,
|
|
});
|
|
return {
|
|
setClientHeight(value: number) {
|
|
currentClientHeight = value;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Scroll/wheel are continuous-priority events, so React flushes the resulting
|
|
* state updates asynchronously — wait a macrotask after dispatching.
|
|
*/
|
|
function flushEvents() {
|
|
return new Promise<void>((resolve) => {
|
|
setTimeout(resolve);
|
|
});
|
|
}
|
|
|
|
describe("TaskMessageScroller", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
function render(contentKey: unknown = 0) {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TaskMessageScroller contentKey={contentKey}>
|
|
<div>messages</div>
|
|
</TaskMessageScroller>,
|
|
);
|
|
});
|
|
}
|
|
|
|
function scroller(): HTMLDivElement {
|
|
return container.querySelector<HTMLDivElement>('[data-testid="task-chat-scroller"]')!;
|
|
}
|
|
|
|
function pill(): HTMLButtonElement | null {
|
|
return container.querySelector<HTMLButtonElement>(PILL_SELECTOR);
|
|
}
|
|
|
|
/**
|
|
* Wait for the pill to reach a state, rather than for a fixed number of turns.
|
|
* `flushEvents` yields exactly one macrotask, which is enough on an idle
|
|
* machine and not when the suite runs many workers in parallel — React
|
|
* flushes continuous-priority updates asynchronously, so the DOM read can
|
|
* land before the state does. Sites that dereference `pill()!` turn that into
|
|
* a hard failure rather than a retry.
|
|
*/
|
|
async function waitForPill(present: boolean): Promise<void> {
|
|
await vi.waitFor(() =>
|
|
present ? expect(pill()).not.toBeNull() : expect(pill()).toBeNull(),
|
|
);
|
|
}
|
|
|
|
/** Set scrollTop and fire a scroll event, like a user or the browser would. */
|
|
async function scrollTo(el: HTMLElement, top: number) {
|
|
el.scrollTop = top;
|
|
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
await flushEvents();
|
|
}
|
|
|
|
async function dispatch(el: HTMLElement, event: Event) {
|
|
el.dispatchEvent(event);
|
|
await flushEvents();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
document.documentElement.style.removeProperty("--motion-scrollbar-idle-delay");
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("applies same-task hash changes and restores each history entry without remounting", async () => {
|
|
let initialized = false;
|
|
function navigate(key: string, hash: string, restore = false) {
|
|
flushSync(() => root.render(
|
|
<TaskChatScrollNavigation.Provider value={{ key, hash, restore }}>
|
|
<TaskMessageScroller contentKey="unchanged">
|
|
<div ref={(node) => {
|
|
if (node && !initialized) {
|
|
fakeGeometry(node.parentElement!);
|
|
initialized = true;
|
|
}
|
|
}}>
|
|
{[100, 500].map((top, index) => <div key={index} id={`nav-comment-${index}`} data-thread-anchor={`nav-comment-${index}`} ref={(node) => {
|
|
if (node) node.getBoundingClientRect = () => ({ top: top - scroller().scrollTop, bottom: top + 100 - scroller().scrollTop, height: 100 } as DOMRect);
|
|
}}>Comment {index}</div>)}
|
|
</div>
|
|
</TaskMessageScroller>
|
|
</TaskChatScrollNavigation.Provider>,
|
|
));
|
|
}
|
|
navigate("desktop-entry-one", "#nav-comment-0");
|
|
const viewport = scroller();
|
|
expect(viewport.scrollTop).toBe(100);
|
|
await scrollTo(viewport, 150);
|
|
navigate("desktop-entry-two", "#nav-comment-1");
|
|
expect(scroller()).toBe(viewport);
|
|
expect(viewport.scrollTop).toBe(500);
|
|
navigate("desktop-entry-one", "#nav-comment-0", true);
|
|
expect(viewport.scrollTop).toBe(150);
|
|
navigate("desktop-entry-one", "#nav-comment-1", true);
|
|
expect(viewport.scrollTop).toBe(500);
|
|
});
|
|
|
|
it("renders children inside the scroll container, pill hidden, scrolled to bottom on mount", () => {
|
|
render();
|
|
const el = scroller();
|
|
expect(el.textContent).toContain("messages");
|
|
expect(pill()).toBeNull();
|
|
// Mount effect pins to the bottom.
|
|
expect(el.scrollTop).toBe(el.scrollHeight);
|
|
});
|
|
|
|
it("extends only the streamlined scroll box through the page gutter", () => {
|
|
render();
|
|
const el = scroller();
|
|
const frame = el.parentElement;
|
|
|
|
expect(frame?.className).toBe("relative min-h-0 flex-1");
|
|
expect(el.classList).toContain("-right-4");
|
|
expect(el.classList).toContain("pr-4");
|
|
expect(el.classList).toContain("md:-right-6");
|
|
expect(el.classList).toContain("md:pr-6");
|
|
expect(el.classList).not.toContain("right-0");
|
|
});
|
|
|
|
it("shows the scrollbar only while scroll activity is recent", () => {
|
|
vi.useFakeTimers();
|
|
document.documentElement.style.setProperty("--motion-scrollbar-idle-delay", "600ms");
|
|
render();
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
|
|
expect(el.className).toContain("scrollbar-while-scrolling");
|
|
expect(el.getAttribute("data-scroll-active")).toBeNull();
|
|
|
|
el.scrollTop = 100;
|
|
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
expect(el.getAttribute("data-scroll-active")).toBe("true");
|
|
|
|
vi.advanceTimersByTime(599);
|
|
expect(el.getAttribute("data-scroll-active")).toBe("true");
|
|
vi.advanceTimersByTime(1);
|
|
expect(el.getAttribute("data-scroll-active")).toBeNull();
|
|
});
|
|
|
|
it("contains horizontal overflow so no scrollbar appears above the composer", () => {
|
|
render();
|
|
|
|
expect(scroller().classList).toContain("overflow-x-hidden");
|
|
expect(scroller().classList).toContain("overflow-y-auto");
|
|
});
|
|
|
|
it("auto-follows content instantly while pinned", async () => {
|
|
render(1);
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
await scrollTo(el, 600); // bottom: 1000 - 600 - 400 = 0 → pinned
|
|
await waitForPill(false); // pinned state settled before content grows
|
|
render(2);
|
|
expect(el.scrollTop).toBe(1000);
|
|
expect(pill()).toBeNull();
|
|
});
|
|
|
|
it("holds position and shows the icon-only pill when scrolled up", async () => {
|
|
render(1);
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
await scrollTo(el, 100); // 500px from bottom → unpinned
|
|
await waitForPill(true);
|
|
const btn = pill();
|
|
expect(btn).not.toBeNull();
|
|
expect(btn!.className).toContain("tc-scroll-pill-in");
|
|
expect(btn!.className).toContain("size-8");
|
|
expect(btn!.className).toContain("bottom-7");
|
|
expect(btn!.className).not.toContain("bottom-3");
|
|
expect(btn!.className).not.toContain("-translate-x-1/2");
|
|
// Icon-only: no visible text.
|
|
expect(btn!.textContent).toBe("");
|
|
// New content must not yank the held position.
|
|
render(2);
|
|
expect(el.scrollTop).toBe(100);
|
|
});
|
|
|
|
it("follows a shrinking viewport while pinned so a growing composer cannot cover the latest text", async () => {
|
|
let triggerResize = () => {};
|
|
vi.stubGlobal(
|
|
"ResizeObserver",
|
|
class {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
triggerResize = () =>
|
|
callback([], this as unknown as ResizeObserver);
|
|
}
|
|
observe() {}
|
|
disconnect() {}
|
|
},
|
|
);
|
|
render();
|
|
const el = scroller();
|
|
const geometry = fakeResizableGeometry(el);
|
|
triggerResize();
|
|
await scrollTo(el, 600);
|
|
|
|
geometry.setClientHeight(200);
|
|
triggerResize();
|
|
|
|
expect(el.scrollTop).toBe(el.scrollHeight);
|
|
expect(pill()).toBeNull();
|
|
});
|
|
|
|
it("holds a reader's position when the composer grows while they are scrolled up", async () => {
|
|
let triggerResize = () => {};
|
|
vi.stubGlobal(
|
|
"ResizeObserver",
|
|
class {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
triggerResize = () =>
|
|
callback([], this as unknown as ResizeObserver);
|
|
}
|
|
observe() {}
|
|
disconnect() {}
|
|
},
|
|
);
|
|
render();
|
|
const el = scroller();
|
|
const geometry = fakeResizableGeometry(el);
|
|
triggerResize();
|
|
await scrollTo(el, 100);
|
|
await waitForPill(true);
|
|
|
|
geometry.setClientHeight(200);
|
|
triggerResize();
|
|
|
|
expect(el.scrollTop).toBe(100);
|
|
expect(pill()).not.toBeNull();
|
|
});
|
|
|
|
it("small drifts within the pin threshold do not show the pill", async () => {
|
|
render();
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
await scrollTo(el, 560); // 40px from bottom, threshold is 48
|
|
expect(pill()).toBeNull();
|
|
});
|
|
|
|
it("clicking the pill smooth-scrolls, ignores intermediate scroll events, re-pins on arrival", async () => {
|
|
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
|
|
render(1);
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
const scrollToSpy = vi.fn();
|
|
el.scrollTo = scrollToSpy as unknown as typeof el.scrollTo;
|
|
|
|
await scrollTo(el, 100);
|
|
await waitForPill(true);
|
|
const btn = pill()!;
|
|
btn.click();
|
|
await flushEvents();
|
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
|
|
|
// Intermediate glide positions fire scroll events; they must not unpin
|
|
// (i.e. the easing flag swallows them and the pill state stays put).
|
|
await scrollTo(el, 250);
|
|
await scrollTo(el, 400);
|
|
expect(pill()).not.toBeNull();
|
|
|
|
// Arrival within the threshold re-pins and hides the pill (immediately
|
|
// here: no matchMedia in jsdom → reduced-motion/unmount-now path).
|
|
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
|
|
await scrollTo(el, 600);
|
|
expect(pill()).toBeNull();
|
|
|
|
// Re-pinned: content growth follows instantly again.
|
|
render(2);
|
|
expect(el.scrollTop).toBe(1000);
|
|
});
|
|
|
|
it("finishes following at the new bottom when content changes during the latest glide", async () => {
|
|
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
|
|
render(1);
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
el.scrollTo = vi.fn() as unknown as typeof el.scrollTo;
|
|
await scrollTo(el, 100);
|
|
await waitForPill(true);
|
|
pill()!.click();
|
|
await flushEvents();
|
|
await scrollTo(el, 250);
|
|
render(2);
|
|
expect(el.scrollTop).toBe(1000);
|
|
});
|
|
|
|
it("a wheel gesture during the glide cancels easing and stays unpinned", async () => {
|
|
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
|
|
render(1);
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
el.scrollTo = vi.fn() as unknown as typeof el.scrollTo;
|
|
|
|
await scrollTo(el, 100);
|
|
await waitForPill(true);
|
|
pill()!.click();
|
|
await flushEvents();
|
|
await dispatch(el, new Event("wheel", { bubbles: true }));
|
|
|
|
// Easing cancelled → user is unpinned, pill visible, content holds.
|
|
expect(pill()).not.toBeNull();
|
|
render(2);
|
|
expect(el.scrollTop).toBe(100);
|
|
|
|
// Scroll events far from the bottom now behave normally (still unpinned).
|
|
await scrollTo(el, 200);
|
|
expect(pill()).not.toBeNull();
|
|
});
|
|
|
|
it("plays the exit animation before unmounting when motion is enabled", async () => {
|
|
vi.stubGlobal(
|
|
"matchMedia",
|
|
vi.fn().mockReturnValue({ matches: false }), // motion allowed
|
|
);
|
|
render();
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
await scrollTo(el, 100);
|
|
await vi.waitFor(() => expect(pill()?.className).toContain("tc-scroll-pill-in"));
|
|
|
|
// Scrolling back to the bottom starts the exit animation but keeps the
|
|
// pill mounted until animationend.
|
|
await scrollTo(el, 600);
|
|
await vi.waitFor(() => expect(pill()?.className).toContain("tc-scroll-pill-out"));
|
|
const exiting = pill();
|
|
expect(exiting).not.toBeNull();
|
|
|
|
// jsdom has no window.AnimationEvent, so React's vendor-prefix detection
|
|
// maps onAnimationEnd to "webkitAnimationEnd" here (real browsers get the
|
|
// unprefixed event).
|
|
await dispatch(exiting!, new Event("webkitAnimationEnd", { bubbles: true }));
|
|
await waitForPill(false);
|
|
});
|
|
|
|
it("unmounts immediately on hide under prefers-reduced-motion", async () => {
|
|
vi.stubGlobal(
|
|
"matchMedia",
|
|
vi.fn().mockReturnValue({ matches: true }), // reduce
|
|
);
|
|
render();
|
|
const el = scroller();
|
|
fakeGeometry(el);
|
|
await scrollTo(el, 100);
|
|
await waitForPill(true);
|
|
await scrollTo(el, 600);
|
|
await waitForPill(false); // no animationend needed
|
|
});
|
|
});
|