[ui] fix: nsec PIN/passphrase field unclickable when login opens over the profile modal

Opening the login modal's unlock-saved-key (nsec passphrase/PIN) field on top of the
profile modal left it unclickable/untypable. The profile modal's document-level focusin
trap yanks focus back to itself whenever focus leaves the modal — but it didn't
recognize a modal STACKED on top (the login modal is also a .bv-modal), so it stole
focus away from the PIN field the moment it was clicked.

Fix: the focusin trap now yields when the focus target is inside a different OPEN
.bv-modal (isInStackedModal, extracted to js/ui/focusTrapStacking.js so it's unit-
testable without constructing the whole controller). The stacked login modal keeps focus
and the field is typable again. This also makes the trap robust for any future stacked
modal, not just the #36 re-unlock flow.

Test: tests/focus-trap-stacking.test.mjs (5 — yields to an open stacked modal's field;
does not yield within the trapping modal, to a hidden modal, to a bare target, or on a
null/non-element). Build + lint clean; profile-modal suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 15:50:27 -04:00
1 parent 5afd602d52
commit ffeafa90d5
3 files changed
+77

No files matched your search

+13
View File
@@ -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);
}
+7
View File
@@ -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 ||
+57
View File
@@ -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(`<!doctype html><html><body>
<div id="profileModal" class="bv-modal">
<input id="profileInput" />
</div>
<div id="loginModal" class="bv-modal">
<input id="pinInput" />
</div>
<div id="closedModal" class="bv-modal hidden">
<input id="closedInput" />
</div>
<input id="bareInput" />
</body></html>`);
const { document } = dom.window;
const profileModal = document.getElementById("profileModal");
test("yields focus to an input inside a different OPEN modal (the login PIN field)", () => {
assert.equal(
isInStackedModal(document.getElementById("pinInput"), profileModal),
true,
);
});
test("does NOT yield for focus moving inside the trapping modal itself", () => {
assert.equal(
isInStackedModal(document.getElementById("profileInput"), profileModal),
false,
);
});
test("does NOT yield to a HIDDEN modal's field", () => {
assert.equal(
isInStackedModal(document.getElementById("closedInput"), profileModal),
false,
);
});
test("does NOT yield for a target that isn't in any modal (trap should reclaim focus)", () => {
assert.equal(
isInStackedModal(document.getElementById("bareInput"), profileModal),
false,
);
});
test("safe for a null / non-element target", () => {
assert.equal(isInStackedModal(null, profileModal), false);
assert.equal(isInStackedModal({}, profileModal), false);
});