From 9377d39be0448ae3372314a710988e67fd516d51 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:36:18 -0400 Subject: [PATCH 1/2] Fix add profile flow to prioritize login modal --- js/ui/profileModalController.js | 179 ++++++++++++++++++++++++ tests/profile-modal-controller.test.mjs | 109 +++++++++++++++ 2 files changed, 288 insertions(+) diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index c1fc1bac..eaa8d428 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1132,6 +1132,9 @@ export class ProfileModalController { this.boundFocusIn = null; this.focusableElements = []; this.focusTrapContainer = null; + this.focusTrapSuspended = false; + this.focusTrapSuspendCount = 0; + this.focusTrapAriaHiddenBeforeSuspend = null; this.profileSwitcherSelectionPubkey = null; this.previouslyFocusedElement = null; this.largeLayoutQuery = null; @@ -2843,6 +2846,26 @@ export class ProfileModalController { this.setAddAccountLoading(true); + try { + this.bringLoginModalToFront(); + } catch (error) { + devLogger.warn( + "[ProfileModalController] Failed to elevate login modal before add profile authentication:", + error, + ); + } + + let suspendedFocusTrap = false; + try { + this.suspendFocusTrap(); + suspendedFocusTrap = true; + } catch (error) { + devLogger.warn( + "[ProfileModalController] Failed to suspend profile modal focus trap before login:", + error, + ); + } + try { const loginResult = await requestLogin({ controller: this, @@ -2884,6 +2907,17 @@ export class ProfileModalController { this.showError(message); } } finally { + if (suspendedFocusTrap) { + try { + this.resumeFocusTrap(); + } catch (error) { + devLogger.warn( + "[ProfileModalController] Failed to resume profile modal focus trap after login:", + error, + ); + } + } + this.setAddAccountLoading(false); } } @@ -8118,6 +8152,11 @@ export class ProfileModalController { return; } + if (this.focusTrapSuspended) { + this.focusTrapContainer = targetContainer; + return; + } + if (!this.boundKeydown) { this.boundKeydown = (event) => { if (event.key === "Escape") { @@ -8198,6 +8237,136 @@ export class ProfileModalController { document.addEventListener("focusin", this.boundFocusIn); } + getModalRootElement() { + if (this.profileModalRoot instanceof HTMLElement) { + return this.profileModalRoot; + } + if (this.profileModal instanceof HTMLElement) { + return this.profileModal; + } + return null; + } + + getModalPanelElement() { + if (this.profileModalPanel instanceof HTMLElement) { + return this.profileModalPanel; + } + return this.getModalRootElement(); + } + + suspendFocusTrap() { + this.focusTrapSuspendCount += 1; + this.focusTrapSuspended = true; + + if (this.focusTrapSuspendCount > 1) { + return this.focusTrapSuspendCount; + } + + if ( + this.boundKeydown && + this.focusTrapContainer instanceof HTMLElement + ) { + this.focusTrapContainer.removeEventListener( + "keydown", + this.boundKeydown, + ); + } + + if (this.boundFocusIn) { + document.removeEventListener("focusin", this.boundFocusIn); + } + + const modalRoot = this.getModalRootElement(); + if (modalRoot) { + this.focusTrapAriaHiddenBeforeSuspend = modalRoot.getAttribute( + "aria-hidden", + ); + modalRoot.dataset.nestedModalActive = "true"; + modalRoot.setAttribute("aria-hidden", "true"); + } else { + this.focusTrapAriaHiddenBeforeSuspend = null; + } + + const panel = this.getModalPanelElement(); + if (panel) { + panel.setAttribute("inert", ""); + } + + return this.focusTrapSuspendCount; + } + + resumeFocusTrap() { + if (this.focusTrapSuspendCount === 0) { + return 0; + } + + this.focusTrapSuspendCount = Math.max( + 0, + this.focusTrapSuspendCount - 1, + ); + + if (this.focusTrapSuspendCount > 0) { + return this.focusTrapSuspendCount; + } + + this.focusTrapSuspended = false; + + const modalRoot = this.getModalRootElement(); + if (modalRoot) { + delete modalRoot.dataset.nestedModalActive; + if (!modalRoot.classList.contains("hidden")) { + if (this.focusTrapAriaHiddenBeforeSuspend === null) { + modalRoot.removeAttribute("aria-hidden"); + } else { + modalRoot.setAttribute( + "aria-hidden", + this.focusTrapAriaHiddenBeforeSuspend, + ); + } + } + } + + const panel = this.getModalPanelElement(); + if (panel) { + panel.removeAttribute("inert"); + } + + this.focusTrapAriaHiddenBeforeSuspend = null; + + this.updateFocusTrap(); + + return 0; + } + + bringLoginModalToFront() { + if (typeof document === "undefined") { + return; + } + + const loginModal = document.getElementById("loginModal"); + if (!(loginModal instanceof HTMLElement)) { + return; + } + + const container = + this.modalContainer instanceof HTMLElement + ? this.modalContainer + : loginModal.parentElement; + + if (!container) { + return; + } + + if (loginModal.parentElement !== container) { + container.appendChild(loginModal); + return; + } + + if (container.lastElementChild !== loginModal) { + container.appendChild(loginModal); + } + } + ensureModalOrder(modalRoot) { if (!(modalRoot instanceof HTMLElement)) { return; @@ -8421,6 +8590,7 @@ export class ProfileModalController { if (modalElement) { modalElement.classList.add("hidden"); modalElement.setAttribute("aria-hidden", "true"); + delete modalElement.dataset.nestedModalActive; this.setGlobalModalState("profile", false); this.setMobileView("menu", { skipFocusTrap: true }); @@ -8436,8 +8606,17 @@ export class ProfileModalController { document.removeEventListener("focusin", this.boundFocusIn); } + const panel = this.getModalPanelElement(); + if (panel) { + panel.removeAttribute("inert"); + } + this.focusTrapContainer = null; + this.focusTrapSuspendCount = 0; + this.focusTrapSuspended = false; + this.focusTrapAriaHiddenBeforeSuspend = null; + if ( this.boundProfileHistoryVisibility && typeof document !== "undefined" && diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index da81dd44..0975c4f6 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -26,6 +26,20 @@ async function waitForAnimationFrame(window, cycles = 1) { } } +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { + promise, + resolve, + reject, + }; +} + function createMatchMediaList(query, initialMatches = false) { let matches = Boolean(initialMatches); const listeners = new Set(); @@ -384,6 +398,101 @@ for (const _ of [0]) { }, ); + test( + 'Add profile request suspends focus trap and elevates login modal', + async (t) => { + const loginDeferred = createDeferred(); + const loginModal = document.createElement('div'); + loginModal.id = 'loginModal'; + loginModal.className = 'bv-modal hidden'; + container.appendChild(loginModal); + + const controller = createController({ + services: { + requestAddProfileLogin: async ({ triggerElement }) => { + assert.strictEqual(triggerElement, controller.addAccountButton); + assert.equal(controller.focusTrapSuspended, true); + return loginDeferred.promise; + }, + }, + callbacks: { + onAddAccount: async () => {}, + }, + }); + + let cleanupRan = false; + const cleanup = () => { + try { + loginModal.remove(); + } catch {} + try { + controller.hide({ silent: true }); + } catch {} + cleanupRan = true; + }; + + try { + await controller.load(); + applyDesignSystemAttributes(document); + await controller.show('account'); + await waitForAnimationFrame(window, 1); + + const loginPromise = controller.handleAddAccountRequest(); + await Promise.resolve(); + + const profileModalRoot = + document.getElementById('profileModal') || controller.profileModal; + assert.ok(profileModalRoot, 'profile modal root should exist'); + assert.equal( + profileModalRoot?.dataset.nestedModalActive, + 'true', + 'profile modal should mark nested modal state', + ); + assert.equal( + profileModalRoot?.getAttribute('aria-hidden'), + 'true', + 'profile modal should be hidden from assistive tech', + ); + + const panel = + profileModalRoot?.querySelector('.bv-modal__panel') || profileModalRoot; + assert.ok(panel?.hasAttribute('inert')); + + assert.strictEqual( + container.lastElementChild, + loginModal, + 'login modal should be last in the container', + ); + + loginDeferred.resolve({ provider: 'test' }); + await loginPromise; + await waitForAnimationFrame(window, 1); + + assert.equal(controller.focusTrapSuspended, false); + assert.equal( + profileModalRoot?.dataset.nestedModalActive, + undefined, + 'profile modal nested state should clear after login', + ); + assert.equal( + profileModalRoot?.getAttribute('aria-hidden'), + 'false', + 'profile modal should restore aria-hidden', + ); + assert.equal(panel?.hasAttribute('inert'), false); + } finally { + cleanup(); + } + + t.after(() => { + if (!cleanupRan) { + cleanup(); + } + resetRuntimeFlags(); + }); + }, + ); + test( 'Profile modal navigation buttons toggle active state', async (t) => { From 6a1b9a86e23e3b3b470ccc75e0505d52e06fbe9c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:43:52 -0400 Subject: [PATCH 2/2] Hide profile modal while add-profile login is active --- js/ui/profileModalController.js | 49 +++++++++++++++++++++++++ tests/profile-modal-controller.test.mjs | 21 +++++++++++ 2 files changed, 70 insertions(+) diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index eaa8d428..fbf2b803 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1135,6 +1135,8 @@ export class ProfileModalController { this.focusTrapSuspended = false; this.focusTrapSuspendCount = 0; this.focusTrapAriaHiddenBeforeSuspend = null; + this.focusTrapPointerEventsBeforeSuspend = null; + this.focusTrapVisibilityBeforeSuspend = null; this.profileSwitcherSelectionPubkey = null; this.previouslyFocusedElement = null; this.largeLayoutQuery = null; @@ -8281,10 +8283,25 @@ export class ProfileModalController { this.focusTrapAriaHiddenBeforeSuspend = modalRoot.getAttribute( "aria-hidden", ); + this.focusTrapPointerEventsBeforeSuspend = + typeof modalRoot.style.pointerEvents === "string" && + modalRoot.style.pointerEvents + ? modalRoot.style.pointerEvents + : null; + this.focusTrapVisibilityBeforeSuspend = + typeof modalRoot.style.visibility === "string" && + modalRoot.style.visibility + ? modalRoot.style.visibility + : null; + modalRoot.dataset.nestedModalActive = "true"; modalRoot.setAttribute("aria-hidden", "true"); + modalRoot.style.setProperty("pointer-events", "none"); + modalRoot.style.setProperty("visibility", "hidden"); } else { this.focusTrapAriaHiddenBeforeSuspend = null; + this.focusTrapPointerEventsBeforeSuspend = null; + this.focusTrapVisibilityBeforeSuspend = null; } const panel = this.getModalPanelElement(); @@ -8324,6 +8341,32 @@ export class ProfileModalController { ); } } + + if ( + this.focusTrapPointerEventsBeforeSuspend === null || + this.focusTrapPointerEventsBeforeSuspend === undefined || + this.focusTrapPointerEventsBeforeSuspend === "" + ) { + modalRoot.style.removeProperty("pointer-events"); + } else { + modalRoot.style.setProperty( + "pointer-events", + this.focusTrapPointerEventsBeforeSuspend, + ); + } + + if ( + this.focusTrapVisibilityBeforeSuspend === null || + this.focusTrapVisibilityBeforeSuspend === undefined || + this.focusTrapVisibilityBeforeSuspend === "" + ) { + modalRoot.style.removeProperty("visibility"); + } else { + modalRoot.style.setProperty( + "visibility", + this.focusTrapVisibilityBeforeSuspend, + ); + } } const panel = this.getModalPanelElement(); @@ -8332,6 +8375,8 @@ export class ProfileModalController { } this.focusTrapAriaHiddenBeforeSuspend = null; + this.focusTrapPointerEventsBeforeSuspend = null; + this.focusTrapVisibilityBeforeSuspend = null; this.updateFocusTrap(); @@ -8591,6 +8636,8 @@ export class ProfileModalController { modalElement.classList.add("hidden"); modalElement.setAttribute("aria-hidden", "true"); delete modalElement.dataset.nestedModalActive; + modalElement.style.removeProperty("pointer-events"); + modalElement.style.removeProperty("visibility"); this.setGlobalModalState("profile", false); this.setMobileView("menu", { skipFocusTrap: true }); @@ -8616,6 +8663,8 @@ export class ProfileModalController { this.focusTrapSuspendCount = 0; this.focusTrapSuspended = false; this.focusTrapAriaHiddenBeforeSuspend = null; + this.focusTrapPointerEventsBeforeSuspend = null; + this.focusTrapVisibilityBeforeSuspend = null; if ( this.boundProfileHistoryVisibility && diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index 0975c4f6..c56b951e 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -458,6 +458,17 @@ for (const _ of [0]) { profileModalRoot?.querySelector('.bv-modal__panel') || profileModalRoot; assert.ok(panel?.hasAttribute('inert')); + assert.equal( + profileModalRoot?.style.visibility, + 'hidden', + 'profile modal should hide visually while login modal is active', + ); + assert.equal( + profileModalRoot?.style.pointerEvents, + 'none', + 'profile modal should not intercept pointer events while login modal is active', + ); + assert.strictEqual( container.lastElementChild, loginModal, @@ -480,6 +491,16 @@ for (const _ of [0]) { 'profile modal should restore aria-hidden', ); assert.equal(panel?.hasAttribute('inert'), false); + assert.equal( + profileModalRoot?.style.visibility ?? '', + '', + 'profile modal visibility should restore after login', + ); + assert.equal( + profileModalRoot?.style.pointerEvents ?? '', + '', + 'profile modal pointer events should restore after login', + ); } finally { cleanup(); }