a11y: real modal — focus trap, Tab cycle, Escape, focus restore
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user