fix(ui): the reader's own use of a stand-in stops the restore; the pill's name carries its cap

Round 2 of the deep review:
- A click on, or a key typed into, the focus keeper's stand-in fires no focusin, so a restore still
  polling for its list could take the focus from under the reader. A pointerdown or keydown now
  makes it the reader's own focus.
- The Jump-to pill hid its "Ctrl K" from its name (the voice command "click Ctrl K" still matched
  nothing); the key cap's tooltip now equals its name, so it is not read twice as a description.
- listNav's comment still cited DataTable's two layouts as the reason for its visibility filter.
This commit is contained in:
2026-09-19 19:30:27 +02:00
parent 0ca0cac534
commit 8d0063d514
5 changed files with 40 additions and 9 deletions
+2 -2
View File
@@ -66,8 +66,8 @@ function itemAt(list: NavList, index: number): HTMLElement | null {
function ordered(): NavList[] {
return (
[...lists.values()]
// Only lists on screen: a responsive table renders its rows twice — a table and a card list, one
// of them display:none at any width (ui/DataTable) — and `j` must not walk into the hidden copy.
// Only lists on screen: a list can be mounted yet hidden (display:none in a collapsed panel or
// an inactive tab), and `j` must not walk into rows nobody can see.
.filter((l) => l.el.isConnected && l.el.checkVisibility?.() !== false)
.sort((a, b) =>
a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
@@ -61,4 +61,26 @@ describe("Back from a sub-view whose held control the swap removed", () => {
document.activeElement?.closest("[data-nav-index]")?.getAttribute("data-nav-index"),
).toBe("2");
});
it("leaves the focus alone once the reader has used the stand-in", async () => {
render(<Page />);
button("c").focus();
const pos = navPosition()!;
act(() => show("detail"));
button("Back to library").focus();
act(() => show("grid"));
await act(() => Promise.resolve()); // the keeper places its stand-in
const standIn = document.activeElement;
expect(standIn?.textContent).toBe("a");
// A key typed into it fires no focusin — but it is the reader's focus now.
act(() => {
standIn!.dispatchEvent(new KeyboardEvent("keydown", { key: "x", bubbles: true }));
});
const cancel = restoreNavPosition(pos);
await wait(120);
cancel();
expect(document.activeElement).toBe(standIn);
});
});
+10
View File
@@ -70,6 +70,12 @@ export function useFocusKeeper(ref: RefObject<HTMLElement | null>): void {
if (t !== standIn) standIn = null;
held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : null;
};
// Using the stand-in makes it the reader's own focus: a click on it or a key typed into it fires
// no focusin, and a restore still polling for its list would otherwise take the focus from
// under the reader's hands.
const onUse = () => {
standIn = null;
};
const watch = new MutationObserver((records) => {
const lost = held;
if (!lost || lost.isConnected) return;
@@ -86,9 +92,13 @@ export function useFocusKeeper(ref: RefObject<HTMLElement | null>): void {
}
});
document.addEventListener("focusin", onFocusIn);
document.addEventListener("pointerdown", onUse, true);
document.addEventListener("keydown", onUse, true);
watch.observe(root, { childList: true, subtree: true });
return () => {
document.removeEventListener("focusin", onFocusIn);
document.removeEventListener("pointerdown", onUse, true);
document.removeEventListener("keydown", onUse, true);
watch.disconnect();
};
}, [ref]);