fix(ui): a control removed by its own action leaves the focus in its slot

The second half of the pending-button sweep: where the pressed control does
not merely disable but LEAVES the page — an invite row gone after Approve,
the inbox's Clear all gone with the last notification, a section gone once
its last row is handled — the browser dropped the focus to <body> and the
next Tab started over at the top.

useFocusKeeper, on the page scroller: when the element holding the focus is
removed and the focus fell to <body>, the first focusable thing in its slot
takes it (what followed, else what preceded, else the nearest ancestor still
on the page). Portaled layers (menus, dialogs) sit outside the scroller and
return focus themselves; listNav's keyboard lists are left alone — their `j`
already goes on from the slot, and a row scrolled out of a virtualized list
must not drag the focus after it.
This commit is contained in:
2026-09-19 08:10:03 +02:00
parent 7a47223a58
commit 444f9b8166
3 changed files with 140 additions and 0 deletions
@@ -1,5 +1,6 @@
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useScrollFade } from "../../lib/useScrollFade";
import { useFocusKeeper } from "../../lib/useFocusKeeper";
import { StickyTopProvider } from "./stickyTop";
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
@@ -105,6 +106,10 @@ export default function PageScroller({
[fadeRef],
);
// A focused control removed by its own action (a row gone after Approve, a toolbar after Clear
// all) leaves the focus in its slot rather than on <body> — see useFocusKeeper.
useFocusKeeper(elRef);
// Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
const scrollKeyRef = useRef(scrollKey);
scrollKeyRef.current = scrollKey;