Compare commits
1 Commits
1ecef406e2
...
feat/dock-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
996ba8716f |
@@ -176,6 +176,9 @@ export function AgentDock({
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
@@ -336,6 +339,7 @@ export function AgentDock({
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
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]"
|
||||
>
|
||||
@@ -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({
|
||||
label,
|
||||
dataAction,
|
||||
|
||||
Reference in New Issue
Block a user