From 4b9db1f9000f19305f4e24b571dbac41b7a9b4b4 Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Thu, 12 Nov 2026 14:38:31 +0100 Subject: [PATCH 1/2] fix: keep nested fixed targets focusable A null parent offsetParent does not establish that a fixed target is hidden. Use the target's rendered client rectangles and preserve existing visibility/accessibility checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/docs/core.md | 4 ++ src/Utils.ts | 6 +- tests/ObservedElement.test.tsx | 110 +++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/docs/docs/core.md b/docs/docs/core.md index 481e802d..868278ac 100644 --- a/docs/docs/core.md +++ b/docs/docs/core.md @@ -153,6 +153,10 @@ Finds and classifies focusable elements. This is the same engine Mover, Groupper, and Modalizer use internally to compute tabbing order, so it's reliable to build custom navigation on top of. +Visibility checks include fixed-position targets inside fixed-position or +`display: contents` containers. A container having no `offsetParent` does +not by itself make its descendants hidden. + ```ts interface FocusableAPI { getProps(element: HTMLElement): FocusableProps; diff --git a/src/Utils.ts b/src/Utils.ts index 4d8e1183..ee02b827 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -557,10 +557,8 @@ export function isDisplayNone(element: HTMLElement): boolean { return true; } - if ( - element.parentElement?.offsetParent === null && - elementDocument.body !== element.parentElement - ) { + // A visible fixed or display:contents parent can also have no offsetParent. + if (element.getClientRects().length === 0) { return true; } } diff --git a/tests/ObservedElement.test.tsx b/tests/ObservedElement.test.tsx index 1a766dff..a2606181 100644 --- a/tests/ObservedElement.test.tsx +++ b/tests/ObservedElement.test.tsx @@ -17,6 +17,116 @@ describe("Focusable", () => { await BroTest.bootstrapTabsterPage({ observed: true }); }); + it.each([ + ["button", "block"], + ["input", "block"], + ["div", "block"], + ["button", "contents"], + ])( + "should focus a visible fixed %s inside a fixed parent with display %s", + async (tag, display) => { + await new BroTest.BroTest( +
+ +
+ {React.createElement( + tag, + { + id: "target", + tabIndex: -1, + style: { position: "fixed" }, + ...getTabsterAttribute({ + observed: { names: ["target"] }, + }), + }, + tag === "input" ? undefined : "Target" + )} +
+
+ ) + .eval(async () => { + const { observedElement, dom } = getTabsterTestVariables(); + const start = dom?.getElementById(document, "start"); + const target = dom?.getElementById(document, "target"); + if (!observedElement || !start || !target) { + throw new Error("Missing observed test setup"); + } + + let nativeFocused = false; + target.addEventListener( + "focus", + () => { + nativeFocused = true; + }, + { once: true } + ); + target.focus(); + start.focus(); + + const request = observedElement.requestFocus("target", 500); + return { + nativeFocused, + result: await request.result, + status: request.status, + }; + }) + .check(({ nativeFocused, result, status }) => { + expect(nativeFocused).toBe(true); + expect(result).toBe(true); + expect(status).toBe( + ObservedElementRequestStatuses.Succeeded + ); + }) + .activeElement((el) => { + expect(el?.attributes.id).toBe("target"); + }); + } + ); + + it("should wait for a hidden fixed parent to become visible", async () => { + await new BroTest.BroTest( +
+
+ +
+
+ ) + .eval(async () => { + const { observedElement, dom } = getTabsterTestVariables(); + const parent = dom?.getElementById(document, "parent"); + if (!observedElement || !parent) { + throw new Error("Missing observed test setup"); + } + + const request = observedElement.requestFocus("target", 1000); + const initialStatus = request.status; + parent.style.display = "block"; + return { + initialStatus, + result: await request.result, + status: request.status, + }; + }) + .check(({ initialStatus, result, status }) => { + expect(initialStatus).toBe( + ObservedElementRequestStatuses.Waiting + ); + expect(result).toBe(true); + expect(status).toBe(ObservedElementRequestStatuses.Succeeded); + }) + .activeElement((el) => { + expect(el?.attributes.id).toBe("target"); + }); + }); + it("should forward focus options", async () => { const name = "test"; const focusOptions: Types.TabsterFocusOptions = { From 89ebbb74582455d5bd4a36b53e1f463a1ffe2a00 Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Thu, 12 Nov 2026 15:20:36 +0100 Subject: [PATCH 2/2] docs: remove visibility note from focusability fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/docs/core.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/docs/core.md b/docs/docs/core.md index 868278ac..481e802d 100644 --- a/docs/docs/core.md +++ b/docs/docs/core.md @@ -153,10 +153,6 @@ Finds and classifies focusable elements. This is the same engine Mover, Groupper, and Modalizer use internally to compute tabbing order, so it's reliable to build custom navigation on top of. -Visibility checks include fixed-position targets inside fixed-position or -`display: contents` containers. A container having no `offsetParent` does -not by itself make its descendants hidden. - ```ts interface FocusableAPI { getProps(element: HTMLElement): FocusableProps;