mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path > - Paperclip keeps human-agent conversations attached to tasks. > - People need to read and reply comfortably from a phone. > - The mobile task page scrolled the document and positioned its composer with a sticky navigation offset. > - Safari's keyboard resize and later focus pan could move that document again, causing overlap, gaps, and jumps while editing. > - This change gives mobile and desktop the same message scroller and a normal flex footer. > - CSS owns the layout. A small VisualViewport adapter handles the visible height and Safari's stale document focus scroll. > - Streaming no longer measures every historical row while following the bottom. ## Linked Issues or Issue Description Fixes https://github.com/paperclipai/paperclip/issues/16497 Related: https://github.com/paperclipai/paperclip/pull/15245 also addresses keyboard positioning. This change additionally removes mobile document auto-follow and reduces historical-row measurement work. ## What Changed ### One scroll owner and a natural-flow composer Before: ```text Document scroll + mobile auto-follow └─ Conversation + sticky composer + animated navigation offset ``` After: ```text Height-constrained conversation shell ├─ Header ├─ Message scroller — owns follow/reading position └─ Composer — normal flex footer Navigation stays at the layout viewport bottom, behind the keyboard ``` - Reuse TaskMessageScroller on mobile. Delete the separate 136-line useWindowAutoFollow implementation and its superseded tests. - Keep the composer in normal flex flow. Remove its sticky bottom positioning and navigation-offset animation. - Constrain the mobile conversation with CSS. Classic tasks and other pages retain their existing layout. - Add a 47-line useMobileVisualViewport hook. It changes shell height on resize, not on typing or incoming messages. It never translates the shell or hides navigation. - Reject Safari's stale document focus pan only for an unzoomed, keyboard-open conversation. Inner message and editor scrolling stay native. - Keep a saved reading anchor when reading older messages. Skip historical-row measurements while following the bottom. - Update tests and the existing mobile streaming fixture. Keep only two layout-contract sentences in DESIGN.md. **Scope:** 19 files, +367 / -482 lines: 115 fewer lines overall. Production UI/CSS alone is +158 / -190 lines. No API, database, or dependency changes. ## Verification Bottom-follow is also saved explicitly for Back navigation. A new regression covers returning after rotation changes message wrapping, without restoring historical-row scans. ### Physical iPhone acceptance Tested on an iPhone 15 Pro running iOS 26.7.1. The reporter confirmed: - Opening the keyboard positions the composer correctly immediately, before any typing. - The installed web app works. - Long drafts, insertion, deletion, and sending are acceptable. The reporter described long drafts as not perfect but good enough. - Reading older messages during incoming output preserves position; returning to the bottom resumes following. - Rotation, keyboard close/reopen, and attachment/picker transitions work. Remote inspection corroborated the corrected open-keyboard state: visible height 358px, composer bottom 358px, document scrollY 0. Navigation remained display:block at y=631px, behind the keyboard. Earlier candidates failed device testing. The decisive trace showed a correct viewport resize followed about one frame later by a stale 337px native document focus pan. Typing caused Safari to correct it. Tests now cover that event ordering, the reverse ordering, and zoom panning. ### Performance evidence The existing TaskChatPerfHarness supplied 200 historical responses, 4,000 collapsed tools, and 30 updates at 100ms intervals. | Measurement | Before | After | | --- | ---: | ---: | | Historical row geometry reads | 6,000 | 0 | | Renderer main-thread work | 331.2ms | 273.5ms | | Largest task | 27.1ms | 12.3ms | This is one instrumented Chromium development run on the Mac, not a phone benchmark or a statistical speedup claim. The zero-row-measurement result also has a regression test that failed with 6,000 reads before the change. To reproduce, serve ui/tests/task-chat-perf.html with the UI Vite server. Record 30 streaming updates at the bottom. Inspect TaskMessageScroller and row getBoundingClientRect calls. Existing historical-render tests also confirm that unchanged Markdown does not reparse on live-tail updates. ### Automated and full-app checks - [Current-head CI](https://github.com/paperclipai/paperclip/actions/runs/39862524031) is green: 54 successful checks and 2 skipped checks. Greptile rates the current head 5/5, confirms the Back-navigation fix, and has no unresolved findings. - **601 focused tests pass** across the task page, layout, composer, scroller, viewport adapter, and historical-render coverage. - UI typecheck and production build pass. - Token gates, Storybook catalog checks, and catalog/map structural tests pass. - Independent review of the final source tree passed with no findings. - The full source app ran on macOS with an isolated database and saved conversation history. A reply sent through the real UI persisted after reload. Provider execution was not part of this layout test. - **Full local repository checks did not pass.** Build/typecheck stop at missing Rust cargo. The full test run reported 142 failed server tests and 3 failed suites; missing cargo is confirmed in those three suites. The other server failures were not diagnosed in this UI task. Current-head CI now supplies the repository-wide evidence; these local failures remain disclosed. ## Risks - Mobile conversations now share the desktop scroll owner. Navigation, loading, history restoration, and reading-position tests cover that change. - The keyboard-state threshold is a 120px viewport reduction. Accessory-only keyboards retain the normal navigation spacing. - The document-scroll fallback is scoped to rendered conversations with an open keyboard at scale 1. It does not handle inner scrolling or prevent zoom panning. - Long-draft behavior is accepted, with minor polish still possible. - The UI change is reversible and requires no data migration. ## Model Used OpenAI GPT-6 Astra, exact model ID gpt-6-astra, through OpenCode. Capabilities used: reasoning, code editing, tests, Chromium performance tracing, and Safari remote inspection. The harness does not expose the context-window size. An independent general-purpose subagent reviewed the final tree; its serving model ID was not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with Fixes / Closes / Refs OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run focused tests locally and they pass; repository-wide failures are disclosed above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
497 lines
17 KiB
TypeScript
497 lines
17 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("does not measure historical rows on each streaming update while following the bottom", () => {
|
|
let initialized = false;
|
|
let rowMeasurements = 0;
|
|
const update = (tick: number) => flushSync(() => root.render(
|
|
<TaskMessageScroller contentKey={tick}>
|
|
<div ref={(node) => {
|
|
if (node && !initialized) {
|
|
fakeGeometry(node.parentElement!, { scrollHeight: 20000, clientHeight: 400 });
|
|
initialized = true;
|
|
}
|
|
}}>
|
|
{Array.from({ length: 200 }, (_, index) => <div key={index} data-thread-anchor={`history-${index}`} ref={(node) => {
|
|
if (node) node.getBoundingClientRect = () => {
|
|
rowMeasurements++;
|
|
const top = index * 100 - scroller().scrollTop;
|
|
return { top, bottom: top + 100, height: 100 } as DOMRect;
|
|
};
|
|
}}>History {index}</div>)}
|
|
<p>Live response {tick}</p>
|
|
</div>
|
|
</TaskMessageScroller>,
|
|
));
|
|
update(0);
|
|
rowMeasurements = 0;
|
|
for (let tick = 1; tick <= 30; tick++) update(tick);
|
|
expect(container.textContent).toContain("Live response 30");
|
|
expect(scroller().scrollTop).toBe(20000);
|
|
expect(rowMeasurements).toBe(0);
|
|
});
|
|
|
|
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("does not replace a saved reading position with a hidden route's geometry", async () => {
|
|
function mount(restore: boolean) {
|
|
flushSync(() => root.render(
|
|
<TaskChatScrollNavigation.Provider value={{ key: "hidden-route-entry", hash: "", restore }}>
|
|
<TaskMessageScroller contentKey="saved-thread">
|
|
<div ref={(node) => { if (node) fakeGeometry(node.parentElement!); }}>Saved messages</div>
|
|
</TaskMessageScroller>
|
|
</TaskChatScrollNavigation.Provider>,
|
|
));
|
|
}
|
|
mount(false);
|
|
const viewport = scroller();
|
|
await scrollTo(viewport, 150);
|
|
Object.defineProperty(viewport, "clientHeight", { value: 0, configurable: true });
|
|
await scrollTo(viewport, 0);
|
|
flushSync(() => root.render(null));
|
|
mount(true);
|
|
expect(scroller().scrollTop).toBe(150);
|
|
});
|
|
|
|
it("restores bottom-follow on Back after the viewport and message heights change", () => {
|
|
const configured = new WeakSet<HTMLElement>();
|
|
function mount(restore: boolean, scrollHeight: number, clientHeight: number) {
|
|
flushSync(() => root.render(
|
|
<TaskChatScrollNavigation.Provider value={{ key: "rotated-bottom-entry", hash: "", restore }}>
|
|
<TaskMessageScroller contentKey={scrollHeight}>
|
|
<div ref={(node) => {
|
|
const viewport = node?.parentElement;
|
|
if (viewport && !configured.has(viewport)) {
|
|
fakeGeometry(viewport, { scrollHeight, clientHeight });
|
|
let top = 0;
|
|
Object.defineProperty(viewport, "scrollTop", {
|
|
configurable: true,
|
|
get: () => top,
|
|
set: (value: number) => {
|
|
top = Math.max(0, Math.min(value, viewport.scrollHeight - viewport.clientHeight));
|
|
},
|
|
});
|
|
configured.add(viewport);
|
|
}
|
|
}}>Latest response</div>
|
|
</TaskMessageScroller>
|
|
</TaskChatScrollNavigation.Provider>,
|
|
));
|
|
}
|
|
mount(false, 1000, 400);
|
|
flushSync(() => root.render(null));
|
|
// Rotation changed wrapping and the available message viewport while away.
|
|
mount(true, 2000, 500);
|
|
expect(scroller().scrollTop).toBe(1500);
|
|
expect(pill()).toBeNull();
|
|
Object.defineProperty(scroller(), "scrollHeight", { value: 2400, configurable: true });
|
|
mount(true, 2400, 500);
|
|
expect(scroller().scrollTop).toBe(1900);
|
|
});
|
|
|
|
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
|
|
});
|
|
});
|