diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 568af3d7..d0eb77a6 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -70,7 +70,9 @@ The tab also has an agent badge, with the agent's icon and name, ahead of the connection badge. It opens the agent's details in a popover under the badge: the same status, wanted state, inference settings, resource caps and linked accounts as the panel on `/agents`, without the terminal preview. The popover doesn't dim the -page. Selecting outside it, or Escape, closes it. You can stop or pause the +page. Selecting outside it, or Escape, closes it. From the keyboard, opening a +popover moves focus into it, and Escape puts focus back on the badge. The filter +popovers on table headers work the same way. You can stop or pause the agent, or link an account, without leaving the terminal. A subagent's tab opens its agent's details. diff --git a/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx index 1f86b735..5193b5d8 100644 --- a/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx +++ b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx @@ -13,9 +13,21 @@ // A modal `` opened from inside it (a confirm, a form) is not // "outside": a click in an open dialog, or Escape while one is open, goes to // that dialog and leaves the popover alone. -import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +// +// Keyboard: focus moves into the box once it is placed, and back to the +// trigger when it closes and focus was lost. The trigger is the anchor itself +// when Tab reaches it, else the first focusable control inside it; it gets +// `aria-haspopup` and, while open, `aria-controls`. +import { + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "preact/hooks"; import { createPortal } from "preact/compat"; import type { ComponentChildren, RefObject } from "preact"; +import { focusInto, restoreFocus, triggerOf } from "./focus.js"; import "./Popover.css"; // Space kept between the popover and the viewport's edges. @@ -40,6 +52,33 @@ export function Popover({ }) { const ref = useRef(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const id = useId(); + const wasOpen = useRef(false); + + // Tells assistive tech that the trigger opens a dialog, and which one. + useEffect(() => { + const trigger = triggerOf(anchorRef.current); + if (!trigger) return; + trigger.setAttribute("aria-haspopup", "dialog"); + if (!open) return; + trigger.setAttribute("aria-controls", id); + return () => trigger.removeAttribute("aria-controls"); + }, [open, anchorRef, id]); + + // Into the box once it is placed (a hidden box cannot take focus), and back + // to the trigger after a close that left focus on nothing. + const placed = pos !== null; + useEffect(() => { + if (open && placed) focusInto(ref.current); + }, [open, placed]); + useEffect(() => { + if (open) { + wasOpen.current = true; + } else if (wasOpen.current) { + wasOpen.current = false; + restoreFocus(triggerOf(anchorRef.current)); + } + }, [open, anchorRef]); // Placed from the anchor on open, and again on scroll and resize, so it // follows the anchor instead of floating where it first appeared. @@ -102,8 +141,10 @@ export function Popover({ return createPortal(