diff options
Diffstat (limited to 'src/features/smart-scroll')
| -rw-r--r-- | src/features/smart-scroll/index.ts | 28 | ||||
| -rw-r--r-- | src/features/smart-scroll/logic/smart-scroll.test.ts | 156 | ||||
| -rw-r--r-- | src/features/smart-scroll/logic/smart-scroll.ts | 56 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/ScrollToBottom.svelte | 60 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/controller.svelte.ts | 242 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/controller.test.ts | 296 |
6 files changed, 419 insertions, 419 deletions
diff --git a/src/features/smart-scroll/index.ts b/src/features/smart-scroll/index.ts index 0d30257..73b11d6 100644 --- a/src/features/smart-scroll/index.ts +++ b/src/features/smart-scroll/index.ts @@ -1,17 +1,17 @@ export type { - ScrollCommand, - ScrollGeometry, - SmartScrollResult, - SmartScrollState, + ScrollCommand, + ScrollGeometry, + SmartScrollResult, + SmartScrollState, } from "./logic/smart-scroll"; export { - createSmartScrollState, - isNearBottom, - NEAR_BOTTOM_THRESHOLD, - onContentChange, - onReset, - onResume, - onScroll, + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, } from "./logic/smart-scroll"; export type { SmartScrollController } from "./ui/controller.svelte"; export { createSmartScrollController } from "./ui/controller.svelte"; @@ -19,7 +19,7 @@ export { default as ScrollToBottom } from "./ui/ScrollToBottom.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "smart-scroll", - description: - "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up", + name: "smart-scroll", + description: + "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up", } as const; diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts index fc3e3d1..94ba1d7 100644 --- a/src/features/smart-scroll/logic/smart-scroll.test.ts +++ b/src/features/smart-scroll/logic/smart-scroll.test.ts @@ -1,103 +1,103 @@ import { describe, expect, it } from "vitest"; import { - createSmartScrollState, - isNearBottom, - NEAR_BOTTOM_THRESHOLD, - onContentChange, - onReset, - onResume, - onScroll, - type ScrollGeometry, + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollGeometry, } from "./smart-scroll"; // A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom. const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }; const nearBottom: ScrollGeometry = { - scrollTop: 900 - NEAR_BOTTOM_THRESHOLD, - scrollHeight: 1000, - clientHeight: 100, + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD, + scrollHeight: 1000, + clientHeight: 100, }; const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 }; describe("isNearBottom", () => { - it("is true exactly at the bottom", () => { - expect(isNearBottom(atBottom)).toBe(true); - }); + it("is true exactly at the bottom", () => { + expect(isNearBottom(atBottom)).toBe(true); + }); - it("is true within the threshold of the bottom", () => { - expect(isNearBottom(nearBottom)).toBe(true); - }); + it("is true within the threshold of the bottom", () => { + expect(isNearBottom(nearBottom)).toBe(true); + }); - it("is false just beyond the threshold", () => { - expect( - isNearBottom({ - scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1, - scrollHeight: 1000, - clientHeight: 100, - }), - ).toBe(false); - }); + it("is false just beyond the threshold", () => { + expect( + isNearBottom({ + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1, + scrollHeight: 1000, + clientHeight: 100, + }), + ).toBe(false); + }); - it("is false when scrolled well up", () => { - expect(isNearBottom(scrolledUp)).toBe(false); - }); + it("is false when scrolled well up", () => { + expect(isNearBottom(scrolledUp)).toBe(false); + }); - it("honours a custom threshold", () => { - const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }; - expect(isNearBottom(geom, 50)).toBe(false); - expect(isNearBottom(geom, 150)).toBe(true); - }); + it("honours a custom threshold", () => { + const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }; + expect(isNearBottom(geom, 50)).toBe(false); + expect(isNearBottom(geom, 150)).toBe(true); + }); }); describe("smart-scroll reducer", () => { - it("starts stuck and hides the button", () => { - const s = createSmartScrollState(); - expect(s.stuck).toBe(true); - }); + it("starts stuck and hides the button", () => { + const s = createSmartScrollState(); + expect(s.stuck).toBe(true); + }); - it("onScroll up unsticks and shows the button, with no command", () => { - const r = onScroll(createSmartScrollState(), scrolledUp); - expect(r.state.stuck).toBe(false); - expect(r.showButton).toBe(true); - expect(r.command).toBeNull(); - }); + it("onScroll up unsticks and shows the button, with no command", () => { + const r = onScroll(createSmartScrollState(), scrolledUp); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + expect(r.command).toBeNull(); + }); - it("onScroll back to the bottom re-sticks and hides the button", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onScroll(up, atBottom); - expect(r.state.stuck).toBe(true); - expect(r.showButton).toBe(false); - expect(r.command).toBeNull(); - }); + it("onScroll back to the bottom re-sticks and hides the button", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onScroll(up, atBottom); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toBeNull(); + }); - it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => { - const r = onContentChange(createSmartScrollState(), atBottom); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); - expect(r.state.stuck).toBe(true); - }); + it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => { + const r = onContentChange(createSmartScrollState(), atBottom); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.state.stuck).toBe(true); + }); - it("onContentChange while unstuck emits NO command (leave the reader in place)", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onContentChange(up, scrolledUp); - expect(r.command).toBeNull(); - expect(r.state.stuck).toBe(false); - expect(r.showButton).toBe(true); - }); + it("onContentChange while unstuck emits NO command (leave the reader in place)", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onContentChange(up, scrolledUp); + expect(r.command).toBeNull(); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + }); - it("onResume re-sticks and emits an ANIMATED scroll", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onResume(up); - expect(r.state.stuck).toBe(true); - expect(r.showButton).toBe(false); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true }); - }); + it("onResume re-sticks and emits an ANIMATED scroll", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onResume(up); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true }); + }); - it("onReset returns to stuck and snaps (non-animated) to the bottom", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onReset(); - void up; - expect(r.state.stuck).toBe(true); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); - expect(r.showButton).toBe(false); - }); + it("onReset returns to stuck and snaps (non-animated) to the bottom", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onReset(); + void up; + expect(r.state.stuck).toBe(true); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.showButton).toBe(false); + }); }); diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts index 021b3fe..4a04812 100644 --- a/src/features/smart-scroll/logic/smart-scroll.ts +++ b/src/features/smart-scroll/logic/smart-scroll.ts @@ -6,12 +6,12 @@ /** A snapshot of a scroll container's vertical geometry (in CSS pixels). */ export interface ScrollGeometry { - /** Current scroll offset from the top. */ - readonly scrollTop: number; - /** Total scrollable content height. */ - readonly scrollHeight: number; - /** Visible viewport height. */ - readonly clientHeight: number; + /** Current scroll offset from the top. */ + readonly scrollTop: number; + /** Total scrollable content height. */ + readonly scrollHeight: number; + /** Visible viewport height. */ + readonly clientHeight: number; } /** Distance (px) from the bottom within which we still consider the view "at bottom". */ @@ -19,43 +19,43 @@ export const NEAR_BOTTOM_THRESHOLD = 64; /** True when the viewport is within `threshold` px of the content's bottom edge. */ export function isNearBottom( - geom: ScrollGeometry, - threshold: number = NEAR_BOTTOM_THRESHOLD, + geom: ScrollGeometry, + threshold: number = NEAR_BOTTOM_THRESHOLD, ): boolean { - return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold; + return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold; } /** A scroll the shell should perform on the real element. */ export interface ScrollCommand { - readonly kind: "scroll-to-bottom"; - /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */ - readonly animate: boolean; + readonly kind: "scroll-to-bottom"; + /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */ + readonly animate: boolean; } export interface SmartScrollState { - /** - * Whether the view is currently following the bottom. While `stuck`, new - * content keeps the view pinned to the bottom; once the user scrolls up it - * goes false and stays false until they return to the bottom (or resume). - */ - readonly stuck: boolean; + /** + * Whether the view is currently following the bottom. While `stuck`, new + * content keeps the view pinned to the bottom; once the user scrolls up it + * goes false and stays false until they return to the bottom (or resume). + */ + readonly stuck: boolean; } /** A reducer step's result: the next state, an optional command, and whether to show the button. */ export interface SmartScrollResult { - readonly state: SmartScrollState; - readonly command: ScrollCommand | null; - /** Show the "scroll to bottom" affordance exactly when not stuck. */ - readonly showButton: boolean; + readonly state: SmartScrollState; + readonly command: ScrollCommand | null; + /** Show the "scroll to bottom" affordance exactly when not stuck. */ + readonly showButton: boolean; } /** Initial state — start stuck so the first content snaps to the bottom. */ export function createSmartScrollState(): SmartScrollState { - return { stuck: true }; + return { stuck: true }; } function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult { - return { state, command, showButton: !state.stuck }; + return { state, command, showButton: !state.stuck }; } /** @@ -64,7 +64,7 @@ function result(state: SmartScrollState, command: ScrollCommand | null): SmartSc * command — reacting to the user's own scroll with a scroll would fight them. */ export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult { - return result({ stuck: isNearBottom(geom) }, null); + return result({ stuck: isNearBottom(geom) }, null); } /** @@ -73,7 +73,7 @@ export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartS * they are. State is unchanged — content growth alone never flips `stuck`. */ export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult { - return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null); + return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null); } /** @@ -81,7 +81,7 @@ export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): * emit an animated scroll. */ export function onResume(_state: SmartScrollState): SmartScrollResult { - return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true }); + return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true }); } /** @@ -89,5 +89,5 @@ export function onResume(_state: SmartScrollState): SmartScrollResult { * Reset to stuck and snap (non-animated) to the bottom of the new content. */ export function onReset(): SmartScrollResult { - return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false }); + return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false }); } diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte index 6fbd326..38ec1f5 100644 --- a/src/features/smart-scroll/ui/ScrollToBottom.svelte +++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte @@ -1,36 +1,36 @@ <script lang="ts"> - // Thin affordance: a floating "scroll to bottom" button shown while the reader - // has scrolled up. Holds no logic — `show` and `onResume` come from the - // smart-scroll controller. - let { - show, - onResume, - }: { - show: boolean; - onResume: () => void; - } = $props(); + // Thin affordance: a floating "scroll to bottom" button shown while the reader + // has scrolled up. Holds no logic — `show` and `onResume` come from the + // smart-scroll controller. + let { + show, + onResume, + }: { + show: boolean; + onResume: () => void; + } = $props(); </script> <button - type="button" - class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200" - class:opacity-0={!show} - class:pointer-events-none={!show} - class:opacity-100={show} - onclick={onResume} - aria-label="Scroll to bottom" - aria-hidden={!show} - tabindex={show ? 0 : -1} + type="button" + class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200" + class:opacity-0={!show} + class:pointer-events-none={!show} + class:opacity-100={show} + onclick={onResume} + aria-label="Scroll to bottom" + aria-hidden={!show} + tabindex={show ? 0 : -1} > - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2.5" - class="size-4" - aria-hidden="true" - > - <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> - </svg> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + class="size-4" + aria-hidden="true" + > + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> + </svg> </button> diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts index dbe65d1..fee6561 100644 --- a/src/features/smart-scroll/ui/controller.svelte.ts +++ b/src/features/smart-scroll/ui/controller.svelte.ts @@ -7,134 +7,134 @@ // it on unmount. import { - createSmartScrollState, - onContentChange, - onReset, - onResume, - onScroll, - type ScrollCommand, - type ScrollGeometry, - type SmartScrollResult, - type SmartScrollState, + createSmartScrollState, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollCommand, + type ScrollGeometry, + type SmartScrollResult, + type SmartScrollState, } from "../logic/smart-scroll"; export interface SmartScrollController { - /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ - readonly showButton: boolean; - /** - * Non-reactive point-in-time query: is the view stuck to the bottom right now? - * For imperative callers (e.g. the chat-limit unload gate) that poll at event - * time rather than subscribing — reads the reducer state, not a rune. - */ - isAtBottom(): boolean; - /** - * Attach to the scroll container; returns a teardown to call on unmount. - * Pass the inner CONTENT element to also follow height changes that aren't a - * transcript update (async markdown/highlight, image loads, a collapse toggling, - * viewport reflow) via a ResizeObserver. - */ - attach(el: HTMLElement, content?: HTMLElement): () => void; - /** - * Notify that the transcript content changed (a streamed delta / new message). - * While stuck, keeps the view pinned to the bottom. - */ - contentChanged(): void; - /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */ - reset(): void; - /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */ - resume(): void; + /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ + readonly showButton: boolean; + /** + * Non-reactive point-in-time query: is the view stuck to the bottom right now? + * For imperative callers (e.g. the chat-limit unload gate) that poll at event + * time rather than subscribing — reads the reducer state, not a rune. + */ + isAtBottom(): boolean; + /** + * Attach to the scroll container; returns a teardown to call on unmount. + * Pass the inner CONTENT element to also follow height changes that aren't a + * transcript update (async markdown/highlight, image loads, a collapse toggling, + * viewport reflow) via a ResizeObserver. + */ + attach(el: HTMLElement, content?: HTMLElement): () => void; + /** + * Notify that the transcript content changed (a streamed delta / new message). + * While stuck, keeps the view pinned to the bottom. + */ + contentChanged(): void; + /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */ + reset(): void; + /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */ + resume(): void; } function geometryOf(el: HTMLElement): ScrollGeometry { - return { - scrollTop: el.scrollTop, - scrollHeight: el.scrollHeight, - clientHeight: el.clientHeight, - }; + return { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }; } export function createSmartScrollController(): SmartScrollController { - let state: SmartScrollState = createSmartScrollState(); - let showButton = $state(false); - let el: HTMLElement | null = null; - // True while WE drive a programmatic scroll, so the resulting `scroll` event - // doesn't get misread as the user scrolling up. Cleared on `scrollend`. - let selfScrolling = false; - - function run(command: ScrollCommand | null): void { - if (!command || !el) return; - selfScrolling = true; - el.scrollTo({ - top: el.scrollHeight, - behavior: command.animate ? "smooth" : "instant", - }); - } - - function apply(r: SmartScrollResult): void { - state = r.state; - showButton = r.showButton; - run(r.command); - } - - function handleScroll(): void { - if (!el || selfScrolling) return; - apply(onScroll(state, geometryOf(el))); - } - - function handleScrollEnd(): void { - selfScrolling = false; - } - - return { - get showButton(): boolean { - return showButton; - }, - - isAtBottom(): boolean { - return state.stuck; - }, - - attach(node: HTMLElement, content?: HTMLElement): () => void { - el = node; - node.addEventListener("scroll", handleScroll, { passive: true }); - node.addEventListener("scrollend", handleScrollEnd); - - // A ResizeObserver keeps the view pinned through height changes that are - // NOT a transcript update — async markdown/syntax-highlight, image loads, a - // collapse toggling, font swaps, viewport reflow — which a content-count - // signal can't see. Observe the CONTENT (it grows) and the container (it - // changes on viewport resize). Routed through `onContentChange`, so it only - // scrolls while stuck and never fights the reader. The `selfScrolling` guard - // (and the fact that scrolling doesn't resize content) prevents any loop. - let ro: ResizeObserver | null = null; - if (typeof ResizeObserver !== "undefined") { - ro = new ResizeObserver(() => { - if (!el || selfScrolling) return; - apply(onContentChange(state, geometryOf(el))); - }); - if (content) ro.observe(content); - ro.observe(node); - } - - return () => { - node.removeEventListener("scroll", handleScroll); - node.removeEventListener("scrollend", handleScrollEnd); - ro?.disconnect(); - if (el === node) el = null; - }; - }, - - contentChanged(): void { - if (!el) return; - apply(onContentChange(state, geometryOf(el))); - }, - - reset(): void { - apply(onReset()); - }, - - resume(): void { - apply(onResume(state)); - }, - }; + let state: SmartScrollState = createSmartScrollState(); + let showButton = $state(false); + let el: HTMLElement | null = null; + // True while WE drive a programmatic scroll, so the resulting `scroll` event + // doesn't get misread as the user scrolling up. Cleared on `scrollend`. + let selfScrolling = false; + + function run(command: ScrollCommand | null): void { + if (!command || !el) return; + selfScrolling = true; + el.scrollTo({ + top: el.scrollHeight, + behavior: command.animate ? "smooth" : "instant", + }); + } + + function apply(r: SmartScrollResult): void { + state = r.state; + showButton = r.showButton; + run(r.command); + } + + function handleScroll(): void { + if (!el || selfScrolling) return; + apply(onScroll(state, geometryOf(el))); + } + + function handleScrollEnd(): void { + selfScrolling = false; + } + + return { + get showButton(): boolean { + return showButton; + }, + + isAtBottom(): boolean { + return state.stuck; + }, + + attach(node: HTMLElement, content?: HTMLElement): () => void { + el = node; + node.addEventListener("scroll", handleScroll, { passive: true }); + node.addEventListener("scrollend", handleScrollEnd); + + // A ResizeObserver keeps the view pinned through height changes that are + // NOT a transcript update — async markdown/syntax-highlight, image loads, a + // collapse toggling, font swaps, viewport reflow — which a content-count + // signal can't see. Observe the CONTENT (it grows) and the container (it + // changes on viewport resize). Routed through `onContentChange`, so it only + // scrolls while stuck and never fights the reader. The `selfScrolling` guard + // (and the fact that scrolling doesn't resize content) prevents any loop. + let ro: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(() => { + if (!el || selfScrolling) return; + apply(onContentChange(state, geometryOf(el))); + }); + if (content) ro.observe(content); + ro.observe(node); + } + + return () => { + node.removeEventListener("scroll", handleScroll); + node.removeEventListener("scrollend", handleScrollEnd); + ro?.disconnect(); + if (el === node) el = null; + }; + }, + + contentChanged(): void { + if (!el) return; + apply(onContentChange(state, geometryOf(el))); + }, + + reset(): void { + apply(onReset()); + }, + + resume(): void { + apply(onResume(state)); + }, + }; } diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts index 614f4b0..906cc91 100644 --- a/src/features/smart-scroll/ui/controller.test.ts +++ b/src/features/smart-scroll/ui/controller.test.ts @@ -5,168 +5,168 @@ import { createSmartScrollController } from "./controller.svelte"; // geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend". // Faking this outermost edge is the sanctioned mock (no internal modules mocked). function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) { - const listeners = new Map<string, Set<EventListener>>(); - const el = { - scrollTop: 0, - scrollHeight: opts?.scrollHeight ?? 1000, - clientHeight: opts?.clientHeight ?? 100, - scrollTo: vi.fn((arg: ScrollToOptions) => { - // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend. - el.scrollTop = (arg.top ?? 0) - 0; - if (arg.behavior !== "smooth") { - fire("scroll"); - fire("scrollend"); - } - }), - addEventListener: (type: string, fn: EventListener) => { - if (!listeners.has(type)) listeners.set(type, new Set()); - listeners.get(type)?.add(fn); - }, - removeEventListener: (type: string, fn: EventListener) => { - listeners.get(type)?.delete(fn); - }, - }; - function fire(type: string): void { - for (const fn of listeners.get(type) ?? []) fn(new Event(type)); - } - // Simulate the USER scrolling to a given offset (fires scroll, not self-driven). - function userScrollTo(top: number): void { - el.scrollTop = top; - fire("scroll"); - } - return { - el: el as unknown as HTMLElement, - scrollTo: el.scrollTo, - fire, - userScrollTo, - listenerCount: () => listeners, - }; + const listeners = new Map<string, Set<EventListener>>(); + const el = { + scrollTop: 0, + scrollHeight: opts?.scrollHeight ?? 1000, + clientHeight: opts?.clientHeight ?? 100, + scrollTo: vi.fn((arg: ScrollToOptions) => { + // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend. + el.scrollTop = (arg.top ?? 0) - 0; + if (arg.behavior !== "smooth") { + fire("scroll"); + fire("scrollend"); + } + }), + addEventListener: (type: string, fn: EventListener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)?.add(fn); + }, + removeEventListener: (type: string, fn: EventListener) => { + listeners.get(type)?.delete(fn); + }, + }; + function fire(type: string): void { + for (const fn of listeners.get(type) ?? []) fn(new Event(type)); + } + // Simulate the USER scrolling to a given offset (fires scroll, not self-driven). + function userScrollTo(top: number): void { + el.scrollTop = top; + fire("scroll"); + } + return { + el: el as unknown as HTMLElement, + scrollTo: el.scrollTo, + fire, + userScrollTo, + listenerCount: () => listeners, + }; } describe("smart-scroll controller", () => { - it("starts with the button hidden", () => { - const c = createSmartScrollController(); - expect(c.showButton).toBe(false); - }); + it("starts with the button hidden", () => { + const c = createSmartScrollController(); + expect(c.showButton).toBe(false); + }); - it("contentChanged while stuck scrolls to the bottom instantly", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - c.contentChanged(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "instant", - }); - expect(c.showButton).toBe(false); - }); + it("contentChanged while stuck scrolls to the bottom instantly", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + c.contentChanged(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); - it("a user scroll up shows the button and stops auto-following", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); // far from the bottom - expect(c.showButton).toBe(true); + it("a user scroll up shows the button and stops auto-following", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); // far from the bottom + expect(c.showButton).toBe(true); - const scrollTo = fake.scrollTo; - scrollTo.mockClear(); - c.contentChanged(); // streaming more content... - expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down - expect(c.showButton).toBe(true); - }); + const scrollTo = fake.scrollTo; + scrollTo.mockClear(); + c.contentChanged(); // streaming more content... + expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down + expect(c.showButton).toBe(true); + }); - it("self-driven scrolls are not misread as the user scrolling up", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - // contentChanged drives an instant scrollTo, whose synthetic scroll event - // must NOT flip us to unstuck (selfScrolling guard). - c.contentChanged(); - expect(c.showButton).toBe(false); - }); + it("self-driven scrolls are not misread as the user scrolling up", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + // contentChanged drives an instant scrollTo, whose synthetic scroll event + // must NOT flip us to unstuck (selfScrolling guard). + c.contentChanged(); + expect(c.showButton).toBe(false); + }); - it("resume re-sticks and smooth-scrolls to the bottom", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); - expect(c.showButton).toBe(true); + it("resume re-sticks and smooth-scrolls to the bottom", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); - c.resume(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "smooth", - }); - expect(c.showButton).toBe(false); - }); + c.resume(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "smooth", + }); + expect(c.showButton).toBe(false); + }); - it("reset snaps to the bottom and hides the button", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); - expect(c.showButton).toBe(true); - c.reset(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "instant", - }); - expect(c.showButton).toBe(false); - }); + it("reset snaps to the bottom and hides the button", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); + c.reset(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); - it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => { - const holder: { cb: ResizeObserverCallback | null } = { cb: null }; - const observed: unknown[] = []; - const disconnect = vi.fn(); - class FakeResizeObserver { - constructor(cb: ResizeObserverCallback) { - holder.cb = cb; - } - observe(target: Element): void { - observed.push(target); - } - unobserve(): void {} - disconnect = disconnect; - } - vi.stubGlobal("ResizeObserver", FakeResizeObserver); - try { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - const content = { id: "content" } as unknown as HTMLElement; - const teardown = c.attach(fake.el, content); + it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => { + const holder: { cb: ResizeObserverCallback | null } = { cb: null }; + const observed: unknown[] = []; + const disconnect = vi.fn(); + class FakeResizeObserver { + constructor(cb: ResizeObserverCallback) { + holder.cb = cb; + } + observe(target: Element): void { + observed.push(target); + } + unobserve(): void {} + disconnect = disconnect; + } + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + try { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const content = { id: "content" } as unknown as HTMLElement; + const teardown = c.attach(fake.el, content); - // Observes both the content (it grows) and the scroll container (viewport resize). - expect(observed).toContain(content); - expect(observed).toContain(fake.el); + // Observes both the content (it grows) and the scroll container (viewport resize). + expect(observed).toContain(content); + expect(observed).toContain(fake.el); - // Stuck → a resize keeps us pinned to the bottom. - fake.scrollTo.mockClear(); - holder.cb?.([], {} as ResizeObserver); - expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" }); + // Stuck → a resize keeps us pinned to the bottom. + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" }); - // Reader scrolls up → a later resize must NOT yank them down. - fake.userScrollTo(200); - fake.scrollTo.mockClear(); - holder.cb?.([], {} as ResizeObserver); - expect(fake.scrollTo).not.toHaveBeenCalled(); + // Reader scrolls up → a later resize must NOT yank them down. + fake.userScrollTo(200); + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).not.toHaveBeenCalled(); - // Teardown disconnects the observer. - teardown(); - expect(disconnect).toHaveBeenCalled(); - } finally { - vi.unstubAllGlobals(); - } - }); + // Teardown disconnects the observer. + teardown(); + expect(disconnect).toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); - it("attach returns a teardown that removes both listeners", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - const teardown = c.attach(fake.el); - const before = fake.listenerCount(); - expect(before.get("scroll")?.size).toBe(1); - expect(before.get("scrollend")?.size).toBe(1); - teardown(); - expect(before.get("scroll")?.size).toBe(0); - expect(before.get("scrollend")?.size).toBe(0); - }); + it("attach returns a teardown that removes both listeners", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const teardown = c.attach(fake.el); + const before = fake.listenerCount(); + expect(before.get("scroll")?.size).toBe(1); + expect(before.get("scrollend")?.size).toBe(1); + teardown(); + expect(before.get("scroll")?.size).toBe(0); + expect(before.get("scrollend")?.size).toBe(0); + }); }); |
