diff --git a/js/ui/focusTrapStacking.js b/js/ui/focusTrapStacking.js new file mode 100644 index 00000000..af29381b --- /dev/null +++ b/js/ui/focusTrapStacking.js @@ -0,0 +1,13 @@ +// A focus trap (e.g. the profile modal's) must YIELD focus to any modal stacked on +// top of it — otherwise it yanks focus back and the stacked modal's fields (like the +// login modal's nsec passphrase/PIN input) can't be clicked or typed into. +// +// Returns true when the focus target lives inside a DIFFERENT open `.bv-modal` than the +// trapping modal — i.e. the trap should leave focus alone. +export function isInStackedModal(target, modalRoot) { + if (!target || typeof target.closest !== "function") { + return false; + } + const stacked = target.closest(".bv-modal:not(.hidden)"); + return Boolean(stacked && stacked !== modalRoot); +} diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index d44ed442..e5c293f2 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -51,6 +51,7 @@ import { ProfileAdminController } from "./profileModal/ProfileAdminController.js import { ProfileModerationController } from "./profileModal/ProfileModerationController.js"; import { ProfileBlockListController } from "./profileModal/ProfileBlockListController.js"; import { ProfileEditController } from "./profileModal/ProfileEditController.js"; +import { isInStackedModal } from "./focusTrapStacking.js"; const noop = () => {}; @@ -4670,6 +4671,12 @@ export class ProfileModalController { return; } + // Don't fight a modal stacked on top of the profile modal (e.g. the login + // modal's nsec passphrase/PIN field) — let it own focus so it stays typable. + if (isInStackedModal(event.target, modalRoot)) { + return; + } + const fallback = this.focusableElements[0] || this.profileModalPanel || diff --git a/tests/focus-trap-stacking.test.mjs b/tests/focus-trap-stacking.test.mjs new file mode 100644 index 00000000..414a4c3a --- /dev/null +++ b/tests/focus-trap-stacking.test.mjs @@ -0,0 +1,57 @@ +// Bug: opening the login modal's nsec passphrase/PIN field on top of the profile modal +// left it unclickable/untypable — the profile modal's focusin trap yanked focus back +// because it didn't recognize the stacked login modal. isInStackedModal() is the guard +// that makes the trap yield to a modal stacked on top. + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { isInStackedModal } from "../js/ui/focusTrapStacking.js"; + +const dom = new JSDOM(`
+