diff --git a/frontend/src/lib/useFocusKeeper.dom.test.tsx b/frontend/src/lib/useFocusKeeper.dom.test.tsx
index 5602a22e..e91f79e0 100644
--- a/frontend/src/lib/useFocusKeeper.dom.test.tsx
+++ b/frontend/src/lib/useFocusKeeper.dom.test.tsx
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { useRef, useState } from "react";
-import { useFocusKeeper } from "./useFocusKeeper";
+import { focusNeighbour, useFocusKeeper } from "./useFocusKeeper";
afterEach(cleanup);
@@ -77,3 +77,30 @@ describe("useFocusKeeper", () => {
expect(document.activeElement).toBe(document.body);
});
});
+
+describe("focusNeighbour", () => {
+ // E28 F2: the neighbour of the hidden-filter cue's chip is the table's first header button — the
+ // hidden column's own, `display: none`, which refuses focus(): the focus fell to
. jsdom paints
+ // nothing, so `checkVisibility` is stood in for, answering for the one hidden button.
+ it("passes over a focusable the CSS has taken off screen", () => {
+ render(
+
+
+
+
+
+
+
,
+ );
+ Object.defineProperty(HTMLElement.prototype, "checkVisibility", {
+ configurable: true,
+ value(this: HTMLElement) {
+ return !this.hasAttribute("data-off");
+ },
+ });
+ btn("chip").focus();
+ expect(focusNeighbour(btn("chip"))).toBe(true);
+ expect(document.activeElement).toBe(btn("shown header"));
+ delete (HTMLElement.prototype as Partial).checkVisibility;
+ });
+});
diff --git a/frontend/src/lib/useFocusKeeper.ts b/frontend/src/lib/useFocusKeeper.ts
index 48c8a339..9f915742 100644
--- a/frontend/src/lib/useFocusKeeper.ts
+++ b/frontend/src/lib/useFocusKeeper.ts
@@ -15,12 +15,17 @@ import { useEffect, type RefObject } from "react";
const FOCUSABLE =
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+// A focusable element the CSS has taken off screen (a `tabletHidden` column's header button) refuses
+// `focus()`, and the focus it was meant to catch fell to (E28 F2, measured in Chrome). An engine
+// without `checkVisibility` (jsdom) paints nothing, so there everything counts as shown.
+const shown = (el: HTMLElement) => !el.checkVisibility || el.checkVisibility();
+
function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null {
let n: Node | null = node;
while (n) {
if (n instanceof HTMLElement) {
- if (n.matches(FOCUSABLE)) return n;
- const all = n.querySelectorAll(FOCUSABLE);
+ if (n.matches(FOCUSABLE) && shown(n)) return n;
+ const all = [...n.querySelectorAll(FOCUSABLE)].filter(shown);
const hit = fromEnd ? all[all.length - 1] : all[0];
if (hit) return hit;
}