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
1 changed files with 109 additions and 6 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue