Bug 1470199 - Add a tooltip for the CVV input. r=MattN
authorJared Wein <jwein@mozilla.com>
Sat, 13 Oct 2018 00:39:02 +0000
changeset 489433 d545a48c1bfd7863036e179312f9fff45e335cff
parent 489432 6beac5dc489916e57c24d79465c99085663f2cd0
child 489434 474b2a78d4fac1f3006c198ad063449ded904e8d
push id247
push userfmarier@mozilla.com
push dateSat, 27 Oct 2018 01:06:44 +0000
reviewersMattN
bugs1470199
milestone64.0a1
Bug 1470199 - Add a tooltip for the CVV input. r=MattN Differential Revision: https://phabricator.services.mozilla.com/D7473
browser/components/payments/res/components/csc-input.js
browser/components/payments/res/containers/basic-card-form.css
browser/components/payments/res/containers/basic-card-form.js
browser/components/payments/res/containers/cvv-hint-image-back.svg
browser/components/payments/res/containers/cvv-hint-image-front.svg
browser/components/payments/res/containers/payment-dialog.js
browser/components/payments/res/containers/payment-method-picker.js
browser/components/payments/res/containers/rich-picker.css
browser/components/payments/res/debugging.js
browser/components/payments/res/paymentRequest.css
browser/components/payments/res/paymentRequest.xhtml
browser/components/payments/test/PaymentTestUtils.jsm
browser/components/payments/test/mochitest/test_basic_card_form.html
browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
browser/components/payments/test/mochitest/test_payment_method_picker.html
browser/extensions/formautofill/content/editCreditCard.xhtml
browser/extensions/formautofill/skin/shared/editDialog-shared.css
new file mode 100644
--- /dev/null
+++ b/browser/components/payments/res/components/csc-input.js
@@ -0,0 +1,103 @@
+/* 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 ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
+
+/**
+ *  <csc-input placeholder="CVV*"
+               default-value="123"
+               front-tooltip="Look on front of card for CSC"
+               back-tooltip="Look on back of card for CSC"></csc-input>
+ */
+
+export default class CscInput extends ObservedPropertiesMixin(HTMLElement) {
+  static get observedAttributes() {
+    return [
+      "back-tooltip",
+      "card-type",
+      "default-value",
+      "disabled",
+      "front-tooltip",
+      "placeholder",
+      "value",
+    ];
+  }
+  constructor({
+      useAlwaysVisiblePlaceholder,
+      inputId,
+    } = {}) {
+    super();
+
+    this.useAlwaysVisiblePlaceholder = useAlwaysVisiblePlaceholder;
+
+    this._input = document.createElement("input");
+    this._input.id = inputId || "";
+    this._input.setAttribute("type", "text");
+    this._input.autocomplete = "off";
+    this._input.size = 3;
+    this._input.required = true;
+    // 3 or more digits
+    this._input.pattern = "[0-9]{3,}";
+    this._input.classList.add("security-code");
+    if (useAlwaysVisiblePlaceholder) {
+      this._label = document.createElement("span");
+      this._label.dataset.localization = "cardCVV";
+      this._label.className = "label-text";
+    }
+    this._tooltip = document.createElement("span");
+    this._tooltip.className = "info-tooltip csc";
+    this._tooltip.setAttribute("tabindex", "0");
+    this._tooltip.setAttribute("role", "tooltip");
+
+    // The parent connectedCallback calls its render method before
+    // our connectedCallback can run. This causes issues for parent
+    // code that is looking for all the form elements. Thus, we
+    // append the children during the constructor to make sure they
+    // be part of the DOM sooner.
+    this.appendChild(this._input);
+    if (this.useAlwaysVisiblePlaceholder) {
+      this.appendChild(this._label);
+    }
+    this.appendChild(this._tooltip);
+  }
+
+  connectedCallback() {
+    this.render();
+  }
+
+  render() {
+    if (this.value) {
+      // Setting the value will trigger form validation
+      // so only set the value if one has been provided.
+      this._input.value = this.value;
+    }
+    if (this.useAlwaysVisiblePlaceholder) {
+      this._label.textContent = this.placeholder || "";
+    } else {
+      this._input.placeholder = this.placeholder || "";
+    }
+    if (this.cardType == "amex") {
+      this._tooltip.setAttribute("aria-label", this.frontTooltip || "");
+    } else {
+      this._tooltip.setAttribute("aria-label", this.backTooltip || "");
+    }
+  }
+
+  get value() {
+    return this._input.value;
+  }
+
+  get isValid() {
+    return this._input.validity.valid;
+  }
+
+  set disabled(value) {
+    // This is kept out of render() since callers
+    // are expecting it to apply immediately.
+    this._input.disabled = value;
+    return !!value;
+  }
+}
+
+customElements.define("csc-input", CscInput);
--- a/browser/components/payments/res/containers/basic-card-form.css
+++ b/browser/components/payments/res/containers/basic-card-form.css
@@ -7,16 +7,21 @@ basic-card-form .editCreditCardForm {
   grid-template-areas:
     "cc-number cc-exp-month cc-exp-year"
     "cc-name   cc-type      cc-csc"
     "accepted  accepted     accepted"
     "persist-checkbox persist-checkbox persist-checkbox"
     "billingAddressGUID billingAddressGUID billingAddressGUID";
 }
 
