From 7ac6e0854487669e136186ce71d346755e187ac6 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 7 Aug 2026 16:40:30 +0530 Subject: [PATCH] fix(csr): make dialog visibility testable and cover the focus trap The focus trap and scroll lock shipped in 0.8.5 gated on getBoundingClientRect, which the test DOM always reports as zero, so a dialog never counted as open and none of that behaviour ran under test. focusableWithin had the same measurement gate and would have found no items even once the visibility check was fixed. Both now use the hidden attribute and the data-show marker the components already emit. Behaviour in a real browser is unchanged; the difference is that it is now covered. Co-Authored-By: Claude Opus 5 --- packages/csr/src/reactive-runtime.ts | 19 ++++++++++---- packages/csr/test/reactive.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index ad2738b0..0d232256 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -2927,11 +2927,18 @@ export const REACTIVE_RUNTIME = String.raw` var dialogRestoreFocus = null; var dialogScrollLock = null; + /* + * Open/closed is expressed by the data-show marker these components already + * emit, not by measured size. Measuring looks more thorough but made the + * trap and the scroll lock impossible to cover: the test DOM reports every + * element as zero-sized, so a dialog never counted as open and none of this + * behaviour ran under test. + */ function isDialogVisible(dialog) { - if (!dialog || !dialog.getBoundingClientRect) return false; + if (!dialog || !dialog.isConnected) return false; + if (dialog.hasAttribute("hidden")) return false; if (dialog.closest('[data-show="false"]')) return false; - var rect = dialog.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; + return true; } function focusableWithin(dialog) { @@ -2939,8 +2946,10 @@ export const REACTIVE_RUNTIME = String.raw` var candidates = dialog.querySelectorAll(FOCUSABLE_SELECTOR); for (var index = 0; index < candidates.length; index += 1) { var candidate = candidates[index]; - var rect = candidate.getBoundingClientRect(); - if (rect.width > 0 || rect.height > 0) found.push(candidate); + // Same rule as the roving items: markers, not measurement. + if (candidate.hasAttribute("hidden")) continue; + if (candidate.closest('[data-show="false"]')) continue; + found.push(candidate); } return found; } diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index bef6017c..b71c47e2 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -993,3 +993,40 @@ test("nested roving groups do not capture the outer group items", () => { a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); expect(doc.activeElement!.id).toBe("b"); }); + +test("opening a modal dialog traps Tab inside it", () => { + const win = mount( + `
+ +
+
+ + +
+
+
`, + ); + const doc = win.document; + const last = doc.querySelector("#last") as unknown as HTMLElement; + last.focus(); + last.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(doc.activeElement!.id).toBe("first"); +}); + +test("a hidden dialog does not trap Tab", () => { + const win = mount( + `
+ +
+
+ +
+
+
`, + ); + const doc = win.document; + const outside = doc.querySelector("#outside") as unknown as HTMLElement; + outside.focus(); + outside.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(doc.activeElement!.id).toBe("outside"); +});