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
2 changed files with 109 additions and 6 deletions

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);
}