Files
nvrl 6e961d3e0e Fix small front-end faults found in the audit
- A failed likes request shows the error in the panel instead of an empty list.
- A failed sign-out says why instead of doing nothing.
- Long toasts, like a copied link, wrap instead of running off narrow screens.
- Keys no longer act on the feed behind the help; ? still toggles it, and focus
  goes into the help and back to where it was.
- Phones reach the help from the account panel; the bar has no room for ?.
- Cards next to the current one are inert, so Tab no longer walks into buttons
  that are off screen.
- Keyboard focus is visible everywhere, and panels use the shared colour and
  radius tokens.
2026-11-01 19:07:16 +01:00

241 lines
8.4 KiB
TypeScript

// Full-screen vertical pager. One card fills the screen; drag, fling, wheel and keys
// move between cards. Only the current card and its two neighbours are in the DOM,
// placed with transforms and settled by a spring, so long sessions stay light and
// the motion follows the finger exactly.
export interface PagerSource {
/// How many cards exist right now.
count(): number;
/// The element for card `i`; the owner builds and caches it.
element(i: number): HTMLElement;
/// Card `i` became the current one. Called as soon as a move is decided, before
/// the animation ends, so audio can start right away.
onChange(i: number): void;
/// The user is at (or pulling past) the last card.
onNeedMore(): void;
}
const STIFFNESS = 380; // 1/s², spring towards the resting position
const DAMPING = 34; // 1/s, a little under critical (39): settles with a hint of bounce
const FLING = 0.45; // px/ms; a flick faster than this turns the page
const DISTANCE = 0.22; // or dragging further than this share of the height
const SLOP = 8; // px before a touch counts as a drag
const WHEEL_STEP = 40; // px of wheel delta per page
const WHEEL_IDLE = 180; // ms of quiet that ends one wheel gesture (trackpad inertia)
interface Drag {
id: number;
x0: number;
y0: number;
base: number;
active: boolean;
samples: [number, number][];
}
export class Pager {
index = 0;
/// True right after a drag, so the click that follows it is not a tap.
suppressClick = false;
private offset = 0; // px; positive while pulled down (towards the previous card)
private velocity = 0; // px/s
private height = 1;
private mounted = new Map<number, HTMLElement>();
private raf = 0;
private last = 0;
private drag: Drag | null = null;
private wheelAcc = 0;
private wheelLocked = false;
private wheelTimer = 0;
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
constructor(
private root: HTMLElement,
private src: PagerSource,
) {
this.height = root.clientHeight || innerHeight;
root.addEventListener("pointerdown", (e) => this.down(e));
root.addEventListener("pointermove", (e) => this.move(e));
root.addEventListener("pointerup", (e) => this.up(e));
root.addEventListener("pointercancel", (e) => this.up(e, true));
root.addEventListener("wheel", (e) => this.wheel(e), { passive: false });
new ResizeObserver(() => {
this.height = root.clientHeight || innerHeight;
this.render();
}).observe(root);
}
/// Move to card `i`. Returns false when there is no such card (yet).
go(i: number, { instant = false } = {}): boolean {
if (i < 0) return false;
if (i >= this.src.count()) {
this.src.onNeedMore();
return false;
}
if (i === this.index) return true;
this.offset += (i - this.index) * this.height;
this.index = i;
this.refresh();
this.src.onChange(i);
if (instant || this.reduced.matches || document.hidden) this.settleNow();
else this.animate();
if (i >= this.src.count() - 1) this.src.onNeedMore();
return true;
}
/// Re-read the cards around the current one (after inserting or removing cards).
refresh(): void {
const want = new Map<number, HTMLElement>();
for (let i = this.index - 1; i <= this.index + 1; i++) {
if (i >= 0 && i < this.src.count()) want.set(i, this.src.element(i));
}
for (const [i, el] of this.mounted) {
if (want.get(i) !== el && ![...want.values()].includes(el)) el.remove();
}
for (const el of want.values()) {
if (el.parentElement !== this.root) this.root.append(el);
}
this.mounted = want;
this.render();
}
private render(): void {
const h = this.height;
for (const [i, el] of this.mounted) {
const p = i - this.index + this.offset / h;
el.style.transform = `translate3d(0, ${(p * h).toFixed(2)}px, 0)`;
// 0 when resting in place, 1 one full page away; cards use it for depth effects.
el.style.setProperty("--away", Math.min(1, Math.abs(p)).toFixed(3));
// Only the current card's buttons are reachable with the keyboard.
el.inert = i !== this.index;
}
}
private settleNow(): void {
cancelAnimationFrame(this.raf);
this.raf = 0;
this.offset = 0;
this.velocity = 0;
this.render();
}
private animate(): void {
if (this.raf) return;
this.last = performance.now();
const step = (now: number) => {
// Small fixed steps keep the spring stable on slow frames.
let dt = Math.min(0.05, (now - this.last) / 1000);
this.last = now;
while (dt > 0) {
const h = Math.min(dt, 1 / 240);
this.velocity += (-STIFFNESS * this.offset - DAMPING * this.velocity) * h;
this.offset += this.velocity * h;
dt -= h;
}
if (Math.abs(this.offset) < 0.5 && Math.abs(this.velocity) < 20) {
this.raf = 0;
this.settleNow();
return;
}
this.render();
this.raf = requestAnimationFrame(step);
};
this.raf = requestAnimationFrame(step);
}
// Pointer ---------------------------------------------------------------
private down(e: PointerEvent): void {
if (this.drag || (e.pointerType === "mouse" && e.button !== 0)) return;
if ((e.target as Element).closest("button, a, input, textarea, select, .wave-wrap")) return;
this.drag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, base: this.offset, active: false, samples: [] };
}
private move(e: PointerEvent): void {
const d = this.drag;
if (!d || e.pointerId !== d.id) return;
// The button came up where we did not see it (outside the window, or while
// the drop was being turned).
if (e.pointerType === "mouse" && !(e.buttons & 1)) {
this.up(e, true);
return;
}
const dx = e.clientX - d.x0;
const dy = e.clientY - d.y0;
if (!d.active) {
if (Math.abs(dy) < SLOP || Math.abs(dy) < Math.abs(dx)) return;
d.active = true;
this.root.setPointerCapture(e.pointerId);
// Catch a card that is still settling where it is.
cancelAnimationFrame(this.raf);
this.raf = 0;
d.base = this.offset;
d.y0 = e.clientY;
}
let off = d.base + (e.clientY - d.y0);
const atStart = this.index === 0 && off > 0;
const atEnd = this.index >= this.src.count() - 1 && off < 0;
if (atStart || atEnd) off = Math.sign(off) * this.rubber(Math.abs(off));
if (atEnd) this.src.onNeedMore();
this.offset = off;
const now = performance.now();
d.samples.push([now, e.clientY]);
while (d.samples.length > 2 && now - d.samples[0][0] > 100) d.samples.shift();
this.render();
}
/// Resistance past the first and last card.
private rubber(x: number): number {
const h = this.height;
return (1 - 1 / ((x * 0.55) / h + 1)) * h;
}
private up(e: PointerEvent, cancelled = false): void {
const d = this.drag;
if (!d || e.pointerId !== d.id) return;
this.drag = null;
if (!d.active) return;
this.suppressClick = true;
setTimeout(() => (this.suppressClick = false), 350);
let v = 0; // px/ms
if (!cancelled && d.samples.length > 1) {
const [t0, y0] = d.samples[0];
const [t1, y1] = d.samples[d.samples.length - 1];
if (t1 > t0) v = (y1 - y0) / (t1 - t0);
}
const h = this.height;
let target = this.index;
if ((this.offset < -h * DISTANCE || v < -FLING) && this.index + 1 < this.src.count()) target++;
else if ((this.offset > h * DISTANCE || v > FLING) && this.index > 0) target--;
this.velocity = v * 1000;
if (target !== this.index) {
this.offset += (target - this.index) * h;
this.index = target;
this.refresh();
this.src.onChange(target);
if (target >= this.src.count() - 1) this.src.onNeedMore();
}
if (this.reduced.matches) this.settleNow();
else this.animate();
}
// Wheel and trackpad -------------------------------------------------------
private wheel(e: WheelEvent): void {
if (e.ctrlKey) return; // pinch zoom
e.preventDefault();
clearTimeout(this.wheelTimer);
this.wheelTimer = window.setTimeout(() => {
this.wheelLocked = false;
this.wheelAcc = 0;
}, WHEEL_IDLE);
if (this.wheelLocked) return;
this.wheelAcc += e.deltaY * (e.deltaMode === 1 ? 30 : e.deltaMode === 2 ? this.height : 1);
if (Math.abs(this.wheelAcc) >= WHEEL_STEP) {
// One page per gesture: the rest of a trackpad swipe's inertia is ignored.
this.wheelLocked = true;
this.go(this.index + Math.sign(this.wheelAcc));
this.wheelAcc = 0;
}
}
}