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; }