diff --git a/frontend/src/components/PaletteLauncher.dom.test.tsx b/frontend/src/components/PaletteLauncher.dom.test.tsx
index 05c7dc29..d2b0e067 100644
--- a/frontend/src/components/PaletteLauncher.dom.test.tsx
+++ b/frontend/src/components/PaletteLauncher.dom.test.tsx
@@ -57,6 +57,8 @@ describe("the command palette's visible entry", () => {
const cap = screen.getByRole("button", { name: /^palette\.open \((Ctrl|⌘) K\)$/ });
// Label in Name: the name carries what the cap shows, so "click Ctrl K" finds it.
expect(cap.getAttribute("aria-label")).toContain(cap.textContent);
+ // …and a tooltip equal to that name, so a screen reader does not read it again as a description.
+ expect(cap.getAttribute("title")).toBe(cap.getAttribute("aria-label"));
expect(screen.getByRole("textbox")).toBeTruthy();
fireEvent.click(cap);
expect(isOpen()).toBe(true);
@@ -66,8 +68,8 @@ describe("the command palette's visible entry", () => {
page = "home";
const isOpen = mount();
expect(screen.queryByRole("textbox")).toBeNull();
- // Named by what it shows ("Jump to…"), not by a label that replaced it.
- const pill = screen.getByRole("button", { name: "palette.title" });
+ // Named by what it shows ("Jump to…" and its key cap), not by a label that replaced it.
+ const pill = screen.getByRole("button", { name: /^palette\.title ?(Ctrl|⌘) K$/ });
expect(pill.getAttribute("title")).toBe("palette.open");
fireEvent.click(pill);
expect(isOpen()).toBe(true);
diff --git a/frontend/src/components/PaletteLauncher.tsx b/frontend/src/components/PaletteLauncher.tsx
index ce4cefbd..b1f1a206 100644
--- a/frontend/src/components/PaletteLauncher.tsx
+++ b/frontend/src/components/PaletteLauncher.tsx
@@ -34,7 +34,7 @@ export function PaletteKeycap() {
type="button"
onClick={openPalette}
aria-label={`${t("palette.open")} (${cap})`}
- title={t("palette.open")}
+ title={`${t("palette.open")} (${cap})`}
className="shrink-0 rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
>
{cap}
@@ -55,10 +55,7 @@ export function PaletteLauncher() {
>
{t("palette.title")}
-
+
{cap}
diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts
index d9e4a53e..cac5c29a 100644
--- a/frontend/src/lib/listNav.ts
+++ b/frontend/src/lib/listNav.ts
@@ -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,
diff --git a/frontend/src/lib/useFocusKeeper.restore.dom.test.tsx b/frontend/src/lib/useFocusKeeper.restore.dom.test.tsx
index 3476c4af..e9fef60d 100644
--- a/frontend/src/lib/useFocusKeeper.restore.dom.test.tsx
+++ b/frontend/src/lib/useFocusKeeper.restore.dom.test.tsx
@@ -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();
+ 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);
+ });
});
diff --git a/frontend/src/lib/useFocusKeeper.ts b/frontend/src/lib/useFocusKeeper.ts
index bf4b9ec8..4a08de02 100644
--- a/frontend/src/lib/useFocusKeeper.ts
+++ b/frontend/src/lib/useFocusKeeper.ts
@@ -70,6 +70,12 @@ export function useFocusKeeper(ref: RefObject): 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): 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]);