mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 06:43:35 +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 {
|
export interface AutofillInlineMenuContentService {
|
||||||
messageHandlers: InlineMenuExtensionMessageHandlers;
|
messageHandlers: InlineMenuExtensionMessageHandlers;
|
||||||
isElementInlineMenu(element: HTMLElement): boolean;
|
isElementInlineMenu(element: HTMLElement): boolean;
|
||||||
|
getOwnedTagNames: () => string[];
|
||||||
|
getUnownedTopLayerItems: (includeCandidates?: boolean) => NodeListOf<Element>;
|
||||||
|
refreshTopLayerPosition: () => void;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ describe("AutofillInlineMenuContentService", () => {
|
|||||||
"sendExtensionMessage",
|
"sendExtensionMessage",
|
||||||
);
|
);
|
||||||
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque");
|
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque");
|
||||||
jest
|
|
||||||
.spyOn(autofillInlineMenuContentService as any, "getPageTopLayerInUse")
|
|
||||||
.mockResolvedValue(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -390,20 +387,6 @@ describe("AutofillInlineMenuContentService", () => {
|
|||||||
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
|
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 () => {
|
it("closes the inline menu if the page body is not sufficiently opaque", async () => {
|
||||||
document.querySelector("html").style.opacity = "0.9";
|
document.querySelector("html").style.opacity = "0.9";
|
||||||
document.body.style.opacity = "0";
|
document.body.style.opacity = "0";
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
if (!(await this.isInlineMenuButtonVisible())) {
|
if (!(await this.isInlineMenuButtonVisible())) {
|
||||||
this.appendInlineMenuElementToDom(this.buttonElement);
|
this.appendInlineMenuElementToDom(this.buttonElement);
|
||||||
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.Button, true);
|
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.Button, true);
|
||||||
|
this.buttonElement.showPopover();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
if (!(await this.isInlineMenuListVisible())) {
|
if (!(await this.isInlineMenuListVisible())) {
|
||||||
this.appendInlineMenuElementToDom(this.listElement);
|
this.appendInlineMenuElementToDom(this.listElement);
|
||||||
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.List, true);
|
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.List, true);
|
||||||
|
this.listElement.showPopover();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +221,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
private createButtonElement() {
|
private createButtonElement() {
|
||||||
if (this.isFirefoxBrowser) {
|
if (this.isFirefoxBrowser) {
|
||||||
this.buttonElement = globalThis.document.createElement("div");
|
this.buttonElement = globalThis.document.createElement("div");
|
||||||
|
this.buttonElement.setAttribute("popover", "manual");
|
||||||
new AutofillInlineMenuButtonIframe(this.buttonElement);
|
new AutofillInlineMenuButtonIframe(this.buttonElement);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -235,6 +238,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.buttonElement = globalThis.document.createElement(customElementName);
|
this.buttonElement = globalThis.document.createElement(customElementName);
|
||||||
|
this.buttonElement.setAttribute("popover", "manual");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,6 +248,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
private createListElement() {
|
private createListElement() {
|
||||||
if (this.isFirefoxBrowser) {
|
if (this.isFirefoxBrowser) {
|
||||||
this.listElement = globalThis.document.createElement("div");
|
this.listElement = globalThis.document.createElement("div");
|
||||||
|
this.listElement.setAttribute("popover", "manual");
|
||||||
new AutofillInlineMenuListIframe(this.listElement);
|
new AutofillInlineMenuListIframe(this.listElement);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -260,6 +265,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.listElement = globalThis.document.createElement(customElementName);
|
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.containerElementMutationObserver = new MutationObserver(
|
||||||
this.handleContainerElementMutationObserverUpdate,
|
this.handleContainerElementMutationObserverUpdate,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.observePageAttributes();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,9 +308,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
* elements are not modified by the website.
|
* elements are not modified by the website.
|
||||||
*/
|
*/
|
||||||
private observeCustomElements() {
|
private observeCustomElements() {
|
||||||
this.htmlMutationObserver?.observe(document.querySelector("html"), { attributes: true });
|
|
||||||
this.bodyMutationObserver?.observe(document.body, { attributes: true });
|
|
||||||
|
|
||||||
if (this.buttonElement) {
|
if (this.buttonElement) {
|
||||||
this.inlineMenuElementsMutationObserver?.observe(this.buttonElement, {
|
this.inlineMenuElementsMutationObserver?.observe(this.buttonElement, {
|
||||||
attributes: true,
|
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
|
* Disconnects the mutation observers that are used to verify that the inline menu
|
||||||
* elements are not modified by the website.
|
* elements are not modified by the website.
|
||||||
@@ -405,9 +429,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
|
|
||||||
private checkPageRisks = async () => {
|
private checkPageRisks = async () => {
|
||||||
const pageIsOpaque = await this.getPageIsOpaque();
|
const pageIsOpaque = await this.getPageIsOpaque();
|
||||||
const pageTopLayerInUse = await this.getPageTopLayerInUse();
|
|
||||||
|
|
||||||
const risksFound = !pageIsOpaque || pageTopLayerInUse;
|
const risksFound = !pageIsOpaque;
|
||||||
|
|
||||||
if (risksFound) {
|
if (risksFound) {
|
||||||
this.closeInlineMenu();
|
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 = () => {
|
getOwnedTagNames = (): string[] => {
|
||||||
const pageHasOpenPopover = !!globalThis.document.querySelector(":popover-open");
|
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 = () => {
|
private getPageIsOpaque = () => {
|
||||||
// These are computed style values, so we don't need to worry about non-float values
|
// These are computed style values, so we don't need to worry about non-float values
|
||||||
// for `opacity`, here
|
// for `opacity`, here
|
||||||
const htmlOpacity = globalThis.window.getComputedStyle(
|
// @TODO for definitive checks, traverse up the node tree from the inline menu container;
|
||||||
globalThis.document.querySelector("html"),
|
// nodes can exist between `html` and `body`
|
||||||
).opacity;
|
const htmlElement = globalThis.document.querySelector("html");
|
||||||
const bodyOpacity = globalThis.window.getComputedStyle(
|
const bodyElement = globalThis.document.querySelector("body");
|
||||||
globalThis.document.querySelector("body"),
|
|
||||||
).opacity;
|
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
|
// Any value above this is considered "opaque" for our purposes
|
||||||
const opacityThreshold = 0.6;
|
const opacityThreshold = 0.6;
|
||||||
@@ -607,5 +684,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
|||||||
destroy() {
|
destroy() {
|
||||||
this.closeInlineMenu();
|
this.closeInlineMenu();
|
||||||
this.clearPersistentLastChildOverrideTimeout();
|
this.clearPersistentLastChildOverrideTimeout();
|
||||||
|
this.unobservePageAttributes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export interface AutofillOverlayContentService {
|
|||||||
pageDetails: AutofillPageDetails,
|
pageDetails: AutofillPageDetails,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void;
|
blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void;
|
||||||
|
getOwnedInlineMenuTagNames(): string[];
|
||||||
|
getUnownedTopLayerItems(includeCandidates?: boolean): NodeListOf<Element> | undefined;
|
||||||
|
refreshMenuLayerPosition(): void;
|
||||||
clearUserFilledFields(): void;
|
clearUserFilledFields(): void;
|
||||||
destroy(): 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.
|
* Clears all cached user filled fields.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
private mutationObserver: MutationObserver;
|
private mutationObserver: MutationObserver;
|
||||||
private mutationsQueue: MutationRecord[][] = [];
|
private mutationsQueue: MutationRecord[][] = [];
|
||||||
private updateAfterMutationIdleCallback: NodeJS.Timeout | number;
|
private updateAfterMutationIdleCallback: NodeJS.Timeout | number;
|
||||||
|
private ownedExperienceTagNames: string[] = [];
|
||||||
private readonly updateAfterMutationTimeout = 1000;
|
private readonly updateAfterMutationTimeout = 1000;
|
||||||
private readonly formFieldQueryString;
|
private readonly formFieldQueryString;
|
||||||
private readonly nonInputFormFieldTags = new Set(["textarea", "select"]);
|
private readonly nonInputFormFieldTags = new Set(["textarea", "select"]);
|
||||||
@@ -85,6 +86,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
async getPageDetails(): Promise<AutofillPageDetails> {
|
async getPageDetails(): Promise<AutofillPageDetails> {
|
||||||
|
// Set up listeners on top-layer candidates that predate Mutation Observer setup
|
||||||
|
this.setupInitialTopLayerListeners();
|
||||||
|
|
||||||
if (!this.mutationObserver) {
|
if (!this.mutationObserver) {
|
||||||
this.setupMutationObserver();
|
this.setupMutationObserver();
|
||||||
}
|
}
|
||||||
@@ -919,6 +923,18 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute;
|
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
|
* 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.
|
* DOM elements to ensure we have an updated set of autofill field data.
|
||||||
@@ -1044,6 +1060,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private processMutationRecord(mutation: MutationRecord) {
|
private processMutationRecord(mutation: MutationRecord) {
|
||||||
|
this.handleTopLayerChanges(mutation);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
mutation.type === "childList" &&
|
mutation.type === "childList" &&
|
||||||
(this.isAutofillElementNodeMutated(mutation.removedNodes, true) ||
|
(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.
|
* Checks if the passed nodes either contain or are autofill elements.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user