Compare commits

...

1 Commits

Author SHA1 Message Date
jules
996ba8716f a11y: real modal — focus trap, Tab cycle, Escape, focus restore
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:45:53 +10:00

View File

@@ -176,6 +176,9 @@ export function AgentDock({
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
// The slide-over panel — target for the focus trap while `open`.
const panelRef = useRef<HTMLDivElement | null>(null);
useFocusTrap(open, panelRef, () => setOpen(false));
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -336,6 +339,7 @@ export function AgentDock({
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
/> />
<div <div
ref={panelRef}
data-slot="sheet-content" data-slot="sheet-content"
className="absolute right-0 top-0 flex h-full w-full flex-col bg-[var(--background)] shadow-2xl sm:w-[480px] md:w-[560px]" className="absolute right-0 top-0 flex h-full w-full flex-col bg-[var(--background)] shadow-2xl sm:w-[480px] md:w-[560px]"
> >
@@ -523,6 +527,89 @@ export function AgentDock({
); );
} }
/* ------------------------------------------------------------------ */
/* Focus management */
/* ------------------------------------------------------------------ */
/* Descendants that can receive keyboard focus. Kept in sync with the
* focus-trap's Tab cycling below. */
const FOCUSABLE_SELECTOR =
'button, [href], input, textarea, select, [tabindex]:not([tabindex="-1"])';
/** Small self-contained focus trap for a modal container — no external
* dependency, so the dock stays generic. While `active`, it moves focus
* into `containerRef`, keeps Tab / Shift+Tab cycling inside it, routes
* Escape to `onEscape`, and restores focus to whatever was focused before
* activation once the panel closes/unmounts. */
function useFocusTrap(
active: boolean,
containerRef: React.RefObject<HTMLElement | null>,
onEscape: () => void,
) {
// Track the latest onEscape without re-running the effect each render.
const onEscapeRef = useRef(onEscape);
onEscapeRef.current = onEscape;
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
// Remember where focus was so we can hand it back on close.
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((el) => !el.hasAttribute("disabled"));
// Move focus into the panel — first focusable, or the container itself.
const first = focusables()[0];
if (first) {
first.focus();
} else {
container.tabIndex = -1;
container.focus();
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscapeRef.current();
return;
}
if (e.key !== "Tab") return;
const items = focusables();
if (items.length === 0) {
// Nothing to cycle — keep focus pinned inside the panel.
e.preventDefault();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
const activeEl = document.activeElement;
if (e.shiftKey) {
// Shift+Tab at the first (or focus escaped the panel) → wrap to last.
if (activeEl === firstEl || !container.contains(activeEl)) {
e.preventDefault();
lastEl.focus();
}
} else if (activeEl === lastEl || !container.contains(activeEl)) {
// Tab at the last (or focus escaped the panel) → wrap to first.
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
// Restore focus to the trigger that opened the panel.
previouslyFocused?.focus?.();
};
}, [active, containerRef]);
}
function IconButton({ function IconButton({
label, label,
dataAction, dataAction,