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:
parent
bf9d07aedc
commit
d4f58b3667
4 changed files with 109 additions and 6 deletions
|
|
@ -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:
|
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
|
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
|
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
|
agent, or link an account, without leaving the terminal. A subagent's tab opens
|
||||||
its agent's details.
|
its agent's details.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,21 @@
|
||||||
// A modal `<dialog>` opened from inside it (a confirm, a form) is not
|
// 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
|
// "outside": a click in an open dialog, or Escape while one is open, goes to
|
||||||
// that dialog and leaves the popover alone.
|
// 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 { createPortal } from "preact/compat";
|
||||||
import type { ComponentChildren, RefObject } from "preact";
|
import type { ComponentChildren, RefObject } from "preact";
|
||||||
|
import { focusInto, restoreFocus, triggerOf } from "./focus.js";
|
||||||
import "./Popover.css";
|
import "./Popover.css";
|
||||||
|
|
||||||
// Space kept between the popover and the viewport's edges.
|
// Space kept between the popover and the viewport's edges.
|
||||||
|
|
@ -40,6 +52,33 @@ export function Popover({
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(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
|
// Placed from the anchor on open, and again on scroll and resize, so it
|
||||||
// follows the anchor instead of floating where it first appeared.
|
// follows the anchor instead of floating where it first appeared.
|
||||||
|
|
@ -102,8 +141,10 @@ export function Popover({
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
class="ui-popover"
|
class="ui-popover"
|
||||||
|
id={id}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
tabIndex={-1}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={
|
style={
|
||||||
pos
|
pos
|
||||||
|
|
|
||||||
34
frontend/packages/swarm-ui/src/ui/popover/focus.ts
Normal file
34
frontend/packages/swarm-ui/src/ui/popover/focus.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
// cramped.
|
// cramped.
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
|
@ -21,6 +22,7 @@ import type { ComponentChildren } from "preact";
|
||||||
import { FilterIcon } from "@hive/shared/icons.js";
|
import { FilterIcon } from "@hive/shared/icons.js";
|
||||||
import { useLocalSetting } from "@hive/shared/settings-storage.js";
|
import { useLocalSetting } from "@hive/shared/settings-storage.js";
|
||||||
import { MultiselectFilterOptions } from "../multiselect-filter/MultiselectFilter.js";
|
import { MultiselectFilterOptions } from "../multiselect-filter/MultiselectFilter.js";
|
||||||
|
import { focusInto, restoreFocus } from "../popover/focus.js";
|
||||||
import { clampLeft } from "./popover-position.js";
|
import { clampLeft } from "./popover-position.js";
|
||||||
import "./Table.css";
|
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
|
// 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
|
// per-column ref map) is enough to focus whichever control just
|
||||||
// mounted — typing immediately after the click that opened it,
|
// mounted — typing immediately after the click that opened it,
|
||||||
// without a separate click into the field first. (The multiselect
|
// without a separate click into the field first. The multiselect
|
||||||
// checkbox list below doesn't use this — there's no single "the"
|
// checkbox list has no single "the" control, so focus goes to its first
|
||||||
// control to focus in a list of several.)
|
// checkbox, which keeps the popover reachable from the keyboard.
|
||||||
const popoverControlRef = useRef<HTMLInputElement | null>(null);
|
const popoverControlRef = useRef<HTMLInputElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openFilterKey !== null) popoverControlRef.current?.focus();
|
if (openFilterKey === null) return;
|
||||||
|
popoverControlRef.current?.focus();
|
||||||
|
focusInto(popoverRef.current);
|
||||||
}, [openFilterKey]);
|
}, [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 {
|
function filterLabel(c: TableColumn<T>): string {
|
||||||
return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
|
return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
|
||||||
}
|
}
|
||||||
|
|
@ -536,7 +554,13 @@ export function Table<T>({
|
||||||
: "")
|
: "")
|
||||||
}
|
}
|
||||||
aria-label={filterLabel(c)}
|
aria-label={filterLabel(c)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
aria-expanded={filterOpen}
|
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={() => {
|
onClick={() => {
|
||||||
setOpenFilterKey((k) => {
|
setOpenFilterKey((k) => {
|
||||||
const next = k === c.key ? null : c.key;
|
const next = k === c.key ? null : c.key;
|
||||||
|
|
@ -591,8 +615,10 @@ export function Table<T>({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="ui-table-filter-popover"
|
class="ui-table-filter-popover"
|
||||||
|
id={popoverId}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={filterLabel(openColumn)}
|
aria-label={filterLabel(openColumn)}
|
||||||
|
tabIndex={-1}
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue