summaryrefslogtreecommitdiffstats
path: root/browser/components/aboutlogins/content/components
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/aboutlogins/content/components')
-rw-r--r--browser/components/aboutlogins/content/components/confirmation-dialog.css73
-rw-r--r--browser/components/aboutlogins/content/components/confirmation-dialog.mjs105
-rw-r--r--browser/components/aboutlogins/content/components/fxaccounts-button.css74
-rw-r--r--browser/components/aboutlogins/content/components/fxaccounts-button.mjs83
-rw-r--r--browser/components/aboutlogins/content/components/generic-dialog.css64
-rw-r--r--browser/components/aboutlogins/content/components/generic-dialog.mjs63
-rw-r--r--browser/components/aboutlogins/content/components/import-details-row.mjs60
-rw-r--r--browser/components/aboutlogins/content/components/import-error-dialog.css28
-rw-r--r--browser/components/aboutlogins/content/components/import-error-dialog.mjs59
-rw-r--r--browser/components/aboutlogins/content/components/import-summary-dialog.css42
-rw-r--r--browser/components/aboutlogins/content/components/import-summary-dialog.mjs72
-rw-r--r--browser/components/aboutlogins/content/components/input-field/input-field.css60
-rw-r--r--browser/components/aboutlogins/content/components/input-field/input-field.mjs32
-rw-r--r--browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs133
-rw-r--r--browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs43
-rw-r--r--browser/components/aboutlogins/content/components/input-field/login-password-field.mjs84
-rw-r--r--browser/components/aboutlogins/content/components/input-field/login-username-field.mjs30
-rw-r--r--browser/components/aboutlogins/content/components/login-alert.css81
-rw-r--r--browser/components/aboutlogins/content/components/login-alert.mjs150
-rw-r--r--browser/components/aboutlogins/content/components/login-alert.stories.mjs79
-rw-r--r--browser/components/aboutlogins/content/components/login-command-button.css34
-rw-r--r--browser/components/aboutlogins/content/components/login-command-button.mjs187
-rw-r--r--browser/components/aboutlogins/content/components/login-filter.css29
-rw-r--r--browser/components/aboutlogins/content/components/login-filter.mjs99
-rw-r--r--browser/components/aboutlogins/content/components/login-intro.css23
-rw-r--r--browser/components/aboutlogins/content/components/login-intro.mjs65
-rw-r--r--browser/components/aboutlogins/content/components/login-item.css328
-rw-r--r--browser/components/aboutlogins/content/components/login-item.mjs1038
-rw-r--r--browser/components/aboutlogins/content/components/login-list-item.mjs34
-rw-r--r--browser/components/aboutlogins/content/components/login-list-item.stories.mjs62
-rw-r--r--browser/components/aboutlogins/content/components/login-list-lit-item.css81
-rw-r--r--browser/components/aboutlogins/content/components/login-list-lit-item.mjs169
-rw-r--r--browser/components/aboutlogins/content/components/login-list-section.mjs34
-rw-r--r--browser/components/aboutlogins/content/components/login-list.css163
-rw-r--r--browser/components/aboutlogins/content/components/login-list.mjs923
-rw-r--r--browser/components/aboutlogins/content/components/login-message-popup.css47
-rw-r--r--browser/components/aboutlogins/content/components/login-message-popup.mjs59
-rw-r--r--browser/components/aboutlogins/content/components/login-timeline.css62
-rw-r--r--browser/components/aboutlogins/content/components/login-timeline.mjs79
-rw-r--r--browser/components/aboutlogins/content/components/menu-button.css93
-rw-r--r--browser/components/aboutlogins/content/components/menu-button.mjs183
-rw-r--r--browser/components/aboutlogins/content/components/remove-logins-dialog.css102
-rw-r--r--browser/components/aboutlogins/content/components/remove-logins-dialog.mjs117
43 files changed, 5426 insertions, 0 deletions
diff --git a/browser/components/aboutlogins/content/components/confirmation-dialog.css b/browser/components/aboutlogins/content/components/confirmation-dialog.css
new file mode 100644
index 0000000000..49c3188f6f
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/confirmation-dialog.css
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ .overlay {
+ position: fixed;
+ z-index: 1;
+ inset: 0;
+ /* TODO: this color is used in the about:preferences overlay, but
+ why isn't it declared as a variable? */
+ background-color: rgba(0,0,0,0.5);
+ display: flex;
+}
+
+.container {
+ z-index: 2;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 250px;
+ max-width: 500px;
+ min-height: 200px;
+ margin: auto;
+ background: var(--in-content-page-background);
+ color: var(--in-content-page-color);
+ box-shadow: var(--shadow-30);
+ /* show a border in high contrast mode */
+ outline: 1px solid transparent;
+}
+
+.title {
+ font-size: 1.5em;
+ font-weight: normal;
+ user-select: none;
+ margin: 0;
+}
+
+.message {
+ color: var(--text-color-deemphasized);
+ margin-bottom: 0;
+}
+
+.dismiss-button {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 0;
+ min-width: 20px;
+ min-height: 20px;
+ margin: 16px;
+ padding: 0;
+ line-height: 0;
+}
+
+.dismiss-icon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ user-select: none;
+}
+
+.warning-icon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ user-select: none;
+ width: 40px;
+ height: 40px;
+ margin: 16px;
+}
+
+.content,
+.buttons {
+ text-align: center;
+ padding: 16px 32px;
+}
diff --git a/browser/components/aboutlogins/content/components/confirmation-dialog.mjs b/browser/components/aboutlogins/content/components/confirmation-dialog.mjs
new file mode 100644
index 0000000000..91a9c3a9d7
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/confirmation-dialog.mjs
@@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { setKeyboardAccessForNonDialogElements } from "../aboutLoginsUtils.mjs";
+
+export default class ConfirmationDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ let template = document.querySelector("#confirmation-dialog-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(template.content.cloneNode(true));
+
+ this._buttons = this.shadowRoot.querySelector(".buttons");
+ this._cancelButton = this.shadowRoot.querySelector(".cancel-button");
+ this._confirmButton = this.shadowRoot.querySelector(".confirm-button");
+ this._dismissButton = this.shadowRoot.querySelector(".dismiss-button");
+ this._message = this.shadowRoot.querySelector(".message");
+ this._overlay = this.shadowRoot.querySelector(".overlay");
+ this._title = this.shadowRoot.querySelector(".title");
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keydown":
+ if (event.repeat) {
+ // Prevent repeat keypresses from accidentally confirming the
+ // dialog since the confirmation button is focused by default.
+ event.preventDefault();
+ return;
+ }
+ if (event.key === "Escape" && !event.defaultPrevented) {
+ this.onCancel();
+ }
+ break;
+ case "click":
+ if (
+ event.target.classList.contains("cancel-button") ||
+ event.currentTarget.classList.contains("dismiss-button") ||
+ event.target.classList.contains("overlay")
+ ) {
+ this.onCancel();
+ } else if (event.target.classList.contains("confirm-button")) {
+ this.onConfirm();
+ }
+ }
+ }
+
+ hide() {
+ setKeyboardAccessForNonDialogElements(true);
+ this._cancelButton.removeEventListener("click", this);
+ this._confirmButton.removeEventListener("click", this);
+ this._dismissButton.removeEventListener("click", this);
+ this._overlay.removeEventListener("click", this);
+ window.removeEventListener("keydown", this);
+
+ this.hidden = true;
+ }
+
+ show({ title, message, confirmButtonLabel }) {
+ setKeyboardAccessForNonDialogElements(false);
+ this.hidden = false;
+
+ document.l10n.setAttributes(this._title, title);
+ document.l10n.setAttributes(this._message, message);
+ document.l10n.setAttributes(this._confirmButton, confirmButtonLabel);
+
+ this._cancelButton.addEventListener("click", this);
+ this._confirmButton.addEventListener("click", this);
+ this._dismissButton.addEventListener("click", this);
+ this._overlay.addEventListener("click", this);
+ window.addEventListener("keydown", this);
+
+ // For speed-of-use, focus the confirm button when the
+ // dialog loads. Showing the dialog itself provides enough
+ // of a buffer for accidental deletions.
+ this._confirmButton.focus();
+
+ this._promise = new Promise((resolve, reject) => {
+ this._resolve = resolve;
+ this._reject = reject;
+ });
+
+ return this._promise;
+ }
+
+ onCancel() {
+ this._reject();
+ this.hide();
+ }
+
+ onConfirm() {
+ this._resolve();
+ this.hide();
+ }
+}
+customElements.define("confirmation-dialog", ConfirmationDialog);
diff --git a/browser/components/aboutlogins/content/components/fxaccounts-button.css b/browser/components/aboutlogins/content/components/fxaccounts-button.css
new file mode 100644
index 0000000000..a6d136ff70
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/fxaccounts-button.css
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.logged-out-view,
+.logged-in-view {
+ display: flex;
+ align-items: center;
+}
+
+.fxaccounts-extra-text {
+ /* Only show at most 3 lines of text to limit the
+ text from overflowing the header. */
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ overflow: hidden;
+ text-align: end;
+}
+
+@media (max-width: 830px) {
+ .fxaccounts-extra-text,
+ .fxaccount-email {
+ display: none;
+ }
+}
+
+.fxaccount-avatar,
+.fxaccounts-enable-button {
+ font-size: var(--font-size-small);
+ margin-inline-start: 9px;
+}
+
+.fxaccounts-enable-button {
+ min-width: 120px;
+ padding-inline: 16px;
+ /* See bug 1626764: The width of button could go lesser than 120px in small window size which could wrap the texts into two lines in systems with different default fonts */
+ flex-shrink: 0;
+}
+
+.fxaccounts-avatar-button {
+ cursor: pointer;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.fxaccount-email {
+ font-size: var(--font-size-small);
+ vertical-align: middle;
+}
+
+.fxaccount-avatar {
+ display: inline-block;
+ vertical-align: middle;
+ background-image: var(--avatar-url,
+ url(chrome://browser/skin/fxa/avatar-color.svg));
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ border-radius: 1000px;
+ width: 32px;
+ height: 32px;
+}
+
+@media not (prefers-contrast) {
+ .fxaccounts-avatar-button:hover {
+ background-color: transparent !important;
+ }
+
+ .fxaccounts-avatar-button:hover > .fxaccount-email {
+ text-decoration: underline;
+ }
+}
diff --git a/browser/components/aboutlogins/content/components/fxaccounts-button.mjs b/browser/components/aboutlogins/content/components/fxaccounts-button.mjs
new file mode 100644
index 0000000000..d39969d726
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/fxaccounts-button.mjs
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export default class FxAccountsButton extends HTMLElement {
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ let template = document.querySelector("#fxaccounts-button-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(template.content.cloneNode(true));
+
+ this._avatarButton = shadowRoot.querySelector(".fxaccounts-avatar-button");
+ this._extraText = shadowRoot.querySelector(".fxaccounts-extra-text");
+ this._enableButton = shadowRoot.querySelector(".fxaccounts-enable-button");
+ this._loggedOutView = shadowRoot.querySelector(".logged-out-view");
+ this._loggedInView = shadowRoot.querySelector(".logged-in-view");
+ this._emailText = shadowRoot.querySelector(".fxaccount-email");
+
+ this._avatarButton.addEventListener("click", this);
+ this._enableButton.addEventListener("click", this);
+
+ this.render();
+ }
+
+ handleEvent(event) {
+ if (event.currentTarget == this._avatarButton) {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsSyncOptions", {
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ if (event.target == this._enableButton) {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsSyncEnable", {
+ bubbles: true,
+ })
+ );
+ }
+ }
+
+ render() {
+ this._loggedOutView.hidden = !!this._loggedIn;
+ this._loggedInView.hidden = !this._loggedIn;
+ this._emailText.textContent = this._email;
+ if (this._avatarURL) {
+ this._avatarButton.style.setProperty(
+ "--avatar-url",
+ `url(${this._avatarURL})`
+ );
+ } else {
+ let defaultAvatar = "chrome://browser/skin/fxa/avatar-color.svg";
+ this._avatarButton.style.setProperty(
+ "--avatar-url",
+ `url(${defaultAvatar})`
+ );
+ }
+ }
+
+ /**
+ *
+ * @param {object} state
+ * loggedIn: {Boolean} FxAccount authentication
+ * status.
+ * email: {String} Email address used with FxAccount. Must
+ * be empty if `loggedIn` is false.
+ * avatarURL: {String} URL of account avatar. Must
+ * be empty if `loggedIn` is false.
+ */
+ updateState(state) {
+ this.hidden = !state.fxAccountsEnabled;
+ this._loggedIn = state.loggedIn;
+ this._email = state.email;
+ this._avatarURL = state.avatarURL;
+
+ this.render();
+ }
+}
+customElements.define("fxaccounts-button", FxAccountsButton);
diff --git a/browser/components/aboutlogins/content/components/generic-dialog.css b/browser/components/aboutlogins/content/components/generic-dialog.css
new file mode 100644
index 0000000000..897ef5da20
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/generic-dialog.css
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.overlay {
+ position: fixed;
+ z-index: 1;
+ inset: 0;
+ /* TODO: this color is used in the about:preferences overlay, but
+ why isn't it declared as a variable? */
+ background-color: rgba(0,0,0,0.5);
+ display: flex;
+}
+
+.container {
+ z-index: 2;
+ position: relative;
+ display: grid;
+ grid-template-columns: 37px auto;
+ grid-template-rows: 32px auto 50px;
+ grid-gap: 5px;
+ align-items: center;
+ width: 580px;
+ height: 290px;
+ padding: 50px 50px 20px;
+ margin: auto;
+ background-color: var(--in-content-page-background);
+ color: var(--in-content-page-color);
+ box-shadow: var(--shadow-30);
+ /* show a border in high contrast mode */
+ outline: 1px solid transparent;
+}
+
+::slotted([slot="dialog-icon"]) {
+ width: 32px;
+ height: 32px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+::slotted([slot="dialog-title"]) {
+ font-size: 2.2em;
+ user-select: none;
+ margin: 0;
+}
+
+::slotted([slot="content"]) {
+ grid-column-start: 2;
+ align-self: baseline;
+ margin-top: 16px;
+ line-height: 1.4em;
+}
+
+::slotted([slot="buttons"]) {
+ grid-column: 1 / 4;
+ grid-row-start: 3;
+ border-top: 1px solid var(--in-content-border-color);
+ padding-top: 12px;
+}
+
+.dialog-body {
+ padding-block: 40px 16px;
+ padding-inline: 45px 32px;
+}
diff --git a/browser/components/aboutlogins/content/components/generic-dialog.mjs b/browser/components/aboutlogins/content/components/generic-dialog.mjs
new file mode 100644
index 0000000000..8d9ddc9d36
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/generic-dialog.mjs
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ setKeyboardAccessForNonDialogElements,
+ initDialog,
+} from "../aboutLoginsUtils.mjs";
+
+export default class GenericDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ const shadowRoot = initDialog(this, "#generic-dialog-template");
+ this._dismissButton = this.querySelector(".dismiss-button");
+ this._overlay = shadowRoot.querySelector(".overlay");
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keydown":
+ if (event.key === "Escape" && !event.defaultPrevented) {
+ this.hide();
+ }
+ break;
+ case "click":
+ if (
+ event.currentTarget.classList.contains("dismiss-button") ||
+ event.target.classList.contains("overlay")
+ ) {
+ this.hide();
+ }
+ }
+ }
+
+ show() {
+ setKeyboardAccessForNonDialogElements(false);
+ this.hidden = false;
+ this.parentNode.host.hidden = false;
+
+ this._dismissButton.addEventListener("click", this);
+ this._overlay.addEventListener("click", this);
+ window.addEventListener("keydown", this);
+ }
+
+ hide() {
+ setKeyboardAccessForNonDialogElements(true);
+ this._dismissButton.removeEventListener("click", this);
+ this._overlay.removeEventListener("click", this);
+ window.removeEventListener("keydown", this);
+
+ this.hidden = true;
+ this.parentNode.host.hidden = true;
+ }
+}
+
+customElements.define("generic-dialog", GenericDialog);
diff --git a/browser/components/aboutlogins/content/components/import-details-row.mjs b/browser/components/aboutlogins/content/components/import-details-row.mjs
new file mode 100644
index 0000000000..8b6fe269a3
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-details-row.mjs
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const resultToUiData = {
+ no_change: {
+ message: "about-logins-import-report-row-description-no-change2",
+ },
+ modified: {
+ message: "about-logins-import-report-row-description-modified2",
+ },
+ added: {
+ message: "about-logins-import-report-row-description-added2",
+ },
+ error: {
+ message: "about-logins-import-report-row-description-error",
+ isError: true,
+ },
+ error_multiple_values: {
+ message: "about-logins-import-report-row-description-error-multiple-values",
+ isError: true,
+ },
+ error_missing_field: {
+ message: "about-logins-import-report-row-description-error-missing-field",
+ isError: true,
+ },
+};
+
+export default class ImportDetailsRow extends HTMLElement {
+ constructor(number, reportRow) {
+ super();
+ this._login = reportRow;
+
+ let rowElement = document
+ .querySelector("#import-details-row-template")
+ .content.cloneNode(true);
+
+ const uiData = resultToUiData[reportRow.result];
+ if (uiData.isError) {
+ this.classList.add("error");
+ }
+ const rowCount = rowElement.querySelector(".row-count");
+ const rowDetails = rowElement.querySelector(".row-details");
+ while (rowElement.childNodes.length) {
+ this.appendChild(rowElement.childNodes[0]);
+ }
+ document.l10n.connectRoot(this);
+ document.l10n.setAttributes(
+ rowCount,
+ "about-logins-import-report-row-index",
+ {
+ number,
+ }
+ );
+ document.l10n.setAttributes(rowDetails, uiData.message, {
+ field: reportRow.field_name,
+ });
+ }
+}
+customElements.define("import-details-row", ImportDetailsRow);
diff --git a/browser/components/aboutlogins/content/components/import-error-dialog.css b/browser/components/aboutlogins/content/components/import-error-dialog.css
new file mode 100644
index 0000000000..6fc2e945e4
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-error-dialog.css
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.content {
+ display: flex;
+ flex-direction: column;
+ grid-area: 2 / 2 / 3 / 4;
+ align-items: flex-start;
+}
+
+.error-title {
+ font-weight: 600;
+ margin-top: 20px;
+}
+
+.no-logins {
+ margin-top: 25px;
+}
+
+.error-learn-more-link {
+ font-weight: 600;
+}
+
+.warning-icon {
+ -moz-context-properties: fill;
+ fill: #FFBF00;
+}
diff --git a/browser/components/aboutlogins/content/components/import-error-dialog.mjs b/browser/components/aboutlogins/content/components/import-error-dialog.mjs
new file mode 100644
index 0000000000..31ad29512f
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-error-dialog.mjs
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { initDialog } from "../aboutLoginsUtils.mjs";
+
+export default class ImportErrorDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ this._errorMessages = {};
+ this._errorMessages.CONFLICTING_VALUES_ERROR = {
+ title: "about-logins-import-dialog-error-conflicting-values-title",
+ description:
+ "about-logins-import-dialog-error-conflicting-values-description",
+ };
+ this._errorMessages.FILE_FORMAT_ERROR = {
+ title: "about-logins-import-dialog-error-file-format-title",
+ description: "about-logins-import-dialog-error-file-format-description",
+ };
+ this._errorMessages.FILE_PERMISSIONS_ERROR = {
+ title: "about-logins-import-dialog-error-file-permission-title",
+ description:
+ "about-logins-import-dialog-error-file-permission-description",
+ };
+ this._errorMessages.UNABLE_TO_READ_ERROR = {
+ title: "about-logins-import-dialog-error-unable-to-read-title",
+ description:
+ "about-logins-import-dialog-error-unable-to-read-description",
+ };
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ const shadowRoot = initDialog(this, "#import-error-dialog-template");
+ this._titleElement = shadowRoot.querySelector(".error-title");
+ this._descriptionElement = shadowRoot.querySelector(".error-description");
+ this._genericDialog = this.shadowRoot.querySelector("generic-dialog");
+ this._focusedElement = this.shadowRoot.querySelector("a");
+ const tryImportAgain = this.shadowRoot.querySelector(".try-import-again");
+ tryImportAgain.addEventListener("click", () => {
+ this._genericDialog.hide();
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsImportFromFile", { bubbles: true })
+ );
+ });
+ }
+
+ show(errorType) {
+ const { title, description } = this._errorMessages[errorType];
+ document.l10n.setAttributes(this._titleElement, title);
+ document.l10n.setAttributes(this._descriptionElement, description);
+ this._genericDialog.show();
+ window.AboutLoginsUtils.setFocus(this._focusedElement);
+ }
+}
+customElements.define("import-error-dialog", ImportErrorDialog);
diff --git a/browser/components/aboutlogins/content/components/import-summary-dialog.css b/browser/components/aboutlogins/content/components/import-summary-dialog.css
new file mode 100644
index 0000000000..20dd987958
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-summary-dialog.css
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+.import-summary {
+ display: grid;
+ grid-template-columns: max-content max-content max-content;
+}
+
+.import-summary > * > span {
+ margin-block: 0 2px;
+ margin-inline: 0 10px;
+}
+
+.import-items-row {
+ grid-column: 1 / 4;
+ display: grid;
+ grid-template-columns: subgrid;
+}
+
+.result-count {
+ text-align: end;
+ font-weight: bold;
+}
+
+.result-meta {
+ font-style: italic;
+}
+.import-items-errors .result-meta {
+ color: var(--dialog-warning-text-color);
+}
+
+.open-detailed-report {
+ margin-block-start: 30px;
+ font-weight: 600;
+}
diff --git a/browser/components/aboutlogins/content/components/import-summary-dialog.mjs b/browser/components/aboutlogins/content/components/import-summary-dialog.mjs
new file mode 100644
index 0000000000..3b84338527
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/import-summary-dialog.mjs
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { initDialog } from "../aboutLoginsUtils.mjs";
+
+export default class ImportSummaryDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ initDialog(this, "#import-summary-dialog-template");
+ this._added = this.shadowRoot.querySelector(".import-items-added");
+ this._modified = this.shadowRoot.querySelector(".import-items-modified");
+ this._noChange = this.shadowRoot.querySelector(".import-items-no-change");
+ this._error = this.shadowRoot.querySelector(".import-items-errors");
+ this._genericDialog = this.shadowRoot.querySelector("generic-dialog");
+ }
+
+ show({ logins }) {
+ const report = {
+ added: 0,
+ modified: 0,
+ no_change: 0,
+ error: 0,
+ };
+ for (let loginRow of logins) {
+ if (loginRow.result.includes("error")) {
+ report.error++;
+ } else {
+ report[loginRow.result]++;
+ }
+ }
+ this._updateCount(
+ report.added,
+ this._added,
+ "about-logins-import-dialog-items-added2"
+ );
+ this._updateCount(
+ report.modified,
+ this._modified,
+ "about-logins-import-dialog-items-modified2"
+ );
+ this._updateCount(
+ report.no_change,
+ this._noChange,
+ "about-logins-import-dialog-items-no-change2"
+ );
+ this._updateCount(
+ report.error,
+ this._error,
+ "about-logins-import-dialog-items-error"
+ );
+ this._noChange.querySelector(".result-meta").hidden =
+ report.no_change === 0;
+ this._error.querySelector(".result-meta").hidden = report.error === 0;
+ this._genericDialog.show();
+ window.AboutLoginsUtils.setFocus(this._genericDialog._dismissButton);
+ }
+
+ _updateCount(count, component, message) {
+ if (count != document.l10n.getAttributes(component).args.count) {
+ document.l10n.setAttributes(component, message, { count });
+ }
+ }
+}
+customElements.define("import-summary-dialog", ImportSummaryDialog);
diff --git a/browser/components/aboutlogins/content/components/input-field/input-field.css b/browser/components/aboutlogins/content/components/input-field/input-field.css
new file mode 100644
index 0000000000..0e65b7f1fc
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/input-field/input-field.css
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host {
+ display: grid;
+ grid-template-areas: "label label" "input actions";
+ align-items: center;
+ font-family: monospace;
+}
+
+label {
+ grid-area: label;
+ display: block;
+ color: var(--in-content-page-color);
+ margin-bottom: 8px;
+}
+
+/** input.input-field needed to override margin in themes/osx/global/in-content/common.css */
+input.input-field {
+ grid-area: input;
+ margin: 0;
+}
+
+
+.input-field:read-only {
+ all: unset;
+ display: inline-block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.reveal-password-button {
+ grid-area: actions;
+ width: 32px;
+ height: 32px;
+ min-width: 0;
+ background: url("chrome://browser/content/aboutlogins/icons/password.svg") center no-repeat;
+ cursor: pointer;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ color: inherit;
+ opacity: 0.8;
+}
+
+.reveal-password-button.revealed {
+ background-image: url("chrome://browser/content/aboutlogins/icons/password-hide.svg");
+}
+
+/** button.reveal-password-button needed to override --in-content-button-background-hover in common-shared.css **/
+button.reveal-password-button:hover {
+ opacity: 0.6;
+ background-color: transparent;
+}
+
+/** button.reveal-password-button needed to override --in-content-button-background-active in common-shared.css **/
+button.reveal-password-button:hover:active {
+ opacity: 1;
+ background-color: transparent;
+}
diff --git a/browser/components/aboutlogins/content/components/input-field/input-field.mjs b/browser/components/aboutlogins/content/components/input-field/input-field.mjs
new file mode 100644
index 0000000000..dd65f167fe
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/input-field/input-field.mjs
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs";
+
+export const stylesTemplate = () =>
+ html`
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/input-field/input-field.css"
+ />
+ `;
+
+export const editableFieldTemplate = ({
+ type,
+ value,
+ inputId,
+ disabled,
+ onFocus,
+ onBlur,
+}) =>
+ html`<input
+ class="input-field"
+ data-l10n-id=${ifDefined(inputId)}
+ type=${type}
+ value=${value}
+ ?disabled=${disabled}
+ @focus=${onFocus}
+ @blur=${onBlur}
+ />`;
diff --git a/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs b/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs
new file mode 100644
index 0000000000..99dfa83738
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/input-field/input-field.stories.mjs
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// eslint-disable-next-line import/no-unresolved
+import { html } from "lit.all.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "./login-password-field.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "./login-username-field.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "./login-origin-field.mjs";
+
+export default {
+ title: "Domain-specific UI Widgets/Credential Management/Input Fields",
+};
+
+window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl");
+
+export const LoginUsernameField = ({ value, readonly }) => {
+ return html`
+ <div style="max-width: 500px">
+ <login-username-field .value=${value} .readonly=${readonly}>
+ </login-username-field>
+ </div>
+ `;
+};
+
+LoginUsernameField.argTypes = {
+ value: {
+ control: "text",
+ defaultValue: "username",
+ },
+ readonly: {
+ control: "boolean",
+ defaultValue: false,
+ },
+};
+
+export const LoginOriginField = ({ value, readonly }) => {
+ return html`
+ <div style="max-width: 500px">
+ <login-origin-field .value=${value} .readonly=${readonly}>
+ </login-origin-field>
+ </div>
+ `;
+};
+
+LoginOriginField.argTypes = {
+ value: {
+ control: "text",
+ defaultValue: "https://example.com",
+ },
+ readonly: {
+ control: "boolean",
+ defaultValue: false,
+ },
+};
+
+export const LoginPasswordField = ({
+ readonly,
+ visible,
+ value = "longpassword".repeat(2),
+}) => {
+ return html`
+ <div style="max-width: 500px">
+ <login-password-field
+ .value=${value}
+ .readonly=${readonly}
+ .visible=${visible}
+ .onPasswordVisible=${() => alert("auth...")}
+ >
+ </login-password-field>
+ </div>
+ `;
+};
+
+LoginPasswordField.argTypes = {
+ readonly: {
+ control: "boolean",
+ defaultValue: true,
+ },
+ visible: {
+ control: "boolean",
+ defaultValue: false,
+ },
+};
+
+export const LoginPasswordFieldDisplayMode = ({
+ visible,
+ value = "longpassword".repeat(2),
+}) => {
+ return html`
+ <div style="max-width: 500px">
+ <login-password-field
+ .value=${value}
+ .readonly=${true}
+ .visible=${visible}
+ >
+ </login-password-field>
+ </div>
+ `;
+};
+
+LoginPasswordFieldDisplayMode.argTypes = {
+ visible: {
+ control: "boolean",
+ defaultValue: false,
+ },
+};
+
+export const LoginPasswordFieldEditMode = ({
+ visible,
+ value = "longpassword".repeat(2),
+}) => {
+ return html`
+ <div style="max-width: 500px">
+ <login-password-field
+ .value=${value}
+ .readonly=${false}
+ .visible=${visible}
+ >
+ </login-password-field>
+ </div>
+ `;
+};
+
+LoginPasswordFieldEditMode.argTypes = {
+ visible: {
+ control: "boolean",
+ defaultValue: false,
+ },
+};
diff --git a/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs
new file mode 100644
index 0000000000..a6170eae5f
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/input-field/login-origin-field.mjs
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+import { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs";
+
+class LoginOriginField extends MozLitElement {
+ static properties = {
+ value: { type: String, reflect: true },
+ readonly: { type: Boolean, reflect: true },
+ };
+
+ get readonlyTemplate() {
+ return html`
+ <a
+ class="origin-input"
+ dir="auto"
+ target="_blank"
+ rel="noreferrer"
+ name="origin"
+ href=${this.value}
+ >
+ ${this.value}
+ </a>
+ `;
+ }
+
+ render() {
+ return html`
+ ${stylesTemplate()}
+ <label class="field-label" data-l10n-id="login-item-origin-label"></label>
+ ${this.readonly
+ ? this.readonlyTemplate
+ : editableFieldTemplate({
+ type: "url",
+ value: this.value,
+ })}
+ `;
+ }
+}
+
+customElements.define("login-origin-field", LoginOriginField);
diff --git a/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs
new file mode 100644
index 0000000000..6d903beb0a
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/input-field/login-password-field.mjs
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html, classMap } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs";
+
+class LoginPasswordField extends MozLitElement {
+ static CONCEALED_PASSWORD_TEXT = " ".repeat(8);
+
+ static properties = {
+ _value: { type: String, state: true },
+ readonly: { type: Boolean, reflect: true },
+ visible: { type: Boolean, reflect: true },
+ };
+
+ static queries = {
+ input: "input",
+ button: "button",
+ };
+
+ set value(newValue) {
+ this._value = newValue;
+ }
+
+ get #type() {
+ return this.visible ? "text" : "password";
+ }
+
+ get #password() {
+ return this.readonly && !this.visible
+ ? LoginPasswordField.CONCEALED_PASSWORD_TEXT
+ : this._value;
+ }
+
+ render() {
+ return html`
+ ${stylesTemplate()}
+ <label
+ class="field-label"
+ data-l10n-id="login-item-password-label"
+ ></label>
+ ${editableFieldTemplate({
+ type: this.#type,
+ value: this.#password,
+ labelId: "login-item-password-label",
+ disabled: this.readonly,
+ onFocus: this.handleFocus,
+ onBlur: this.handleBlur,
+ })}
+ <button
+ class=${classMap({
+ revealed: this.visible,
+ "reveal-password-button": true,
+ })}
+ data-l10n-id="login-item-password-reveal-checkbox"
+ @click=${this.toggleVisibility}
+ ></button>
+ `;
+ }
+
+ handleFocus(ev) {
+ if (ev.relatedTarget !== this.button) {
+ this.visible = true;
+ }
+ }
+
+ handleBlur(ev) {
+ if (ev.relatedTarget !== this.button) {
+ this.visible = false;
+ }
+ }
+
+ toggleVisibility() {
+ this.visible = !this.visible;
+ if (this.visible) {
+ this.onPasswordVisible?.();
+ }
+ this.input.focus();
+ }
+}
+
+customElements.define("login-password-field", LoginPasswordField);
diff --git a/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs b/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs
new file mode 100644
index 0000000000..87743f3689
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/input-field/login-username-field.mjs
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+import { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import { editableFieldTemplate, stylesTemplate } from "./input-field.mjs";
+
+class LoginUsernameField extends MozLitElement {
+ static properties = {
+ value: { type: String, reflect: true },
+ readonly: { type: Boolean, reflect: true },
+ };
+
+ render() {
+ return html`
+ ${stylesTemplate()}
+ <label
+ class="field-label"
+ data-l10n-id="login-item-username-label"
+ ></label>
+ ${editableFieldTemplate({
+ type: "text",
+ value: this.value,
+ disabled: this.readonly,
+ })}
+ `;
+ }
+}
+
+customElements.define("login-username-field", LoginUsernameField);
diff --git a/browser/components/aboutlogins/content/components/login-alert.css b/browser/components/aboutlogins/content/components/login-alert.css
new file mode 100644
index 0000000000..b2d17b433e
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-alert.css
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host(login-alert) {
+ display: grid;
+ column-gap: 16px;
+ grid-template-areas: "icon title action" "icon content content";
+ grid-template-columns: min-content 1fr auto;
+ padding: 16px 32px;
+ color: var(--in-content-text-color);
+ background-color: var(--in-content-box-background);
+ border-radius: 4px;
+ border: 1px solid var(--in-content-border-color);
+ box-shadow: 0 2px 8px 0 rgba(0, 0, 0, .1);
+ font-size: .9em;
+}
+
+:host([variant="info"]) {
+ background-color: var(--in-content-box-background);
+}
+
+:host([variant="error"]) {
+ background-color: #a4000f;
+ color: white;
+}
+
+:host([variant="warning"]) {
+ background: #d7b600;
+ color: black;
+}
+
+:host(login-alert) img {
+ grid-area: icon;
+ width: 24px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+:host(login-alert) h3 {
+ grid-area: title;
+ font-size: 1.5em;
+ font-weight: normal;
+ margin: 0;
+ padding: 0;
+}
+
+:host(login-alert) slot[name="action"] {
+ grid-area: action;
+}
+
+:host(login-alert) slot[name="content"] {
+ grid-area: content;
+}
+
+:host(login-breach-alert) div[slot="content"],
+:host(login-vulnerable-password-alert) div[slot="content"] {
+ margin-block-start: 8px;
+}
+
+:host(login-vulnerable-password-alert) a {
+ font-weight: 600;
+}
+
+:host(login-vulnerable-password-alert) div[slot="content"] > a {
+ color: var(--link-color);
+}
+
+:host(login-vulnerable-password-alert) a[slot="action"] {
+ color: var(--text-color-deemphasized);
+}
+
+:host(login-breach-alert) h4 {
+ margin: 0;
+ padding: 0;
+}
+
+:host(login-breach-alert) a {
+ font-weight: 600;
+ color: inherit;
+}
diff --git a/browser/components/aboutlogins/content/components/login-alert.mjs b/browser/components/aboutlogins/content/components/login-alert.mjs
new file mode 100644
index 0000000000..f435b9daef
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-alert.mjs
@@ -0,0 +1,150 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ html,
+ ifDefined,
+ guard,
+} from "chrome://global/content/vendor/lit.all.mjs";
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+export class LoginAlert extends MozLitElement {
+ static get properties() {
+ return {
+ variant: { type: String, reflect: true },
+ icon: { type: String },
+ titleId: { type: String },
+ };
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-alert.css"
+ />
+ <img src=${ifDefined(this.icon)} />
+ <h3 data-l10n-id=${ifDefined(this.titleId)}></h3>
+ <div>
+ <slot name="action"></slot>
+ </div>
+ <slot name="content"></slot>
+ `;
+ }
+}
+
+export class VulnerablePasswordAlert extends MozLitElement {
+ static get properties() {
+ return {
+ hostname: { type: String, reflect: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this.hostname = "";
+ }
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-alert.css"
+ />
+ <login-alert
+ variant="info"
+ icon="chrome://browser/content/aboutlogins/icons/vulnerable-password.svg"
+ titleId="about-logins-vulnerable-alert-title"
+ >
+ <div slot="content">
+ <span
+ class="alert-text"
+ data-l10n-id="about-logins-vulnerable-alert-text2"
+ ></span>
+ <a
+ class="alert-link"
+ data-l10n-id="about-logins-vulnerable-alert-link"
+ data-l10n-args=${JSON.stringify({
+ hostname: this.hostname,
+ })}
+ href=${this.hostname}
+ rel="noreferrer"
+ target="_blank"
+ ></a>
+ </div>
+ <a
+ slot="action"
+ class="alert-learn-more-link"
+ data-l10n-id="about-logins-vulnerable-alert-learn-more-link"
+ href="https://support.mozilla.org/1/firefox/114.0.1/Darwin/en-CA/lockwise-alerts"
+ rel="noreferrer"
+ target="_blank"
+ ></a>
+ </login-alert>
+ `;
+ }
+}
+
+export class LoginBreachAlert extends MozLitElement {
+ static get properties() {
+ return {
+ date: { type: Number, reflect: true },
+ hostname: { type: String, reflect: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this.date = 0;
+ this.hostname = "";
+ }
+
+ get displayHostname() {
+ try {
+ return new URL(this.hostname).hostname;
+ } catch (err) {
+ return this.hostname;
+ }
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-alert.css"
+ />
+ <login-alert
+ variant="error"
+ icon="chrome://browser/content/aboutlogins/icons/breached-website.svg"
+ titleId="about-logins-breach-alert-title"
+ >
+ <div slot="content">
+ <h4
+ data-l10n-id="about-logins-breach-alert-date"
+ data-l10n-args=${JSON.stringify({ date: this.date })}
+ ></h4>
+ <span data-l10n-id="breach-alert-text"></span>
+ <a
+ data-l10n-id="about-logins-breach-alert-link"
+ data-l10n-args=${guard([this.hostname], () =>
+ JSON.stringify({
+ hostname: this.displayHostname,
+ })
+ )}
+ href=${this.hostname}
+ rel="noreferrer"
+ target="_blank"
+ ></a>
+ </div>
+ </login-alert>
+ `;
+ }
+}
+
+customElements.define(
+ "login-vulnerable-password-alert",
+ VulnerablePasswordAlert
+);
+customElements.define("login-breach-alert", LoginBreachAlert);
+customElements.define("login-alert", LoginAlert);
diff --git a/browser/components/aboutlogins/content/components/login-alert.stories.mjs b/browser/components/aboutlogins/content/components/login-alert.stories.mjs
new file mode 100644
index 0000000000..7eaa2dadf4
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-alert.stories.mjs
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// eslint-disable-next-line import/no-unresolved
+import { html } from "lit.all.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "./login-alert.mjs";
+
+export default {
+ title: "Domain-specific UI Widgets/Credential Management/Login Alert",
+ component: "login-alert",
+};
+
+window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl");
+
+export const BasicLoginAlert = ({ variant, icon }) => {
+ return html`
+ <login-alert
+ .variant=${variant}
+ .icon=${icon}
+ titleId="about-logins-login-intro-heading-message"
+ >
+ <a slot="action"> Some action </a>
+ <div slot="content">
+ Alert extra content, could be a description for more context.
+ </div>
+ </login-alert>
+ `;
+};
+
+BasicLoginAlert.argTypes = {
+ variant: {
+ options: ["info", "error", "warning"],
+ control: { type: "radio" },
+ defaultValue: "info",
+ },
+ icon: {
+ options: {
+ info: "chrome://global/skin/icons/info-filled.svg",
+ "breached-website":
+ "chrome://browser/content/aboutlogins/icons/breached-website.svg",
+ "vulnerable-password":
+ "chrome://browser/content/aboutlogins/icons/vulnerable-password.svg",
+ },
+ control: { type: "select" },
+ defaultValue: "chrome://global/skin/icons/info-filled.svg",
+ },
+};
+
+export const VulnerablePasswordAlert = ({ hostname }) =>
+ html`
+ <login-vulnerable-password-alert
+ .hostname=${hostname}
+ ></login-vulnerable-password-alert>
+ `;
+
+VulnerablePasswordAlert.args = {
+ hostname: "https://www.example.com",
+};
+
+export const LoginBreachAlert = ({ date, hostname }) =>
+ html`
+ <login-breach-alert
+ .date=${date}
+ .hostname=${hostname}
+ ></login-breach-alert>
+ `;
+
+LoginBreachAlert.argTypes = {
+ date: {
+ control: { type: "date" },
+ defaultValue: 1684849435571,
+ },
+ hostname: {
+ control: { type: "text" },
+ defaultValue: "https://www.example.com",
+ },
+};
diff --git a/browser/components/aboutlogins/content/components/login-command-button.css b/browser/components/aboutlogins/content/components/login-command-button.css
new file mode 100644
index 0000000000..40b3f9a455
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-command-button.css
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host button {
+ margin: 0;
+ display: inline-flex;
+ flex-direction: row;
+ align-items: center;
+ min-width: auto;
+}
+
+:host img {
+ padding-inline-end: 8px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+:host(create-login-button) img,
+:host(.copy-button) img {
+ padding: 0;
+}
+
+:host([data-copied]) button {
+ color: var(--in-content-success-icon-color) !important;
+ background-color: transparent;
+ opacity: 1;
+ /* override common.css fading out disabled buttons */
+}
+
+:host([data-copied]) {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
diff --git a/browser/components/aboutlogins/content/components/login-command-button.mjs b/browser/components/aboutlogins/content/components/login-command-button.mjs
new file mode 100644
index 0000000000..d8d195bfcc
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-command-button.mjs
@@ -0,0 +1,187 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Note: For now, to display the tooltip for a <login-command-button> you need to
+ * use data-l10n-id attribute instead of the l10nId attribute in the tag.
+ * Bug 1844869 will make an attempt to fix this.
+ */
+
+import {
+ html,
+ ifDefined,
+ when,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+export const stylesTemplate = () => html`<link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/common.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-command-button.css"
+ />`;
+
+export const LoginCommandButton = ({
+ onClick,
+ l10nId,
+ icon,
+ variant,
+ disabled,
+ buttonText,
+}) => html`<button
+ class=${variant}
+ data-l10n-id=${ifDefined(l10nId)}
+ ?disabled=${disabled}
+ @click=${ifDefined(onClick)}
+>
+ <img src=${ifDefined(icon)} role="presentation" />
+
+ <span data-l10n-id=${ifDefined(buttonText)}></span>
+</button>`;
+
+export class CreateLoginButton extends MozLitElement {
+ static get properties() {
+ return {
+ disabled: { type: Boolean, reflect: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this.disabled = false;
+ }
+ render() {
+ return html`
+ ${stylesTemplate()}
+ ${LoginCommandButton({
+ l10nId: "create-login-button",
+ variant: "icon-button",
+ icon: "chrome://global/skin/icons/plus.svg",
+ disabled: this.disabled,
+ })}
+ `;
+ }
+}
+
+export class EditButton extends MozLitElement {
+ static get properties() {
+ return {
+ disabled: { type: Boolean, reflect: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this.disabled = false;
+ }
+ render() {
+ return html`
+ ${stylesTemplate()}
+ ${LoginCommandButton({
+ buttonText: "login-item-edit-button",
+ variant: "ghost-button",
+ icon: "chrome://global/skin/icons/edit.svg",
+ disabled: this.disabled,
+ })}
+ `;
+ }
+}
+
+export class DeleteButton extends MozLitElement {
+ static get properties() {
+ return {
+ disabled: { type: Boolean, reflect: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this.disabled = false;
+ }
+ render() {
+ return html` ${stylesTemplate()}
+ ${LoginCommandButton({
+ buttonText: "about-logins-login-item-remove-button",
+ variant: "ghost-button",
+ icon: "chrome://global/skin/icons/delete.svg",
+ disabled: this.disabled,
+ })}`;
+ }
+}
+
+export class CopyUsernameButton extends MozLitElement {
+ static get properties() {
+ return {
+ copiedText: { type: Boolean, reflect: true },
+ disabled: { type: Boolean, reflect: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this.copiedText = false;
+ this.disabled = false;
+ }
+ render() {
+ this.className = this.copiedText ? "copied-button" : "copy-button";
+ return html` ${stylesTemplate()}
+ ${when(
+ this.copiedText,
+ () =>
+ html`${LoginCommandButton({
+ buttonText: "login-item-copied-username-button-text",
+ icon: "chrome://global/skin/icons/check.svg",
+ disabled: this.disabled,
+ })}`,
+ () =>
+ html`${LoginCommandButton({
+ variant: "text-button",
+ buttonText: "login-item-copy-username-button-text",
+ disabled: this.disabled,
+ })}`
+ )}`;
+ }
+}
+
+export class CopyPasswordButton extends MozLitElement {
+ static get properties() {
+ return {
+ copiedText: { type: Boolean, reflect: true },
+ disabled: { type: Boolean, reflect: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this.copiedText = false;
+ this.disabled = false;
+ }
+ render() {
+ this.className = this.copiedText ? "copied-button" : "copy-button";
+ return html` ${stylesTemplate()}
+ ${when(
+ this.copiedText,
+ () =>
+ html`${LoginCommandButton({
+ buttonText: "login-item-copied-password-button-text",
+ icon: "chrome://global/skin/icons/check.svg",
+ disabled: this.disabled,
+ })}`,
+ () =>
+ html`${LoginCommandButton({
+ variant: "text-button",
+ buttonText: "login-item-copy-password-button-text",
+ disabled: this.disabled,
+ })}`
+ )}`;
+ }
+}
+
+customElements.define("copy-password-button", CopyPasswordButton);
+customElements.define("copy-username-button", CopyUsernameButton);
+customElements.define("delete-button", DeleteButton);
+customElements.define("edit-button", EditButton);
+customElements.define("create-login-button", CreateLoginButton);
diff --git a/browser/components/aboutlogins/content/components/login-filter.css b/browser/components/aboutlogins/content/components/login-filter.css
new file mode 100644
index 0000000000..f7db0e6770
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-filter.css
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.filter[type="text"] {
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.4;
+ background-image: url("chrome://global/skin/icons/search-glass.svg");
+ background-position: 8px center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ text-align: match-parent;
+ width: 100%;
+ margin: 0;
+ box-sizing: border-box;
+ padding-block: 6px;
+}
+
+:host(:dir(ltr)) .filter {
+ /* We use separate RTL rules over logical properties since we want the visual direction
+ to be independent from the user input direction */
+ padding-left: 32px;
+}
+
+:host(:dir(rtl)) .filter {
+ background-position-x: right 8px;
+ padding-right: 32px;
+}
diff --git a/browser/components/aboutlogins/content/components/login-filter.mjs b/browser/components/aboutlogins/content/components/login-filter.mjs
new file mode 100644
index 0000000000..e5b89327d6
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-filter.mjs
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { recordTelemetryEvent } from "../aboutLoginsUtils.mjs";
+
+export default class LoginFilter extends HTMLElement {
+ get #loginList() {
+ return document.querySelector("login-list");
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+
+ let loginFilterTemplate = document.querySelector("#login-filter-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginFilterTemplate.content.cloneNode(true));
+
+ this._input = this.shadowRoot.querySelector("input");
+
+ this.addEventListener("input", this);
+ this._input.addEventListener("keydown", this);
+ window.addEventListener("AboutLoginsFilterLogins", this);
+ }
+
+ focus() {
+ this._input.focus();
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "AboutLoginsFilterLogins":
+ this.#filterLogins(event.detail);
+ break;
+ case "input":
+ this.#input(event.originalTarget.value);
+ break;
+ case "keydown":
+ this.#keyDown(event);
+ break;
+ }
+ }
+
+ #filterLogins(filterText) {
+ if (this.value != filterText) {
+ this.value = filterText;
+ }
+ }
+
+ #input(value) {
+ this._dispatchFilterEvent(value);
+ }
+
+ #keyDown(e) {
+ switch (e.code) {
+ case "ArrowUp":
+ e.preventDefault();
+ this.#loginList.selectPrevious();
+ break;
+ case "ArrowDown":
+ e.preventDefault();
+ this.#loginList.selectNext();
+ break;
+ case "Escape":
+ e.preventDefault();
+ this.value = "";
+ break;
+ case "Enter":
+ e.preventDefault();
+ this.#loginList.clickSelected();
+ break;
+ }
+ }
+
+ get value() {
+ return this._input.value;
+ }
+
+ set value(val) {
+ this._input.value = val;
+ this._dispatchFilterEvent(val);
+ }
+
+ _dispatchFilterEvent(value) {
+ this.dispatchEvent(
+ new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ composed: true,
+ detail: value,
+ })
+ );
+
+ recordTelemetryEvent({ object: "list", method: "filter" });
+ }
+}
+customElements.define("login-filter", LoginFilter);
diff --git a/browser/components/aboutlogins/content/components/login-intro.css b/browser/components/aboutlogins/content/components/login-intro.css
new file mode 100644
index 0000000000..e27f0bddd0
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-intro.css
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host {
+ padding: 60px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+section {
+ line-height: 2;
+}
+
+.description {
+ font-weight: var(--font-weight-bold);
+ margin-bottom: 0;
+}
+
+.illustration.logged-in {
+ opacity: .5;
+}
diff --git a/browser/components/aboutlogins/content/components/login-intro.mjs b/browser/components/aboutlogins/content/components/login-intro.mjs
new file mode 100644
index 0000000000..f988910eb6
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-intro.mjs
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export default class LoginIntro extends HTMLElement {
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+
+ let loginIntroTemplate = document.querySelector("#login-intro-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginIntroTemplate.content.cloneNode(true));
+ }
+
+ focus() {
+ let helpLink = this.shadowRoot.querySelector(".intro-help-link");
+ helpLink.focus();
+ }
+
+ handleEvent(event) {
+ if (
+ event.currentTarget.classList.contains("intro-import-text") &&
+ event.target.localName == "a"
+ ) {
+ let eventName =
+ event.target.dataset.l10nName == "import-file-link"
+ ? "AboutLoginsImportFromFile"
+ : "AboutLoginsImportFromBrowser";
+ document.dispatchEvent(
+ new CustomEvent(eventName, {
+ bubbles: true,
+ })
+ );
+ }
+ event.preventDefault();
+ }
+
+ updateState(syncState) {
+ let l10nId = "about-logins-login-intro-heading-message";
+ document.l10n.setAttributes(
+ this.shadowRoot.querySelector(".heading"),
+ l10nId
+ );
+
+ this.shadowRoot
+ .querySelector(".illustration")
+ .classList.toggle("logged-in", syncState.loggedIn);
+ let supportURL =
+ window.AboutLoginsUtils.supportBaseURL +
+ "password-manager-remember-delete-edit-logins";
+ this.shadowRoot
+ .querySelector(".intro-help-link")
+ .setAttribute("href", supportURL);
+
+ let importClass = window.AboutLoginsUtils.fileImportEnabled
+ ? ".intro-import-text.file-import"
+ : ".intro-import-text.no-file-import";
+ let importText = this.shadowRoot.querySelector(importClass);
+ importText.addEventListener("click", this);
+ importText.hidden = !window.AboutLoginsUtils.importVisible;
+ }
+}
+customElements.define("login-intro", LoginIntro);
diff --git a/browser/components/aboutlogins/content/components/login-item.css b/browser/components/aboutlogins/content/components/login-item.css
new file mode 100644
index 0000000000..07471c35ef
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-item.css
@@ -0,0 +1,328 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ :host {
+ overflow: hidden;
+
+ --reveal-checkbox-opacity: .8;
+ --reveal-checkbox-opacity-hover: .6;
+ --reveal-checkbox-opacity-active: 1;
+}
+
+/* Only overwrite the deemphasized text color in non-dark mode. */
+@media not (prefers-color-scheme: dark) {
+ :host {
+ --text-color-deemphasized: #737373;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :host {
+ --reveal-checkbox-opacity: .8;
+ --reveal-checkbox-opacity-hover: 1;
+ --reveal-checkbox-opacity-active: .6;
+ }
+}
+
+.container {
+ overflow: auto;
+ padding: 0 40px;
+ box-sizing: border-box;
+ height: 100%;
+}
+
+@media (max-width: 830px) {
+ .container {
+ padding-inline: 20px;
+ }
+}
+
+.column {
+ min-height: 100%;
+ max-width: 700px;
+ display: flex;
+ flex-direction: column;
+}
+
+button {
+ min-width: 100px;
+}
+
+form {
+ flex-grow: 1;
+}
+
+:host([data-editing]) edit-button,
+:host([data-editing]) :is(.copy-button, .copied-button),
+:host([data-is-new-login]) delete-button,
+:host([data-is-new-login]) .origin-saved-value,
+:host([data-is-new-login]) login-timeline,
+:host([data-is-new-login]) .login-item-title,
+:host(:not([data-is-new-login])) .new-login-title,
+:host(:not([data-editing])) .form-actions-row {
+ display: none;
+}
+
+input[type="password"],
+input[type="text"],
+input[type="url"] {
+ text-align: match-parent !important; /* override `all: unset` in the rule below */
+}
+
+:host(:not([data-editing])) input[type="password"]:read-only,
+input[type="text"]:read-only,
+input[type="url"]:read-only {
+ all: unset;
+ font-size: 1.1em;
+ display: inline-block;
+ background-color: transparent !important; /* override common.inc.css */
+ text-overflow: ellipsis;
+ overflow: hidden;
+ width: 100%;
+}
+
+/* We can't use `margin-inline-start` here because we force
+ * the input to have dir="ltr", so we set the margin manually
+ * using the parent element's directionality. */
+.detail-cell:dir(ltr) input:not([type="checkbox"]) {
+ margin-left: 0;
+}
+
+.detail-cell:dir(rtl) input:not([type="checkbox"]) {
+ margin-right: 0;
+}
+
+.save-changes-button {
+ margin-inline-start: 0; /* Align the button on the start side */
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 40px;
+ margin-top: 5px;
+}
+
+.title {
+ margin-block: 0;
+ flex-grow: 1;
+}
+
+origin-warning, password-warning {
+ display: none;
+}
+
+input[type = "url"]:focus:not(:user-invalid):invalid ~ origin-warning,
+input[type = "url"]:focus:user-invalid:not(:placeholder-shown) ~ origin-warning {
+ display: block;
+}
+
+input[name = "password"]:focus ~ password-warning {
+ display: block;
+}
+
+.reveal-password-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: minmax(240px, max-content) auto;
+ grid-template-rows: auto;
+ column-gap: 20px;
+ row-gap: 40px;
+ justify-content: start;
+}
+
+:host([data-editing]) .detail-grid {
+ grid-template-columns: auto;
+}
+
+:host([data-editing]) .detail-grid > .detail-row {
+ display: flex;
+}
+
+.detail-grid > .detail-row:not([hidden]) {
+ display: contents;
+}
+
+.detail-grid > .detail-row > .detail-cell {
+ grid-column: 1;
+}
+
+.detail-grid > .detail-row > :is(.copy-button, .copied-button) {
+ grid-column: 2;
+ margin-inline-start: 0; /* Reset button's margin so it doesn't affect the overall grid's width */
+ justify-self: start;
+ align-self: end;
+}
+
+.detail-row {
+ display: flex;
+ position: relative; /* Allows for the hint message to be positioned correctly */
+}
+
+.detail-grid,
+.detail-row {
+ margin-bottom: 40px;
+}
+
+.detail-cell {
+ flex-grow: 1;
+ min-width: 0; /* Allow long passwords to collapse down to flex item width */
+}
+
+.field-label {
+ display: block;
+ margin-bottom: 8px;
+}
+
+moz-button-group,
+:host([data-editing]) .detail-cell input:read-write:not([type="checkbox"]),
+:host([data-editing]) input[type="password"]:read-only {
+ width: 298px;
+ box-sizing: border-box;
+}
+
+.copy-button,
+.copied-button {
+ margin-bottom: 0; /* Align button at the bottom of the row */
+}
+
+.copied-button[data-copied]:focus-visible {
+ outline-width: 0;
+ box-shadow: none;
+}
+
+input.password-display,
+input[name="password"] {
+ font-family: monospace !important; /* override `all: unset` in the rule above */
+}
+
+.reveal-password-checkbox {
+ appearance: none;
+ background-image: url("resource://gre-resources/password.svg");
+ margin-inline: 10px 0;
+ cursor: pointer;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ color: inherit;
+ opacity: var(--reveal-checkbox-opacity);
+
+ &:hover {
+ opacity: var(--reveal-checkbox-opacity-hover);
+
+ &:active {
+ opacity: var(--reveal-checkbox-opacity-active);
+ }
+ }
+
+ &:checked {
+ background-image: url("resource://gre-resources/password-hide.svg");
+ }
+}
+
+.login-item-favicon {
+ margin-inline-end: 12px;
+ height: 24px;
+ width: 24px;
+ flex-shrink: 0;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.75;
+}
+
+login-breach-alert,
+login-vulnerable-password-alert {
+ margin-block-end: 40px;
+}
+
+login-command-button {
+ margin-block-start: 4px; /* Focus did not display entirely on edit and remove with margin:0 */
+}
+
+.alert-title {
+ font-size: var(--font-size-xlarge);
+ font-weight: var(--font-weight-default);
+ line-height: 1em;
+ margin-block: 0 12px;
+}
+
+.alert-date {
+ display: block;
+ font-weight: var(--font-weight-bold);
+}
+
+.alert-link:visited,
+.alert-link {
+ font-weight: var(--font-weight-bold);
+ overflow-wrap: anywhere;
+}
+
+.breach-alert > .alert-link:visited,
+.breach-alert > .alert-link {
+ color: inherit;
+ text-decoration: underline;
+}
+
+.alert-icon {
+ position: absolute;
+ inset-block-start: 16px;
+ inset-inline-start: 32px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 24px;
+}
+
+.alert-learn-more-link:hover,
+.alert-learn-more-link:visited,
+.alert-learn-more-link {
+ position: absolute;
+ inset-block-start: 16px;
+ inset-inline-end: 32px;
+ color: inherit;
+ font-size: var(--font-size-small);
+}
+
+.vulnerable-alert > .alert-learn-more-link {
+ color: var(--text-color-deemphasized);
+}
+
+.error-message {
+ color: #fff;
+ background-color: var(--red-60);
+ border: 1px solid transparent;
+ padding-block: 6px;
+ display: inline-block;
+ padding-inline: 32px 16px;
+ background-image: url("chrome://global/skin/icons/warning.svg");
+ background-repeat: no-repeat;
+ background-position: left 10px center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ margin-bottom: 38px;
+}
+
+.error-message:dir(rtl) {
+ background-position-x: right 10px;
+}
+
+.error-message-link > a,
+.error-message-link > a:hover,
+.error-message-link > a:hover:active {
+ color: currentColor;
+ text-decoration: underline;
+ font-weight: var(--font-weight-bold);
+}
+
+.action-buttons {
+ display: flex;
+ flex-direction: row;
+}
+
+.action-buttons .form-actions-row {
+ margin-inline: 0 5px;
+}
diff --git a/browser/components/aboutlogins/content/components/login-item.mjs b/browser/components/aboutlogins/content/components/login-item.mjs
new file mode 100644
index 0000000000..fc7dffff8b
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-item.mjs
@@ -0,0 +1,1038 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ CONCEALED_PASSWORD_TEXT,
+ recordTelemetryEvent,
+ promptForPrimaryPassword,
+} from "../aboutLoginsUtils.mjs";
+
+export default class LoginItem extends HTMLElement {
+ /**
+ * The number of milliseconds to display the "Copied" success message
+ * before reverting to the normal "Copy" button.
+ */
+ static get COPY_BUTTON_RESET_TIMEOUT() {
+ return 5000;
+ }
+
+ constructor() {
+ super();
+ this._login = {};
+ this._error = null;
+ this._copyUsernameTimeoutId = 0;
+ this._copyPasswordTimeoutId = 0;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ this.render();
+ return;
+ }
+
+ let loginItemTemplate = document.querySelector("#login-item-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginItemTemplate.content.cloneNode(true));
+
+ this._cancelButton = this.shadowRoot.querySelector(".cancel-button");
+ this._confirmDeleteDialog = document.querySelector("confirm-delete-dialog");
+ this._copyPasswordButton = this.shadowRoot.querySelector(
+ "copy-password-button"
+ );
+ this._copyUsernameButton = this.shadowRoot.querySelector(
+ "copy-username-button"
+ );
+ this._deleteButton = this.shadowRoot.querySelector("delete-button");
+ this._editButton = this.shadowRoot.querySelector("edit-button");
+ this._errorMessage = this.shadowRoot.querySelector(".error-message");
+ this._errorMessageLink = this._errorMessage.querySelector(
+ ".error-message-link"
+ );
+ this._errorMessageText = this._errorMessage.querySelector(
+ ".error-message-text"
+ );
+ this._form = this.shadowRoot.querySelector("form");
+ this._originInput = this.shadowRoot.querySelector("input[name='origin']");
+ this._originDisplayInput =
+ this.shadowRoot.querySelector("a[name='origin']");
+ this._usernameInput = this.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ // type=password field for display which only ever contains spaces the correct
+ // length of the password.
+ this._passwordDisplayInput = this.shadowRoot.querySelector(
+ "input.password-display"
+ );
+ // type=text field for editing the password with the actual password value.
+ this._passwordInput = this.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+ this._revealCheckbox = this.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ this._saveChangesButton = this.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ this._favicon = this.shadowRoot.querySelector(".login-item-favicon");
+ this._title = this.shadowRoot.querySelector(".login-item-title");
+ this._breachAlert = this.shadowRoot.querySelector("login-breach-alert");
+ this._vulnerableAlert = this.shadowRoot.querySelector(
+ "login-vulnerable-password-alert"
+ );
+ this._passwordWarning = this.shadowRoot.querySelector("password-warning");
+ this._originWarning = this.shadowRoot.querySelector("origin-warning");
+
+ this.render();
+
+ this._cancelButton.addEventListener("click", e =>
+ this.handleCancelEvent(e)
+ );
+
+ window.addEventListener("keydown", e => this.handleKeydown(e));
+
+ // TODO: Using the addEventListener to listen for clicks and pass the event handler due to a CSP error.
+ // This will be fixed as login-item itself is converted into a lit component. We will then be able to use the onclick
+ // prop of login-command-button as seen in the example below (functionality works and passes tests).
+ // this._editButton.onClick = e => this.handleEditEvent(e);
+
+ this._editButton.addEventListener("click", e => this.handleEditEvent(e));
+
+ this._copyPasswordButton.addEventListener("click", e =>
+ this.handleCopyPasswordClick(e)
+ );
+
+ this._copyUsernameButton.addEventListener("click", e =>
+ this.handleCopyUsernameClick(e)
+ );
+
+ this._deleteButton.addEventListener("click", e =>
+ this.handleDeleteEvent(e)
+ );
+
+ this._errorMessageLink.addEventListener("click", e =>
+ this.handleDuplicateErrorGuid(e)
+ );
+
+ this._form.addEventListener("submit", e => this.handleInputSubmit(e));
+ this._originInput.addEventListener("blur", e => this.addHTTPSPrefix(e));
+
+ this._originInput.addEventListener("click", e =>
+ this.handleOriginInputClick(e)
+ );
+
+ this._originInput.addEventListener(
+ "mousedown",
+ e => this.handleInputMousedown(e),
+ true
+ );
+
+ this._originInput.addEventListener("auxclick", e =>
+ this.handleInputAuxclick(e)
+ );
+
+ this._originDisplayInput.addEventListener("click", e =>
+ this.handleOriginInputClick(e)
+ );
+
+ this._revealCheckbox.addEventListener("click", e =>
+ this.handleRevealPasswordClick(e)
+ );
+
+ this._passwordInput.addEventListener("focus", e =>
+ this.handlePasswordDisplayFocus(e)
+ );
+
+ this._passwordInput.addEventListener("blur", e =>
+ this.dataset.editing
+ ? this.handleEditPasswordInputBlur(e)
+ : this.addHTTPSPrefix(e)
+ );
+
+ this._passwordDisplayInput.addEventListener("focus", e =>
+ this.handlePasswordDisplayFocus(e)
+ );
+ this._passwordDisplayInput.addEventListener("blur", e =>
+ this.handlePasswordDisplayBlur(e)
+ );
+
+ window.addEventListener("AboutLoginsInitialLoginSelected", e =>
+ this.handleAboutLoginsInitial(e)
+ );
+ window.addEventListener("AboutLoginsLoginSelected", e =>
+ this.handleAboutLoginsLoginSelected(e)
+ );
+ window.addEventListener("AboutLoginsShowBlankLogin", e =>
+ this.handleAboutLoginsShowBlankLogin(e)
+ );
+ window.addEventListener("AboutLoginsRemaskPassword", e =>
+ this.handleAboutLoginsRemaskPassword(e)
+ );
+ }
+
+ focus() {
+ if (!this._editButton.disabled) {
+ this._editButton.focus();
+ } else if (!this._deleteButton.disabled) {
+ this._deleteButton.focus();
+ } else {
+ this._originInput.focus();
+ }
+ }
+
+ async render(
+ { onlyUpdateErrorsAndAlerts } = { onlyUpdateErrorsAndAlerts: false }
+ ) {
+ if (this._error) {
+ if (this._error.errorMessage.includes("This login already exists")) {
+ document.l10n.setAttributes(
+ this._errorMessageLink,
+ "about-logins-error-message-duplicate-login-with-link",
+ {
+ loginTitle: this._error.login.title,
+ }
+ );
+ this._errorMessageLink.dataset.errorGuid =
+ this._error.existingLoginGuid;
+ this._errorMessageText.hidden = true;
+ this._errorMessageLink.hidden = false;
+ } else {
+ this._errorMessageText.hidden = false;
+ this._errorMessageLink.hidden = true;
+ }
+ }
+ this._errorMessage.hidden = !this._error;
+
+ this._breachAlert.hidden =
+ !this._breachesMap || !this._breachesMap.has(this._login.guid);
+ if (!this._breachAlert.hidden) {
+ const breachDetails = this._breachesMap.get(this._login.guid);
+ const breachTimestamp = new Date(breachDetails.BreachDate ?? 0).getTime();
+ this.#updateBreachAlert(this._login.origin, breachTimestamp);
+ }
+ this._vulnerableAlert.hidden =
+ !this._vulnerableLoginsMap ||
+ !this._vulnerableLoginsMap.has(this._login.guid) ||
+ !this._breachAlert.hidden;
+ if (!this._vulnerableAlert.hidden) {
+ this.#updateVulnerablePasswordAlert(this._login.origin);
+ }
+ if (onlyUpdateErrorsAndAlerts) {
+ return;
+ }
+
+ this._favicon.src = `page-icon:${this._login.origin}`;
+ this._title.textContent = this._login.title;
+ this._title.title = this._login.title;
+ this._originInput.defaultValue = this._login.origin || "";
+ if (this._login.origin) {
+ // Creates anchor element with origin URL
+ this._originDisplayInput.href = this._login.origin || "";
+ this._originDisplayInput.innerText = this._login.origin || "";
+ }
+ this._usernameInput.defaultValue = this._login.username || "";
+ if (this._login.password) {
+ // We use .value instead of .defaultValue since the latter updates the
+ // content attribute making the password easily viewable with Inspect
+ // Element even when Primary Password is enabled. This is only run when
+ // the password is non-empty since setting the field to an empty value
+ // would mark the field as 'dirty' for form validation and thus trigger
+ // the error styling since the password field is 'required'.
+ // This element is only in the document while unmasked or editing.
+ this._passwordInput.value = this._login.password;
+
+ // In masked non-edit mode we use a different "display" element to render
+ // the masked password so that one cannot simply remove/change
+ // @type=password to reveal the real password.
+ this._passwordDisplayInput.value = CONCEALED_PASSWORD_TEXT;
+ }
+
+ if (this.dataset.editing) {
+ this._usernameInput.removeAttribute("data-l10n-id");
+ this._usernameInput.placeholder = "";
+ } else {
+ document.l10n.setAttributes(
+ this._usernameInput,
+ "about-logins-login-item-username"
+ );
+ }
+ this._copyUsernameButton.disabled = !this._login.username;
+ document.l10n.setAttributes(
+ this._saveChangesButton,
+ this.dataset.isNewLogin
+ ? "login-item-save-new-button"
+ : "about-logins-login-item-save-changes-button"
+ );
+ this._updatePasswordRevealState();
+ this._updateOriginDisplayState();
+ this.#updateTimeline();
+ this.#updatePasswordMessage();
+ }
+
+ #updateTimeline() {
+ let timeline = this.shadowRoot.querySelector("login-timeline");
+ timeline.hidden = !this._login.guid;
+ const createdTime = {
+ actionId: "login-item-timeline-action-created",
+ time: this._login.timeCreated,
+ };
+ const lastUpdatedTime = {
+ actionId: "login-item-timeline-action-updated",
+ time: this._login.timePasswordChanged,
+ };
+ const lastUsedTime = {
+ actionId: "login-item-timeline-action-used",
+ time: this._login.timeLastUsed,
+ };
+ timeline.history =
+ this._login.timeCreated == this._login.timePasswordChanged
+ ? [createdTime, lastUsedTime]
+ : [createdTime, lastUpdatedTime, lastUsedTime];
+ }
+
+ setBreaches(breachesByLoginGUID) {
+ this._internalSetMonitorData("_breachesMap", breachesByLoginGUID);
+ }
+
+ updateBreaches(breachesByLoginGUID) {
+ this._internalUpdateMonitorData("_breachesMap", breachesByLoginGUID);
+ }
+
+ setVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalSetMonitorData(
+ "_vulnerableLoginsMap",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ updateVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalUpdateMonitorData(
+ "_vulnerableLoginsMap",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ _internalSetMonitorData(internalMemberName, mapByLoginGUID) {
+ this[internalMemberName] = mapByLoginGUID;
+ this.render({ onlyUpdateErrorsAndAlerts: true });
+ }
+
+ _internalUpdateMonitorData(internalMemberName, mapByLoginGUID) {
+ if (!this[internalMemberName]) {
+ this[internalMemberName] = new Map();
+ }
+ for (const [guid, data] of [...mapByLoginGUID]) {
+ if (data) {
+ this[internalMemberName].set(guid, data);
+ } else {
+ this[internalMemberName].delete(guid);
+ }
+ }
+ this._internalSetMonitorData(internalMemberName, this[internalMemberName]);
+ }
+
+ showLoginItemError(error) {
+ this._error = error;
+ this.render();
+ }
+
+ async handleKeydown(e) {
+ // The below handleKeydown will be cleaned up when Bug 1848785 lands.
+ if (e.key === "Escape" && this.dataset.editing) {
+ this.handleCancelEvent();
+ } else if (e.altKey && e.key === "Enter" && !this.dataset.editing) {
+ this.handleEditEvent();
+ } else if (e.altKey && (e.key === "Backspace" || e.key === "Delete")) {
+ this.handleDeleteEvent();
+ }
+ }
+
+ async handlePasswordDisplayFocus(e) {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ const focusFromCheckbox = e && e.relatedTarget === this._revealCheckbox;
+ const isEditingMode = this.dataset.editing || this.dataset.isNewLogin;
+ if (focusFromCheckbox && isEditingMode) {
+ this._passwordInput.type = this._revealCheckbox.checked
+ ? "text"
+ : "password";
+ return;
+ }
+
+ this._revealCheckbox.checked = !!this.dataset.editing;
+ this._updatePasswordRevealState();
+ }
+
+ async addHTTPSPrefix(e) {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox;
+ if (focusCheckboxNext) {
+ return;
+ }
+
+ // Add https:// prefix if one was not provided.
+ let originValue = this._originInput.value.trim();
+ if (!originValue) {
+ return;
+ }
+
+ if (!originValue.match(/:\/\//)) {
+ this._originInput.value = "https://" + originValue;
+ }
+ }
+
+ async handlePasswordDisplayBlur(e) {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox;
+ if (focusCheckboxNext) {
+ return;
+ }
+
+ this._revealCheckbox.checked = !!this.dataset.editing;
+ this._updatePasswordRevealState();
+
+ this.addHTTPSPrefix();
+ }
+
+ async handleEditPasswordInputBlur(e) {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ const focusCheckboxNext = e && e.relatedTarget === this._revealCheckbox;
+ if (focusCheckboxNext) {
+ return;
+ }
+
+ this._revealCheckbox.checked = false;
+ this._updatePasswordRevealState();
+
+ this.addHTTPSPrefix();
+ }
+
+ async handleRevealPasswordClick() {
+ // TODO(Bug 1838494): Remove this if block
+ // This is a temporary fix until Bug 1750072 lands
+ if (this.dataset.editing || this.dataset.isNewLogin) {
+ this._passwordDisplayInput.replaceWith(this._passwordInput);
+ this._passwordInput.type = "text";
+ this._passwordInput.focus();
+ return;
+ }
+
+ // We prompt for the primary password when entering edit mode already.
+ if (this._revealCheckbox.checked && !this.dataset.editing) {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-reveal-password-os-auth-dialog-message"
+ );
+ if (!primaryPasswordAuth) {
+ this._revealCheckbox.checked = false;
+ return;
+ }
+ }
+ this._updatePasswordRevealState();
+
+ let method = this._revealCheckbox.checked ? "show" : "hide";
+ this._recordTelemetryEvent({ object: "password", method });
+ }
+
+ async handleCancelEvent(e) {
+ let wasExistingLogin = !!this._login.guid;
+ if (wasExistingLogin) {
+ if (this.hasPendingChanges()) {
+ this.showConfirmationDialog("discard-changes", () => {
+ this.setLogin(this._login);
+ });
+ } else {
+ this.setLogin(this._login);
+ }
+ } else if (!this.hasPendingChanges()) {
+ window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection"));
+ this._recordTelemetryEvent({
+ object: "new_login",
+ method: "cancel",
+ });
+
+ this.setLogin(this._login, { skipFocusChange: true });
+ this._toggleEditing(false);
+ this.render();
+ } else {
+ this.showConfirmationDialog("discard-changes", () => {
+ window.dispatchEvent(new CustomEvent("AboutLoginsClearSelection"));
+
+ this.setLogin({}, { skipFocusChange: true });
+ this._toggleEditing(false);
+ this.render();
+ });
+ }
+ }
+
+ async handleCopyPasswordClick({ currentTarget }) {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-copy-password-os-auth-dialog-message"
+ );
+ if (!primaryPasswordAuth) {
+ return;
+ }
+ currentTarget.dataset.copied = true;
+ currentTarget.copiedText = true;
+ currentTarget.disabled = true;
+ let propertyToCopy = this._login.password;
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsCopyLoginDetail", {
+ bubbles: true,
+ detail: propertyToCopy,
+ })
+ );
+ // If there is no username, this must be triggered by the password button,
+ // don't enable otherCopyButton (username copy button) in this case.
+ if (this._login.username) {
+ this._copyUsernameButton.copiedText = false;
+ this._copyUsernameButton.disabled = false;
+ delete this._copyUsernameButton.dataset.copied;
+ }
+ clearTimeout(this._copyUsernameTimeoutId);
+ clearTimeout(this._copyPasswordTimeoutId);
+ let timeoutId = setTimeout(() => {
+ currentTarget.disabled = false;
+ currentTarget.copiedText = false;
+ delete currentTarget.dataset.copied;
+ }, LoginItem.COPY_BUTTON_RESET_TIMEOUT);
+ this._copyPasswordTimeoutId = timeoutId;
+ this._recordTelemetryEvent({
+ object: "password",
+ method: "copy",
+ });
+ }
+
+ async handleCopyUsernameClick({ currentTarget }) {
+ currentTarget.dataset.copied = true;
+ currentTarget.copiedText = true;
+ currentTarget.disabled = true;
+ let propertyToCopy = this._login.username;
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsCopyLoginDetail", {
+ bubbles: true,
+ detail: propertyToCopy,
+ })
+ );
+ // If there is no username, this must be triggered by the password button,
+ // don't enable otherCopyButton (username copy button) in this case.
+ if (this._login.username) {
+ this._copyPasswordButton.copiedText = false;
+ this._copyPasswordButton.disabled = false;
+ delete this._copyPasswordButton.dataset.copied;
+ }
+ clearTimeout(this._copyUsernameTimeoutId);
+ clearTimeout(this._copyPasswordTimeoutId);
+ let timeoutId = setTimeout(() => {
+ currentTarget.disabled = false;
+ currentTarget.copiedText = false;
+ delete currentTarget.dataset.copied;
+ }, LoginItem.COPY_BUTTON_RESET_TIMEOUT);
+ this._copyUsernameTimeoutId = timeoutId;
+ this._recordTelemetryEvent({
+ object: "username",
+ method: "copy",
+ });
+ }
+
+ async handleDeleteEvent() {
+ this.showConfirmationDialog("delete", () => {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsDeleteLogin", {
+ bubbles: true,
+ detail: this._login,
+ })
+ );
+ });
+ }
+
+ async handleEditEvent() {
+ let primaryPasswordAuth = await promptForPrimaryPassword(
+ "about-logins-edit-login-os-auth-dialog-message2"
+ );
+ if (!primaryPasswordAuth) {
+ return;
+ }
+
+ this._toggleEditing();
+ this.render();
+
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "edit",
+ });
+ }
+
+ async handleAlertLearnMoreClick({ currentTarget }) {
+ if (currentTarget.closest(".vulnerable-alert")) {
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "learn_more_vuln",
+ });
+ }
+ }
+
+ async handleOriginInputClick() {
+ this._handleOriginClick();
+ }
+
+ async handleDuplicateErrorGuid({ currentTarget }) {
+ let existingDuplicateLogin = {
+ guid: currentTarget.dataset.errorGuid,
+ };
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: existingDuplicateLogin,
+ cancelable: true,
+ })
+ );
+ }
+
+ async handleInputSubmit(event) {
+ // Prevent page navigation form submit behavior.
+ event.preventDefault();
+ if (!this._isFormValid({ reportErrors: true })) {
+ return;
+ }
+ if (!this.hasPendingChanges()) {
+ this._toggleEditing(false);
+ this.render();
+ return;
+ }
+ let loginUpdates = this._loginFromForm();
+ if (this._login.guid) {
+ loginUpdates.guid = this._login.guid;
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsUpdateLogin", {
+ bubbles: true,
+ detail: loginUpdates,
+ })
+ );
+
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "save",
+ });
+ this._toggleEditing(false);
+ this.render();
+ } else {
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsCreateLogin", {
+ bubbles: true,
+ detail: loginUpdates,
+ })
+ );
+
+ this._recordTelemetryEvent({ object: "new_login", method: "save" });
+ }
+ }
+
+ async handleInputAuxclick({ button }) {
+ if (button == 1) {
+ this._handleOriginClick();
+ }
+ }
+
+ async handleInputMousedown(event) {
+ // No AutoScroll when middle clicking on origin input.
+ if (event.currentTarget == this._originInput && event.button == 1) {
+ event.preventDefault();
+ }
+ }
+
+ async handleAboutLoginsInitial({ detail }) {
+ this.setLogin(detail, { skipFocusChange: true });
+ }
+
+ async handleAboutLoginsLoginSelected(event) {
+ this.#confirmPendingChangesOnEvent(event, event.detail);
+ }
+
+ async handleAboutLoginsShowBlankLogin(event) {
+ this.#confirmPendingChangesOnEvent(event, {});
+ }
+
+ async handleAboutLoginsRemaskPassword() {
+ if (this._revealCheckbox.checked && !this.dataset.editing) {
+ this._revealCheckbox.checked = false;
+ }
+ this._updatePasswordRevealState();
+ let method = this._revealCheckbox.checked ? "show" : "hide";
+ this._recordTelemetryEvent({ object: "password", method });
+ }
+
+ /**
+ * Helper to show the "Discard changes" confirmation dialog and delay the
+ * received event after confirmation.
+ * @param {object} event The event to be delayed.
+ * @param {object} login The login to be shown on confirmation.
+ */
+ #confirmPendingChangesOnEvent(event, login) {
+ if (this.hasPendingChanges()) {
+ event.preventDefault();
+ this.showConfirmationDialog("discard-changes", () => {
+ // Clear any pending changes
+ this.setLogin(login);
+
+ window.dispatchEvent(
+ new CustomEvent(event.type, {
+ detail: login,
+ cancelable: false,
+ })
+ );
+ });
+ } else {
+ this.setLogin(login, { skipFocusChange: true });
+ }
+ }
+
+ /**
+ * Shows a confirmation dialog.
+ * @param {string} type The type of confirmation dialog to display.
+ * @param {boolean} onConfirm Optional, the function to execute when the confirm button is clicked.
+ */
+ showConfirmationDialog(type, onConfirm = () => {}) {
+ const dialog = document.querySelector("confirmation-dialog");
+ let options;
+ switch (type) {
+ case "delete": {
+ options = {
+ title: "about-logins-confirm-delete-dialog-title",
+ message: "about-logins-confirm-delete-dialog-message",
+ confirmButtonLabel:
+ "about-logins-confirm-remove-dialog-confirm-button",
+ };
+ break;
+ }
+ case "discard-changes": {
+ options = {
+ title: "confirm-discard-changes-dialog-title",
+ message: "confirm-discard-changes-dialog-message",
+ confirmButtonLabel: "confirm-discard-changes-dialog-confirm-button",
+ };
+ break;
+ }
+ }
+ let wasExistingLogin = !!this._login.guid;
+ let method = type == "delete" ? "delete" : "cancel";
+ let dialogPromise = dialog.show(options);
+ dialogPromise.then(
+ () => {
+ try {
+ onConfirm();
+ } catch (ex) {}
+ this._recordTelemetryEvent({
+ object: wasExistingLogin ? "existing_login" : "new_login",
+ method,
+ });
+ },
+ () => {}
+ );
+ return dialogPromise;
+ }
+
+ hasPendingChanges() {
+ let valuesChanged = !window.AboutLoginsUtils.doLoginsMatch(
+ Object.assign({ username: "", password: "", origin: "" }, this._login),
+ this._loginFromForm()
+ );
+
+ return this.dataset.editing && valuesChanged;
+ }
+
+ resetForm() {
+ // If the password input (which uses HTML form validation) wasn't connected,
+ // append it to the form so it gets included in the reset, specifically for
+ // .value and the dirty state for validation.
+ let wasConnected = this._passwordInput.isConnected;
+ if (!wasConnected) {
+ this._revealCheckbox.insertAdjacentElement(
+ "beforebegin",
+ this._passwordInput
+ );
+ }
+
+ this._form.reset();
+ if (!wasConnected) {
+ this._passwordInput.remove();
+ }
+ }
+
+ /**
+ * @param {login} login The login that should be displayed. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ * @param {boolean} skipFocusChange Optional, if present and set to true, the Edit button of the
+ * login will not get focus automatically. This is used to prevent
+ * stealing focus from the search filter upon page load.
+ */
+ setLogin(login, { skipFocusChange } = {}) {
+ this._login = login;
+ this._error = null;
+
+ this.resetForm();
+
+ if (login.guid) {
+ delete this.dataset.isNewLogin;
+ } else {
+ this.dataset.isNewLogin = true;
+ }
+ document.documentElement.classList.toggle("login-selected", login.guid);
+ this._toggleEditing(!login.guid);
+
+ this._revealCheckbox.checked = false;
+
+ clearTimeout(this._copyUsernameTimeoutId);
+ clearTimeout(this._copyPasswordTimeoutId);
+ for (let currentTarget of [
+ this._copyUsernameButton,
+ this._copyPasswordButton,
+ ]) {
+ currentTarget.disabled = false;
+ this._copyPasswordButton.copiedText = false;
+ this._copyUsernameButton.copiedText = false;
+ delete currentTarget.dataset.copied;
+ }
+
+ if (!skipFocusChange) {
+ this._editButton.focus();
+ }
+ this.render();
+ }
+
+ /**
+ * Updates the view if the login argument matches the login currently
+ * displayed.
+ *
+ * @param {login} login The login that was added to storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginAdded(login) {
+ if (
+ this._login.guid ||
+ !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm())
+ ) {
+ return;
+ }
+
+ this.setLogin(login);
+ this.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ bubbles: true,
+ composed: true,
+ detail: login,
+ })
+ );
+ }
+
+ /**
+ * Updates the view if the login argument matches the login currently
+ * displayed.
+ *
+ * @param {login} login The login that was modified in storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginModified(login) {
+ if (this._login.guid != login.guid) {
+ return;
+ }
+
+ let valuesChanged =
+ this.dataset.editing &&
+ !window.AboutLoginsUtils.doLoginsMatch(login, this._loginFromForm());
+ if (valuesChanged) {
+ this.showConfirmationDialog("discard-changes", () => {
+ this.setLogin(login);
+ });
+ } else {
+ this.setLogin(login);
+ }
+ }
+
+ /**
+ * Clears the displayed login if the argument matches the currently
+ * displayed login.
+ *
+ * @param {login} login The login that was removed from storage. The login object is
+ * a plain JS object representation of nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginRemoved(login) {
+ if (login.guid != this._login.guid) {
+ return;
+ }
+
+ this.setLogin({}, { skipFocusChange: true });
+ this._toggleEditing(false);
+ }
+
+ _handleOriginClick() {
+ this._recordTelemetryEvent({
+ object: "existing_login",
+ method: "open_site",
+ });
+ }
+
+ /**
+ * Checks that the edit/new-login form has valid values present for their
+ * respective required fields.
+ *
+ * @param {boolean} reportErrors If true, validation errors will be reported
+ * to the user.
+ */
+ _isFormValid({ reportErrors } = {}) {
+ let fields = [this._passwordInput];
+ if (this.dataset.isNewLogin) {
+ fields.push(this._originInput);
+ }
+ let valid = true;
+ // Check validity on all required fields so each field will get :invalid styling
+ // if applicable.
+ for (let field of fields) {
+ if (reportErrors) {
+ valid &= field.reportValidity();
+ } else {
+ valid &= field.checkValidity();
+ }
+ }
+ return valid;
+ }
+
+ _loginFromForm() {
+ return Object.assign({}, this._login, {
+ username: this._usernameInput.value.trim(),
+ password: this._passwordInput.value,
+ origin:
+ window.AboutLoginsUtils.getLoginOrigin(this._originInput.value) || "",
+ });
+ }
+
+ _recordTelemetryEvent(eventObject) {
+ // Breach alerts have higher priority than vulnerable logins, the
+ // following conditionals must reflect this priority.
+ const extra = eventObject.hasOwnProperty("extra") ? eventObject.extra : {};
+ if (this._breachesMap && this._breachesMap.has(this._login.guid)) {
+ Object.assign(extra, { breached: "true" });
+ eventObject.extra = extra;
+ } else if (
+ this._vulnerableLoginsMap &&
+ this._vulnerableLoginsMap.has(this._login.guid)
+ ) {
+ Object.assign(extra, { vulnerable: "true" });
+ eventObject.extra = extra;
+ }
+ recordTelemetryEvent(eventObject);
+ }
+
+ /**
+ * Toggles the login-item view from editing to non-editing mode.
+ *
+ * @param {boolean} force When true puts the form in 'edit' mode, otherwise
+ * puts the form in read-only mode.
+ */
+ _toggleEditing(force) {
+ let shouldEdit = force !== undefined ? force : !this.dataset.editing;
+
+ if (!shouldEdit) {
+ delete this.dataset.isNewLogin;
+ }
+
+ // Reset cursor to the start of the input for long text names.
+ this._usernameInput.scrollLeft = 0;
+
+ if (shouldEdit) {
+ this._passwordInput.style.removeProperty("width");
+ } else {
+ // Need to set a shorter width than -moz-available so the reveal checkbox
+ // will still appear next to the password.
+ this._passwordInput.style.width =
+ (this._login.password || "").length + "ch";
+ }
+
+ this._deleteButton.disabled = this.dataset.isNewLogin;
+ this._editButton.disabled = shouldEdit;
+ let inputTabIndex = shouldEdit ? 0 : -1;
+ this._originInput.readOnly = !this.dataset.isNewLogin;
+ this._originInput.tabIndex = inputTabIndex;
+ this._usernameInput.readOnly = !shouldEdit;
+ this._usernameInput.tabIndex = inputTabIndex;
+ this._passwordInput.readOnly = !shouldEdit;
+ this._passwordInput.tabIndex = inputTabIndex;
+ if (shouldEdit) {
+ this.dataset.editing = true;
+ this._usernameInput.focus();
+ this._usernameInput.select();
+ } else {
+ delete this.dataset.editing;
+ // Only reset the reveal checkbox when exiting 'edit' mode
+ this._revealCheckbox.checked = false;
+ }
+ }
+
+ _updatePasswordRevealState() {
+ if (
+ window.AboutLoginsUtils &&
+ window.AboutLoginsUtils.passwordRevealVisible === false
+ ) {
+ this._revealCheckbox.hidden = true;
+ }
+
+ let { checked } = this._revealCheckbox;
+ let inputType = checked ? "text" : "password";
+ this._passwordInput.type = inputType;
+
+ if (this.dataset.editing) {
+ this._passwordDisplayInput.removeAttribute("tabindex");
+ this._revealCheckbox.hidden = true;
+ } else {
+ this._passwordDisplayInput.setAttribute("tabindex", -1);
+ this._revealCheckbox.hidden = false;
+ }
+
+ // Swap which <input> is in the document depending on whether we need the
+ // real .value (which means that the primary password was already entered,
+ // if applicable)
+ if (checked || this.dataset.isNewLogin) {
+ this._passwordDisplayInput.replaceWith(this._passwordInput);
+
+ // Focus the input if it hasn't been already.
+ if (this.dataset.editing && inputType === "text") {
+ this._passwordInput.focus();
+ }
+ } else {
+ this._passwordInput.replaceWith(this._passwordDisplayInput);
+ }
+ }
+
+ _updateOriginDisplayState() {
+ // Switches between the origin input and anchor tag depending
+ // if a new login is being created.
+ if (this.dataset.isNewLogin) {
+ this._originDisplayInput.replaceWith(this._originInput);
+ this._originInput.focus();
+ } else {
+ this._originInput.replaceWith(this._originDisplayInput);
+ }
+ }
+
+ // TODO(Bug 1838182): This is glue code to make lit component work
+ // Once login-item itself is a lit component, this method is going to be deleted
+ // in favour of updating the props themselves.
+ // NOTE: Adding this method here instead of login-alert because this file will be
+ // refactored soon.
+ #updateBreachAlert(hostname, date) {
+ this._breachAlert.hostname = hostname;
+ this._breachAlert.date = date;
+ }
+
+ #updateVulnerablePasswordAlert(hostname) {
+ this._vulnerableAlert.hostname = hostname;
+ }
+
+ #updatePasswordMessage() {
+ this._passwordWarning.isNewLogin = this.dataset.isNewLogin;
+ this._passwordWarning.webTitle = this._login.title;
+ }
+}
+customElements.define("login-item", LoginItem);
diff --git a/browser/components/aboutlogins/content/components/login-list-item.mjs b/browser/components/aboutlogins/content/components/login-list-item.mjs
new file mode 100644
index 0000000000..32c8dec98f
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list-item.mjs
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { LoginListItem, NewListItem } from "./login-list-lit-item.mjs";
+/**
+ * This file will be removed once login-list is made into a lit-component since
+ * whatever the update function does, lit does internally automatically.
+ */
+export default class LoginListItemFactory {
+ static create(login) {
+ if (!login.guid) {
+ const newListItem = new NewListItem();
+ newListItem.classList.add("list-item");
+ return newListItem;
+ }
+ const loginListItem = new LoginListItem();
+ loginListItem.classList.add("list-item");
+ LoginListItemFactory.update(loginListItem, login);
+ return loginListItem;
+ }
+
+ static update(listItem, login) {
+ listItem.title = login.title;
+ listItem.username = login.username;
+ listItem.favicon = `page-icon:${login.origin}`;
+
+ // Prepend the ID with a string since IDs must not begin with a number.
+ if (!listItem.id) {
+ listItem.id = "lli-" + login.guid;
+ listItem.dataset.guid = login.guid;
+ }
+ }
+}
diff --git a/browser/components/aboutlogins/content/components/login-list-item.stories.mjs b/browser/components/aboutlogins/content/components/login-list-item.stories.mjs
new file mode 100644
index 0000000000..994f32922c
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list-item.stories.mjs
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// eslint-disable-next-line import/no-unresolved
+import { html } from "lit.all.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "./login-list-lit-item.mjs";
+
+export default {
+ title: "Domain-specific UI Widgets/Credential Management/Login List Item",
+ component: "login-list-item",
+};
+
+window.MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl");
+
+export const NewLoginListItem = ({ selected }) => {
+ return html` <new-list-item .selected=${selected}> </new-list-item> `;
+};
+
+NewLoginListItem.argTypes = {
+ selected: {
+ options: [true, false],
+ control: { type: "radio" },
+ defaultValue: false,
+ },
+};
+
+export const LoginListItem = ({
+ title,
+ username,
+ notificationIcon,
+ selected,
+}) => {
+ return html`
+ <login-list-item
+ .title=${title}
+ .username=${username}
+ .notificationIcon=${notificationIcon}
+ .selected=${selected}
+ >
+ </login-list-item>
+ `;
+};
+
+LoginListItem.argTypes = {
+ notificationIcon: {
+ options: ["default", "breached", "vulnerable"],
+ control: { type: "radio" },
+ defaultValue: "default",
+ },
+ selected: {
+ options: [true, false],
+ control: { type: "radio" },
+ defaultValue: false,
+ },
+};
+
+LoginListItem.args = {
+ title: "https://www.example.com",
+ username: "test-username",
+};
diff --git a/browser/components/aboutlogins/content/components/login-list-lit-item.css b/browser/components/aboutlogins/content/components/login-list-lit-item.css
new file mode 100644
index 0000000000..69b6d72b0c
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list-lit-item.css
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.list-item {
+ display: flex;
+ align-items: center;
+ padding-block: 10px;
+ padding-inline: 12px 18px;
+ border-inline-start: 4px solid transparent;
+ user-select: none;
+}
+
+.list-item:not(.selected):hover {
+ background-color: var(--in-content-button-background-hover);
+ color: var(--in-content-button-text-color-hover);
+}
+
+.list-item:not(.selected):hover:active {
+ background-color: var(--in-content-button-background-active);
+ color: var(--in-content-button-text-color-active);
+}
+
+.list-item.keyboard-selected {
+ border-inline-start-color: var(--in-content-border-color);
+ background-color: var(--in-content-button-background-hover);
+}
+
+.list-item.selected {
+ border-inline-start-color: var(--in-content-accent-color);
+ background-color: var(--in-content-page-background);
+}
+
+.list-item.selected .title {
+ font-weight: 600;
+}
+
+.labels {
+ flex-grow: 1;
+ overflow: hidden;
+ min-height: 40px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.title,
+.subtitle {
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.icon {
+ height: 16px;
+ width: 16px;
+ margin-inline-end: 12px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.8;
+}
+
+.subtitle {
+ font-size: 0.85em;
+ color: var(--text-color-deemphasized);
+}
+
+.alert-icon {
+ min-width: 16px;
+ width: 16px;
+ margin-inline-start: 12px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.75;
+
+ :host([notificationIcon="breached"]) & {
+ fill: var(--red-60);
+ fill-opacity: 1;
+ }
+}
diff --git a/browser/components/aboutlogins/content/components/login-list-lit-item.mjs b/browser/components/aboutlogins/content/components/login-list-lit-item.mjs
new file mode 100644
index 0000000000..9bd9632971
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list-lit-item.mjs
@@ -0,0 +1,169 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ html,
+ classMap,
+ choose,
+ when,
+} from "chrome://global/content/vendor/lit.all.mjs";
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+export class ListItem extends MozLitElement {
+ static get properties() {
+ return {
+ icon: { type: String },
+ selected: { type: Boolean },
+ };
+ }
+
+ constructor() {
+ super();
+ this.icon = "";
+ this.selected = false;
+ }
+
+ render() {
+ const classes = { selected: this.selected, "list-item": true };
+ return html` <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-list-lit-item.css"
+ />
+ <li class=${classMap(classes)} role="option">
+ <img class="icon" src=${this.icon} />
+ <slot name="login-info"></slot>
+ <slot name="notificationIcon"></slot>
+ </li>`;
+ }
+}
+
+export class NewListItem extends MozLitElement {
+ static properties = {
+ icon: { type: String },
+ selected: { type: Boolean },
+ };
+
+ constructor() {
+ super();
+ this.id = "new-login-list-item";
+ this.selected = false;
+ this.icon = "page-icon:undefined";
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-list-lit-item.css"
+ />
+ <list-item ?selected=${this.selected} icon=${this.icon}>
+ <div class="labels" slot="login-info">
+ <span
+ class="title"
+ dir="auto"
+ data-l10n-id="login-list-item-title-new-login2"
+ ></span
+ ><span class="subtitle" dir="auto"></span>
+ </div>
+ </list-item>
+ `;
+ }
+}
+
+export class LoginListItem extends MozLitElement {
+ static get properties() {
+ return {
+ favicon: { type: String },
+ title: { type: String, reflect: true },
+ username: { type: String, reflect: true },
+ notificationIcon: { type: String, reflect: true },
+ selected: { type: Boolean },
+ };
+ }
+
+ constructor() {
+ super();
+ this.favicon = "";
+ this.title = "";
+ this.username = "";
+ this.notificationIcon = "";
+ this.selected = false;
+ }
+ render() {
+ switch (this.notificationIcon) {
+ case "breached":
+ this.classList.add("breached");
+ break;
+ case "vulnerable":
+ this.classList.add("vulnerable");
+ break;
+ default:
+ this.classList.remove("breached");
+ this.classList.remove("vulnerable");
+ break;
+ }
+
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-list-lit-item.css"
+ />
+ <list-item
+ icon=${this.favicon}
+ title=${this.title}
+ username=${this.username}
+ notificationIcon=${this.notificationIcon}
+ ?selected=${this.selected}
+ >
+ <div class="labels" slot="login-info">
+ <span class="title" dir="auto">${this.title}</span>
+ ${when(
+ this.username,
+ () => html` <span class="subtitle" dir="auto">
+ ${this.username}
+ </span>`,
+ () => html`<span
+ class="subtitle"
+ dir="auto"
+ data-l10n-id="login-list-item-subtitle-missing-username"
+ ></span>`
+ )}
+ </div>
+ <div slot="notificationIcon">
+ ${choose(
+ this.notificationIcon,
+ [
+ [
+ "breached",
+ () =>
+ html`<img
+ class="alert-icon"
+ data-l10n-id="about-logins-list-item-breach-icon"
+ title=""
+ src="chrome://browser/content/aboutlogins/icons/breached-website.svg"
+ />`,
+ ],
+ [
+ "vulnerable",
+ () =>
+ html`<img
+ class="alert-icon"
+ data-l10n-id="about-logins-list-item-vulnerable-password-icon"
+ title=""
+ src="chrome://browser/content/aboutlogins/icons/vulnerable-password.svg"
+ />`,
+ ],
+ ],
+ () => html`<img class="alert-icon" title="" src="" />`
+ )}
+ </div>
+ </list-item>
+ `;
+ }
+}
+
+customElements.define("list-item", ListItem);
+customElements.define("new-list-item", NewListItem);
+customElements.define("login-list-item", LoginListItem);
diff --git a/browser/components/aboutlogins/content/components/login-list-section.mjs b/browser/components/aboutlogins/content/components/login-list-section.mjs
new file mode 100644
index 0000000000..5495f55e28
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list-section.mjs
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export default class LoginListHeaderFactory {
+ static ID_PREFIX = "id-";
+
+ static create(header) {
+ let template = document.querySelector("#login-list-section-template");
+ let fragment = template.content.cloneNode(true);
+ let sectionItem = fragment.firstElementChild;
+
+ this.update(sectionItem, header);
+
+ return sectionItem;
+ }
+
+ static update(headerItem, header) {
+ let headerElement = headerItem.querySelector(".login-list-header");
+ if (header) {
+ if (header.startsWith(this.ID_PREFIX)) {
+ document.l10n.setAttributes(
+ headerElement,
+ header.substring(this.ID_PREFIX.length)
+ );
+ } else {
+ headerElement.textContent = header;
+ }
+ headerElement.hidden = false;
+ } else {
+ headerElement.hidden = true;
+ }
+ }
+}
diff --git a/browser/components/aboutlogins/content/components/login-list.css b/browser/components/aboutlogins/content/components/login-list.css
new file mode 100644
index 0000000000..bc8e72a3cd
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list.css
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host {
+ border-inline-end: 1px solid var(--in-content-border-color);
+ background-color: var(--in-content-box-background);
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+}
+
+.meta {
+ display: flex;
+ align-items: center;
+ padding: 0 16px 16px;
+ border-bottom: 1px solid var(--in-content-border-color);
+ background-color: var(--in-content-box-background);
+}
+
+.meta > label > span {
+ margin-inline-end: 2px;
+}
+
+#login-sort {
+ --logical-padding: 0px;
+ margin: 0;
+ background-color: transparent;
+ color: var(--in-content-text-color) !important;
+}
+
+#login-sort:hover:not([disabled]) {
+ background-color: var(--in-content-button-background);
+}
+
+#login-sort > option {
+ font-weight: var(--font-weight-default);
+}
+
+.count {
+ flex-grow: 1;
+ text-align: end;
+ margin-inline-start: 18px;
+}
+
+.container {
+ display: contents;
+}
+
+.listHeader {
+ display: flex;
+ justify-content: center;
+ align-content: center;
+ gap: 16px;
+ padding: 16px;
+}
+
+:host(.no-logins) .empty-search-message,
+:host(:not(.empty-search)) .empty-search-message,
+:host(.empty-search:not(.create-login-selected)) ol,
+:host(.no-logins:not(.create-login-selected)) ol,
+:host(:not(.no-logins)) .intro,
+:host(.create-login-selected) .intro,
+:host(.create-login-selected) .empty-search-message {
+ display: none;
+}
+
+:host(:not(.initialized)) .count,
+:host(:not(.initialized)) .empty-search-message {
+ visibility: hidden;
+}
+
+.empty-search-message,
+.intro {
+ text-align: center;
+ padding: 1em;
+ max-width: 50ch; /* This should be kept in sync with login-list-item username and title max-width */
+ flex-grow: 1;
+ border-bottom: 1px solid var(--in-content-border-color);
+}
+
+.empty-search-message span,
+.intro span {
+ font-size: 0.85em;
+}
+
+ol {
+ outline-offset: var(--in-content-focus-outline-inset);
+ margin-block: 0;
+ padding-inline-start: 0;
+ overflow: hidden auto;
+ flex-grow: 1;
+ scroll-padding-top: 24px; /* there is the section header that is sticky to the top */
+}
+
+.login-list-item {
+ display: flex;
+ align-items: center;
+ padding-block: 10px;
+ padding-inline: 12px 18px;
+ border-inline-start: 4px solid transparent;
+ user-select: none;
+}
+
+.login-list-header {
+ display: block;
+ position: sticky;
+ top: 0;
+ font-size: .85em;
+ font-weight: var(--font-weight-bold);
+ padding: 4px 16px;
+ border-bottom: 1px solid var(--in-content-border-color);
+ background-color: var(--in-content-box-background);
+ margin-block-start: 2px;
+ margin-inline: 2px;
+}
+
+.login-list-item:not(.selected):hover {
+ background-color: var(--in-content-button-background-hover);
+ color: var(--in-content-button-text-color-hover);
+}
+
+.login-list-item:not(.selected):hover:active {
+ background-color: var(--in-content-button-background-active);
+ color: var(--in-content-button-text-color-active);
+}
+
+.login-list-item.keyboard-selected {
+ border-inline-start-color: var(--in-content-border-color);
+ background-color: var(--in-content-button-background-hover);
+}
+
+.login-list-item.selected {
+ border-inline-start-color: var(--in-content-accent-color);
+ background-color: var(--in-content-page-background);
+}
+
+.login-list-item.selected .title {
+ font-weight: var(--font-weight-bold);
+}
+
+.labels {
+ flex-grow: 1;
+ overflow: hidden;
+ min-height: 40px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.favicon {
+ height: 16px;
+ width: 16px;
+ margin-inline-end: 12px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: 0.8;
+}
+
+.username {
+ font-size: 0.85em;
+ color: var(--text-color-deemphasized);
+}
diff --git a/browser/components/aboutlogins/content/components/login-list.mjs b/browser/components/aboutlogins/content/components/login-list.mjs
new file mode 100644
index 0000000000..80afdbb58d
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-list.mjs
@@ -0,0 +1,923 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import LoginListItemFactory from "./login-list-item.mjs";
+import LoginListSectionFactory from "./login-list-section.mjs";
+import { recordTelemetryEvent } from "../aboutLoginsUtils.mjs";
+
+const collator = new Intl.Collator();
+const monthFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });
+const yearMonthFormatter = new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+});
+const dayDuration = 24 * 60 * 60_000;
+const sortFnOptions = {
+ name: (a, b) => collator.compare(a.title, b.title),
+ "name-reverse": (a, b) => collator.compare(b.title, a.title),
+ username: (a, b) => collator.compare(a.username, b.username),
+ "username-reverse": (a, b) => collator.compare(b.username, a.username),
+ "last-used": (a, b) => a.timeLastUsed < b.timeLastUsed,
+ "last-changed": (a, b) => a.timePasswordChanged < b.timePasswordChanged,
+ alerts: (a, b, breachesByLoginGUID, vulnerableLoginsByLoginGUID) => {
+ const aIsBreached = breachesByLoginGUID && breachesByLoginGUID.has(a.guid);
+ const bIsBreached = breachesByLoginGUID && breachesByLoginGUID.has(b.guid);
+ const aIsVulnerable =
+ vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(a.guid);
+ const bIsVulnerable =
+ vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(b.guid);
+
+ if ((aIsBreached && !bIsBreached) || (aIsVulnerable && !bIsVulnerable)) {
+ return -1;
+ }
+
+ if ((!aIsBreached && bIsBreached) || (!aIsVulnerable && bIsVulnerable)) {
+ return 1;
+ }
+ return sortFnOptions.name(a, b);
+ },
+};
+
+const headersFnOptions = {
+ // TODO: name should use the ICU API, see Bug 1592834
+ // name: l =>
+ // l.title.length && letterRegExp.test(l.title[0])
+ // ? l.title[0].toUpperCase()
+ // : "#",
+ // "name-reverse": l => headersFnOptions.name(l),
+ name: () => "",
+ "name-reverse": () => "",
+ username: () => "",
+ "username-reverse": () => "",
+ "last-used": l => headerFromDate(l.timeLastUsed),
+ "last-changed": l => headerFromDate(l.timePasswordChanged),
+ alerts: (l, breachesByLoginGUID, vulnerableLoginsByLoginGUID) => {
+ const isBreached = breachesByLoginGUID && breachesByLoginGUID.has(l.guid);
+ const isVulnerable =
+ vulnerableLoginsByLoginGUID && vulnerableLoginsByLoginGUID.has(l.guid);
+ if (isBreached) {
+ return (
+ LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-breach"
+ );
+ } else if (isVulnerable) {
+ return (
+ LoginListSectionFactory.ID_PREFIX +
+ "about-logins-list-section-vulnerable"
+ );
+ }
+ return (
+ LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-nothing"
+ );
+ },
+};
+
+function headerFromDate(timestamp) {
+ let now = new Date();
+ now.setHours(0, 0, 0, 0); // reset to start of day
+ let date = new Date(timestamp);
+
+ if (now < date) {
+ return (
+ LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-today"
+ );
+ } else if (now - dayDuration < date) {
+ return (
+ LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-yesterday"
+ );
+ } else if (now - 7 * dayDuration < date) {
+ return LoginListSectionFactory.ID_PREFIX + "about-logins-list-section-week";
+ } else if (now.getFullYear() == date.getFullYear()) {
+ return monthFormatter.format(date);
+ } else if (now.getFullYear() - 1 == date.getFullYear()) {
+ return yearMonthFormatter.format(date);
+ }
+ return String(date.getFullYear());
+}
+
+export default class LoginList extends HTMLElement {
+ // An array of login GUIDs, stored in sorted order.
+ _loginGuidsSortedOrder = [];
+ // A map of login GUID -> {login, listItem}.
+ _logins = {};
+ // A map of section header -> sectionItem
+ _sections = {};
+ _filter = "";
+ _selectedGuid = null;
+ _blankLoginListItem = LoginListItemFactory.create({});
+
+ constructor() {
+ super();
+ this._blankLoginListItem.hidden = true;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ let loginListTemplate = document.querySelector("#login-list-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(loginListTemplate.content.cloneNode(true));
+
+ this._count = shadowRoot.querySelector(".count");
+ this._createLoginButton = shadowRoot.querySelector("create-login-button");
+ this._list = shadowRoot.querySelector("ol");
+ this._list.appendChild(this._blankLoginListItem);
+ this._sortSelect = shadowRoot.querySelector("#login-sort");
+
+ this.render();
+
+ this.shadowRoot
+ .getElementById("login-sort")
+ .addEventListener("change", this);
+ window.addEventListener("AboutLoginsClearSelection", this);
+ window.addEventListener("AboutLoginsFilterLogins", this);
+ window.addEventListener("AboutLoginsInitialLoginSelected", this);
+ window.addEventListener("AboutLoginsLoginSelected", this);
+ window.addEventListener("AboutLoginsShowBlankLogin", this);
+ this._list.addEventListener("click", this);
+ this.addEventListener("keydown", this);
+ this.addEventListener("keyup", this);
+
+ // TODO: Using the addEventListener to listen for clicks and pass the event handler due to a CSP error.
+ // This will be fixed as login-list itself is converted into a lit component. We will then be able to use the onclick
+ // prop of login-command-button as seen in the example below (functionality works and passes tests).
+ // this._createLoginButton.onClick = e => this.handleCreateNewLogin(e);
+
+ this._createLoginButton.addEventListener("click", e =>
+ this.handleCreateNewLogin(e)
+ );
+ }
+
+ get #activeDescendant() {
+ const activeDescendantId = this._list.getAttribute("aria-activedescendant");
+ let activeDescendant =
+ activeDescendantId && this.shadowRoot.getElementById(activeDescendantId);
+ return activeDescendant;
+ }
+
+ selectLoginByDomainOrGuid(searchParam) {
+ this._preselectLogin = searchParam;
+ }
+
+ render() {
+ let visibleLoginGuids = this._applyFilter();
+ this.#updateVisibleLoginCount(
+ visibleLoginGuids.size,
+ this._loginGuidsSortedOrder.length
+ );
+ this.classList.toggle("empty-search", !visibleLoginGuids.size);
+ document.documentElement.classList.toggle(
+ "empty-search",
+ this._filter && !visibleLoginGuids.size
+ );
+ this._sortSelect.disabled = !visibleLoginGuids.size;
+
+ // Add all of the logins that are not in the DOM yet.
+ let fragment = document.createDocumentFragment();
+ for (let guid of this._loginGuidsSortedOrder) {
+ if (this._logins[guid].listItem) {
+ continue;
+ }
+ let login = this._logins[guid].login;
+ let listItem = LoginListItemFactory.create(login);
+ this._logins[login.guid] = Object.assign(this._logins[login.guid], {
+ listItem,
+ });
+ fragment.appendChild(listItem);
+ }
+ this._list.appendChild(fragment);
+
+ // Show, hide, and update state of the list items per the applied search filter.
+ for (let guid of this._loginGuidsSortedOrder) {
+ let { listItem } = this._logins[guid];
+
+ if (guid == this._selectedGuid) {
+ this._setListItemAsSelected(listItem);
+ }
+
+ if (
+ !!this._breachesByLoginGUID &&
+ this._breachesByLoginGUID.has(listItem.dataset.guid)
+ ) {
+ listItem.notificationIcon = "breached";
+ } else if (
+ !!this._vulnerableLoginsByLoginGUID &&
+ this._vulnerableLoginsByLoginGUID.has(listItem.dataset.guid) &&
+ listItem.notificationIcon !== "breached"
+ ) {
+ listItem.notificationIcon = "vulnerable";
+ } else {
+ listItem.notificationIcon = "";
+ }
+ listItem.hidden = !visibleLoginGuids.has(listItem.dataset.guid);
+ }
+
+ let sectionsKey = Object.keys(this._sections);
+ for (let sectionKey of sectionsKey) {
+ this._sections[sectionKey]._inUse = false;
+ }
+
+ if (this._loginGuidsSortedOrder.length) {
+ let section = null;
+ let currentHeader = null;
+ // Re-arrange the login-list-items according to their sort and
+ // create / re-arrange sections
+ for (let i = this._loginGuidsSortedOrder.length - 1; i >= 0; i--) {
+ let guid = this._loginGuidsSortedOrder[i];
+ let { listItem, _header } = this._logins[guid];
+
+ if (!listItem.hidden) {
+ if (currentHeader != _header) {
+ section = this.renderSectionHeader((currentHeader = _header));
+ }
+
+ section.insertBefore(
+ listItem,
+ section.firstElementChild.nextElementSibling
+ );
+ }
+ }
+ }
+
+ for (let sectionKey of sectionsKey) {
+ let section = this._sections[sectionKey];
+ if (section._inUse) {
+ continue;
+ }
+
+ section.hidden = true;
+ }
+
+ let activeDescendant = this.#activeDescendant;
+ if (!activeDescendant || activeDescendant.hidden) {
+ let visibleListItem = this._list.querySelector(
+ "login-list-item:not([hidden])"
+ );
+ if (visibleListItem) {
+ this._list.setAttribute("aria-activedescendant", visibleListItem.id);
+ }
+ }
+
+ if (
+ this._sortSelect.namedItem("alerts").hidden &&
+ ((this._breachesByLoginGUID &&
+ this._loginGuidsSortedOrder.some(loginGuid =>
+ this._breachesByLoginGUID.has(loginGuid)
+ )) ||
+ (this._vulnerableLoginsByLoginGUID &&
+ this._loginGuidsSortedOrder.some(loginGuid =>
+ this._vulnerableLoginsByLoginGUID.has(loginGuid)
+ )))
+ ) {
+ // Make available the "alerts" option but don't change the
+ // selected sort so the user's current task isn't interrupted.
+ this._sortSelect.namedItem("alerts").hidden = false;
+ }
+ }
+
+ renderSectionHeader(header) {
+ let section = this._sections[header];
+ if (!section) {
+ section = this._sections[header] = LoginListSectionFactory.create(header);
+ }
+
+ this._list.insertBefore(
+ section,
+ this._blankLoginListItem.nextElementSibling
+ );
+
+ section._inUse = true;
+ section.hidden = false;
+ return section;
+ }
+
+ handleCreateNewLogin() {
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsShowBlankLogin", {
+ cancelable: true,
+ })
+ );
+ recordTelemetryEvent({ object: "new_login", method: "new" });
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click": {
+ let listItem;
+ if (event.originalTarget.tagName === "LOGIN-LIST-ITEM") {
+ listItem = event.originalTarget;
+ } else {
+ listItem = event.originalTarget
+ ? event.originalTarget.getRootNode().host
+ : null;
+ }
+ if (!listItem || !listItem.dataset.guid) {
+ return;
+ }
+
+ let { login } = this._logins[listItem.dataset.guid];
+ this.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ bubbles: true,
+ composed: true,
+ cancelable: true, // allow calling preventDefault() on event
+ detail: login,
+ })
+ );
+
+ let extra = {};
+ if (listItem.notificationIcon === "breached") {
+ extra = { breached: "true" };
+ } else if (listItem.notificationIcon === "vulnerable") {
+ extra = { vulnerable: "true" };
+ }
+ recordTelemetryEvent({
+ object: "existing_login",
+ method: "select",
+ extra,
+ });
+ break;
+ }
+ case "change": {
+ this._applyHeaders();
+ this._applySortAndScrollToTop();
+ const extra = { sort_key: this._sortSelect.value };
+ recordTelemetryEvent({ object: "list", method: "sort", extra });
+ document.dispatchEvent(
+ new CustomEvent("AboutLoginsSortChanged", {
+ bubbles: true,
+ detail: this._sortSelect.value,
+ })
+ );
+ break;
+ }
+ case "AboutLoginsClearSelection": {
+ if (!this._loginGuidsSortedOrder.length) {
+ this._createLoginButton.disabled = false;
+ this.classList.remove("create-login-selected");
+ return;
+ }
+
+ let firstVisibleListItem = this._list.querySelector(
+ "login-list-item[data-guid]:not([hidden])"
+ );
+ let newlySelectedLogin;
+ if (firstVisibleListItem) {
+ newlySelectedLogin =
+ this._logins[firstVisibleListItem.dataset.guid].login;
+ } else {
+ // Clear the filter if all items have been filtered out.
+ this.classList.remove("create-login-selected");
+ this._createLoginButton.disabled = false;
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsFilterLogins", {
+ detail: "",
+ })
+ );
+ newlySelectedLogin =
+ this._logins[this._loginGuidsSortedOrder[0]].login;
+ }
+
+ // Select the first visible login after any possible filter is applied.
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: newlySelectedLogin,
+ cancelable: true,
+ })
+ );
+ break;
+ }
+ case "AboutLoginsFilterLogins": {
+ this._filter = event.detail.toLocaleLowerCase();
+ this.render();
+ break;
+ }
+ case "AboutLoginsInitialLoginSelected":
+ case "AboutLoginsLoginSelected": {
+ if (event.defaultPrevented || this._selectedGuid == event.detail.guid) {
+ return;
+ }
+
+ // XXX If an AboutLoginsLoginSelected event is received that doesn't contain
+ // the full login object, re-dispatch the event with the full login object since
+ // only the login-list knows the full details of each login object.
+ if (
+ Object.keys(event.detail).length == 1 &&
+ event.detail.hasOwnProperty("guid")
+ ) {
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: this._logins[event.detail.guid].login,
+ cancelable: true,
+ })
+ );
+ return;
+ }
+
+ let listItem = this._list.querySelector(
+ `login-list-item[data-guid="${event.detail.guid}"]`
+ );
+ if (listItem) {
+ this._setListItemAsSelected(listItem);
+ } else {
+ this.render();
+ }
+ break;
+ }
+ case "AboutLoginsShowBlankLogin": {
+ if (!event.defaultPrevented) {
+ this._selectedGuid = null;
+ this._setListItemAsSelected(this._blankLoginListItem);
+ }
+ break;
+ }
+ case "keyup":
+ case "keydown": {
+ if (event.type == "keydown") {
+ if (
+ this.shadowRoot.activeElement &&
+ this.shadowRoot.activeElement.closest("ol") &&
+ (event.key == " " ||
+ event.key == "ArrowUp" ||
+ event.key == "ArrowDown")
+ ) {
+ // Since Space, ArrowUp and ArrowDown will perform actions, prevent
+ // them from also scrolling the list.
+ event.preventDefault();
+ }
+ }
+
+ this._handleKeyboardNavWithinList(event);
+ break;
+ }
+ }
+ }
+
+ /**
+ * @param {login[]} logins An array of logins used for displaying in the list.
+ */
+ setLogins(logins) {
+ this._loginGuidsSortedOrder = [];
+ this._logins = logins.reduce((map, login) => {
+ this._loginGuidsSortedOrder.push(login.guid);
+ map[login.guid] = { login };
+ return map;
+ }, {});
+ this._sections = {};
+ this._applyHeaders();
+ this._applySort();
+ this._list.textContent = "";
+ this._list.appendChild(this._blankLoginListItem);
+ this.render();
+
+ if (!this._selectedGuid || !this._logins[this._selectedGuid]) {
+ this._selectFirstVisibleLogin();
+ }
+ }
+
+ /**
+ * @param {Map} breachesByLoginGUID A Map of breaches by login GUIDs used
+ * for displaying breached login indicators.
+ */
+ setBreaches(breachesByLoginGUID) {
+ this._internalSetMonitorData("_breachesByLoginGUID", breachesByLoginGUID);
+ }
+
+ /**
+ * @param {Map} breachesByLoginGUID A Map of breaches by login GUIDs that
+ * should be added to the local cache of
+ * breaches.
+ */
+ updateBreaches(breachesByLoginGUID) {
+ this._internalUpdateMonitorData(
+ "_breachesByLoginGUID",
+ breachesByLoginGUID
+ );
+ }
+
+ setVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalSetMonitorData(
+ "_vulnerableLoginsByLoginGUID",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ updateVulnerableLogins(vulnerableLoginsByLoginGUID) {
+ this._internalUpdateMonitorData(
+ "_vulnerableLoginsByLoginGUID",
+ vulnerableLoginsByLoginGUID
+ );
+ }
+
+ _internalSetMonitorData(
+ internalMemberName,
+ mapByLoginGUID,
+ updateSortAndSelectedLogin = true
+ ) {
+ this[internalMemberName] = mapByLoginGUID;
+ if (this[internalMemberName].size) {
+ for (let [loginGuid] of mapByLoginGUID) {
+ if (this._logins[loginGuid]) {
+ let { login, listItem } = this._logins[loginGuid];
+ LoginListItemFactory.update(listItem, login);
+ }
+ }
+ if (updateSortAndSelectedLogin) {
+ const alertsSortOptionElement = this._sortSelect.namedItem("alerts");
+ alertsSortOptionElement.hidden = false;
+ this._sortSelect.selectedIndex = alertsSortOptionElement.index;
+ this._applyHeaders();
+ this._applySortAndScrollToTop();
+ this._selectFirstVisibleLogin();
+ }
+ }
+ this.render();
+ }
+
+ _internalUpdateMonitorData(internalMemberName, mapByLoginGUID) {
+ if (!this[internalMemberName]) {
+ this[internalMemberName] = new Map();
+ }
+ for (const [guid, data] of [...mapByLoginGUID]) {
+ if (data) {
+ this[internalMemberName].set(guid, data);
+ } else {
+ this[internalMemberName].delete(guid);
+ }
+ }
+ this._internalSetMonitorData(
+ internalMemberName,
+ this[internalMemberName],
+ false
+ );
+ }
+
+ setSortDirection(sortDirection) {
+ // The 'alerts' sort becomes visible when there are known alerts.
+ // Don't restore to the 'alerts' sort if there are no alerts to show.
+ if (
+ sortDirection == "alerts" &&
+ this._sortSelect.namedItem("alerts").hidden
+ ) {
+ return;
+ }
+ this._sortSelect.value = sortDirection;
+ this._applyHeaders();
+ this._applySortAndScrollToTop();
+ this._selectFirstVisibleLogin();
+ }
+
+ /**
+ * @param {login} login A login that was added to storage.
+ */
+ loginAdded(login) {
+ this._logins[login.guid] = { login };
+ this._loginGuidsSortedOrder.push(login.guid);
+ this._applyHeaders(false);
+ this._applySort();
+
+ // Add the list item and update any other related state that may pertain
+ // to the list item such as breach alerts.
+ this.render();
+
+ if (
+ this.classList.contains("no-logins") &&
+ !this.classList.contains("create-login-selected")
+ ) {
+ this._selectFirstVisibleLogin();
+ }
+ }
+
+ /**
+ * @param {login} login A login that was modified in storage. The related
+ * login-list-item will get updated.
+ */
+ loginModified(login) {
+ this._logins[login.guid] = Object.assign(this._logins[login.guid], {
+ login,
+ _header: null, // reset header
+ });
+ this._applyHeaders(false);
+ this._applySort();
+ let loginObject = this._logins[login.guid];
+ LoginListItemFactory.update(loginObject.listItem, login);
+
+ // Update any other related state that may pertain to the list item
+ // such as breach alerts that may or may not now apply.
+ this.render();
+ }
+
+ /**
+ * @param {login} login A login that was removed from storage. The related
+ * login-list-item will get removed. The login object
+ * is a plain JS object representation of
+ * nsILoginInfo/nsILoginMetaInfo.
+ */
+ loginRemoved(login) {
+ // Update the selected list item to the previous item in the list
+ // if one exists, otherwise the next item. If no logins remain
+ // the login-intro or empty-search text will be shown instead of the login-list.
+ if (this._selectedGuid == login.guid) {
+ let visibleListItems = this._list.querySelectorAll(
+ "login-list-item[data-guid]:not([hidden])"
+ );
+ if (visibleListItems.length > 1) {
+ let index = [...visibleListItems].findIndex(listItem => {
+ return listItem.dataset.guid == login.guid;
+ });
+ let newlySelectedIndex = index > 0 ? index - 1 : index + 1;
+ let newlySelectedLogin =
+ this._logins[visibleListItems[newlySelectedIndex].dataset.guid].login;
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ detail: newlySelectedLogin,
+ cancelable: true,
+ })
+ );
+ }
+ }
+
+ this._logins[login.guid].listItem.remove();
+ delete this._logins[login.guid];
+ this._loginGuidsSortedOrder = this._loginGuidsSortedOrder.filter(guid => {
+ return guid != login.guid;
+ });
+
+ // Render the login-list to update the search result count and show the
+ // empty-search message if needed.
+ this.render();
+ }
+
+ /**
+ * @returns {Set} Set of login guids that match the filter.
+ */
+ _applyFilter() {
+ let matchingLoginGuids;
+ if (this._filter) {
+ matchingLoginGuids = new Set(
+ this._loginGuidsSortedOrder.filter(guid => {
+ let { login } = this._logins[guid];
+ return (
+ login.origin.toLocaleLowerCase().includes(this._filter) ||
+ (!!login.httpRealm &&
+ login.httpRealm.toLocaleLowerCase().includes(this._filter)) ||
+ login.username.toLocaleLowerCase().includes(this._filter) ||
+ login.password.toLocaleLowerCase().includes(this._filter)
+ );
+ })
+ );
+ } else {
+ matchingLoginGuids = new Set([...this._loginGuidsSortedOrder]);
+ }
+
+ return matchingLoginGuids;
+ }
+
+ _applySort() {
+ const sort = this._sortSelect.value;
+ this._loginGuidsSortedOrder = this._loginGuidsSortedOrder.sort((a, b) => {
+ let loginA = this._logins[a].login;
+ let loginB = this._logins[b].login;
+ return sortFnOptions[sort](
+ loginA,
+ loginB,
+ this._breachesByLoginGUID,
+ this._vulnerableLoginsByLoginGUID
+ );
+ });
+ }
+
+ _applyHeaders(updateAll = true) {
+ let headerFn = headersFnOptions[this._sortSelect.value];
+ for (let guid of this._loginGuidsSortedOrder) {
+ let login = this._logins[guid];
+ if (updateAll || !login._header) {
+ login._header = headerFn(
+ login.login,
+ this._breachesByLoginGUID,
+ this._vulnerableLoginsByLoginGUID
+ );
+ }
+ }
+ }
+
+ _applySortAndScrollToTop() {
+ this._applySort();
+ this.render();
+ this._list.scrollTop = 0;
+ }
+
+ #updateVisibleLoginCount(count, total) {
+ const args = document.l10n.getAttributes(this._count).args;
+ if (count != args.count || total != args.total) {
+ document.l10n.setAttributes(
+ this._count,
+ count == total ? "login-list-count2" : "login-list-filtered-count2",
+ { count, total }
+ );
+ }
+ }
+
+ #findPreviousItem(item) {
+ let previousItem = item;
+ do {
+ previousItem =
+ (previousItem.tagName == "SECTION"
+ ? previousItem.lastElementChild
+ : previousItem.previousElementSibling) ||
+ (previousItem.parentElement.tagName == "SECTION" &&
+ previousItem.parentElement.previousElementSibling);
+ } while (
+ previousItem &&
+ (previousItem.hidden || previousItem.tagName !== "LOGIN-LIST-ITEM")
+ );
+
+ return previousItem;
+ }
+
+ #findNextItem(item) {
+ let nextItem = item;
+ do {
+ nextItem =
+ (nextItem.tagName == "SECTION"
+ ? nextItem.firstElementChild.nextElementSibling
+ : nextItem.nextElementSibling) ||
+ (nextItem.parentElement.tagName == "SECTION" &&
+ nextItem.parentElement.nextElementSibling);
+ } while (
+ nextItem &&
+ (nextItem.hidden || nextItem.tagName !== "LOGIN-LIST-ITEM")
+ );
+ return nextItem;
+ }
+
+ #pickByDirection(ltr, rtl) {
+ return document.dir == "ltr" ? ltr : rtl;
+ }
+
+ //TODO May be we can use this fn in render(), but logic is different a bit
+ get #activeDescendantForSelection() {
+ let activeDescendant = this.#activeDescendant;
+ if (
+ !activeDescendant ||
+ activeDescendant.hidden ||
+ activeDescendant.tagName !== "LOGIN-LIST-ITEM"
+ ) {
+ activeDescendant =
+ this._list.querySelector("login-list-item[data-guid]:not([hidden])") ||
+ this._list.firstElementChild;
+ }
+ return activeDescendant;
+ }
+
+ _handleKeyboardNavWithinList(event) {
+ if (this._list != this.shadowRoot.activeElement) {
+ return;
+ }
+
+ let command = null;
+
+ switch (event.type) {
+ case "keyup":
+ switch (event.key) {
+ case " ":
+ case "Enter":
+ command = "click";
+ break;
+ }
+ break;
+ case "keydown":
+ switch (event.key) {
+ case "ArrowDown":
+ command = "next";
+ break;
+ case "ArrowLeft":
+ command = this.#pickByDirection("previous", "next");
+ break;
+ case "ArrowRight":
+ command = this.#pickByDirection("next", "previous");
+ break;
+ case "ArrowUp":
+ command = "previous";
+ break;
+ }
+ break;
+ }
+
+ if (command) {
+ event.preventDefault();
+
+ switch (command) {
+ case "click":
+ this.clickSelected();
+ break;
+ case "next":
+ this.selectNext();
+ break;
+ case "previous":
+ this.selectPrevious();
+ break;
+ }
+ }
+ }
+
+ clickSelected() {
+ this.#activeDescendantForSelection?.click();
+ }
+
+ selectNext() {
+ const activeDescendant = this.#activeDescendantForSelection;
+ if (activeDescendant) {
+ this.#moveSelection(
+ activeDescendant,
+ this.#findNextItem(activeDescendant)
+ );
+ }
+ }
+
+ selectPrevious() {
+ const activeDescendant = this.#activeDescendantForSelection;
+ if (activeDescendant) {
+ this.#moveSelection(
+ activeDescendant,
+ this.#findPreviousItem(activeDescendant)
+ );
+ }
+ }
+
+ #moveSelection(from, to) {
+ if (to) {
+ this._list.setAttribute("aria-activedescendant", to.id);
+ from?.classList.remove("keyboard-selected");
+ to.classList.add("keyboard-selected");
+ to.scrollIntoView({ block: "nearest" });
+ this.clickSelected();
+ }
+ }
+
+ /**
+ * Selects the first visible login as part of the initial load of the page,
+ * which will bypass any focus changes that occur during manual login
+ * selection.
+ */
+ _selectFirstVisibleLogin() {
+ const visibleLoginsGuids = this._applyFilter();
+ let selectedLoginGuid =
+ this._loginGuidsSortedOrder.find(guid => guid === this._preselectLogin) ??
+ this.findLoginGuidFromDomain(this._preselectLogin) ??
+ this._loginGuidsSortedOrder[0];
+
+ selectedLoginGuid = [
+ selectedLoginGuid,
+ ...this._loginGuidsSortedOrder,
+ ].find(guid => visibleLoginsGuids.has(guid));
+
+ const selectedLogin = this._logins[selectedLoginGuid]?.login;
+
+ if (selectedLogin) {
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsInitialLoginSelected", {
+ detail: selectedLogin,
+ })
+ );
+ this.updateSelectedLocationHash(selectedLoginGuid);
+ }
+ }
+
+ _setListItemAsSelected(listItem) {
+ let oldSelectedItem = this._list.querySelector(".selected");
+ if (oldSelectedItem) {
+ oldSelectedItem.classList.remove("selected");
+ oldSelectedItem.selected = false;
+ oldSelectedItem.removeAttribute("aria-selected");
+ }
+ this.classList.toggle("create-login-selected", !listItem.dataset.guid);
+ this._blankLoginListItem.hidden = !!listItem.dataset.guid;
+ this._createLoginButton.disabled = !listItem.dataset.guid;
+ listItem.classList.add("selected");
+ listItem.selected = true;
+ listItem.setAttribute("aria-selected", "true");
+ this._list.setAttribute("aria-activedescendant", listItem.id);
+ this._selectedGuid = listItem.dataset.guid;
+ this.updateSelectedLocationHash(this._selectedGuid);
+ // Scroll item into view if it isn't visible
+ listItem.scrollIntoView({ block: "nearest" });
+ }
+
+ updateSelectedLocationHash(guid) {
+ window.location.hash = guid ? `#${encodeURIComponent(guid)}` : "";
+ }
+
+ findLoginGuidFromDomain(domain) {
+ for (let guid of this._loginGuidsSortedOrder) {
+ let login = this._logins[guid].login;
+ if (login.hostname === domain) {
+ return guid;
+ }
+ }
+ return null;
+ }
+}
+customElements.define("login-list", LoginList);
diff --git a/browser/components/aboutlogins/content/components/login-message-popup.css b/browser/components/aboutlogins/content/components/login-message-popup.css
new file mode 100644
index 0000000000..802c8c0fa4
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-message-popup.css
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+:host .tooltip-container {
+ position: absolute;
+ inset-inline-start: 315px;
+ width: 232px;
+ box-shadow: 2px 2px 10px 1px rgba(0,0,0,0.18);
+ top: 0;
+}
+
+:host .tooltip-message {
+ margin: 0;
+ font-size: 14px;
+}
+
+:host .arrow-box {
+ position: relative;
+ padding: 12px;
+ background-color: var(--in-content-box-background);
+ border: 1px solid var(--in-content-border-color);
+ border-radius: 4px;
+}
+
+:host .arrow-box::before,
+:host .arrow-box::after {
+ inset-inline-end: 100%;
+ top: 40px; /* This allows the arrow to stay in the correct position, even if the text length is changed */
+ border: solid transparent;
+ content: "";
+ height: 0;
+ width: 0;
+ position: absolute;
+ pointer-events: none;
+}
+
+:host .arrow-box::after {
+ border-inline-end-color: var(--in-content-box-background);
+ border-width: 10px;
+ margin-top: -10px;
+}
+:host .arrow-box::before {
+ border-inline-end-color: var(--in-content-border-color);
+ border-width: 11px;
+ margin-top: -11px;
+}
diff --git a/browser/components/aboutlogins/content/components/login-message-popup.mjs b/browser/components/aboutlogins/content/components/login-message-popup.mjs
new file mode 100644
index 0000000000..5949917142
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-message-popup.mjs
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+const stylesTemplate = () => html` <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-message-popup.css"
+/>`;
+
+export const MessagePopup = ({ l10nid, webTitle = "" }) => {
+ return html` <div class="tooltip-container">
+ <div class="arrow-box">
+ <p
+ class="tooltip-message"
+ data-l10n-id=${ifDefined(l10nid)}
+ data-l10n-args=${JSON.stringify({ webTitle })}
+ ></p>
+ </div>
+ </div>`;
+};
+
+export class PasswordWarning extends MozLitElement {
+ static get properties() {
+ return {
+ isNewLogin: { type: Boolean, reflect: true },
+ webTitle: { type: String, reflect: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this.isNewLogin = false;
+ }
+ render() {
+ return this.isNewLogin
+ ? html`${stylesTemplate()}
+ ${MessagePopup({
+ l10nid: "about-logins-add-password-tooltip",
+ })}`
+ : html`${stylesTemplate()}
+ ${MessagePopup({
+ l10nid: "about-logins-edit-password-tooltip",
+ webTitle: this.webTitle,
+ })}`;
+ }
+}
+
+export class OriginWarning extends MozLitElement {
+ render() {
+ return html`${stylesTemplate()}
+ ${MessagePopup({ l10nid: "about-logins-origin-tooltip2" })}`;
+ }
+}
+
+customElements.define("password-warning", PasswordWarning);
+customElements.define("origin-warning", OriginWarning);
diff --git a/browser/components/aboutlogins/content/components/login-timeline.css b/browser/components/aboutlogins/content/components/login-timeline.css
new file mode 100644
index 0000000000..1e6eaa2b30
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-timeline.css
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.timeline {
+ display: grid;
+ grid-template-rows: 24px auto auto;
+ font-size: smaller;
+ color: var(--text-color-deemphasized);
+ padding-inline-start: 0;
+ text-align: center;
+}
+
+.timeline.empty {
+ display: none;
+}
+
+.timeline > svg {
+ grid-row: 1 / 1;
+ fill: var(--in-content-box-background);
+}
+
+.timeline > .line {
+ height: 2px;
+ justify-self: stretch;
+ align-self: center;
+ background-color: var(--in-content-border-color);
+ grid-row: 1;
+}
+
+.timeline > .line:nth-child(1) {
+ grid-column: 1;
+ width: 50%;
+ justify-self: flex-end;
+}
+
+.timeline > .line:nth-child(2) {
+ grid-column: 2/-2;
+}
+
+.timeline > .line:nth-child(3) {
+ grid-column: -2;
+ width: 50%;
+ justify-self: flex-start;
+}
+
+.timeline > .point {
+ width: 24px;
+ height: 24px;
+ stroke: var(--in-content-border-color);
+ stroke-width: 30px;
+ justify-self: center;
+}
+
+.timeline > .date {
+ grid-row: 2;
+ padding: 4px 8px;
+}
+
+.timeline > .action {
+ grid-row: 3;
+}
diff --git a/browser/components/aboutlogins/content/components/login-timeline.mjs b/browser/components/aboutlogins/content/components/login-timeline.mjs
new file mode 100644
index 0000000000..7925602fc9
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/login-timeline.mjs
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ styleMap,
+ classMap,
+ html,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+export default class Timeline extends MozLitElement {
+ static get properties() {
+ return {
+ history: { type: Array },
+ };
+ }
+
+ constructor() {
+ super();
+ this.history = [];
+ }
+
+ render() {
+ this.history = this.history.filter(historyPoint => historyPoint.time);
+ this.history.sort((a, b) => a.time - b.time);
+ let columns = "auto";
+
+ // Add each history event to the timeline
+ let points = this.history.map((entry, index) => {
+ if (index > 0) {
+ // add a gap between previous point and current one
+ columns += ` ${entry.time - this.history[index - 1].time}fr auto`;
+ }
+
+ let columnNumber = 2 * index + 1;
+ let styles = styleMap({ gridColumn: columnNumber });
+ return html`
+ <svg
+ style=${styles}
+ class="point"
+ viewBox="0 0 300 150"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <circle cx="150" cy="75" r="75" />
+ </svg>
+ <div
+ style=${styles}
+ class="date"
+ data-l10n-id="login-item-timeline-point-date"
+ data-l10n-args=${JSON.stringify({ datetime: entry.time })}
+ ></div>
+ <div
+ style=${styles}
+ class="action"
+ data-l10n-id=${entry.actionId}
+ </div>
+ `;
+ });
+
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/aboutlogins/components/login-timeline.css"
+ />
+ <div
+ class="timeline ${classMap({ empty: !this.history.length })}"
+ style=${styleMap({ gridTemplateColumns: columns })}
+ >
+ <div class="line"></div>
+ <div class="line"></div>
+ <div class="line"></div>
+ ${points}
+ </div>
+ `;
+ }
+}
+
+customElements.define("login-timeline", Timeline);
diff --git a/browser/components/aboutlogins/content/components/menu-button.css b/browser/components/aboutlogins/content/components/menu-button.css
new file mode 100644
index 0000000000..57e26676b3
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/menu-button.css
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:host {
+ position: relative;
+}
+
+.menu-button {
+ background-image: url("chrome://global/skin/icons/more.svg");
+ background-repeat: no-repeat;
+ background-position: center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 30px;
+ min-width: 30px;
+ margin: 0;
+}
+
+.menu {
+ position: absolute;
+ inset-inline-end: 0;
+ margin: 0;
+ padding: 5px 0;
+ background-color: var(--in-content-box-background);
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 4px;
+ box-shadow: var(--shadow-30);
+ min-width: max-content;
+ list-style-type: none;
+ display: flex;
+ flex-direction: column;
+ /* Show on top of .breach-alert which is also positioned */
+ z-index: 1;
+ font: menu;
+}
+
+.menuitem-button {
+ padding: 4px 8px;
+ /* 32px = 8px (padding) + 16px (icon) + 8px (padding) */
+ padding-inline-start: 32px;
+ background-repeat: no-repeat;
+ background-position: left 8px center;
+ background-size: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+
+ /* Override common.inc.css properties */
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ text-align: start;
+ min-height: initial;
+ font: inherit;
+}
+
+.menuitem-button:dir(rtl) {
+ background-position-x: right 8px;
+}
+
+.menuitem-button:focus-visible {
+ outline-offset: var(--in-content-focus-outline-inset);
+}
+
+.menuitem-separator {
+ border-top-width: 1px;
+ margin-block: 5px;
+ width: 100%;
+}
+
+.menuitem-help {
+ background-image: url("chrome://global/skin/icons/help.svg");
+}
+
+.menuitem-import-browser {
+ background-image: url("chrome://browser/skin/import.svg");
+}
+
+.menuitem-import-file {
+ background-image: url("chrome://browser/skin/import.svg");
+}
+
+.menuitem-export {
+ background-image: url("chrome://browser/skin/save.svg");
+}
+
+.menuitem-remove-all-logins {
+ background-image: url("chrome://global/skin/icons/delete.svg");
+}
+
+.menuitem-preferences {
+ background-image: url("chrome://global/skin/icons/settings.svg");
+}
diff --git a/browser/components/aboutlogins/content/components/menu-button.mjs b/browser/components/aboutlogins/content/components/menu-button.mjs
new file mode 100644
index 0000000000..bb69b711c9
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/menu-button.mjs
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export default class MenuButton extends HTMLElement {
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+
+ let MenuButtonTemplate = document.querySelector("#menu-button-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(MenuButtonTemplate.content.cloneNode(true));
+
+ for (let menuitem of this.shadowRoot.querySelectorAll(
+ ".menuitem-button[data-supported-platforms]"
+ )) {
+ let supportedPlatforms = menuitem.dataset.supportedPlatforms
+ .split(",")
+ .map(platform => platform.trim());
+ if (supportedPlatforms.includes(navigator.platform)) {
+ menuitem.hidden = false;
+ }
+ }
+
+ this._menu = this.shadowRoot.querySelector(".menu");
+ this._menuButton = this.shadowRoot.querySelector(".menu-button");
+
+ this._menuButton.addEventListener("click", this);
+ document.addEventListener("keydown", this, true);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "blur": {
+ if (event.explicitOriginalTarget) {
+ let node = event.explicitOriginalTarget;
+ if (node.nodeType == Node.TEXT_NODE) {
+ node = node.parentElement;
+ }
+ if (node.closest(".menu") == this._menu) {
+ // Only hide the menu if focus has left the menu-button.
+ return;
+ }
+ }
+ this._hideMenu();
+ break;
+ }
+ case "click": {
+ // Skip the catch-all event listener if it was the menu-button
+ // that was clicked on.
+ if (
+ event.currentTarget == document.documentElement &&
+ event.target == this &&
+ event.originalTarget == this._menuButton
+ ) {
+ return;
+ }
+
+ if (event.originalTarget == this._menuButton) {
+ this._toggleMenu();
+ if (!this._menu.hidden) {
+ this._menuButton.focus();
+ }
+ return;
+ }
+
+ let classList = event.originalTarget.classList;
+ if (classList.contains("menuitem-button")) {
+ let eventName = event.originalTarget.dataset.eventName;
+ const linkTrackingSource = "Elipsis_Menu";
+ document.dispatchEvent(
+ new CustomEvent(eventName, {
+ bubbles: true,
+ detail: linkTrackingSource,
+ })
+ );
+
+ // Bug 1645365: Only hide the menu when the buttons are clicked
+ // So that the menu isn't closed when non-buttons (e.g. separators, paddings) are clicked
+ this._hideMenu();
+ }
+
+ // Explicitly close menu at the catch-all click event (i.e. a click outside of the menu)
+ if (
+ !this._menu.contains(event.originalTarget) &&
+ !this._menuButton.contains(event.originalTarget)
+ ) {
+ this._hideMenu();
+ }
+
+ break;
+ }
+ case "keydown": {
+ this._handleKeyDown(event);
+ }
+ }
+ }
+
+ _handleKeyDown(event) {
+ if (event.key == "Enter" && event.originalTarget == this._menuButton) {
+ event.preventDefault();
+ this._toggleMenu();
+ this._focusSuccessor(true);
+ } else if (event.key == "Escape" && !this._menu.hidden) {
+ this._hideMenu();
+ this._menuButton.focus();
+ } else if (
+ (event.key == "ArrowDown" || event.key == "ArrowUp") &&
+ !this._menu.hidden
+ ) {
+ event.preventDefault();
+ this._focusSuccessor(event.key == "ArrowDown");
+ }
+ }
+
+ _focusSuccessor(next = true) {
+ let items = this._menu.querySelectorAll(".menuitem-button:not([hidden])");
+ let firstItem = items[0];
+ let lastItem = items[items.length - 1];
+
+ let activeItem = this.shadowRoot.activeElement;
+ let activeItemIndex = [...items].indexOf(activeItem);
+
+ let successor = null;
+
+ if (next) {
+ if (!activeItem || activeItem === lastItem) {
+ successor = firstItem;
+ } else {
+ successor = items[activeItemIndex + 1];
+ }
+ } else if (activeItem === this._menuButton || activeItem === firstItem) {
+ successor = lastItem;
+ } else {
+ successor = items[activeItemIndex - 1];
+ }
+
+ if (this._menu.hidden) {
+ this._showMenu();
+ }
+ if (successor.disabled) {
+ if (next) {
+ successor = items[activeItemIndex + 2];
+ } else {
+ successor = items[activeItemIndex - 2];
+ }
+ }
+ window.AboutLoginsUtils.setFocus(successor);
+ }
+
+ _hideMenu() {
+ this._menu.hidden = true;
+
+ this.removeEventListener("blur", this);
+ document.documentElement.removeEventListener("click", this, true);
+ }
+
+ _showMenu() {
+ this._menu.querySelector(".menuitem-import-file").hidden =
+ !window.AboutLoginsUtils.fileImportEnabled;
+
+ this._menu.hidden = false;
+
+ // Event listeners to close the menu
+ this.addEventListener("blur", this);
+ document.documentElement.addEventListener("click", this, true);
+ }
+
+ /**
+ * Toggles the visibility of the menu.
+ */
+ _toggleMenu() {
+ let wasHidden = this._menu.hidden;
+ if (wasHidden) {
+ this._showMenu();
+ } else {
+ this._hideMenu();
+ }
+ }
+}
+customElements.define("menu-button", MenuButton);
diff --git a/browser/components/aboutlogins/content/components/remove-logins-dialog.css b/browser/components/aboutlogins/content/components/remove-logins-dialog.css
new file mode 100644
index 0000000000..160ca47d03
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/remove-logins-dialog.css
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ .overlay {
+ position: fixed;
+ z-index: 1;
+ inset: 0;
+ /* TODO: this color is used in the about:preferences overlay, but
+ why isn't it declared as a variable? */
+ background-color: rgba(0,0,0,0.5);
+ display: flex;
+}
+
+.container {
+ z-index: 2;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 300px;
+ max-width: 660px;
+ min-height: 200px;
+ margin: auto;
+ background-color: var(--in-content-page-background);
+ color: var(--in-content-text-color);
+ box-shadow: var(--shadow-30);
+ /* show a border in high contrast mode */
+ outline: 1px solid transparent;
+}
+
+.title {
+ grid-area: 1 / 2 / 2 / 8;
+}
+
+.message {
+ font-weight: 600;
+ grid-area: 2 / 2 / 3 / 8;
+ font-size: 1.25em;
+}
+
+.checkbox-text {
+ font-size: 1.25em;
+}
+
+.dismiss-button {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 0;
+ min-width: 20px;
+ min-height: 20px;
+ margin: 16px;
+ padding: 0;
+ line-height: 0;
+}
+
+.dismiss-icon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.warning-icon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 32px;
+ height: 32px;
+ margin: 8px;
+}
+
+.content,
+.buttons {
+ padding: 36px 48px;
+ padding-bottom: 24px;
+}
+
+.content {
+ display: grid;
+ grid-template-columns: 0.5fr 1fr 1fr 1fr 1fr 1fr 1fr;
+ grid-template-rows: 0.5fr 0.5fr 0.5fr;
+}
+
+.checkbox-wrapper {
+ grid-area: 3 / 2 / 4 / 8;
+ align-self: first baseline;
+ justify-self: start;
+}
+
+.warning-icon {
+ grid-area: 1 / 1 / 2 / 2;
+}
+
+.checkbox {
+ grid-area: 3 / 2 / 4 / 8;
+ font-size: 1.1em;
+ align-self: center;
+}
+
+.buttons {
+ padding-block: 16px 32px;
+ padding-inline: 48px 0;
+ border-top: 1px solid var(--in-content-border-color);
+ margin-inline: 48px;
+}
diff --git a/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs b/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs
new file mode 100644
index 0000000000..94cd6ef13e
--- /dev/null
+++ b/browser/components/aboutlogins/content/components/remove-logins-dialog.mjs
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { setKeyboardAccessForNonDialogElements } from "../aboutLoginsUtils.mjs";
+
+export default class RemoveLoginsDialog extends HTMLElement {
+ constructor() {
+ super();
+ this._promise = null;
+ }
+
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ let template = document.querySelector("#remove-logins-dialog-template");
+ let shadowRoot = this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(shadowRoot);
+ shadowRoot.appendChild(template.content.cloneNode(true));
+
+ this._buttons = this.shadowRoot.querySelector(".buttons");
+ this._cancelButton = this.shadowRoot.querySelector(".cancel-button");
+ this._confirmButton = this.shadowRoot.querySelector(".confirm-button");
+ this._dismissButton = this.shadowRoot.querySelector(".dismiss-button");
+ this._message = this.shadowRoot.querySelector(".message");
+ this._overlay = this.shadowRoot.querySelector(".overlay");
+ this._title = this.shadowRoot.querySelector(".title");
+ this._checkbox = this.shadowRoot.querySelector(".checkbox");
+ this._checkboxLabel = this.shadowRoot.querySelector(".checkbox-text");
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "keydown":
+ if (event.key === "Escape" && !event.defaultPrevented) {
+ this.onCancel();
+ }
+ break;
+ case "click":
+ if (
+ event.target.classList.contains("cancel-button") ||
+ event.currentTarget.classList.contains("dismiss-button") ||
+ event.target.classList.contains("overlay")
+ ) {
+ this.onCancel();
+ } else if (event.target.classList.contains("confirm-button")) {
+ this.onConfirm();
+ } else if (event.target.classList.contains("checkbox")) {
+ this._confirmButton.disabled = !this._checkbox.checked;
+ }
+ }
+ }
+
+ hide() {
+ setKeyboardAccessForNonDialogElements(true);
+ this._cancelButton.removeEventListener("click", this);
+ this._confirmButton.removeEventListener("click", this);
+ this._dismissButton.removeEventListener("click", this);
+ this._overlay.removeEventListener("click", this);
+ this._checkbox.removeEventListener("click", this);
+ window.removeEventListener("keydown", this);
+
+ this._checkbox.checked = false;
+
+ this.hidden = true;
+ }
+
+ show({ title, message, confirmButtonLabel, confirmCheckboxLabel, count }) {
+ setKeyboardAccessForNonDialogElements(false);
+ this.hidden = false;
+
+ document.l10n.setAttributes(this._title, title, {
+ count,
+ });
+ document.l10n.setAttributes(this._message, message, {
+ count,
+ });
+ document.l10n.setAttributes(this._confirmButton, confirmButtonLabel, {
+ count,
+ });
+ document.l10n.setAttributes(this._checkboxLabel, confirmCheckboxLabel, {
+ count,
+ });
+
+ this._checkbox.addEventListener("click", this);
+ this._cancelButton.addEventListener("click", this);
+ this._confirmButton.addEventListener("click", this);
+ this._dismissButton.addEventListener("click", this);
+ this._overlay.addEventListener("click", this);
+ window.addEventListener("keydown", this);
+
+ this._confirmButton.disabled = true;
+ // For speed-of-use, focus the confirmation checkbox when the dialog loads.
+ // Introducing this checkbox provides enough of a buffer for accidental deletions.
+ this._checkbox.focus();
+
+ this._promise = new Promise((resolve, reject) => {
+ this._resolve = resolve;
+ this._reject = reject;
+ });
+
+ return this._promise;
+ }
+
+ onCancel() {
+ this._reject();
+ this.hide();
+ }
+
+ onConfirm() {
+ this._resolve();
+ this.hide();
+ }
+}
+
+customElements.define("remove-logins-dialog", RemoveLoginsDialog);