From 8aba7757ab98d6876f84c5bbd52192bb66a930ea Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Thu, 28 Aug 2025 12:50:57 -0400 Subject: [PATCH] [PM-25122] Top-layer inline menu population (#16175) * cleanup inline menu content service * move inline menu button and listElement to top-layer popovers * update tests * do not hidePopover on teardown * watch all top layer candidates and attach event listeners to ensure they stay below the owned experience * add extra guards to top page observers * fix checks and cleanup logic * fix typing issues * include dialog elements in top layer candidate queries * send extension message before showing popover --- .../autofill-inline-menu-content.service.ts | 3 + ...tofill-inline-menu-content.service.spec.ts | 17 --- .../autofill-inline-menu-content.service.ts | 108 +++++++++++++++--- .../autofill-overlay-content.service.ts | 3 + .../autofill-overlay-content.service.ts | 7 ++ .../collect-autofill-content.service.ts | 76 ++++++++++++ 6 files changed, 182 insertions(+), 32 deletions(-) diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts index dc5a756250b..31bb37c908e 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts @@ -9,5 +9,8 @@ export type InlineMenuExtensionMessageHandlers = { export interface AutofillInlineMenuContentService { messageHandlers: InlineMenuExtensionMessageHandlers; isElementInlineMenu(element: HTMLElement): boolean; + getOwnedTagNames: () => string[]; + getUnownedTopLayerItems: (includeCandidates?: boolean) => NodeListOf; + refreshTopLayerPosition: () => void; destroy(): void; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts index 2c9484c3a8b..f1a74556b24 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts @@ -42,9 +42,6 @@ describe("AutofillInlineMenuContentService", () => { "sendExtensionMessage", ); jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque"); - jest - .spyOn(autofillInlineMenuContentService as any, "getPageTopLayerInUse") - .mockResolvedValue(false); }); afterEach(() => { @@ -390,20 +387,6 @@ describe("AutofillInlineMenuContentService", () => { expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); }); - it("closes the inline menu if the page has content in the top layer", async () => { - document.querySelector("html").style.opacity = "1"; - document.body.style.opacity = "1"; - - jest - .spyOn(autofillInlineMenuContentService as any, "getPageTopLayerInUse") - .mockResolvedValue(true); - - await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]); - - expect(autofillInlineMenuContentService["getPageIsOpaque"]).toHaveReturnedWith(true); - expect(autofillInlineMenuContentService["closeInlineMenu"]).toHaveBeenCalled(); - }); - it("closes the inline menu if the page body is not sufficiently opaque", async () => { document.querySelector("html").style.opacity = "0.9"; document.body.style.opacity = "0"; diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index c531215af88..247104e13a5 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -159,6 +159,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte if (!(await this.isInlineMenuButtonVisible())) { this.appendInlineMenuElementToDom(this.buttonElement); this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.Button, true); + this.buttonElement.showPopover(); } } @@ -174,6 +175,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte if (!(await this.isInlineMenuListVisible())) { this.appendInlineMenuElementToDom(this.listElement); this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.List, true); + this.listElement.showPopover(); } } @@ -219,6 +221,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private createButtonElement() { if (this.isFirefoxBrowser) { this.buttonElement = globalThis.document.createElement("div"); + this.buttonElement.setAttribute("popover", "manual"); new AutofillInlineMenuButtonIframe(this.buttonElement); return; @@ -235,6 +238,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }, ); this.buttonElement = globalThis.document.createElement(customElementName); + this.buttonElement.setAttribute("popover", "manual"); } /** @@ -244,6 +248,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private createListElement() { if (this.isFirefoxBrowser) { this.listElement = globalThis.document.createElement("div"); + this.listElement.setAttribute("popover", "manual"); new AutofillInlineMenuListIframe(this.listElement); return; @@ -260,6 +265,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }, ); this.listElement = globalThis.document.createElement(customElementName); + this.listElement.setAttribute("popover", "manual"); } /** @@ -293,6 +299,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.containerElementMutationObserver = new MutationObserver( this.handleContainerElementMutationObserverUpdate, ); + + this.observePageAttributes(); }; /** @@ -300,9 +308,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * elements are not modified by the website. */ private observeCustomElements() { - this.htmlMutationObserver?.observe(document.querySelector("html"), { attributes: true }); - this.bodyMutationObserver?.observe(document.body, { attributes: true }); - if (this.buttonElement) { this.inlineMenuElementsMutationObserver?.observe(this.buttonElement, { attributes: true, @@ -314,6 +319,25 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } } + /** + * Sets up mutation observers to verify that the page `html` and `body` attributes + * are not altered in a way that would impact safe display of the inline menu. + */ + private observePageAttributes() { + if (document.documentElement) { + this.htmlMutationObserver?.observe(document.documentElement, { attributes: true }); + } + + if (document.body) { + this.bodyMutationObserver?.observe(document.body, { attributes: true }); + } + } + + private unobservePageAttributes() { + this.htmlMutationObserver?.disconnect(); + this.bodyMutationObserver?.disconnect(); + } + /** * Disconnects the mutation observers that are used to verify that the inline menu * elements are not modified by the website. @@ -405,9 +429,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private checkPageRisks = async () => { const pageIsOpaque = await this.getPageIsOpaque(); - const pageTopLayerInUse = await this.getPageTopLayerInUse(); - const risksFound = !pageIsOpaque || pageTopLayerInUse; + const risksFound = !pageIsOpaque; if (risksFound) { this.closeInlineMenu(); @@ -426,12 +449,61 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }; /** - * Checks if the page top layer has content (will obscure/overlap the inline menu) + * Returns the name of the generated container tags for usage internally to avoid + * unintentional targeting of the owned experience. */ - private getPageTopLayerInUse = () => { - const pageHasOpenPopover = !!globalThis.document.querySelector(":popover-open"); + getOwnedTagNames = (): string[] => { + return [ + ...(this.buttonElement?.tagName ? [this.buttonElement.tagName] : []), + ...(this.listElement?.tagName ? [this.listElement.tagName] : []), + ]; + }; - return pageHasOpenPopover; + /** + * Queries and return elements (excluding those of the inline menu) that exist in the + * top-layer via popover or dialog + * @param {boolean} [includeCandidates=false] indicate whether top-layer candidate (which + * may or may not be active) should be included in the query + */ + getUnownedTopLayerItems = (includeCandidates = false) => { + const inlineMenuTagExclusions = [ + ...(this.buttonElement?.tagName ? [`:not(${this.buttonElement.tagName})`] : []), + ...(this.listElement?.tagName ? [`:not(${this.listElement.tagName})`] : []), + ":popover-open", + ].join(""); + const selector = [ + ":modal", + inlineMenuTagExclusions, + ...(includeCandidates ? ["[popover], dialog"] : []), + ].join(","); + const otherTopLayeritems = globalThis.document.querySelectorAll(selector); + + return otherTopLayeritems; + }; + + refreshTopLayerPosition = () => { + const otherTopLayerItems = this.getUnownedTopLayerItems(); + + // No need to refresh if there are no other top-layer items + if (!otherTopLayerItems.length) { + return; + } + + const buttonInDocument = + this.buttonElement && + (globalThis.document.getElementsByTagName(this.buttonElement.tagName)[0] as HTMLElement); + const listInDocument = + this.listElement && + (globalThis.document.getElementsByTagName(this.listElement.tagName)[0] as HTMLElement); + if (buttonInDocument) { + buttonInDocument.hidePopover(); + buttonInDocument.showPopover(); + } + + if (listInDocument) { + listInDocument.hidePopover(); + listInDocument.showPopover(); + } }; /** @@ -443,12 +515,17 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private getPageIsOpaque = () => { // These are computed style values, so we don't need to worry about non-float values // for `opacity`, here - const htmlOpacity = globalThis.window.getComputedStyle( - globalThis.document.querySelector("html"), - ).opacity; - const bodyOpacity = globalThis.window.getComputedStyle( - globalThis.document.querySelector("body"), - ).opacity; + // @TODO for definitive checks, traverse up the node tree from the inline menu container; + // nodes can exist between `html` and `body` + const htmlElement = globalThis.document.querySelector("html"); + const bodyElement = globalThis.document.querySelector("body"); + + if (!htmlElement || !bodyElement) { + return false; + } + + const htmlOpacity = globalThis.window.getComputedStyle(htmlElement)?.opacity || "0"; + const bodyOpacity = globalThis.window.getComputedStyle(bodyElement)?.opacity || "0"; // Any value above this is considered "opaque" for our purposes const opacityThreshold = 0.6; @@ -607,5 +684,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte destroy() { this.closeInlineMenu(); this.clearPersistentLastChildOverrideTimeout(); + this.unobservePageAttributes(); } } diff --git a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts index ddacb547908..56c2d1704d2 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts @@ -39,6 +39,9 @@ export interface AutofillOverlayContentService { pageDetails: AutofillPageDetails, ): Promise; blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void; + getOwnedInlineMenuTagNames(): string[]; + getUnownedTopLayerItems(includeCandidates?: boolean): NodeListOf | undefined; + refreshMenuLayerPosition(): void; clearUserFilledFields(): void; destroy(): void; } diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 4db00901759..51b7c8c603c 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -225,6 +225,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ } } + refreshMenuLayerPosition = () => this.inlineMenuContentService?.refreshTopLayerPosition(); + + getOwnedInlineMenuTagNames = () => this.inlineMenuContentService?.getOwnedTagNames() || []; + + getUnownedTopLayerItems = (includeCandidates?: boolean) => + this.inlineMenuContentService?.getUnownedTopLayerItems(includeCandidates); + /** * Clears all cached user filled fields. */ diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index c6af9739773..1e59da17699 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -49,6 +49,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ private mutationObserver: MutationObserver; private mutationsQueue: MutationRecord[][] = []; private updateAfterMutationIdleCallback: NodeJS.Timeout | number; + private ownedExperienceTagNames: string[] = []; private readonly updateAfterMutationTimeout = 1000; private readonly formFieldQueryString; private readonly nonInputFormFieldTags = new Set(["textarea", "select"]); @@ -85,6 +86,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * @public */ async getPageDetails(): Promise { + // Set up listeners on top-layer candidates that predate Mutation Observer setup + this.setupInitialTopLayerListeners(); + if (!this.mutationObserver) { this.setupMutationObserver(); } @@ -919,6 +923,18 @@ export class CollectAutofillContentService implements CollectAutofillContentServ return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute; } + private setupInitialTopLayerListeners = () => { + const unownedTopLayerItems = this.autofillOverlayContentService?.getUnownedTopLayerItems(true); + + if (unownedTopLayerItems?.length) { + for (const unownedElement of unownedTopLayerItems) { + if (this.shouldListenToTopLayerCandidate(unownedElement)) { + this.setupTopLayerCandidateListener(unownedElement); + } + } + } + }; + /** * Sets up a mutation observer on the body of the document. Observes changes to * DOM elements to ensure we have an updated set of autofill field data. @@ -1044,6 +1060,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * @private */ private processMutationRecord(mutation: MutationRecord) { + this.handleTopLayerChanges(mutation); + if ( mutation.type === "childList" && (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || @@ -1058,6 +1076,64 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } } + private setupTopLayerCandidateListener = (element: Element) => { + const ownedTags = this.autofillOverlayContentService.getOwnedInlineMenuTagNames() || []; + this.ownedExperienceTagNames = ownedTags; + + if (!ownedTags.includes(element.tagName)) { + element.addEventListener("toggle", (event: ToggleEvent) => { + if (event.newState === "open") { + // Add a slight delay (but faster than a user's reaction), to ensure the layer + // positioning happens after any triggered toggle has completed. + setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100); + } + }); + } + }; + + private isPopoverAttribute = (attr: string | null) => { + const popoverAttributes = new Set(["popover", "popovertarget", "popovertargetaction"]); + + return attr && popoverAttributes.has(attr.toLowerCase()); + }; + + private shouldListenToTopLayerCandidate = (element: Element) => { + return ( + !this.ownedExperienceTagNames.includes(element.tagName) && + (element.tagName === "DIALOG" || + Array.from(element.attributes || []).some((attribute) => + this.isPopoverAttribute(attribute.name), + )) + ); + }; + + /** + * Checks if a mutation record is related features that utilize the top layer. + * If so, it then calls `setupTopLayerElementListener` for future event + * listening on the relevant element. + * + * @param mutation - The MutationRecord to check + */ + private handleTopLayerChanges = (mutation: MutationRecord) => { + // Check attribute mutations + if (mutation.type === "attributes" && this.isPopoverAttribute(mutation.attributeName)) { + this.setupTopLayerCandidateListener(mutation.target as Element); + } + + // Check added nodes for dialog or popover attributes + if (mutation.type === "childList" && mutation.addedNodes?.length > 0) { + for (const node of mutation.addedNodes) { + const mutationElement = node as Element; + + if (this.shouldListenToTopLayerCandidate(mutationElement)) { + this.setupTopLayerCandidateListener(mutationElement); + } + } + } + + return; + }; + /** * Checks if the passed nodes either contain or are autofill elements. *