From d4f58b3667fce9828c24efec6fe93a69bb399cd2 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 21 Oct 2026 22:42:05 +0200 Subject: [PATCH] swarm-ui: make popovers and table filters usable from the keyboard Opening a popover or a table filter now moves focus into it, closing one that left focus on nothing puts it back on the trigger, and the triggers carry aria-haspopup and aria-controls. The shared Popover covers every caller; the table filter popover, which has its own portal, does the same. --- docs/swarm/ui.md | 4 +- .../swarm-ui/src/ui/popover/Popover.tsx | 43 ++++++++++++++++++- .../packages/swarm-ui/src/ui/popover/focus.ts | 34 +++++++++++++++ .../packages/swarm-ui/src/ui/table/Table.tsx | 34 +++++++++++++-- 4 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/popover/focus.ts 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(