Bug 1470199 - Add a tooltip for the CVV input. r=MattN

Differential Revision: https://phabricator.services.mozilla.com/D7473

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Jared Wein 2018-10-13 00:39:02 +00:00
parent 6309162ed8
commit 4a3b48e09b
17 changed files with 277 additions and 46 deletions

View file

@ -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);

View file

@ -12,6 +12,11 @@ basic-card-form .editCreditCardForm {
"billingAddressGUID billingAddressGUID billingAddressGUID";
}
basic-card-form csc-input {
display: flex;
flex-grow: 1;
}
basic-card-form .editCreditCardForm > accepted-cards {
grid-area: accepted;
margin: 0;

View file

@ -4,6 +4,7 @@
/* 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";
@ -36,6 +37,11 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
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.
@ -107,6 +113,11 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
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);
@ -148,6 +159,11 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
} 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;
@ -169,7 +185,7 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
// 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) {
@ -258,6 +274,9 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
}
onChange(evt) {
let ccType = this.form.querySelector("#cc-type");
this.cscInput.setAttribute("card-type", ccType.value);
this.updateSaveButtonState();
}
@ -429,7 +448,7 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
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);

View file

@ -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>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -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>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -264,7 +264,7 @@ export default class PaymentDialog extends PaymentStateSubscriberMixin(HTMLEleme
(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;

View file

@ -3,6 +3,7 @@
* 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 */
@ -17,14 +18,11 @@ 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);
}
@ -81,6 +79,10 @@ export default class PaymentMethodPicker extends RichPicker {
this.securityCodeInput.defaultValue = securityCodeState;
}
let selectedCardType = (basicCards[selectedPaymentCardGUID] &&
basicCards[selectedPaymentCardGUID]["cc-type"]) || "";
this.securityCodeInput.cardType = selectedCardType;
super.render(state);
}
@ -123,7 +125,7 @@ export default class PaymentMethodPicker extends RichPicker {
}
}
onInputOrChange({target}) {
onInputOrChange({currentTarget}) {
let selectedKey = this.selectedStateKey;
let stateChange = {};
@ -131,8 +133,8 @@ export default class PaymentMethodPicker extends RichPicker {
return;
}
switch (target) {
case this.dropdown.popupBox: {
switch (currentTarget) {
case this.dropdown: {
stateChange[selectedKey] = this.dropdown.value;
break;
}

View file

@ -55,16 +55,23 @@ 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;
}

View file

@ -365,6 +365,25 @@ let BASIC_CARDS_1 = {
"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",

View file

@ -210,12 +210,14 @@ payment-dialog[changes-prevented][complete-status="success"] #pay {
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;
}
@ -226,6 +228,28 @@ payment-dialog[changes-prevented][complete-status="success"] #pay {
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;

View file

@ -40,7 +40,9 @@
<!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">
@ -150,7 +152,9 @@
<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>
@ -199,6 +203,9 @@
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>

View file

@ -293,7 +293,7 @@ var PaymentTestUtils = {
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);
},
},

View file

@ -149,7 +149,7 @@ add_task(async function test_saveButton() {
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);
@ -222,7 +222,8 @@ add_task(async function test_requiredAttributePropagated() {
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];
@ -460,7 +461,7 @@ add_task(async function test_field_validity_updates() {
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");

View file

@ -117,7 +117,7 @@ async function setup({shippingRequired, payerRequired}) {
// 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();
}
@ -234,14 +234,14 @@ add_task(async function test_securityCodeRequired() {
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;

View file

@ -175,7 +175,7 @@ add_task(async function test_change_selected_card() {
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;

View file

@ -61,15 +61,7 @@
<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">

View file

@ -39,7 +39,7 @@ form div {
line-height: 1em;
}
form :-moz-any(label, div) > .label-text {
form :-moz-any(label, div) .label-text {
position: absolute;
color: GrayText;
pointer-events: none;
@ -49,8 +49,8 @@ form :-moz-any(label, div) > .label-text {
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);
}
@ -65,8 +65,8 @@ 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);
}