swarm-ui: unit test the popover focus helpers
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.
This commit is contained in:
parent
d4f58b3667
commit
9338496e24
4 changed files with 107 additions and 10 deletions
|
|
@ -69,14 +69,18 @@ export function Popover({
|
|||
// to the trigger after a close that left focus on nothing.
|
||||
const placed = pos !== null;
|
||||
useEffect(() => {
|
||||
if (open && placed) focusInto(ref.current);
|
||||
if (open && placed) focusInto(ref.current, document.activeElement);
|
||||
}, [open, placed]);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true;
|
||||
} else if (wasOpen.current) {
|
||||
wasOpen.current = false;
|
||||
restoreFocus(triggerOf(anchorRef.current));
|
||||
restoreFocus(
|
||||
triggerOf(anchorRef.current),
|
||||
document.activeElement,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
}, [open, anchorRef]);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,18 +10,24 @@ export const FOCUSABLE =
|
|||
/** 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;
|
||||
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
|
||||
* `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();
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ export function Table<T>({
|
|||
useEffect(() => {
|
||||
if (openFilterKey === null) return;
|
||||
popoverControlRef.current?.focus();
|
||||
focusInto(popoverRef.current);
|
||||
focusInto(popoverRef.current, document.activeElement);
|
||||
}, [openFilterKey]);
|
||||
|
||||
// After a close that left focus on nothing (Escape, or the control that
|
||||
|
|
@ -426,7 +426,11 @@ export function Table<T>({
|
|||
if (openFilterKey !== null) {
|
||||
lastOpenKey.current = openFilterKey;
|
||||
} else if (lastOpenKey.current !== null) {
|
||||
restoreFocus(filterBtnRefs.current.get(lastOpenKey.current) ?? null);
|
||||
restoreFocus(
|
||||
filterBtnRefs.current.get(lastOpenKey.current) ?? null,
|
||||
document.activeElement,
|
||||
document.body,
|
||||
);
|
||||
lastOpenKey.current = null;
|
||||
}
|
||||
}, [openFilterKey]);
|
||||
|
|
|
|||
83
frontend/packages/swarm-ui/test/popover-focus.test.mjs
Normal file
83
frontend/packages/swarm-ui/test/popover-focus.test.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { focusInto, restoreFocus, triggerOf } from "../src/ui/popover/focus.ts";
|
||||
|
||||
// Stand-ins for DOM elements: only the members the helpers call.
|
||||
function el({ contains = () => false, first = null, matches = false } = {}) {
|
||||
const calls = { focus: 0 };
|
||||
return {
|
||||
calls,
|
||||
focus() {
|
||||
calls.focus += 1;
|
||||
},
|
||||
contains,
|
||||
matches: () => matches,
|
||||
querySelector: () => first,
|
||||
};
|
||||
}
|
||||
|
||||
test("focusInto: focuses the first control in the box", () => {
|
||||
const control = el();
|
||||
const box = el({ first: control });
|
||||
focusInto(box, null);
|
||||
assert.equal(control.calls.focus, 1);
|
||||
assert.equal(box.calls.focus, 0);
|
||||
});
|
||||
|
||||
test("focusInto: focuses the box when it has no control", () => {
|
||||
const box = el();
|
||||
focusInto(box, null);
|
||||
assert.equal(box.calls.focus, 1);
|
||||
});
|
||||
|
||||
test("focusInto: keeps focus a control inside the box took itself", () => {
|
||||
const inner = el();
|
||||
const control = el();
|
||||
const box = el({ first: control, contains: (n) => n === inner });
|
||||
focusInto(box, inner);
|
||||
assert.equal(control.calls.focus, 0);
|
||||
assert.equal(box.calls.focus, 0);
|
||||
});
|
||||
|
||||
test("focusInto: a missing box does nothing", () => {
|
||||
focusInto(null, null);
|
||||
});
|
||||
|
||||
test("restoreFocus: focus on body goes back to the trigger", () => {
|
||||
const body = el();
|
||||
const trigger = el();
|
||||
restoreFocus(trigger, body, body);
|
||||
assert.equal(trigger.calls.focus, 1);
|
||||
});
|
||||
|
||||
test("restoreFocus: no focused element goes back to the trigger", () => {
|
||||
const trigger = el();
|
||||
restoreFocus(trigger, null, el());
|
||||
assert.equal(trigger.calls.focus, 1);
|
||||
});
|
||||
|
||||
test("restoreFocus: another focused control keeps focus", () => {
|
||||
const trigger = el();
|
||||
restoreFocus(trigger, el(), el());
|
||||
assert.equal(trigger.calls.focus, 0);
|
||||
});
|
||||
|
||||
test("restoreFocus: a missing trigger does nothing", () => {
|
||||
const body = el();
|
||||
restoreFocus(null, body, body);
|
||||
});
|
||||
|
||||
test("triggerOf: the anchor itself when Tab reaches it", () => {
|
||||
const anchor = el({ matches: true, first: el() });
|
||||
assert.equal(triggerOf(anchor), anchor);
|
||||
});
|
||||
|
||||
test("triggerOf: the first control inside a wrapper", () => {
|
||||
const inner = el();
|
||||
const anchor = el({ first: inner });
|
||||
assert.equal(triggerOf(anchor), inner);
|
||||
});
|
||||
|
||||
test("triggerOf: a missing anchor has no trigger", () => {
|
||||
assert.equal(triggerOf(null), null);
|
||||
});
|
||||
Loading…
Reference in a new issue