diff --git a/apps/browser/src/autofill/content/autofill.css b/apps/browser/src/autofill/content/autofill.css deleted file mode 100644 index cbdb776fafa..00000000000 --- a/apps/browser/src/autofill/content/autofill.css +++ /dev/null @@ -1,43 +0,0 @@ -@-webkit-keyframes bitwardenfill { - 0% { - -webkit-transform: scale(1, 1); - } - - 50% { - -webkit-transform: scale(1.2, 1.2); - } - - 100% { - -webkit-transform: scale(1, 1); - } -} - -@-moz-keyframes bitwardenfill { - 0% { - transform: scale(1, 1); - } - - 50% { - transform: scale(1.2, 1.2); - } - - 100% { - transform: scale(1, 1); - } -} - -span[data-bwautofill].com-bitwarden-browser-animated-fill { - display: inline-block; -} - -.com-bitwarden-browser-animated-fill { - animation: bitwardenfill 200ms ease-in-out 0ms 1; - -webkit-animation: bitwardenfill 200ms ease-in-out 0ms 1; -} - -@media (prefers-reduced-motion) { - .com-bitwarden-browser-animated-fill { - animation: none; - -webkit-animation: none; - } -} diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 3541656f4e6..a0748a319b8 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -68,6 +68,8 @@ function setMockWindowLocation({ } describe("InsertAutofillContentService", () => { + let matchesMedia = false; + window.matchMedia = jest.fn(() => mock({ matches: matchesMedia })); const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); const inlineMenuFieldQualificationService = mock(); const domQueryService = new DomQueryService(); @@ -85,6 +87,7 @@ describe("InsertAutofillContentService", () => { let fillScript: AutofillScript; beforeEach(() => { + matchesMedia = false; document.body.innerHTML = mockLoginForm; confirmSpy = jest.spyOn(globalThis, "confirm"); windowLocationSpy = jest.spyOn(globalThis, "location", "get"); @@ -765,45 +768,30 @@ describe("InsertAutofillContentService", () => { }); describe("will not trigger the animation when...", () => { - it("the element is a non-hidden hidden input type", async () => { + it("the user prefers reduced motion", () => { + matchesMedia = true; + const testElement = document.querySelector( + 'input[type="password"]', + ) as FillableFormFieldElement; + jest.spyOn(testElement.style, "setProperty"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.style.setProperty).not.toHaveBeenCalled(); + }); + + it("the element is a non-hidden hidden input type", () => { document.body.innerHTML = mockLoginForm + ''; const testElement = document.querySelector( 'input[type="hidden"]', ) as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); - - insertAutofillContentService["triggerFillAnimationOnElement"](testElement); - await jest.advanceTimersByTime(200); - - expect(testElement.classList.add).not.toHaveBeenCalled(); - expect(testElement.classList.remove).not.toHaveBeenCalled(); - }); - - it("the element is a non-hidden textarea", () => { - document.body.innerHTML = mockLoginForm + ""; - const testElement = document.querySelector("textarea") as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).not.toHaveBeenCalled(); - expect(testElement.classList.remove).not.toHaveBeenCalled(); - }); - - it("the element is a unsupported tag", () => { - document.body.innerHTML = mockLoginForm + '
'; - const testElement = document.querySelector("#input-tag") as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); - - insertAutofillContentService["triggerFillAnimationOnElement"](testElement); - jest.advanceTimersByTime(200); - - expect(testElement.classList.add).not.toHaveBeenCalled(); - expect(testElement.classList.remove).not.toHaveBeenCalled(); + expect(testElement.style.setProperty).not.toHaveBeenCalled(); }); it("the element has a `visibility: hidden;` CSS rule applied to it", () => { @@ -811,14 +799,12 @@ describe("InsertAutofillContentService", () => { 'input[type="password"]', ) as FillableFormFieldElement; testElement.style.visibility = "hidden"; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).not.toHaveBeenCalled(); - expect(testElement.classList.remove).not.toHaveBeenCalled(); + expect(testElement.style.setProperty).not.toHaveBeenCalled(); }); it("the element has a `display: none;` CSS rule applied to it", () => { @@ -826,14 +812,12 @@ describe("InsertAutofillContentService", () => { 'input[type="password"]', ) as FillableFormFieldElement; testElement.style.display = "none"; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).not.toHaveBeenCalled(); - expect(testElement.classList.remove).not.toHaveBeenCalled(); + expect(testElement.style.setProperty).not.toHaveBeenCalled(); }); it("a parent of the element has an `opacity: 0;` CSS rule applied to it", () => { @@ -842,14 +826,12 @@ describe("InsertAutofillContentService", () => { const testElement = document.querySelector( 'input[type="email"]', ) as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).not.toHaveBeenCalled(); - expect(testElement.classList.remove).not.toHaveBeenCalled(); + expect(testElement.style.setProperty).not.toHaveBeenCalled(); }); }); @@ -858,24 +840,15 @@ describe("InsertAutofillContentService", () => { const testElement = document.querySelector( 'input[type="password"]', ) as FillableFormFieldElement; - jest.spyOn( - insertAutofillContentService["domElementVisibilityService"], - "isElementHiddenByCss", - ); - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect( - insertAutofillContentService["domElementVisibilityService"].isElementHiddenByCss, - ).toHaveBeenCalledWith(testElement); - expect(testElement.classList.add).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); - expect(testElement.classList.remove).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", + expect(testElement.style.setProperty).toHaveBeenCalledWith( + "transition", + "background-color 0.2s ease-out, color 0.2s ease-out, border-color 0.2s ease-out", + "important", ); }); @@ -884,17 +857,15 @@ describe("InsertAutofillContentService", () => { const testElement = document.querySelector( 'input[type="email"]', ) as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); - expect(testElement.classList.remove).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", + expect(testElement.style.setProperty).toHaveBeenCalledWith( + "background-color", + "rgba(232, 240, 255, 1)", + "important", ); }); @@ -903,17 +874,15 @@ describe("InsertAutofillContentService", () => { const testElement = document.querySelector( 'input[type="text"]', ) as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); - expect(testElement.classList.remove).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", + expect(testElement.style.setProperty).toHaveBeenCalledWith( + "color", + "rgba(14, 55, 129, 1)", + "important", ); }); @@ -922,69 +891,53 @@ describe("InsertAutofillContentService", () => { const testElement = document.querySelector( 'input[type="number"]', ) as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); - expect(testElement.classList.remove).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", + expect(testElement.style.setProperty).toHaveBeenCalledWith( + "border-color", + "rgba(23, 93, 220, 1)", + "important", ); }); it("the element is a non-hidden tel input", () => { document.body.innerHTML = mockLoginForm + ''; const testElement = document.querySelector('input[type="tel"]') as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); - expect(testElement.classList.remove).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", + expect(testElement.style.setProperty).toHaveBeenCalledWith( + "outline", + "2px solid rgba(23, 93, 220, 0.7)", + "important", ); }); it("the element is a non-hidden url input", () => { document.body.innerHTML = mockLoginForm + ''; const testElement = document.querySelector('input[type="url"]') as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); - expect(testElement.classList.remove).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); + expect(testElement.style.setProperty).toHaveBeenCalled(); }); it("the element is a non-hidden span", () => { document.body.innerHTML = mockLoginForm + ''; const testElement = document.querySelector("#input-tag") as FillableFormFieldElement; - jest.spyOn(testElement.classList, "add"); - jest.spyOn(testElement.classList, "remove"); + jest.spyOn(testElement.style, "setProperty"); insertAutofillContentService["triggerFillAnimationOnElement"](testElement); jest.advanceTimersByTime(200); - expect(testElement.classList.add).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); - expect(testElement.classList.remove).toHaveBeenCalledWith( - "com-bitwarden-browser-animated-fill", - ); + expect(testElement.style.setProperty).toHaveBeenCalled(); }); }); }); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index 058ce087c61..af4f4030281 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -7,6 +7,7 @@ import { elementIsInputElement, elementIsSelectElement, elementIsTextAreaElement, + setElementStyles, } from "../utils"; import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service"; @@ -14,6 +15,7 @@ import { CollectAutofillContentService } from "./collect-autofill-content.servic import DomElementVisibilityService from "./dom-element-visibility.service"; class InsertAutofillContentService implements InsertAutofillContentServiceInterface { + private readonly filledElements: WeakMap> = new Map(); private readonly autofillInsertActions: AutofillInsertActions = { fill_by_opid: ({ opid, value }) => this.handleFillFieldByOpidAction(opid, value), click_on_opid: ({ opid }) => this.handleClickOnFieldByOpidAction(opid), @@ -286,18 +288,64 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf * @private */ private triggerFillAnimationOnElement(element: FormFieldElement): void { - const skipAnimatingElement = - elementIsFillableFormField(element) && - !new Set(["email", "text", "password", "number", "tel", "url"]).has(element?.type); - - if (this.domElementVisibilityService.isElementHiddenByCss(element) || skipAnimatingElement) { + const prefersReducedMotion = !!globalThis.matchMedia(`(prefers-reduced-motion: reduce)`) + .matches; + if (prefersReducedMotion) { return; } - element.classList.add("com-bitwarden-browser-animated-fill"); - setTimeout(() => element.classList.remove("com-bitwarden-browser-animated-fill"), 200); + const skipAnimatingElement = + elementIsInputElement(element) && + !new Set(["email", "text", "password", "number", "tel", "url", "date"]).has(element?.type); + if (skipAnimatingElement || this.domElementVisibilityService.isElementHiddenByCss(element)) { + return; + } + + if (!this.filledElements.has(element)) { + this.filledElements.set(element, { + transition: element.style.transition, + backgroundColor: element.style.backgroundColor, + textColor: element.style.color, + borderColor: element.style.borderColor, + outline: element.style.outline, + }); + } + + setElementStyles( + element, + { + transition: + "background-color 0.2s ease-out, color 0.2s ease-out, border-color 0.2s ease-out", + backgroundColor: "rgba(232, 240, 255, 1)", + color: "rgba(14, 55, 129, 1)", + borderColor: "rgba(23, 93, 220, 1)", + outline: "2px solid rgba(23, 93, 220, 0.7)", + }, + true, + ); + element.addEventListener(EVENTS.INPUT, this.resetElementStyles, { once: true }); } + /** + * Reset the element styles to their original values upon user modification of the form field. + * + * @param event - The keyboard event that triggered the reset. + */ + private resetElementStyles = (event: KeyboardEvent): void => { + const element = event.target as HTMLElement; + if (this.filledElements.has(element)) { + const { backgroundColor, textColor, borderColor, outline, transition } = + this.filledElements.get(element); + setElementStyles(element, { + backgroundColor: backgroundColor, + color: textColor, + borderColor: borderColor, + outline: outline, + transition: transition, + }); + } + }; + /** * Simulates a click event on the element. * @param {HTMLElement} element diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index f2fafac3d8c..3f6bae4d137 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -142,7 +142,7 @@ export function setElementStyles( for (const styleProperty in styles) { element.style.setProperty( - styleProperty.replace(/([a-z])([A-Z])/g, "$1-$2"), // Convert camelCase to kebab-case + styleProperty.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(), // Convert camelCase to kebab-case styles[styleProperty], priority ? "important" : undefined, ); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 35692dd5674..7ef36fd0088 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -24,7 +24,6 @@ }, { "all_frames": true, - "css": ["content/autofill.css"], "js": ["content/trigger-autofill-script-injection.js"], "matches": ["*://*/*", "file:///*"], "exclude_matches": ["*://*/*.xml*", "file:///*.xml*"], diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 7bd40691768..e5bcc730101 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -25,7 +25,6 @@ }, { "all_frames": true, - "css": ["content/autofill.css"], "js": ["content/trigger-autofill-script-injection.js", "content/misc-utils.js"], "matches": ["*://*/*", "file:///*"], "exclude_matches": ["*://*/*.xml*", "file:///*.xml*"], diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 7ac5a635b18..55f55eb32f5 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -189,7 +189,6 @@ const plugins = [ { from: "./src/_locales", to: "_locales" }, { from: "./src/images", to: "images" }, { from: "./src/popup/images", to: "popup/images" }, - { from: "./src/autofill/content/autofill.css", to: "content" }, ], }), new MiniCssExtractPlugin({