From 9338496e24e61d88b999fe77c6ee11213a36a17c Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 21 Oct 2026 22:44:44 +0200 Subject: [PATCH] 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. --- .../swarm-ui/src/ui/popover/Popover.tsx | 8 +- .../packages/swarm-ui/src/ui/popover/focus.ts | 18 ++-- .../packages/swarm-ui/src/ui/table/Table.tsx | 8 +- .../swarm-ui/test/popover-focus.test.mjs | 83 +++++++++++++++++++ 4 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 frontend/packages/swarm-ui/test/popover-focus.test.mjs diff --git a/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx index 5193b5d8..a08e4eba 100644 --- a/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx +++ b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx @@ -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]); diff --git a/frontend/packages/swarm-ui/src/ui/popover/focus.ts b/frontend/packages/swarm-ui/src/ui/popover/focus.ts index 98827f68..06c5c444 100644 --- a/frontend/packages/swarm-ui/src/ui/popover/focus.ts +++ b/frontend/packages/swarm-ui/src/ui/popover/focus.ts @@ -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(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 - * ``. 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, diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.tsx b/frontend/packages/swarm-ui/src/ui/table/Table.tsx index 8c82c6fa..30b3f8f0 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.tsx +++ b/frontend/packages/swarm-ui/src/ui/table/Table.tsx @@ -415,7 +415,7 @@ export function Table({ 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({ 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]); diff --git a/frontend/packages/swarm-ui/test/popover-focus.test.mjs b/frontend/packages/swarm-ui/test/popover-focus.test.mjs new file mode 100644 index 00000000..2cc45d2e --- /dev/null +++ b/frontend/packages/swarm-ui/test/popover-focus.test.mjs @@ -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); +});