diff --git a/frontend/src/components/ui/PageScroller.tsx b/frontend/src/components/ui/PageScroller.tsx index 1a49126e..0a98c9b7 100644 --- a/frontend/src/components/ui/PageScroller.tsx +++ b/frontend/src/components/ui/PageScroller.tsx @@ -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 — 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; diff --git a/frontend/src/lib/useFocusKeeper.dom.test.tsx b/frontend/src/lib/useFocusKeeper.dom.test.tsx new file mode 100644 index 00000000..7bdf765b --- /dev/null +++ b/frontend/src/lib/useFocusKeeper.dom.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { act, cleanup, render } from "@testing-library/react"; +import { useRef, useState } from "react"; + +import { useFocusKeeper } from "./useFocusKeeper"; + +afterEach(cleanup); + +const flush = () => act(() => new Promise((r) => setTimeout(r, 0))); + +function Rows({ nav = false }: { nav?: boolean }) { + const ref = useRef(null); + useFocusKeeper(ref); + const [rows, setRows] = useState(["a", "b", "c"]); + return ( +
+

Invites

+ {rows.map((r, i) => ( +
+ +
+ ))} + +
+ ); +} + +const btn = (name: string) => + [...document.querySelectorAll("button")].find((b) => b.textContent === name)!; + +describe("useFocusKeeper", () => { + it("keeps the focus in the removed row's slot — the next row takes it", async () => { + render(); + btn("approve b").focus(); + act(() => btn("approve b").click()); + await flush(); + expect(document.activeElement).toBe(btn("approve c")); + }); + + it("falls back to what followed the list once the last row is gone", async () => { + render(); + btn("approve c").focus(); + act(() => btn("approve c").click()); + await flush(); + expect(document.activeElement).toBe(btn("after the list")); + }); + + it("leaves a keyboard list's rows to listNav", async () => { + render(); + btn("approve b").focus(); + act(() => btn("approve b").click()); + await flush(); + expect(document.activeElement).toBe(document.body); + }); +}); diff --git a/frontend/src/lib/useFocusKeeper.ts b/frontend/src/lib/useFocusKeeper.ts new file mode 100644 index 00000000..86c30e55 --- /dev/null +++ b/frontend/src/lib/useFocusKeeper.ts @@ -0,0 +1,79 @@ +import { useEffect, type RefObject } from "react"; + +// When the element holding the keyboard focus is REMOVED by what it just did — an invite row gone +// after Approve, a toolbar gone after "Clear all", a section gone once its last row is handled — the +// browser drops the focus to , and the next Tab starts over at the top of the document. That is +// the same stranding FocusSafeButton fixes for a button that merely turns disabled, one step further. +// +// So the page keeps the focus in place: the first focusable thing now in the removed node's slot — +// in what followed it, else what preceded it, else the nearest ancestor still on the page. Scoped to +// one container (the page scroller), which is what keeps it off the layers with their own focus +// logic: a menu or a dialog is portaled outside it and returns the focus to its opener itself. +// Keyboard lists (`listNav`) are left alone as well — their `j` already goes on from the slot, and a +// row that merely scrolled out of a virtualized list must not pull the focus after it. + +const FOCUSABLE = + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null { + let n: Node | null = node; + while (n) { + if (n instanceof HTMLElement) { + if (n.matches(FOCUSABLE)) return n; + const all = n.querySelectorAll(FOCUSABLE); + const hit = fromEnd ? all[all.length - 1] : all[0]; + if (hit) return hit; + } + n = fromEnd ? n.previousSibling : n.nextSibling; + } + return null; +} + +/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page. */ +function slotFor(record: MutationRecord, lost: Node): HTMLElement | null { + const removed = [...record.removedNodes].find((n) => n === lost || n.contains(lost)); + if (!removed) return null; + const after = firstFocusable(record.nextSibling); + if (after) return after; + const before = firstFocusable(record.previousSibling, true); + if (before) return before; + let up: Node | null = record.target; + while (up && up instanceof HTMLElement) { + const inside = firstFocusable(up.firstChild); + if (inside) return inside; + up = up.parentElement; + } + return null; +} + +export function useFocusKeeper(ref: RefObject): void { + useEffect(() => { + const root = ref.current; + if (!root) return; + let held: HTMLElement | null = null; + const onFocusIn = (e: FocusEvent) => { + const t = e.target as HTMLElement | null; + held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : null; + }; + const watch = new MutationObserver((records) => { + const lost = held; + if (!lost || lost.isConnected) return; + held = null; + const active = document.activeElement; + if (active && active !== document.body) return; // something already took the focus + for (const r of records) { + const target = slotFor(r, lost); + if (target) { + target.focus({ preventScroll: true }); + return; + } + } + }); + document.addEventListener("focusin", onFocusIn); + watch.observe(root, { childList: true, subtree: true }); + return () => { + document.removeEventListener("focusin", onFocusIn); + watch.disconnect(); + }; + }, [ref]); +}