summaryrefslogtreecommitdiffhomepage
path: root/src/features/smart-scroll/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:28 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:31 +0900
commit2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch)
tree94e1923180ae38d571d34b578afecb0a18913c24 /src/features/smart-scroll/logic
parent80f99665034a0e510300793205c162fc7a46769f (diff)
parent08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff)
downloaddispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz
dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip
Merge branch 'dev' into feature/heartbeat
# Conflicts: # src/app/App.svelte # src/app/store.svelte.ts # src/app/store.test.ts # src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/smart-scroll/logic')
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.test.ts156
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.ts56
2 files changed, 106 insertions, 106 deletions
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 });
}