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
@@ -125,6 +125,67 @@ describe("PageScroller scroll memory", () => {
expect(main.scrollTop).toBe(450);
});
// Round 3: landing at the end shows Back to top, whose spacer then brings the spot within reach.
it("finishes the way to a spot the spacer brings within reach after it lands", () => {
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
const flush = (n: number) => {
for (let i = 0; i < n; i++) {
const run = frames;
frames = [];
for (const cb of run) cb(0);
}
};
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 500;
fireEvent.scroll(main);
setEntry(2);
notifyNav();
flush(1);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 928 }); // no spacer
setEntry(1);
notifyNav();
flush(12);
expect(main.scrollTop).toBe(428);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 }); // spacer up
flush(1);
expect(main.scrollTop).toBe(500);
});
// Round 3: the give-up put the page at its end 1.5 s in, wherever the reader had scrolled to.
it("stops where the reader takes the page over, even for a spot it has not reached", () => {
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
const flush = (n: number) => {
for (let i = 0; i < n; i++) {
const run = frames;
frames = [];
for (const cb of run) cb(0);
}
};
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 450;
fireEvent.scroll(main);
setEntry(2);
notifyNav();
flush(1);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 600 }); // far short
setEntry(1);
notifyNav();
flush(5);
fireEvent.wheel(main);
main.scrollTop = 30; // where the reader scrolled
flush(100);
expect(main.scrollTop).toBe(30);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 }); // loaded
flush(1);
expect(main.scrollTop).toBe(30);
});
it("restores per content key within one entry: a new key resets to top, returning restores", () => {
setEntry(10);
const { container, rerender } = render(<PageScroller scrollKey="e10:a">x</PageScroller>);