The helpers take the active element and body as arguments, so the cases (focus into the box, keep a control that took focus, restore only when focus was lost, find the trigger) run under node's test runner.
40 lines
1.5 KiB
TypeScript
40 lines
1.5 KiB
TypeScript
// 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,
|
|
active: Element | null,
|
|
): void {
|
|
if (!box || box.contains(active)) 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,
|
|
active: Element | null,
|
|
body: Element,
|
|
): void {
|
|
if (!active || active === 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);
|
|
}
|