fix(side-panel): the key's focus steps into the peeked card in the commit that opens it

A frame later never came in a page the browser was not painting (measured: a hidden page runs no
requestAnimationFrame), and the focus stayed on the inert tab under the card.
This commit is contained in:
2026-09-27 19:58:18 +02:00
parent 740602b9bf
commit a9ac303d3e
+13 -10
View File
@@ -1,4 +1,4 @@
import { useRef, useState, type ReactNode } from "react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
@@ -65,6 +65,7 @@ export default function SidePanel({
}) {
const { t } = useTranslation();
const cardRef = useRef<HTMLElement>(null);
const tabRef = useRef<HTMLButtonElement>(null);
const fade = useScrollFade();
const peek = useHoverFocusWithin();
const drawerMode = useMediaQuery(DRAWER_QUERY, false);
@@ -77,6 +78,16 @@ export default function SidePanel({
// Turning the tablet to a width where the panel is a card again, or pinning it, ends the drawer —
// adjusted during render, so it cannot come back open on the next turn.
if (drawerOpen && (!drawerMode || pinned)) setDrawerOpen(false);
// A key landing on the tab peeks the card open over it, and the tab then goes inert below — with
// the focus still on it: a hidden control, out of the tree, that Enter still pinned (round 2, F1).
// The focus steps into the card as it opens, onto its pin: the same control. In the commit that
// mounts the card, not a frame later — a frame never comes in a page the browser is not painting.
useLayoutEffect(() => {
const tab = tabRef.current;
if (!expanded || pinned || drawerMode || !tab || document.activeElement !== tab) return;
if (tab.matches(":focus-visible"))
cardRef.current?.querySelector<HTMLElement>("button")?.focus();
}, [expanded, pinned, drawerMode]);
const titleNode = onTitleClick ? (
<Pressable
@@ -160,15 +171,7 @@ export default function SidePanel({
);
}
}
// A key landing on the tab peeks the card open over it, and the tab then goes inert below —
// with the focus still on it: a hidden control, out of the tree, that Enter still pinned
// (round 2, F1). The focus steps into the card instead, onto its pin: the same control.
onFocus={(e) => {
if (drawerMode || pinned || !e.currentTarget.matches(":focus-visible")) return;
requestAnimationFrame(() =>
cardRef.current?.querySelector<HTMLElement>("button")?.focus(),
);
}}
ref={tabRef}
// Under an open card the tab is transparent and inert: out of the Tab order AND of the
// accessibility tree, where it would repeat the card's own pin button by name.
tabIndex={expanded ? -1 : undefined}