fix(nav): Back looks for a moved item only in the page's lists, once its own list has had its chance, and its reveal stops the scroll restore
The cross-list match took a same-named row of the app-wide download tray, and took it at the first look while a virtualized list had yet to render the item. The reveal of a moved item ran before the scroll restore's first frame, which put the old offset back over it. The pending restore's name is readable, for a page that folds rows away.
This commit is contained in:
@@ -226,6 +226,14 @@ function itemName(el: HTMLElement): string {
|
||||
/** What a screen reader hears while the item holds the focus: its `label` when it gives one. */
|
||||
const spokenName = (el: HTMLElement): string => el.getAttribute("data-nav-label") || itemName(el);
|
||||
|
||||
/** The event an item brought into view sends up its page: a scroll restore still under way gives the
|
||||
* page up to it (`ui/PageScroller`) — on Back, an item found in another list than the one it was
|
||||
* left in is not where the restored offset shows, and the restore's next frame scrolled it away. */
|
||||
export const NAV_REVEAL_EVENT = "navreveal";
|
||||
export const revealedNavItem = (el: HTMLElement): void => {
|
||||
el.dispatchEvent(new Event(NAV_REVEAL_EVENT, { bubbles: true }));
|
||||
};
|
||||
|
||||
function focusItem(el: HTMLElement, reveal = true): void {
|
||||
if (roved !== el) {
|
||||
if (named) roved?.removeAttribute("role");
|
||||
@@ -259,7 +267,10 @@ function focusItem(el: HTMLElement, reveal = true): void {
|
||||
el.focus({ preventScroll: true });
|
||||
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
|
||||
// header or past the bottom edge scrolls just far enough to clear it.
|
||||
if (reveal) el.scrollIntoView?.({ block: "nearest" });
|
||||
if (reveal) {
|
||||
el.scrollIntoView?.({ block: "nearest" });
|
||||
revealedNavItem(el);
|
||||
}
|
||||
}
|
||||
|
||||
function focusIndex(
|
||||
@@ -483,17 +494,24 @@ export function navPosition(): NavPosition | null {
|
||||
// look — on a timer, not animation frames: a hidden tab runs none.
|
||||
const RESTORE_MS = 2000;
|
||||
const RESTORE_POLL_MS = 50;
|
||||
// The restore under way, while it looks for its item.
|
||||
let pending: NavPosition | null = null;
|
||||
|
||||
/** The item to hand the focus back to: in `list`, the saved place while it still holds the saved
|
||||
* name, else the nearest item of that name (names repeat — a log console's identical request
|
||||
* lines). Then the one item of that name in another of the page's lists: an item can change lists
|
||||
* while the page was away (a notification asked for the reader on the visit it was opened from and
|
||||
* sits in the stream on the next), and the place left behind is now its neighbour's. Only when the
|
||||
* name is the page's alone — of two, either could be the one. Else the saved place. */
|
||||
* name is the page's alone — of two, either could be the one — and only the PAGE's lists: the
|
||||
* running-downloads tray is the app's, on every page, and its row of a download is named like the
|
||||
* feed card it came from. Not while `list` has yet to render a saved place it still counts (a
|
||||
* virtualized list puts its rows back a frame after the page), until the `last` look: the item is
|
||||
* likely still to come there. Else the saved place. */
|
||||
function restoreTarget(
|
||||
all: readonly NavList[],
|
||||
list: NavList | undefined,
|
||||
pos: NavPosition,
|
||||
last: boolean,
|
||||
): HTMLElement | null {
|
||||
const at = list ? itemAt(list, pos.index) : null;
|
||||
if (list && (!pos.name || (at && itemName(at) === pos.name))) return at;
|
||||
@@ -504,7 +522,8 @@ function restoreTarget(
|
||||
const here = named(list).sort((a, b) => distance(a) - distance(b));
|
||||
if (here[0]) return here[0];
|
||||
}
|
||||
const [only, ...more] = all.flatMap(named);
|
||||
if (list && !at && pos.index < list.count() && !last) return null;
|
||||
const [only, ...more] = all.filter((l) => l.el.closest("main")).flatMap(named);
|
||||
return only && more.length === 0 ? only : at;
|
||||
}
|
||||
|
||||
@@ -522,8 +541,12 @@ function restoreTarget(
|
||||
export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
const deadline = Date.now() + RESTORE_MS;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
pending = pos;
|
||||
const done = () => {
|
||||
if (pending === pos) pending = null;
|
||||
};
|
||||
const attempt = () => {
|
||||
if (!focusIsUnclaimed()) return;
|
||||
if (!focusIsUnclaimed()) return done();
|
||||
const all = ordered();
|
||||
const list = pos.label
|
||||
? all.find((l) => labelOf(l) === pos.label)
|
||||
@@ -536,20 +559,33 @@ export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
// The saved place still holding the saved name wins, and among several items of that name the
|
||||
// nearest one: names repeat (a log console's `http.request GET /api/feed 200` lines, two
|
||||
// downloads of one video), and the first of them is rarely the one left behind.
|
||||
const el = restoreTarget(all, list, pos);
|
||||
const last = Date.now() + RESTORE_POLL_MS >= deadline;
|
||||
const el = restoreTarget(all, list, pos, last);
|
||||
// Where the scroll restore put it — unless it is found in another list than the one it was left
|
||||
// in: the restored scroll is the old place's, and the item would hold the focus off screen.
|
||||
if (el) return focusItem(el, !list?.el.contains(el));
|
||||
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
|
||||
if (el) {
|
||||
done();
|
||||
return focusItem(el, !list?.el.contains(el));
|
||||
}
|
||||
if (last) done();
|
||||
else timer = setTimeout(attempt, RESTORE_POLL_MS);
|
||||
};
|
||||
// Not inside the popstate dispatch that landed: a sub-view's own listener (`useHistorySubview`)
|
||||
// may run after ours and has not switched the view yet — focus still sits in the view being left,
|
||||
// and "focus is already somewhere" would give up for good (measured in Chrome:
|
||||
// Plex show → Back left focus on <body>). One task later the view has rendered.
|
||||
timer = setTimeout(attempt, 0);
|
||||
return () => clearTimeout(timer);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
};
|
||||
}
|
||||
|
||||
/** The name of the item a Back is still looking for, while it looks: a page that keeps some of its
|
||||
* rows folded away (the notification center's Earlier) opens the fold holding it — a list it does
|
||||
* not render is one the restore cannot find the item in. Read it as the page mounts. */
|
||||
export const pendingNavName = (): string | null => pending?.name ?? null;
|
||||
|
||||
/** Register `ref` as a keyboard-navigable list while `enabled`. A virtualized list must say how many
|
||||
* items it has (`count` — the loaded ones, for an infinite list), how many per row (`cols`) and how
|
||||
* to scroll an unmounted item into view (`reveal`). A list that mounts everything omits all three:
|
||||
|
||||
Reference in New Issue
Block a user