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 499420 d545a48c1bfd7863036e179312f9fff45e335cff
parent 499419 6beac5dc489916e57c24d79465c99085663f2cd0
child 499421 474b2a78d4fac1f3006c198ad063449ded904e8d
push id1864
push userffxbld-merge
push dateMon, 03 Dec 2018 15:51:40 +0000
treeherdermozilla-release@f040763d99ad [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1470199
milestone64.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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);