feat(desktop): pure scroll-decision logic for new message list
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController';
|
||||||
|
|
||||||
|
const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearBottom', () => {
|
||||||
|
it('true exactly at the bottom', () => {
|
||||||
|
expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('true within threshold', () => {
|
||||||
|
expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false beyond threshold', () => {
|
||||||
|
expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearTop', () => {
|
||||||
|
it('true at top', () => {
|
||||||
|
expect(isNearTop(m(0, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false past threshold', () => {
|
||||||
|
expect(isNearTop(m(200, 1000, 100), 64)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveInitialAnchor', () => {
|
||||||
|
it('anchors to last row at end by default (no saved position)', () => {
|
||||||
|
expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' });
|
||||||
|
});
|
||||||
|
it('anchors to bottom when saved position stuck to bottom', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({
|
||||||
|
index: 49,
|
||||||
|
align: 'end',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('restores the saved row at the top when scrolled up', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({
|
||||||
|
index: 12,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('clamps a stale saved index to the current row count', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({
|
||||||
|
index: 49,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles an empty list', () => {
|
||||||
|
expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the
|
||||||
|
// tricky math is verified without a browser (jsdom has no layout).
|
||||||
|
|
||||||
|
export interface ScrollMetrics {
|
||||||
|
scrollTop: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance from the bottom edge is within `threshold` px. */
|
||||||
|
export function isNearBottom(m: ScrollMetrics, threshold: number): boolean {
|
||||||
|
return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll offset is within `threshold` px of the top. */
|
||||||
|
export function isNearTop(m: ScrollMetrics, threshold: number): boolean {
|
||||||
|
return m.scrollTop <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedPosition {
|
||||||
|
topmostIndex: number;
|
||||||
|
stickToBottom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Anchor {
|
||||||
|
index: number;
|
||||||
|
align: 'start' | 'end';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a freshly-opened chat should start.
|
||||||
|
* - default / "left at bottom" → last row, aligned to the viewport bottom.
|
||||||
|
* - "left scrolled up" → the saved top-most row, aligned to the viewport top
|
||||||
|
* (clamped in case the cached row count shrank).
|
||||||
|
*/
|
||||||
|
export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor {
|
||||||
|
if (rowCount <= 0) return { index: 0, align: 'end' };
|
||||||
|
if (saved && !saved.stickToBottom) {
|
||||||
|
const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1));
|
||||||
|
return { index, align: 'start' };
|
||||||
|
}
|
||||||
|
return { index: rowCount - 1, align: 'end' };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user