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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user