Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-21 22:42:05 +02:00 • committed by mara
commit d4f58b3667
4 changed files with 109 additions and 6 deletions

View file

@ -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.

View file

@ -13,9 +13,21 @@
// A modal `<dialog>` 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<HTMLDivElement>(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(
<div
class="ui-popover"
id={id}
role="dialog"
aria-label={label}
tabIndex={-1}
ref={ref}
style={
pos

View file

@ -0,0 +1,34 @@
// Keyboard focus helpers shared by `Popover` and the table's filter popovers,
// which both render into a portal at the end of `<body>`. Tab from the trigger
// would otherwise skip past the box, and closing it would leave focus on
// nothing.
/** What Tab can reach. */
export const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** Move focus into `box`, unless it is already there: onto its first
* focusable control, or onto the box itself when it has none. A control that
* took focus on its own (a text field) keeps it. */
export function focusInto(box: HTMLElement | null): void {
if (!box || box.contains(document.activeElement)) return;
(box.querySelector<HTMLElement>(FOCUSABLE) ?? box).focus();
}
/** After a close, put focus back on `trigger`, but only when the close left
* it on nothing: removing the focused control from the page leaves focus on
* `<body>`. A close from a click on another control leaves that control
* focused. */
export function restoreFocus(trigger: HTMLElement | null): void {
const active = document.activeElement;
if (!active || active === document.body) trigger?.focus();
}
/** The control that opens a popover: the anchor itself when Tab reaches it,
* else the first focusable control inside it. */
export function triggerOf(anchor: HTMLElement | null): HTMLElement | null {
if (!anchor) return null;
return anchor.matches(FOCUSABLE)
? anchor
: anchor.querySelector<HTMLElement>(FOCUSABLE);
}

View file

@ -11,6 +11,7 @@
// cramped.
import {
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
@ -21,6 +22,7 @@ import type { ComponentChildren } from "preact";
import { FilterIcon } from "@hive/shared/icons.js";
import { useLocalSetting } from "@hive/shared/settings-storage.js";
import { MultiselectFilterOptions } from "../multiselect-filter/MultiselectFilter.js";
import { focusInto, restoreFocus } from "../popover/focus.js";
import { clampLeft } from "./popover-position.js";
import "./Table.css";
@ -406,14 +408,30 @@ export function Table<T>({
// Only one popover is ever open at a time, so one ref (rather than a
// per-column ref map) is enough to focus whichever control just
// mounted — typing immediately after the click that opened it,
// without a separate click into the field first. (The multiselect
// checkbox list below doesn't use this — there's no single "the"
// control to focus in a list of several.)
// without a separate click into the field first. The multiselect
// checkbox list has no single "the" control, so focus goes to its first
// checkbox, which keeps the popover reachable from the keyboard.
const popoverControlRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (openFilterKey !== null) popoverControlRef.current?.focus();
if (openFilterKey === null) return;
popoverControlRef.current?.focus();
focusInto(popoverRef.current);
}, [openFilterKey]);
// After a close that left focus on nothing (Escape, or the control that
// held it going away), focus returns to the icon that opened the popover.
const filterBtnRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const lastOpenKey = useRef<string | null>(null);
useEffect(() => {
if (openFilterKey !== null) {
lastOpenKey.current = openFilterKey;
} else if (lastOpenKey.current !== null) {
restoreFocus(filterBtnRefs.current.get(lastOpenKey.current) ?? null);
lastOpenKey.current = null;
}
}, [openFilterKey]);
const popoverId = useId();
function filterLabel(c: TableColumn<T>): string {
return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
}
@ -536,7 +554,13 @@ export function Table<T>({
: "")
}
aria-label={filterLabel(c)}
aria-haspopup="dialog"
aria-expanded={filterOpen}
aria-controls={filterOpen ? popoverId : undefined}
ref={(el) => {
if (el) filterBtnRefs.current.set(c.key, el);
else filterBtnRefs.current.delete(c.key);
}}
onClick={() => {
setOpenFilterKey((k) => {
const next = k === c.key ? null : c.key;
@ -591,8 +615,10 @@ export function Table<T>({
return (
<div
class="ui-table-filter-popover"
id={popoverId}
role="dialog"
aria-label={filterLabel(openColumn)}
tabIndex={-1}
ref={popoverRef}
style={{
position: "fixed",