mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
5afd602d52
commit
ffeafa90d5
3 files changed
+77
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user