Files
openchamber/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.test.ts
T
Bohdan Triapitsyn 1d1c215230 fix(chat): release live follow on middle-button pan, Shift+Space, and nested wheel
Merge main and reshape the follow opt-out to the gestures the timeline was
missing: a middle-button press starts the platform autoscroll pan (the only
scroll gesture on wheel-less mice and tablets with a pointer), Shift+Space
scrolls up from the keyboard, and an upward wheel over a nested scroller that
still has room above stays with that scroller instead of releasing the chat.

The grace re-pin timer and scroll-direction tracking are dropped: returning to
within the end band already re-arms follow, and the mode machine is built
without timers on purpose. Pause/Break never move the viewport and are not
gestures.

Closes #1640
2026-08-28 23:05:57 +03:00

85 lines
3.9 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { isFollowReleaseKey, isMiddleButtonPan, nestedScrollableConsumesWheelUp } from './timelineScrollIntent';
const key = (
k: string,
modifiers: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>> = {},
) => ({ key: k, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers });
describe('isFollowReleaseKey', () => {
test('upward navigation keys release follow', () => {
for (const k of ['ArrowUp', 'PageUp', 'Home']) expect(isFollowReleaseKey(key(k))).toBe(true);
expect(isFollowReleaseKey(key(' ', { shiftKey: true }))).toBe(true);
});
test('downward keys, plain space, and modified shortcuts do not', () => {
for (const k of ['ArrowDown', 'PageDown', 'End', ' ', 'Pause', 'Enter']) {
expect(isFollowReleaseKey(key(k))).toBe(false);
}
expect(isFollowReleaseKey(key('Home', { ctrlKey: true }))).toBe(false);
expect(isFollowReleaseKey(key('ArrowUp', { metaKey: true }))).toBe(false);
expect(isFollowReleaseKey(key('ArrowUp', { altKey: true }))).toBe(false);
});
});
// The helpers only use Element#closest, scrollTop, and identity, so a minimal
// DOM stand-in built on EventTarget is enough — no renderer or jsdom.
class FakeElement extends EventTarget {
scrollTop = 0;
constructor(private readonly scrollable: boolean, private readonly parent: FakeElement | null = null) {
super();
}
closest(selector: string): FakeElement | null {
if (selector !== '[data-scrollable]') throw new Error(`unexpected selector ${selector}`);
if (this.scrollable) return this;
return this.parent?.closest(selector) ?? null;
}
}
// SAFETY: the helpers narrow with `instanceof Element` / `instanceof HTMLElement`;
// registering the fakes under those globals keeps the narrowing honest in bun.
const installDomGlobals = () => {
const previous = { Element: globalThis.Element, HTMLElement: globalThis.HTMLElement };
Object.assign(globalThis, { Element: FakeElement, HTMLElement: FakeElement });
return () => Object.assign(globalThis, previous);
};
// With the globals above installed, FakeElement IS the HTMLElement the helpers
// narrow to; reading it back through the global bridges the static type without
// asserting anything the runtime does not hold.
const asRoot = (element: FakeElement): HTMLElement => {
if (!(element instanceof globalThis.HTMLElement)) throw new Error('DOM globals not installed');
return element;
};
describe('nested scroller handling', () => {
test('an upward wheel over a nested scroller with room above stays there', () => {
const restore = installDomGlobals();
try {
const root = new FakeElement(false);
const box = new FakeElement(true, root);
const inner = new FakeElement(false, box);
box.scrollTop = 40;
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(true);
box.scrollTop = 0;
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(false);
expect(nestedScrollableConsumesWheelUp(asRoot(root), new FakeElement(false, root))).toBe(false);
} finally {
restore();
}
});
test('a middle-button press pans the timeline unless it lands in a nested scroller', () => {
const restore = installDomGlobals();
try {
const root = new FakeElement(false);
const row = new FakeElement(false, root);
const box = new FakeElement(true, root);
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: row })).toBe(true);
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: box })).toBe(false);
expect(isMiddleButtonPan(asRoot(root), { button: 0, target: row })).toBe(false);
} finally {
restore();
}
});
});