+basic-card-form csc-input {
+  display: flex;
+  flex-grow: 1;
+}
+
 basic-card-form .editCreditCardForm > accepted-cards {
   grid-area: accepted;
   margin: 0;
 }
 
 basic-card-form .editCreditCardForm .persist-checkbox {
   display: flex;
   grid-area: persist-checkbox;
--- a/browser/components/payments/res/containers/basic-card-form.js
+++ b/browser/components/payments/res/containers/basic-card-form.js
@@ -1,14 +1,15 @@
 /* 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-globals-from ../../../../../browser/extensions/formautofill/content/autofillEditForms.js*/
 import AcceptedCards from "../components/accepted-cards.js";
+import CscInput from "../components/csc-input.js";
 import LabelledCheckbox from "../components/labelled-checkbox.js";
 import PaymentDialog from "./payment-dialog.js";
 import PaymentRequestPage from "../components/payment-request-page.js";
 import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
 import paymentRequest from "../paymentRequest.js";
 
 /* import-globals-from ../unprivileged-fallbacks.js */
 
@@ -31,16 +32,21 @@ export default class BasicCardForm exten
     this.addressAddLink.className = "add-link";
     this.addressAddLink.href = "javascript:void(0)";
     this.addressAddLink.addEventListener("click", this);
     this.addressEditLink = document.createElement("a");
     this.addressEditLink.className = "edit-link";
     this.addressEditLink.href = "javascript:void(0)";
     this.addressEditLink.addEventListener("click", this);
 
+    this.cscInput = new CscInput({
+      useAlwaysVisiblePlaceholder: true,
+      inputId: "cc-csc",
+    });
+
     this.persistCheckbox = new LabelledCheckbox();
     // The persist checkbox shouldn't be part of the record which gets saved so
     // exclude it from the form.
     this.persistCheckbox.form = "";
     this.persistCheckbox.className = "persist-checkbox";
 
     this.acceptedCardsList = new AcceptedCards();
 
@@ -102,16 +108,21 @@ export default class BasicCardForm exten
       form.addEventListener("invalid", this);
 
       // The "invalid" event does not bubble and needs to be listened for on each
       // form element.
       for (let field of this.form.elements) {
         field.addEventListener("invalid", this);
       }
 
+      // Replace the form-autofill cc-csc fields with our csc-input.
+      let cscContainer = this.form.querySelector("#cc-csc-container");
+      cscContainer.textContent = "";
+      cscContainer.appendChild(this.cscInput);
+
       let fragment = document.createDocumentFragment();
       fragment.append(" ");
       fragment.append(this.addressEditLink);
       fragment.append(this.addressAddLink);
       let billingAddressRow = this.form.querySelector(".billingAddressRow");
 
       // XXX: Bug 1482689 - Remove the label-text class from the billing field
       // which will be removed when switching to <rich-select>.
@@ -143,16 +154,21 @@ export default class BasicCardForm exten
     let editing = !!basicCardPage.guid;
     this.cancelButton.textContent = this.dataset.cancelButtonLabel;
     this.backButton.textContent = this.dataset.backButtonLabel;
     if (editing) {
       this.saveButton.textContent = this.dataset.updateButtonLabel;
     } else {
       this.saveButton.textContent = this.dataset.nextButtonLabel;
     }
+
+    this.cscInput.placeholder = this.dataset.cscPlaceholder;
+    this.cscInput.frontTooltip = this.dataset.cscFrontInfoTooltip;
+    this.cscInput.backTooltip = this.dataset.cscBackInfoTooltip;
+
     this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
     this.persistCheckbox.infoTooltip = this.dataset.persistCheckboxInfoTooltip;
     this.addressAddLink.textContent = this.dataset.addressAddLinkLabel;
     this.addressEditLink.textContent = this.dataset.addressEditLinkLabel;
     this.acceptedCardsList.label = this.dataset.acceptedCardsLabel;
 
     // The next line needs an onboarding check since we don't set previousId
     // when navigating to add/edit directly from the summary page.
@@ -164,17 +180,17 @@ export default class BasicCardForm exten
     let addresses = paymentRequest.getAddresses(state);
 
     this.genericErrorText.textContent = page.error;
 
     this.form.querySelector("#cc-number").disabled = editing;
 
     // The CVV fields should be hidden and disabled when editing.
     this.form.querySelector("#cc-csc-container").hidden = editing;
-    this.form.querySelector("#cc-csc").disabled = editing;
+    this.cscInput.disabled = editing;
 
     // If a card is selected we want to edit it.
     if (editing) {
       this.pageTitleHeading.textContent = this.dataset.editBasicCardTitle;
       record = basicCards[basicCardPage.guid];
       if (!record) {
         throw new Error("Trying to edit a non-existing card: " + basicCardPage.guid);
       }
@@ -253,16 +269,19 @@ export default class BasicCardForm exten
 
         this.onInvalidField(event);
         break;
       }
     }
   }
 
   onChange(evt) {
+    let ccType = this.form.querySelector("#cc-type");
+    this.cscInput.setAttribute("card-type", ccType.value);
+
     this.updateSaveButtonState();
   }
 
   onClick(evt) {
     switch (evt.target) {
       case this.cancelButton: {
         paymentRequest.cancel();
         break;
@@ -424,17 +443,17 @@ export default class BasicCardForm exten
       if (!selectedStateKey) {
         throw new Error(`state["basic-card-page"].selectedStateKey is required`);
       }
       this.requestStore.setState({
         page: {
           id: "payment-summary",
         },
         [selectedStateKey]: guid,
-        [selectedStateKey + "SecurityCode"]: this.form.querySelector("#cc-csc").value,
+        [selectedStateKey + "SecurityCode"]: this.cscInput.value,
       });
     } catch (ex) {
       log.warn("saveRecord: error:", ex);
       this.requestStore.setState({
         page: {
           id: "basic-card-page",
           error: this.dataset.errorGenericSave,
         },
new file mode 100644
--- /dev/null
+++ b/browser/components/payments/res/containers/cvv-hint-image-back.svg
@@ -0,0 +1,27 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="46" height="27" version="1.1">
+  <defs>
+    <circle id="a" cx="10" cy="10" r="10"/>
+  </defs>
+  <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
+    <path fill="#A1C6FF" d="M37 6.2a10.046 10.046 0 0 0 -2 -0.2c-5.523 0 -10 4.477 -10 10a9.983 9.983 0 0 0 3.999 8h-27.999a1 1 0 0 1 -1 -1v-22a1 1 0 0 1 1 -1h35a1 1 0 0 1 1 1v5.2zm-18 7.8c3.314 0 6 -1.567 6 -3.5s-2.686 -3.5 -6 -3.5 -6 1.567 -6 3.5 2.686 3.5 6 3.5z"/>
+    <path fill="#5F5F5F" d="M2 17h9v2h-9v-2zm0 -15h33v3h-33v-3zm0 18h15v2h-15v-2zm10 -3h13v2h-13v-2z"/>
+    <g transform="translate(25 6)">
+      <mask id="b" fill="#fff">
+        <use xlink:href="#a"/>
+      </mask>
+      <use stroke="#FFF" stroke-width="1.5" xlink:href="#a"/>
+      <g mask="url(#b)">
+        <g transform="translate(-77 -31)">
+          <rect width="99.39" height="69.141" x="0" y="0" fill="#A1C6FF" fill-rule="evenodd" rx="1"/>
+          <path fill="#5F5F5F" fill-rule="evenodd" d="M79 46h17v6h-17z"/>
+          <text fill="none" font-family="sans-serif" font-size="6">
+            <tspan x="80" y="42" fill="#5F5F5F">1234</tspan>
+          </text>
+        </g>
+      </g>
+    </g>
+  </g>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/components/payments/res/containers/cvv-hint-image-front.svg
@@ -0,0 +1,25 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="45" height="27" version="1.1">
+  <defs>
+    <circle id="a" cx="10" cy="10" r="10"/>
+  </defs>
+  <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
+    <path fill="#62A0FF" d="M37 6.458a9.996 9.996 0 0 0 -3 -0.458c-3.701 0 -6.933 2.011 -8.662 5h-22.338v5h21a9.983 9.983 0 0 0 3.999 8h-26.999a1 1 0 0 1 -1 -1v-22a1 1 0 0 1 1 -1h35a1 1 0 0 1 1 1v5.458z"/>
+    <path fill="#5F5F5F" d="M37 6.458a9.996 9.996 0 0 0 -3 -0.458 9.97 9.97 0 0 0 -7.141 3h-26.859v-6h37v3.458z"/>
+    <g transform="translate(24 6)">
+      <mask id="b" fill="#fff">
+        <use xlink:href="#a"/>
+      </mask>
+      <use stroke="#FFF" stroke-width="1.5" xlink:href="#a"/>
+      <g mask="url(#b)">
+        <path fill="#62A0FF" fill-rule="evenodd" d="M-41.923 -15.615h64.476a1 1 0 0 1 1 1v44.244a1 1 0 0 1 -1 1h-64.476a1 1 0 0 1 -1 -1v-44.244a1 1 0 0 1 1 -1zm2.923 19.615v9h55v-9h-55z"/>
+        <path fill="#5F5F5F" fill-rule="evenodd" d="M-43 -10h66v12h-66z"/>
+        <text fill="none" font-family="sans-serif" font-size="6" transform="translate(-43.923 -15.615)">
+          <tspan x="47.676" y="26.104" fill="#5F5F5F">123</tspan>
+        </text>
+      </g>
+    </g>
+  </g>
+</svg>
--- a/browser/components/payments/res/containers/payment-dialog.js
+++ b/browser/components/payments/res/containers/payment-dialog.js
@@ -259,17 +259,17 @@ export default class PaymentDialog exten
         this._payButton.disabled =
           (state.request.paymentOptions.requestShipping &&
            (!this._shippingAddressPicker.selectedOption ||
             this._shippingAddressPicker.classList.contains(INVALID_CLASS_NAME) ||
             !this._shippingOptionPicker.selectedOption)) ||
           (this._isPayerRequested(state.request.paymentOptions) &&
            (!this._payerAddressPicker.selectedOption ||
             this._payerAddressPicker.classList.contains(INVALID_CLASS_NAME))) ||
-          !this._paymentMethodPicker.securityCodeInput.validity.valid ||
+          !this._paymentMethodPicker.securityCodeInput.isValid ||
           !this._paymentMethodPicker.selectedOption ||
           this._paymentMethodPicker.classList.contains(INVALID_CLASS_NAME) ||
           state.changesPrevented;
         break;
       }
       case "fail":
       case "timeout": {
         // pay button is hidden in fail/timeout states.
--- a/browser/components/payments/res/containers/payment-method-picker.js
+++ b/browser/components/payments/res/containers/payment-method-picker.js
@@ -1,35 +1,33 @@
 /* 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 BasicCardOption from "../components/basic-card-option.js";
+import CscInput from "../components/csc-input.js";
 import RichPicker from "./rich-picker.js";
 import paymentRequest from "../paymentRequest.js";
 /* import-globals-from ../unprivileged-fallbacks.js */
 
 /**
  * <payment-method-picker></payment-method-picker>
  * Container around add/edit links and <rich-select> with
  * <basic-card-option> listening to savedBasicCards.
  */
 
 export default class PaymentMethodPicker extends RichPicker {
   constructor() {
     super();
     this.dropdown.setAttribute("option-type", "basic-card-option");
-    this.securityCodeInput = document.createElement("input");
-    this.securityCodeInput.autocomplete = "off";
-    this.securityCodeInput.placeholder = this.dataset.cvvPlaceholder;
-    this.securityCodeInput.size = 3;
-    this.securityCodeInput.required = true;
-    // 3 or more digits
-    this.securityCodeInput.pattern = "[0-9]{3,}";
-    this.securityCodeInput.classList.add("security-code");
+    this.securityCodeInput = new CscInput();
+    this.securityCodeInput.className = "security-code-container";
+    this.securityCodeInput.placeholder = this.dataset.cscPlaceholder;
+    this.securityCodeInput.backTooltip = this.dataset.cscBackTooltip;
+    this.securityCodeInput.frontTooltip = this.dataset.cscFrontTooltip;
     this.securityCodeInput.addEventListener("change", this);
     this.securityCodeInput.addEventListener("input", this);
   }
 
   connectedCallback() {
     super.connectedCallback();
     this.dropdown.after(this.securityCodeInput);
   }
@@ -76,16 +74,20 @@ export default class PaymentMethodPicker
                       `does not exist in the payment method picker`);
     }
 
     let securityCodeState = state[this.selectedStateKey + "SecurityCode"];
     if (securityCodeState && securityCodeState != this.securityCodeInput.value) {
       this.securityCodeInput.defaultValue = securityCodeState;
     }
 
+    let selectedCardType = (basicCards[selectedPaymentCardGUID] &&
+                            basicCards[selectedPaymentCardGUID]["cc-type"]) || "";
+    this.securityCodeInput.cardType = selectedCardType;
+
     super.render(state);
   }
 
   errorForSelectedOption(state) {
     let superError = super.errorForSelectedOption(state);
     if (superError) {
       return superError;
     }
@@ -118,26 +120,26 @@ export default class PaymentMethodPicker
       }
       case "click": {
         this.onClick(event);
         break;
       }
     }
   }
 
-  onInputOrChange({target}) {
+  onInputOrChange({currentTarget}) {
     let selectedKey = this.selectedStateKey;
     let stateChange = {};
 
     if (!selectedKey) {
       return;
     }
 
-    switch (target) {
-      case this.dropdown.popupBox: {
+    switch (currentTarget) {
+      case this.dropdown: {
         stateChange[selectedKey] = this.dropdown.value;
         break;
       }
       case this.securityCodeInput: {
         stateChange[selectedKey + "SecurityCode"] = this.securityCodeInput.value;
         break;
       }
       default: {
--- a/browser/components/payments/res/containers/rich-picker.css
+++ b/browser/components/payments/res/containers/rich-picker.css
@@ -50,21 +50,28 @@
   display: none;
 }
 
 /* Payment Method Picker */
 payment-method-picker.rich-picker {
   grid-template-columns: 20fr 1fr auto auto;
   grid-template-areas:
     "label    spacer edit add"
-    "dropdown cvv    cvv  cvv"
+    "dropdown csc    csc  csc"
     "invalid  invalid invalid invalid";
 }
 
-payment-method-picker > input {
-  border: 1px solid #0C0C0D33;
-  border-inline-start: none;
-  grid-area: cvv;
+.security-code-container {
+  display: flex;
+  flex-grow: 1;
+  grid-area: csc;
   margin: 10px 0; /* Has to be same as rich-select */
-  padding: 8px;
   /* So the error outline appears above the adjacent dropdown */
   z-index: 1;
 }
+
+.rich-picker .security-code {
+  border: 1px solid #0C0C0D33;
+  /* Override the border from common.css */
+  border-inline-start: none !important;
+  flex-grow: 1;
+  padding: 8px;
+}
--- a/browser/components/payments/res/debugging.js
+++ b/browser/components/payments/res/debugging.js
@@ -360,16 +360,35 @@ let BASIC_CARDS_1 = {
     "timeLastUsed": 0,
     "timesUsed": 0,
     "cc-name": "Jane Fields",
     "cc-given-name": "Jane",
     "cc-additional-name": "",
     "cc-family-name": "Fields",
     "cc-type": "discover",
   },
+  "amex-card": {
+    methodName: "basic-card",
+    billingAddressGUID: "68gjdh354j",
+    "cc-number": "************1941",
+    "guid": "amex-card",
+    "version": 1,
+    "timeCreated": 1517890536491,
+    "timeLastModified": 1517890564518,
+    "timeLastUsed": 0,
+    "timesUsed": 0,
+    "cc-name": "Capt America",
+    "cc-given-name": "Capt",
+    "cc-additional-name": "",
+    "cc-family-name": "America",
+    "cc-type": "amex",
+    "cc-exp-month": 6,
+    "cc-exp-year": 2023,
+    "cc-exp": "2023-06",
+  },
   "missing-cc-name": {
     methodName: "basic-card",
     "cc-number": "************8563",
     "guid": "missing-cc-name",
     "version": 1,
     "timeCreated": 1517890536491,
     "timeLastModified": 1517890564518,
     "timeLastUsed": 0,
--- a/browser/components/payments/res/paymentRequest.css
+++ b/browser/components/payments/res/paymentRequest.css
@@ -205,32 +205,56 @@ payment-dialog[changes-prevented][comple
   position: relative;
 }
 
 .info-tooltip:focus::after,
 .info-tooltip:hover::after {
   content: attr(aria-label);
   display: block;
   position: absolute;
-  padding: 2px 5px;
+  padding: 3px 5px;
   background-color: #fff;
   border: 1px solid #bebebf;
   box-shadow: 1px 1px 3px #bebebf;
   font-size: smaller;
-  min-width: 188px;
+  line-height: normal;
+  width: 188px;
+  /* Center the tooltip over the (i) icon (188px / 2 - 5px (padding) - 1px (border)). */
   left: -86px;
   bottom: 20px;
 }
 
 .info-tooltip:dir(rtl):focus::after,
 .info-tooltip:dir(rtl):hover::after {
   left: auto;
   right: -86px;
 }
 
+.csc.info-tooltip:focus::after,
+.csc.info-tooltip:hover::after {
+  /* Right-align the tooltip over the (i) icon (-188px - 60px (padding) - 2px (border) + 4px ((i) start padding) + 16px ((i) icon width)). */
+  left: -226px;
+  background-position: top 5px left 5px;
+  background-image: url(./containers/cvv-hint-image-back.svg);
+  background-repeat: no-repeat;
+  padding-inline-start: 55px;
+}
+
+.csc.info-tooltip[cc-type="amex"]::after {
+  background-image: url(./containers/cvv-hint-image-front.svg);
+}
+
+.csc.info-tooltip:dir(rtl):focus::after,
+.csc.info-tooltip:dir(rtl):hover::after {
+  left: auto;
+  /* Left-align the tooltip over the (i) icon (-188px - 60px (padding) - 2px (border) + 4px ((i) start padding) + 16px ((i) icon width)). */
+  right: -226px;
+  background-position: top 5px right 5px;
+}
+
 .branding {
   background-image: url(chrome://branding/content/icon32.png);
   background-size: 16px;
   background-repeat: no-repeat;
   background-position: left center;
   padding-inline-start: 20px;
   line-height: 20px;
   margin-inline-end: auto;
--- a/browser/components/payments/res/paymentRequest.xhtml
+++ b/browser/components/payments/res/paymentRequest.xhtml
@@ -35,17 +35,19 @@
   <!ENTITY deliveryAddress.addPage.title  "Add Delivery Address">
   <!ENTITY deliveryAddress.editPage.title "Edit Delivery Address">
   <!ENTITY pickupAddress.addPage.title    "Add Pickup Address">
   <!ENTITY pickupAddress.editPage.title   "Edit Pickup Address">
   <!ENTITY billingAddress.addPage.title   "Add Billing Address">
   <!ENTITY billingAddress.editPage.title  "Edit Billing Address">
   <!ENTITY basicCard.addPage.title    "Add Credit Card">
   <!ENTITY basicCard.editPage.title   "Edit Credit Card">
-  <!ENTITY basicCard.cvv.placeholder  "CVV&fieldRequiredSymbol;">
+  <!ENTITY basicCard.csc.placeholder  "CVV">
+  <!ENTITY basicCard.csc.back.infoTooltip   "3 digit number found on the back of your credit card.">
+  <!ENTITY basicCard.csc.front.infoTooltip  "3 digit number found on the front of your credit card.">
   <!ENTITY payer.addPage.title        "Add Payer Contact">
   <!ENTITY payer.editPage.title       "Edit Payer Contact">
   <!ENTITY payerLabel                 "Contact Information">
   <!ENTITY manageInPreferences        "Manage saved address and credit card information in <a>&brandShortName; Preferences</a>.">
   <!ENTITY manageInOptions            "Manage saved address and credit card information in <a>&brandShortName; Options</a>.">
   <!ENTITY cancelPaymentButton.label   "Cancel">
   <!ENTITY approvePaymentButton.label  "Pay">
   <!ENTITY processingPaymentButton.label "Processing">
@@ -145,17 +147,19 @@
           <shipping-option-picker class="shipping-related"
                                   data-shipping-options-label="&shippingOptionsLabel;"
                                   data-delivery-options-label="&deliveryOptionsLabel;"
                                   data-pickup-options-label="&pickupOptionsLabel;"></shipping-option-picker>
 
           <payment-method-picker selected-state-key="selectedPaymentCard"
                                  data-add-link-label="&basicCard.addLink.label;"
                                  data-edit-link-label="&basicCard.editLink.label;"
-                                 data-cvv-placeholder="&basicCard.cvv.placeholder;"
+                                 data-csc-placeholder="&basicCard.csc.placeholder;"
+                                 data-csc-back-tooltip="&basicCard.csc.back.infoTooltip;"
+                                 data-csc-front-tooltip="&basicCard.csc.front.infoTooltip;"
                                  data-invalid-label="&invalidOption.label;"
                                  label="&paymentMethodsLabel;">
           </payment-method-picker>
           <accepted-cards hidden="hidden" label="&acceptedCards.label;"></accepted-cards>
           <address-picker class="payer-related"
                           label="&payerLabel;"
                           data-add-link-label="&payer.addLink.label;"
                           data-edit-link-label="&payer.editLink.label;"
@@ -194,16 +198,19 @@
                        data-billing-address-title-add="&billingAddress.addPage.title;"
                        data-billing-address-title-edit="&billingAddress.editPage.title;"
                        data-back-button-label="&basicCardPage.backButton.label;"
                        data-next-button-label="&basicCardPage.nextButton.label;"
                        data-update-button-label="&basicCardPage.updateButton.label;"
                        data-cancel-button-label="&cancelPaymentButton.label;"
                        data-persist-checkbox-label="&basicCardPage.persistCheckbox.label;"
                        data-persist-checkbox-info-tooltip="&basicCardPage.persistCheckbox.infoTooltip;"
+                       data-csc-placeholder="&basicCard.csc.placeholder;"
+                       data-csc-back-info-tooltip="&basicCard.csc.back.infoTooltip;"
+                       data-csc-front-info-tooltip="&basicCard.csc.front.infoTooltip;"
                        data-accepted-cards-label="&acceptedCards.label;"
                        data-field-required-symbol="&fieldRequiredSymbol;"
                        hidden="hidden"></basic-card-form>
 
       <address-form id="address-page"
                     data-error-generic-save="&addressPage.error.genericSave;"
                     data-cancel-button-label="&addressPage.cancelButton.label;"
                     data-back-button-label="&addressPage.backButton.label;"
--- a/browser/components/payments/test/PaymentTestUtils.jsm
+++ b/browser/components/payments/test/PaymentTestUtils.jsm
@@ -288,17 +288,17 @@ var PaymentTestUtils = {
       button.click();
     },
 
     setSecurityCode: ({securityCode}) => {
       // Waive the xray to access the untrusted `securityCodeInput` property
       let picker = Cu.waiveXrays(content.document.querySelector("payment-method-picker"));
       // Unwaive to access the ChromeOnly `setUserInput` API.
       // setUserInput dispatches changes events.
-      Cu.unwaiveXrays(picker.securityCodeInput).setUserInput(securityCode);
+      Cu.unwaiveXrays(picker.securityCodeInput).querySelector("input").setUserInput(securityCode);
     },
   },
 
   DialogContentUtils: {
     waitForState: async (content, stateCheckFn, msg) => {
       const {
         ContentTaskUtils,
       } = ChromeUtils.import("resource://testing-common/ContentTaskUtils.jsm", {});
--- a/browser/components/payments/test/mochitest/test_basic_card_form.html
+++ b/browser/components/payments/test/mochitest/test_basic_card_form.html
@@ -144,17 +144,17 @@ add_task(async function test_saveButton(
   form.form.querySelector("#cc-name").focus();
   // Check .disabled after .focus() so that it's after both "input" and "change" events.
   ok(form.saveButton.disabled, "Save button should still be disabled without a name");
   sendString("J. Smith");
   fillField(form.form.querySelector("#cc-exp-month"), "11");
   let year = (new Date()).getFullYear().toString();
   fillField(form.form.querySelector("#cc-exp-year"), year);
   fillField(form.form.querySelector("#cc-type"), "visa");
-  fillField(form.form.querySelector("#cc-csc"), "123");
+  fillField(form.form.querySelector("csc-input input"), "123");
   isnot(form.form.querySelector("#billingAddressGUID").value, address2.guid,
         "Check initial billing address");
   fillField(form.form.querySelector("#billingAddressGUID"), address2.guid);
   is(form.form.querySelector("#billingAddressGUID").value, address2.guid,
      "Check selected billing address");
   form.saveButton.focus();
   ok(!form.saveButton.disabled,
      "Save button should be enabled since the required fields are filled");
@@ -217,17 +217,18 @@ add_task(async function test_requiredAtt
   let requiredElements = [...form.form.elements].filter(e => e.required && !e.disabled);
   is(requiredElements.length, 7, "Number of required elements");
   for (let element of requiredElements) {
     if (element.id == "billingAddressGUID") {
       // The billing address has a different layout.
       continue;
     }
     let container = element.closest("label") || element.closest("div");
-    ok(container.hasAttribute("required"), "Container should also be marked as required");
+    ok(container.hasAttribute("required"),
+       `Container ${container.id} should also be marked as required`);
   }
   // Now test that toggling the `required` attribute will affect the container.
   let sampleRequiredElement = requiredElements[0];
   let sampleRequiredContainer = sampleRequiredElement.closest("label") ||
                                 sampleRequiredElement.closest("div");
   sampleRequiredElement.removeAttribute("required");
   await form.requestStore.setState({});
   await asyncElementRendered();
@@ -455,17 +456,17 @@ add_task(async function test_field_valid
   form.dataset.updateButtonLabel = "Update";
   await form.promiseReady;
   display.appendChild(form);
   await asyncElementRendered();
 
   let ccNumber = form.form.querySelector("#cc-number");
   let nameInput = form.form.querySelector("#cc-name");
   let typeInput = form.form.querySelector("#cc-type");
-  let cscInput = form.form.querySelector("#cc-csc");
+  let cscInput = form.form.querySelector("csc-input input");
   let monthInput = form.form.querySelector("#cc-exp-month");
   let yearInput = form.form.querySelector("#cc-exp-year");
 
   info("test with valid cc-number but missing cc-name");
   fillField(ccNumber, "4111111111111111");
   ok(ccNumber.checkValidity(), "cc-number field is valid with good input");
   ok(!nameInput.checkValidity(), "cc-name field is invalid when empty");
   ok(form.saveButton.disabled, "Save button should be disabled with incomplete input");
--- a/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
+++ b/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
@@ -112,17 +112,17 @@ async function setup({shippingRequired, 
   state.selectedPayerAddress = null;
   state.selectedPaymentCard = null;
   state.selectedShippingAddress = null;
   state.selectedShippingOption = null;
   await el1.requestStore.setState(state);
 
   // Fill the security code input so it doesn't interfere with checking the pay
   // button state for dropdown changes.
-  el1._paymentMethodPicker.securityCodeInput.select();
+  el1._paymentMethodPicker.securityCodeInput.querySelector("input").select();
   sendString("123");
   await asyncElementRendered();
 }
 
 function selectFirstItemOfPicker(picker) {
   picker.dropdown.popupBox.focus();
   let options = picker.dropdown.popupBox.children;
   if (options[0].selected) {
@@ -229,24 +229,24 @@ add_task(async function test_securityCod
 
   let picker = el1._paymentMethodPicker;
   let payButton = document.getElementById("pay");
 
   let stateChangedPromise = promiseStateChange(el1.requestStore);
   selectFirstItemOfPicker(picker);
   await stateChangedPromise;
 
-  picker.securityCodeInput.select();
+  picker.securityCodeInput.querySelector("input").select();
   stateChangedPromise = promiseStateChange(el1.requestStore);
   synthesizeKey("VK_DELETE");
   await stateChangedPromise;
 
   ok(payButton.disabled, "Button is disabled when CVV is empty");
 
-  picker.securityCodeInput.select();
+  picker.securityCodeInput.querySelector("input").select();
   stateChangedPromise = promiseStateChange(el1.requestStore);
   sendString("123");
   await stateChangedPromise;
 
   ok(!payButton.disabled, "Button is enabled when CVV is filled");
 });
 </script>
 
--- a/browser/components/payments/test/mochitest/test_payment_method_picker.html
+++ b/browser/components/payments/test/mochitest/test_payment_method_picker.html
@@ -170,17 +170,17 @@ add_task(async function test_change_sele
   selectedPaymentCardSecurityCode = picker1.requestStore.getState().selectedPaymentCardSecurityCode;
   is(selectedPaymentCardSecurityCode, null, "store should have empty security code");
   ok(!picker1.classList.contains("invalid-selected-option"), "The second option has all fields");
   ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
 
   let stateChangePromise = promiseStateChange(picker1.requestStore);
 
   // Type in the security code field
-  picker1.securityCodeInput.focus();
+  picker1.securityCodeInput.querySelector("input").focus();
   sendString("836");
   sendKey("Tab");
   let state = await stateChangePromise;
   ok(state.selectedPaymentCardSecurityCode, "836", "Check security code in state");
 });
 
 add_task(async function test_delete() {
   picker1.requestStore.setState({
--- a/browser/extensions/formautofill/content/editCreditCard.xhtml
+++ b/browser/extensions/formautofill/content/editCreditCard.xhtml
@@ -56,25 +56,17 @@
       <span data-localization="nameOnCard" class="label-text"/>
     </label>
     <label id="cc-type-container" class="container">
       <select id="cc-type" required="required">
       </select>
       <span data-localization="cardNetwork" class="label-text"/>
     </label>
     <label id="cc-csc-container" class="container" hidden="hidden">
-      <!-- Keep these attributes in-sync with securityCodeInput in payment-method-picker.js -->
-      <input id="cc-csc"
-             type="text"
-             autocomplete="off"
-             size="3"
-             required="required"
-             pattern="[0-9]{3,}"
-             disabled="disabled"/>
-      <span data-localization="cardCVV" class="label-text"/>
+      <!-- The CSC container will get filled in by forms that need a CSC (using csc-input.js) -->
     </label>
     <div id="billingAddressGUID-container" class="billingAddressRow container rich-picker">
       <select id="billingAddressGUID" required="required">
       </select>
       <label for="billingAddressGUID" data-localization="billingAddress" class="label-text"/>
     </div>
   </form>
   <div id="controls-container">
--- a/browser/extensions/formautofill/skin/shared/editDialog-shared.css
+++ b/browser/extensions/formautofill/skin/shared/editDialog-shared.css
@@ -34,44 +34,44 @@ form label,
 form div {
   /* Positioned so that the .label-text and .error-text children will be
      positioned relative to this. */
   position: relative;
   display: block;
   line-height: 1em;
 }
 
-form :-moz-any(label, div) > .label-text {
+form :-moz-any(label, div) .label-text {
   position: absolute;
   color: GrayText;
   pointer-events: none;
   left: 10px;
   top: .2em;
   transition: top .2s var(--animation-easing-function),
               font-size .2s var(--animation-easing-function);
 }
 
-form :-moz-any(label, div):focus-within > .label-text,
-form :-moz-any(label, div) > .label-text[field-populated] {
+form :-moz-any(label, div):focus-within .label-text,
+form :-moz-any(label, div) .label-text[field-populated] {
   top: 0;
   font-size: var(--in-field-label-size);
 }
 
 form :-moz-any(input, select, textarea):focus ~ .label-text {
   color: var(--in-content-item-selected);
 }
 
 /* Focused error fields should get a darker text but not the blue one since it
  * doesn't look good with the red error outline. */
 form :-moz-any(input, select, textarea):focus:-moz-ui-invalid ~ .label-text {
   color: var(--in-content-text-color);
 }
 
-form div[required] > label > .label-text::after,
-form :-moz-any(label, div)[required] > .label-text::after {
+form div[required] > label .label-text::after,
+form :-moz-any(label, div)[required] .label-text::after {
   content: attr(fieldRequiredSymbol);
 }
 
 .persist-checkbox label {
   display: flex;
   flex-direction: row;
   align-items: center;
   margin-top: var(--grid-column-row-gap);