1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

[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
This commit is contained in:
Jonathan Prusik
2025-08-28 12:50:57 -04:00
committed by GitHub
parent 2fe9f4b138
commit 8aba7757ab
6 changed files with 182 additions and 32 deletions

View File

@@ -9,5 +9,8 @@ export type InlineMenuExtensionMessageHandlers = {
export interface AutofillInlineMenuContentService {
messageHandlers: InlineMenuExtensionMessageHandlers;
isElementInlineMenu(element: HTMLElement): boolean;
getOwnedTagNames: () => string[];
getUnownedTopLayerItems: (includeCandidates?: boolean) => NodeListOf<Element>;
refreshTopLayerPosition: () => void;
destroy(): void;
}

View File

@@ -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";

View File

@@ -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();
}
}

View File

@@ -39,6 +39,9 @@ export interface AutofillOverlayContentService {
pageDetails: AutofillPageDetails,
): Promise<void>;
blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void;
getOwnedInlineMenuTagNames(): string[];
getUnownedTopLayerItems(includeCandidates?: boolean): NodeListOf<Element> | undefined;
refreshMenuLayerPosition(): void;
clearUserFilledFields(): void;
destroy(): void;
}

View File

@@ -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.
*/

View File

@@ -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<AutofillPageDetails> {
// 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.
*