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 36575a6d73e..a995a81c127 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 @@ -34,6 +34,6 @@ export interface AutofillOverlayContentService { autofillFieldElement: ElementWithOpId, autofillFieldData: AutofillField, ): Promise; - blurMostRecentlyFocusedField(isRemovingInlineMenu?: boolean): void; + blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void; destroy(): void; } diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 3d19847abed..8f0a7ef8225 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -749,131 +749,6 @@ describe("AutofillOverlayContentService", () => { }); }); - describe("openAutofillInlineMenu", () => { - let autofillFieldElement: ElementWithOpId; - - beforeEach(() => { - document.body.innerHTML = ` -
- - -
- `; - - autofillFieldElement = document.getElementById( - "username-field", - ) as ElementWithOpId; - autofillFieldElement.opid = "op-1"; - autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; - }); - - it("skips opening the overlay if a field has not been recently focused", () => { - autofillOverlayContentService["mostRecentlyFocusedField"] = undefined; - - autofillOverlayContentService["openAutofillInlineMenu"](); - - expect(sendExtensionMessageSpy).not.toHaveBeenCalled(); - }); - - it("focuses the most recent overlay field if the field is not focused", () => { - jest.spyOn(autofillFieldElement, "getRootNode").mockReturnValue(document); - Object.defineProperty(document, "activeElement", { - value: document.createElement("div"), - writable: true, - }); - const focusMostRecentOverlayFieldSpy = jest.spyOn( - autofillOverlayContentService as any, - "focusMostRecentlyFocusedField", - ); - - autofillOverlayContentService["openAutofillInlineMenu"]({ isFocusingFieldElement: true }); - - expect(focusMostRecentOverlayFieldSpy).toHaveBeenCalled(); - }); - - it("skips focusing the most recent overlay field if the field is already focused", () => { - jest.spyOn(autofillFieldElement, "getRootNode").mockReturnValue(document); - Object.defineProperty(document, "activeElement", { - value: autofillFieldElement, - writable: true, - }); - const focusMostRecentOverlayFieldSpy = jest.spyOn( - autofillOverlayContentService as any, - "focusMostRecentlyFocusedField", - ); - - autofillOverlayContentService["openAutofillInlineMenu"]({ isFocusingFieldElement: true }); - - expect(focusMostRecentOverlayFieldSpy).not.toHaveBeenCalled(); - }); - - it("stores the user's auth status", () => { - autofillOverlayContentService["authStatus"] = undefined; - - autofillOverlayContentService["openAutofillInlineMenu"]({ - authStatus: AuthenticationStatus.Unlocked, - }); - - expect(autofillOverlayContentService["authStatus"]).toEqual(AuthenticationStatus.Unlocked); - }); - - it("opens both autofill overlay elements", () => { - autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; - - autofillOverlayContentService["openAutofillInlineMenu"](); - - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { - overlayElement: AutofillOverlayElement.Button, - }); - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { - overlayElement: AutofillOverlayElement.List, - }); - }); - - it("opens the autofill overlay button only if overlay visibility is set for onButtonClick", () => { - autofillOverlayContentService["inlineMenuVisibility"] = - AutofillOverlayVisibility.OnButtonClick; - - autofillOverlayContentService["openAutofillInlineMenu"]({ - isOpeningFullAutofillInlineMenu: false, - }); - - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { - overlayElement: AutofillOverlayElement.Button, - }); - expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { - overlayElement: AutofillOverlayElement.List, - }); - }); - - it("overrides the onButtonClick visibility setting to open both overlay elements", () => { - autofillOverlayContentService["inlineMenuVisibility"] = - AutofillOverlayVisibility.OnButtonClick; - - autofillOverlayContentService["openAutofillInlineMenu"]({ - isOpeningFullAutofillInlineMenu: true, - }); - - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { - overlayElement: AutofillOverlayElement.Button, - }); - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { - overlayElement: AutofillOverlayElement.List, - }); - }); - - it("sends an extension message requesting an re-collection of page details if they need to update", () => { - jest.spyOn(autofillOverlayContentService as any, "sendExtensionMessage"); - autofillOverlayContentService.pageDetailsUpdateRequired = true; - - autofillOverlayContentService["openAutofillInlineMenu"](); - - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("bgCollectPageDetails", { - sender: "autofillOverlayContentService", - }); - }); - }); - describe("focusMostRecentlyFocusedField", () => { it("focuses the most recently focused overlay field", () => { const mostRecentlyFocusedField = document.createElement( @@ -888,199 +763,6 @@ describe("AutofillOverlayContentService", () => { }); }); - describe("blurMostRecentlyFocusedField", () => { - it("removes focus from the most recently focused overlay field", () => { - const mostRecentlyFocusedField = document.createElement( - "input", - ) as ElementWithOpId; - autofillOverlayContentService["mostRecentlyFocusedField"] = mostRecentlyFocusedField; - jest.spyOn(mostRecentlyFocusedField, "blur"); - - autofillOverlayContentService["blurMostRecentlyFocusedField"](); - - expect(mostRecentlyFocusedField.blur).toHaveBeenCalled(); - }); - }); - - describe("addNewVaultItem", () => { - it("skips sending the message if the overlay list is not visible", async () => { - jest - .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") - .mockResolvedValue(false); - - await autofillOverlayContentService.addNewVaultItem(); - - expect(sendExtensionMessageSpy).not.toHaveBeenCalled(); - }); - - it("sends a message that facilitates adding a new vault item with empty fields", async () => { - jest - .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") - .mockResolvedValue(true); - - await autofillOverlayContentService.addNewVaultItem(); - - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayAddNewVaultItem", { - login: { - username: "", - password: "", - uri: "http://localhost/", - hostname: "localhost", - }, - }); - }); - - it("sends a message that facilitates adding a new vault item with data from user filled fields", async () => { - document.body.innerHTML = ` -
- - -
- `; - const usernameField = document.getElementById( - "username-field", - ) as ElementWithOpId; - const passwordField = document.getElementById( - "password-field", - ) as ElementWithOpId; - usernameField.value = "test-username"; - passwordField.value = "test-password"; - jest - .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") - .mockResolvedValue(true); - autofillOverlayContentService["userFilledFields"] = { - username: usernameField, - password: passwordField, - }; - - await autofillOverlayContentService.addNewVaultItem(); - - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayAddNewVaultItem", { - login: { - username: "test-username", - password: "test-password", - uri: "http://localhost/", - hostname: "localhost", - }, - }); - }); - }); - - describe("redirectInlineMenuFocusOut", () => { - let autofillFieldElement: ElementWithOpId; - let autofillFieldFocusSpy: jest.SpyInstance; - let findTabsSpy: jest.SpyInstance; - let previousFocusableElement: HTMLElement; - let nextFocusableElement: HTMLElement; - let isInlineMenuListVisibleSpy: jest.SpyInstance; - - beforeEach(() => { - document.body.innerHTML = ` -
-
- - -
-
- `; - autofillFieldElement = document.getElementById( - "username-field", - ) as ElementWithOpId; - autofillFieldElement.opid = "op-1"; - previousFocusableElement = document.querySelector( - ".previous-focusable-element", - ) as HTMLElement; - nextFocusableElement = document.querySelector(".next-focusable-element") as HTMLElement; - autofillFieldFocusSpy = jest.spyOn(autofillFieldElement, "focus"); - findTabsSpy = jest.spyOn(autofillOverlayContentService as any, "findTabs"); - isInlineMenuListVisibleSpy = jest - .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") - .mockResolvedValue(true); - autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; - autofillOverlayContentService["focusableElements"] = [ - previousFocusableElement, - autofillFieldElement, - nextFocusableElement, - ]; - }); - - it("skips focusing an element if the overlay is not visible", async () => { - isInlineMenuListVisibleSpy.mockResolvedValue(false); - - await autofillOverlayContentService["redirectInlineMenuFocusOut"]( - RedirectFocusDirection.Next, - ); - - expect(findTabsSpy).not.toHaveBeenCalled(); - }); - - it("skips focusing an element if no recently focused field exists", async () => { - autofillOverlayContentService["mostRecentlyFocusedField"] = undefined; - - await autofillOverlayContentService["redirectInlineMenuFocusOut"]( - RedirectFocusDirection.Next, - ); - - expect(findTabsSpy).not.toHaveBeenCalled(); - }); - - it("focuses the most recently focused field if the focus direction is `Current`", async () => { - await autofillOverlayContentService["redirectInlineMenuFocusOut"]( - RedirectFocusDirection.Current, - ); - - expect(findTabsSpy).not.toHaveBeenCalled(); - expect(autofillFieldFocusSpy).toHaveBeenCalled(); - }); - - it("removes the overlay if the focus direction is `Current`", async () => { - jest.useFakeTimers(); - await autofillOverlayContentService["redirectInlineMenuFocusOut"]( - RedirectFocusDirection.Current, - ); - jest.advanceTimersByTime(150); - - expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu"); - }); - - it("finds all focusable tabs if the focusable elements array is not populated", async () => { - autofillOverlayContentService["focusableElements"] = []; - findTabsSpy.mockReturnValue([ - previousFocusableElement, - autofillFieldElement, - nextFocusableElement, - ]); - - await autofillOverlayContentService["redirectInlineMenuFocusOut"]( - RedirectFocusDirection.Next, - ); - - expect(findTabsSpy).toHaveBeenCalledWith(globalThis.document.body, { getShadowRoot: true }); - }); - - it("focuses the previous focusable element if the focus direction is `Previous`", async () => { - jest.spyOn(previousFocusableElement, "focus"); - - await autofillOverlayContentService["redirectInlineMenuFocusOut"]( - RedirectFocusDirection.Previous, - ); - - expect(autofillFieldFocusSpy).not.toHaveBeenCalled(); - expect(previousFocusableElement.focus).toHaveBeenCalled(); - }); - - it("focuses the next focusable element if the focus direction is `Next`", async () => { - jest.spyOn(nextFocusableElement, "focus"); - - await autofillOverlayContentService["redirectInlineMenuFocusOut"]( - RedirectFocusDirection.Next, - ); - - expect(autofillFieldFocusSpy).not.toHaveBeenCalled(); - expect(nextFocusableElement.focus).toHaveBeenCalled(); - }); - }); - describe("handleOverlayRepositionEvent", () => { let checkShouldRepositionInlineMenuSpy: jest.SpyInstance; @@ -1319,12 +1001,120 @@ describe("AutofillOverlayContentService", () => { describe("extension onMessage handlers", () => { describe("openAutofillInlineMenu message handler", () => { - it("sends a message to the background to trigger an update in the inline menu's position", async () => { - autofillOverlayContentService["mostRecentlyFocusedField"] = - mock>(); + let autofillFieldElement: ElementWithOpId; + + beforeEach(() => { + document.body.innerHTML = ` +
+ + +
+ `; + + autofillFieldElement = document.getElementById( + "username-field", + ) as ElementWithOpId; + autofillFieldElement.opid = "op-1"; + autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; + }); + + it("skips opening the overlay if a field has not been recently focused", () => { + autofillOverlayContentService["mostRecentlyFocusedField"] = undefined; + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }); + + expect(sendExtensionMessageSpy).not.toHaveBeenCalled(); + }); + + it("focuses the most recent overlay field if the field is not focused", () => { + jest.spyOn(autofillFieldElement, "getRootNode").mockReturnValue(document); + Object.defineProperty(document, "activeElement", { + value: document.createElement("div"), + writable: true, + }); + const focusMostRecentOverlayFieldSpy = jest.spyOn( + autofillOverlayContentService as any, + "focusMostRecentlyFocusedField", + ); sendMockExtensionMessage({ command: "openAutofillInlineMenu", + isFocusingFieldElement: true, + }); + + expect(focusMostRecentOverlayFieldSpy).toHaveBeenCalled(); + }); + + it("skips focusing the most recent overlay field if the field is already focused", () => { + jest.spyOn(autofillFieldElement, "getRootNode").mockReturnValue(document); + Object.defineProperty(document, "activeElement", { + value: autofillFieldElement, + writable: true, + }); + const focusMostRecentOverlayFieldSpy = jest.spyOn( + autofillOverlayContentService as any, + "focusMostRecentlyFocusedField", + ); + + sendMockExtensionMessage({ + command: "openAutofillInlineMenu", + isFocusingFieldElement: true, + }); + + expect(focusMostRecentOverlayFieldSpy).not.toHaveBeenCalled(); + }); + + it("stores the user's auth status", () => { + autofillOverlayContentService["authStatus"] = undefined; + + sendMockExtensionMessage({ + command: "openAutofillInlineMenu", + authStatus: AuthenticationStatus.Unlocked, + }); + + expect(autofillOverlayContentService["authStatus"]).toEqual(AuthenticationStatus.Unlocked); + }); + + it("opens both autofill overlay elements", () => { + autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; + + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { + overlayElement: AutofillOverlayElement.Button, + }); + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { + overlayElement: AutofillOverlayElement.List, + }); + }); + + it("opens the autofill overlay button only if overlay visibility is set for onButtonClick", () => { + autofillOverlayContentService["inlineMenuVisibility"] = + AutofillOverlayVisibility.OnButtonClick; + + sendMockExtensionMessage({ + command: "openAutofillInlineMenu", + isOpeningFullAutofillInlineMenu: false, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { + overlayElement: AutofillOverlayElement.Button, + }); + expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith( + "updateAutofillInlineMenuPosition", + { + overlayElement: AutofillOverlayElement.List, + }, + ); + }); + + it("overrides the onButtonClick visibility setting to open both overlay elements", () => { + autofillOverlayContentService["inlineMenuVisibility"] = + AutofillOverlayVisibility.OnButtonClick; + + sendMockExtensionMessage({ + command: "openAutofillInlineMenu", + isOpeningFullAutofillInlineMenu: true, }); expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { @@ -1334,17 +1124,38 @@ describe("AutofillOverlayContentService", () => { overlayElement: AutofillOverlayElement.List, }); }); + + it("sends an extension message requesting an re-collection of page details if they need to update", () => { + jest.spyOn(autofillOverlayContentService as any, "sendExtensionMessage"); + autofillOverlayContentService.pageDetailsUpdateRequired = true; + + autofillOverlayContentService["openAutofillInlineMenu"](); + sendMockExtensionMessage({ command: "openAutofillInlineMenu" }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("bgCollectPageDetails", { + sender: "autofillOverlayContentService", + }); + }); }); describe("addNewVaultItemFromOverlay message handler", () => { - it("sends an extension message with the cipher login details to add to the user's vault", async () => { + it("skips sending the message if the overlay list is not visible", async () => { + jest + .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") + .mockResolvedValue(false); + + sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); + await flushPromises(); + + expect(sendExtensionMessageSpy).not.toHaveBeenCalled(); + }); + + it("sends a message that facilitates adding a new vault item with empty fields", async () => { jest .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") .mockResolvedValue(true); - sendMockExtensionMessage({ - command: "addNewVaultItemFromOverlay", - }); + sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); await flushPromises(); expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayAddNewVaultItem", { @@ -1356,6 +1167,219 @@ describe("AutofillOverlayContentService", () => { }, }); }); + + it("sends a message that facilitates adding a new vault item with data from user filled fields", async () => { + document.body.innerHTML = ` +
+ + +
+ `; + const usernameField = document.getElementById( + "username-field", + ) as ElementWithOpId; + const passwordField = document.getElementById( + "password-field", + ) as ElementWithOpId; + usernameField.value = "test-username"; + passwordField.value = "test-password"; + jest + .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") + .mockResolvedValue(true); + autofillOverlayContentService["userFilledFields"] = { + username: usernameField, + password: passwordField, + }; + + sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" }); + await flushPromises(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayAddNewVaultItem", { + login: { + username: "test-username", + password: "test-password", + uri: "http://localhost/", + hostname: "localhost", + }, + }); + }); + }); + + describe("unsetMostRecentlyFocusedField message handler", () => { + it("will reset the mostRecentlyFocusedField value to a null value", () => { + autofillOverlayContentService["mostRecentlyFocusedField"] = + mock>(); + + sendMockExtensionMessage({ + command: "unsetMostRecentlyFocusedField", + }); + + expect(autofillOverlayContentService["mostRecentlyFocusedField"]).toBeNull(); + }); + }); + + describe("messages that trigger a blur of the most recently focused field", () => { + const messages = [ + "blurMostRecentlyFocusedField", + "bgUnlockPopoutOpened", + "bgVaultItemRepromptPopoutOpened", + ]; + + messages.forEach((message, index) => { + const isClosingInlineMenu = index >= 1; + it(`will blur the most recently focused field${isClosingInlineMenu ? " and close the inline menu" : ""} when a ${message} message is received`, () => { + autofillOverlayContentService["mostRecentlyFocusedField"] = + mock>(); + + sendMockExtensionMessage({ command: message }); + + expect(autofillOverlayContentService["mostRecentlyFocusedField"].blur).toHaveBeenCalled(); + + if (isClosingInlineMenu) { + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu"); + } + }); + }); + }); + + describe("redirectAutofillInlineMenuFocusOut message handler", () => { + let autofillFieldElement: ElementWithOpId; + let autofillFieldFocusSpy: jest.SpyInstance; + let findTabsSpy: jest.SpyInstance; + let previousFocusableElement: HTMLElement; + let nextFocusableElement: HTMLElement; + let isInlineMenuListVisibleSpy: jest.SpyInstance; + + beforeEach(() => { + document.body.innerHTML = ` +
+
+ + +
+
+ `; + autofillFieldElement = document.getElementById( + "username-field", + ) as ElementWithOpId; + autofillFieldElement.opid = "op-1"; + previousFocusableElement = document.querySelector( + ".previous-focusable-element", + ) as HTMLElement; + nextFocusableElement = document.querySelector(".next-focusable-element") as HTMLElement; + autofillFieldFocusSpy = jest.spyOn(autofillFieldElement, "focus"); + findTabsSpy = jest.spyOn(autofillOverlayContentService as any, "findTabs"); + isInlineMenuListVisibleSpy = jest + .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") + .mockResolvedValue(true); + autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; + autofillOverlayContentService["focusableElements"] = [ + previousFocusableElement, + autofillFieldElement, + nextFocusableElement, + ]; + }); + + it("skips focusing an element if the overlay is not visible", async () => { + isInlineMenuListVisibleSpy.mockResolvedValue(false); + + sendMockExtensionMessage({ + command: "redirectAutofillInlineMenuFocusOut", + data: { direction: RedirectFocusDirection.Next }, + }); + + expect(findTabsSpy).not.toHaveBeenCalled(); + }); + + it("skips focusing an element if no recently focused field exists", async () => { + autofillOverlayContentService["mostRecentlyFocusedField"] = undefined; + + sendMockExtensionMessage({ + command: "redirectAutofillInlineMenuFocusOut", + data: { direction: RedirectFocusDirection.Next }, + }); + + expect(findTabsSpy).not.toHaveBeenCalled(); + }); + + it("focuses the most recently focused field if the focus direction is `Current`", async () => { + sendMockExtensionMessage({ + command: "redirectAutofillInlineMenuFocusOut", + data: { direction: RedirectFocusDirection.Current }, + }); + await flushPromises(); + + expect(findTabsSpy).not.toHaveBeenCalled(); + expect(autofillFieldFocusSpy).toHaveBeenCalled(); + }); + + it("removes the overlay if the focus direction is `Current`", async () => { + jest.useFakeTimers(); + sendMockExtensionMessage({ + command: "redirectAutofillInlineMenuFocusOut", + data: { direction: RedirectFocusDirection.Current }, + }); + await flushPromises(); + jest.advanceTimersByTime(150); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu"); + }); + + it("finds all focusable tabs if the focusable elements array is not populated", async () => { + autofillOverlayContentService["focusableElements"] = []; + findTabsSpy.mockReturnValue([ + previousFocusableElement, + autofillFieldElement, + nextFocusableElement, + ]); + + sendMockExtensionMessage({ + command: "redirectAutofillInlineMenuFocusOut", + data: { direction: RedirectFocusDirection.Next }, + }); + await flushPromises(); + + expect(findTabsSpy).toHaveBeenCalledWith(globalThis.document.body, { getShadowRoot: true }); + }); + + it("focuses the previous focusable element if the focus direction is `Previous`", async () => { + jest.spyOn(previousFocusableElement, "focus"); + + sendMockExtensionMessage({ + command: "redirectAutofillInlineMenuFocusOut", + data: { direction: RedirectFocusDirection.Previous }, + }); + await flushPromises(); + + expect(autofillFieldFocusSpy).not.toHaveBeenCalled(); + expect(previousFocusableElement.focus).toHaveBeenCalled(); + }); + + it("focuses the next focusable element if the focus direction is `Next`", async () => { + jest.spyOn(nextFocusableElement, "focus"); + + sendMockExtensionMessage({ + command: "redirectAutofillInlineMenuFocusOut", + data: { direction: RedirectFocusDirection.Next }, + }); + await flushPromises(); + + expect(autofillFieldFocusSpy).not.toHaveBeenCalled(); + expect(nextFocusableElement.focus).toHaveBeenCalled(); + }); + }); + + describe("updateAutofillInlineMenuVisibility message handler", () => { + it("updates the inlineMenuVisibility property", () => { + sendMockExtensionMessage({ + command: "updateAutofillInlineMenuVisibility", + data: { inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick }, + }); + + expect(autofillOverlayContentService["inlineMenuVisibility"]).toEqual( + AutofillOverlayVisibility.OnButtonClick, + ); + }); }); }); }); 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 8d297c724f8..c0fcc3130f8 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -157,14 +157,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ /** * Removes focus from the most recently focused field element. */ - blurMostRecentlyFocusedField(isRemovingInlineMenu: boolean = false) { + blurMostRecentlyFocusedField(isClosingInlineMenu: boolean = false) { this.mostRecentlyFocusedField?.blur(); - if (isRemovingInlineMenu) { - void sendExtensionMessage("closeAutofillInlineMenu"); + if (isClosingInlineMenu) { + void this.sendExtensionMessage("closeAutofillInlineMenu"); } } + /** + * Sets the most recently focused field within the current frame to a `null` value. + */ unsetMostRecentlyFocusedField() { this.mostRecentlyFocusedField = null; } @@ -694,6 +697,14 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return !isLoginCipherField; } + /** + * Validates whether a field is considered to be "hidden" based on the field's attributes. + * If the field is hidden, a fallback listener will be set up to ensure that the + * field will have the inline menu set up on it when it becomes visible. + * + * @param formFieldElement - The form field element that triggered the focus event. + * @param autofillFieldData - Autofill field data captured from the form field element. + */ private isHiddenField( formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, @@ -708,6 +719,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return true; } + /** + * Sets up a fallback listener that will facilitate setting up the + * inline menu on the field when it becomes visible and focused. + * + * @param formFieldElement - The form field element that triggered the focus event. + * @param autofillFieldData - Autofill field data captured from the form field element. + */ private setupHiddenFieldFallbackListener( formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, @@ -716,11 +734,23 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ formFieldElement.addEventListener(EVENTS.FOCUS, this.handleHiddenFieldFocusEvent); } + /** + * Removes the fallback listener that facilitates setting up the inline + * menu on the field when it becomes visible and focused. + * + * @param formFieldElement - The form field element that triggered the focus event. + */ private removeHiddenFieldFallbackListener(formFieldElement: ElementWithOpId) { formFieldElement.removeEventListener(EVENTS.FOCUS, this.handleHiddenFieldFocusEvent); this.hiddenFormFieldElements.delete(formFieldElement); } + /** + * Handles the focus event on a hidden field. When + * triggered, the inline menu is set up on the field. + * + * @param event - The focus event. + */ private handleHiddenFieldFocusEvent = (event: FocusEvent) => { const formFieldElement = event.target as ElementWithOpId; const autofillFieldData = this.hiddenFormFieldElements.get(formFieldElement); @@ -766,8 +796,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ globalThis.removeEventListener(EVENTS.RESIZE, this.handleOverlayRepositionEvent); } - private overlayRepositionTimeout: number | NodeJS.Timeout; - /** * Handles the resize or scroll events that enact * repositioning of existing overlay elements. @@ -783,6 +811,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ this.userInteractionEventTimeout = setTimeout(this.triggerOverlayRepositionUpdates, 750); }; + /** + * Triggers a rebuild of a sub frame's offsets within the tab. + */ private rebuildSubFrameOffsets() { this.clearRecalculateSubFrameOffsetsTimeout(); this.recalculateSubFrameOffsetsTimeout = setTimeout( @@ -840,12 +871,19 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ } } + /** + * Clears the timeout that facilitates recalculating the sub frame offsets. + */ private clearRecalculateSubFrameOffsetsTimeout() { if (this.recalculateSubFrameOffsetsTimeout) { clearTimeout(this.recalculateSubFrameOffsetsTimeout); } } + /** + * Checks if the focused field is present within the bounds of the viewport. + * If not present, the inline menu will be closed. + */ private isFocusedFieldWithinViewportBounds() { const focusedFieldRectsTop = this.focusedFieldData?.focusedFieldRects?.top; return ( @@ -855,6 +893,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ ); } + /** + * Returns a value that indicates if we should hide the inline menu list due to a filled field. + * + * @param formFieldElement - The form field element that triggered the focus event. + */ private async hideAutofillInlineMenuListOnFilledField( formFieldElement?: FillableFormFieldElement, ): Promise { @@ -864,6 +907,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ ); } + /** + * Indicates whether the most recently focused field has a value. + */ private mostRecentlyFocusedFieldHasValue() { return Boolean((this.mostRecentlyFocusedField as FillableFormFieldElement)?.value); } @@ -889,7 +935,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return; } - this.mostRecentlyFocusedField = null; + this.unsetMostRecentlyFocusedField(); void this.sendExtensionMessage("closeAutofillInlineMenu", { forceCloseAutofillInlineMenu: true, }); @@ -909,6 +955,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return documentRoot?.activeElement; } + /** + * Queries all iframe elements within the document and returns the + * sub frame offsets for each iframe element. + * + * @param message - The message object from the extension. + */ private async getSubFrameOffsets( message: AutofillExtensionMessage, ): Promise { @@ -930,6 +982,14 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return this.calculateSubFrameOffsets(iframeElement, subFrameUrl); } + /** + * Calculates the bounding rect for the queried frame and returns the + * offset data for the sub frame. + * + * @param iframeElement - The iframe element to calculate the sub frame offsets for. + * @param subFrameUrl - The URL of the sub frame. + * @param frameId - The frame ID of the sub frame. + */ private calculateSubFrameOffsets( iframeElement: HTMLIFrameElement, subFrameUrl?: string, @@ -950,6 +1010,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ }; } + /** + * Posts a message to the parent frame to calculate the sub frame offset of the current frame. + * + * @param message - The message object from the extension. + */ private getSubFrameOffsetsFromWindowMessage(message: any) { globalThis.parent.postMessage( { @@ -966,14 +1031,24 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ ); } + /** + * Handles window messages that are sent to the current frame. Will trigger a + * calculation of the sub frame offsets through the parent frame. + * + * @param event - The message event. + */ private handleWindowMessageEvent = (event: MessageEvent) => { - if (event.data?.command !== "calculateSubFramePositioning") { - return; + if (event.data?.command === "calculateSubFramePositioning") { + void this.calculateSubFramePositioning(event); } - - void this.calculateSubFramePositioning(event); }; + /** + * Calculates the sub frame positioning for the current frame + * through all parent frames until the top frame is reached. + * + * @param event - The message event. + */ private calculateSubFramePositioning = async (event: MessageEvent) => { const subFrameData = event.data.subFrameData; let subFrameOffsets: SubFrameOffsetData; @@ -1006,30 +1081,49 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ }); }; + /** + * Updates the local reference to the inline menu visibility setting. + * + * @param data - The data object from the extension message. + */ private updateAutofillInlineMenuVisibility({ data }: AutofillExtensionMessage) { - if (isNaN(data?.inlineMenuVisibility)) { - return; + if (!isNaN(data?.inlineMenuVisibility)) { + this.inlineMenuVisibility = data.inlineMenuVisibility; } - - this.inlineMenuVisibility = data.inlineMenuVisibility; } + /** + * Checks if a field is currently filling within an frame in the tab. + */ private async isFieldCurrentlyFilling() { return (await this.sendExtensionMessage("checkIsFieldCurrentlyFilling")) === true; } + /** + * Checks if the inline menu button is visible at the top frame. + */ private async isInlineMenuButtonVisible() { return (await this.sendExtensionMessage("checkIsAutofillInlineMenuButtonVisible")) === true; } + /** + * Checks if the inline menu list if visible at the top frame. + */ private async isInlineMenuListVisible() { return (await this.sendExtensionMessage("checkIsAutofillInlineMenuListVisible")) === true; } + /** + * Checks if the current tab contains ciphers that can be used to populate the inline menu. + */ private async isInlineMenuCiphersPopulated() { return (await this.sendExtensionMessage("checkIsInlineMenuCiphersPopulated")) === true; } + /** + * Triggers a validation to ensure that the inline menu is repositioned only when the + * current frame contains the focused field at any given depth level. + */ private async checkShouldRepositionInlineMenu() { return (await this.sendExtensionMessage("checkShouldRepositionInlineMenu")) === true; }