fix(ui): a removed chip's focus outlives its neighbour; a scroll restore yields to the reader

A chip handed its focus to a Clear all or +N that went in the same removal, and the focus fell to
the body. The restore now stops when the reader scrolls, and finishes the way once the Back-to-top
spacer brings the saved spot within reach.
This commit is contained in:
2026-09-27 21:56:23 +02:00
parent 5ab6778b97
commit 25f2d9d0e4
5 changed files with 152 additions and 19 deletions
+33 -13
View File
@@ -31,7 +31,12 @@ function rememberScroll(key: string, top: number): void {
// A spot the page can no longer reach — the last stretch of a page scrolled to its very end, whose
// Back-to-top spacer only shows once scrolled — goes as far as the page does: it used to be left at
// the top (round 2, F8). Only a spacer's worth short, and only once the height has held still, is
// that answer early; a list still loading is further short than that, and gets the full wait.
// that answer early; a list still loading is further short than that, and gets the full wait. And
// landing there is not the end of it: that scroll is what shows Back to top, whose spacer then
// makes the spot reachable, so the loop watches a few frames more and finishes the way (round 3).
//
// The reader scrolling meanwhile — a wheel, a finger, a key, the scrollbar — takes the page over:
// the restore stops where they are, instead of pulling them back or dropping them at the end.
const SETTLED_FRAMES = 10;
const SPACER_REM = 6;
function restoreScroll(el: HTMLElement, top: number): () => void {
@@ -43,27 +48,42 @@ function restoreScroll(el: HTMLElement, top: number): () => void {
let frames = 0;
let still = 0;
let lastMax = -1;
let landedAt = -1;
let cancelled = false;
const stop = () => {
cancelled = true;
cancelAnimationFrame(raf);
el.removeEventListener("wheel", stop);
el.removeEventListener("touchstart", stop);
el.removeEventListener("pointerdown", stop);
document.removeEventListener("keydown", stop, true);
};
el.addEventListener("wheel", stop, { passive: true });
el.addEventListener("touchstart", stop, { passive: true });
el.addEventListener("pointerdown", stop);
document.addEventListener("keydown", stop, true);
const tick = () => {
if (cancelled) return;
const max = el.scrollHeight - el.clientHeight;
if (max >= top) el.scrollTop = top;
if (el.scrollTop >= top - 1) return; // reached
still = max === lastMax ? still + 1 : 0;
lastMax = max;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const nearEnd = max > 0 && top - max <= SPACER_REM * rem;
if ((nearEnd && still >= SETTLED_FRAMES) || frames++ > 90) {
el.scrollTop = max; // as near as the page reaches now
return;
if (el.scrollTop >= top - 1) return stop(); // reached
if (landedAt >= 0) {
if (frames - landedAt > SETTLED_FRAMES) return stop(); // no spacer came: the end it is
} else {
still = max === lastMax ? still + 1 : 0;
lastMax = max;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const nearEnd = max > 0 && top - max <= SPACER_REM * rem;
if ((nearEnd && still >= SETTLED_FRAMES) || frames > 90) {
el.scrollTop = max; // as near as the page reaches now
landedAt = frames;
}
}
frames++;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
};
return stop;
}
// The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the