Bug 1463554 - Use native <option> UI with <rich-select> in the open state. r=MattN

MozReview-Commit-ID: KwuLdb6bz9L

--HG--
extra : rebase_source : e52911c90b1d01cc153900f8e567d81119292740
This commit is contained in:
prathiksha 2018-05-31 12:19:54 -07:00
Родитель a543f35d4b
Коммит 63b207993d
12 изменённых файлов: 109 добавлений и 240 удалений

Просмотреть файл

@ -47,7 +47,7 @@ address-option > .tel {
}
address-picker.shipping-related address-option > .email,
address-picker.shipping-related address-option.rich-select-selected-clone > .tel {
address-picker.shipping-related address-option.rich-select-selected-option > .tel {
display: none;
}

Просмотреть файл

@ -4,6 +4,7 @@
import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
import RichOption from "./rich-option.js";
/* import-globals-from ../unprivileged-fallbacks.js */
/**
* <rich-select>
@ -56,6 +57,10 @@ export default class AddressOption extends ObservedPropertiesMixin(RichOption) {
super.connectedCallback();
}
static formatSingleLineLabel(address) {
return PaymentDialogUtils.getAddressLabel(address);
}
render() {
this._name.textContent = this.name;
this["_street-address"].textContent = `${this.streetAddress} ` +

Просмотреть файл

@ -5,15 +5,7 @@
basic-card-option {
grid-row-gap: 5px;
grid-column-gap: 10px;
grid-template-areas:
"cc-name type"
"cc-number ...";
}
rich-select[open] > .rich-select-popup-box > basic-card-option {
grid-template-areas:
"cc-name type"
"cc-number cc-exp";
grid-template-areas: "type cc-number cc-name cc-exp";
}
basic-card-option > .cc-number {
@ -30,6 +22,7 @@ basic-card-option > .cc-exp {
basic-card-option > .type {
grid-area: type;
display: none;
}
basic-card-option > .cc-number,
@ -38,7 +31,3 @@ basic-card-option > .cc-exp,
basic-card-option > .type {
white-space: nowrap;
}
rich-select > .rich-select-selected-clone > .cc-exp {
display: none;
}

Просмотреть файл

@ -42,6 +42,10 @@ export default class BasicCardOption extends ObservedPropertiesMixin(RichOption)
super.connectedCallback();
}
static formatSingleLineLabel(basicCard) {
return basicCard["cc-number"] + " " + basicCard["cc-exp"] + " " + basicCard["cc-name"];
}
render() {
this["_cc-name"].textContent = this.ccName;
this["_cc-number"].textContent = this.ccNumber;

Просмотреть файл

@ -26,15 +26,8 @@ export default class CurrencyAmount extends ObservedPropertiesMixin(HTMLElement)
this._currencyCodeElement.classList.add("currency-code");
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
this.append(this._currencyAmountTextNode, this._currencyCodeElement);
}
render() {
this.append(this._currencyAmountTextNode, this._currencyCodeElement);
let currencyAmount = "";
let currencyCode = "";
try {

Просмотреть файл

@ -6,27 +6,16 @@ rich-select {
display: inline-block;
}
rich-select:not([open]) > .rich-select-popup-box {
display: none;
}
rich-select[open] {
position: relative;
}
rich-select[open] > .rich-select-popup-box {
box-shadow: 0 0 5px black;
position: absolute;
z-index: 1;
}
.rich-select-popup-box > .rich-option[selected] {
background-color: #ffa;
/*
* The HTML select element is hidden and placed on the rich-option
* element to make it look like clicking on the rich-option element
* in the closed state opens the HTML select dropdown. */
rich-select > select {
opacity: 0;
}
.rich-option {
display: grid;
border-bottom: 1px solid #ddd;
background: #fff; /* TODO: system colors */
padding: 8px;
}

Просмотреть файл

@ -11,12 +11,11 @@ import RichOption from "./rich-option.js";
* </rich-select>
*
* Note: The only supported way to change the selected option is via the
* `selectedOption` setter.
* `value` setter.
*/
export default class RichSelect extends ObservedPropertiesMixin(HTMLElement) {
static get observedAttributes() {
return [
"open",
"disabled",
"hidden",
];
@ -24,58 +23,26 @@ export default class RichSelect extends ObservedPropertiesMixin(HTMLElement) {
constructor() {
super();
this.addEventListener("blur", this);
this.addEventListener("click", this);
this.addEventListener("keydown", this);
this.popupBox = document.createElement("div");
this.popupBox.classList.add("rich-select-popup-box");
this._mutationObserver = new MutationObserver((mutations) => {
for (let mutation of mutations) {
for (let addedNode of mutation.addedNodes) {
if (addedNode.nodeType != Node.ELEMENT_NODE ||
!addedNode.matches(".rich-option:not(.rich-select-selected-clone)")) {
continue;
}
// Move the added rich option to the popup.
this.popupBox.appendChild(addedNode);
}
}
});
this._mutationObserver.observe(this, {
childList: true,
});
this.popupBox = document.createElement("select");
this.popupBox.addEventListener("change", this);
}
connectedCallback() {
this.tabIndex = 0;
this.appendChild(this.popupBox);
// Move options initially placed inside the select to the popup box.
let options = this.querySelectorAll(":scope > .rich-option:not(.rich-select-selected-clone)");
for (let option of options) {
this.popupBox.appendChild(option);
}
this.render();
}
get selectedOption() {
return this.popupBox.querySelector(":scope > [selected]");
return this.getOptionByValue(this.value);
}
/**
* This is the only supported method of changing the selected option. Do not
* manipulate the `selected` property or attribute on options directly.
* @param {HTMLOptionElement} option
*/
set selectedOption(option) {
for (let child of this.popupBox.children) {
child.selected = child == option;
}
get value() {
return this.popupBox.value;
}
set value(guid) {
this.popupBox.value = guid;
this.render();
}
@ -85,128 +52,43 @@ export default class RichSelect extends ObservedPropertiesMixin(HTMLElement) {
handleEvent(event) {
switch (event.type) {
case "blur": {
this.onBlur(event);
break;
}
case "click": {
this.onClick(event);
break;
}
case "keydown": {
this.onKeyDown(event);
case "change": {
// Since the render function depends on the popupBox's value, we need to
// re-render if the value changes.
this.render();
break;
}
}
}
onBlur(event) {
if (event.target == this) {
this.open = false;
}
}
onClick(event) {
if (event.button != 0) {
return;
}
// Cache the state of .open since the payment-method-picker change handler
// may cause onBlur to change .open to false and cause !this.open to change.
let isOpen = this.open;
let option = event.target.closest(".rich-option");
if (isOpen && option && !option.matches(".rich-select-selected-clone") && !option.selected) {
this.selectedOption = option;
this._dispatchChangeEvent();
}
this.open = !isOpen;
}
onKeyDown(event) {
if (event.key == " ") {
this.open = !this.open;
} else if (event.key == "ArrowDown") {
let selectedOption = this.selectedOption;
let next = selectedOption.nextElementSibling;
if (next) {
next.selected = true;
selectedOption.selected = false;
this._dispatchChangeEvent();
}
} else if (event.key == "ArrowUp") {
let selectedOption = this.selectedOption;
let next = selectedOption.previousElementSibling;
if (next) {
next.selected = true;
selectedOption.selected = false;
this._dispatchChangeEvent();
}
} else if (event.key == "Enter" ||
event.key == "Escape") {
this.open = false;
}
}
/**
* Only dispatched upon a user-initiated change.
*/
_dispatchChangeEvent() {
let changeEvent = document.createEvent("UIEvent");
changeEvent.initEvent("change", true, true);
this.dispatchEvent(changeEvent);
}
_optionsAreEquivalent(a, b) {
if (!a || !b) {
return false;
}
let aAttrs = a.constructor.observedAttributes;
let bAttrs = b.constructor.observedAttributes;
if (aAttrs.length != bAttrs.length) {
return false;
}
for (let aAttr of aAttrs) {
if (aAttr == "selected") {
continue;
}
if (a.getAttribute(aAttr) != b.getAttribute(aAttr)) {
return false;
}
}
return true;
}
render() {
let selectedChild;
for (let child of this.popupBox.children) {
if (child.selected) {
selectedChild = child;
break;
let selectedRichOption = this.querySelector(":scope > .rich-select-selected-option");
if (selectedRichOption) {
selectedRichOption.remove();
}
if (this.value) {
let optionType = this.getAttribute("option-type");
if (selectedRichOption.localName != optionType) {
selectedRichOption = document.createElement(optionType);
}
}
let selectedClone = this.querySelector(":scope > .rich-select-selected-clone");
if (this._optionsAreEquivalent(selectedClone, selectedChild)) {
return;
}
if (selectedClone) {
selectedClone.remove();
}
if (selectedChild) {
selectedClone = selectedChild.cloneNode(false);
selectedClone.removeAttribute("id");
selectedClone.removeAttribute("selected");
let option = this.getOptionByValue(this.value);
let attributeNames = selectedRichOption.constructor.recordAttributes;
for (let attributeName of attributeNames) {
let attributeValue = option.getAttribute(attributeName);
if (attributeValue) {
selectedRichOption.setAttribute(attributeName, attributeValue);
} else {
selectedRichOption.removeAttribute(attributeName);
}
}
} else {
selectedClone = new RichOption();
selectedClone.textContent = "(None selected)";
selectedRichOption = new RichOption();
selectedRichOption.textContent = "(None selected)";
}
selectedClone.classList.add("rich-select-selected-clone");
selectedClone = this.appendChild(selectedClone);
selectedRichOption.classList.add("rich-select-selected-option");
selectedRichOption = this.appendChild(selectedRichOption);
}
}

Просмотреть файл

@ -13,12 +13,16 @@ import RichOption from "./rich-option.js";
*/
export default class ShippingOption extends ObservedPropertiesMixin(RichOption) {
static get observedAttributes() {
return RichOption.observedAttributes.concat([
static get recordAttributes() {
return [
"label",
"amount-currency",
"amount-value",
]);
];
}
static get observedAttributes() {
return RichOption.observedAttributes.concat(ShippingOption.recordAttributes);
}
constructor() {
@ -38,6 +42,15 @@ export default class ShippingOption extends ObservedPropertiesMixin(RichOption)
super.connectedCallback();
}
static formatSingleLineLabel(option) {
let amount = new CurrencyAmount();
amount.value = option.amount.value;
amount.currency = option.amount.currency;
amount.render();
return amount.textContent + " " + option.label;
}
render() {
this._label.textContent = this.label;
this._currencyAmount.currency = this.amountCurrency;

Просмотреть файл

@ -22,6 +22,7 @@ export default class AddressPicker extends PaymentStateSubscriberMixin(HTMLEleme
super();
this.dropdown = new RichSelect();
this.dropdown.addEventListener("change", this);
this.dropdown.setAttribute("option-type", "address-option");
this.addLink = document.createElement("a");
this.addLink.className = "add-link";
this.addLink.href = "javascript:void(0)";
@ -99,11 +100,10 @@ export default class AddressPicker extends PaymentStateSubscriberMixin(HTMLEleme
}
}
let filteredAddresses = this.filterAddresses(addresses, fieldNames);
for (let [guid, address] of Object.entries(filteredAddresses)) {
let optionEl = this.dropdown.getOptionByValue(guid);
if (!optionEl) {
optionEl = new AddressOption();
optionEl = document.createElement("option");
optionEl.value = guid;
}
@ -116,25 +116,22 @@ export default class AddressPicker extends PaymentStateSubscriberMixin(HTMLEleme
}
}
optionEl.textContent = AddressOption.formatSingleLineLabel(address);
desiredOptions.push(optionEl);
}
let el = null;
while ((el = this.dropdown.popupBox.querySelector(":scope > address-option"))) {
el.remove();
}
this.dropdown.popupBox.textContent = "";
for (let option of desiredOptions) {
this.dropdown.popupBox.appendChild(option);
}
// Update selectedness after the options are updated
let selectedAddressGUID = state[this.selectedStateKey];
let optionWithGUID = this.dropdown.getOptionByValue(selectedAddressGUID);
this.dropdown.selectedOption = optionWithGUID;
this.dropdown.value = selectedAddressGUID;
if (selectedAddressGUID && !optionWithGUID) {
if (selectedAddressGUID && selectedAddressGUID !== this.dropdown.value) {
throw new Error(`${this.selectedStateKey} option ${selectedAddressGUID}` +
`does not exist in options`);
`does not exist in the address picker`);
}
}
@ -155,11 +152,10 @@ export default class AddressPicker extends PaymentStateSubscriberMixin(HTMLEleme
}
onChange(event) {
let select = event.target;
let selectedKey = this.selectedStateKey;
if (selectedKey) {
this.requestStore.setState({
[selectedKey]: select.selectedOption && select.selectedOption.guid,
[selectedKey]: this.dropdown.value,
});
}
}

Просмотреть файл

@ -18,6 +18,7 @@ export default class PaymentMethodPicker extends PaymentStateSubscriberMixin(HTM
super();
this.dropdown = new RichSelect();
this.dropdown.addEventListener("change", this);
this.dropdown.setAttribute("option-type", "basic-card-option");
this.spacerText = document.createTextNode(" ");
this.securityCodeInput = document.createElement("input");
this.securityCodeInput.autocomplete = "off";
@ -51,9 +52,10 @@ export default class PaymentMethodPicker extends PaymentStateSubscriberMixin(HTM
for (let [guid, basicCard] of Object.entries(basicCards)) {
let optionEl = this.dropdown.getOptionByValue(guid);
if (!optionEl) {
optionEl = new BasicCardOption();
optionEl = document.createElement("option");
optionEl.value = guid;
}
for (let key of BasicCardOption.recordAttributes) {
let val = basicCard[key];
if (val) {
@ -62,24 +64,23 @@ export default class PaymentMethodPicker extends PaymentStateSubscriberMixin(HTM
optionEl.removeAttribute(key);
}
}
optionEl.textContent = BasicCardOption.formatSingleLineLabel(basicCard);
desiredOptions.push(optionEl);
}
let el = null;
while ((el = this.dropdown.popupBox.querySelector(":scope > basic-card-option"))) {
el.remove();
}
this.dropdown.popupBox.textContent = "";
for (let option of desiredOptions) {
this.dropdown.popupBox.appendChild(option);
}
// Update selectedness after the options are updated
let selectedPaymentCardGUID = state[this.selectedStateKey];
let optionWithGUID = this.dropdown.getOptionByValue(selectedPaymentCardGUID);
this.dropdown.selectedOption = optionWithGUID;
this.dropdown.value = selectedPaymentCardGUID;
if (selectedPaymentCardGUID && !optionWithGUID) {
throw new Error(`${this.selectedStateKey} option ${selectedPaymentCardGUID}` +
`does not exist in options`);
if (selectedPaymentCardGUID && selectedPaymentCardGUID !== this.dropdown.value) {
throw new Error(`The option ${selectedPaymentCardGUID} ` +
`does not exist in the payment method picker`);
}
}
@ -109,11 +110,8 @@ export default class PaymentMethodPicker extends PaymentStateSubscriberMixin(HTM
}
switch (target) {
case this.dropdown: {
stateChange[selectedKey] = target.selectedOption && target.selectedOption.guid;
// Select the security code text since the user is likely to edit it next.
// We don't want to do this if the user simply blurs the dropdown.
this.securityCodeInput.select();
case this.dropdown.popupBox: {
stateChange[selectedKey] = this.dropdown.value;
break;
}
case this.securityCodeInput: {

Просмотреть файл

@ -9,7 +9,7 @@ import ShippingOption from "../components/shipping-option.js";
/**
* <shipping-option-picker></shipping-option-picker>
* Container around <rich-select> with
* <rich-option> listening to shippingOptions.
* <option> listening to shippingOptions.
*/
export default class ShippingOptionPicker extends PaymentStateSubscriberMixin(HTMLElement) {
@ -17,6 +17,7 @@ export default class ShippingOptionPicker extends PaymentStateSubscriberMixin(HT
super();
this.dropdown = new RichSelect();
this.dropdown.addEventListener("change", this);
this.dropdown.setAttribute("option-type", "shipping-option");
}
connectedCallback() {
@ -30,31 +31,30 @@ export default class ShippingOptionPicker extends PaymentStateSubscriberMixin(HT
for (let option of shippingOptions) {
let optionEl = this.dropdown.getOptionByValue(option.id);
if (!optionEl) {
optionEl = new ShippingOption();
optionEl = document.createElement("option");
optionEl.value = option.id;
}
optionEl.label = option.label;
optionEl.amountCurrency = option.amount.currency;
optionEl.amountValue = option.amount.value;
optionEl.setAttribute("label", option.label);
optionEl.setAttribute("amount-currency", option.amount.currency);
optionEl.setAttribute("amount-value", option.amount.value);
optionEl.textContent = ShippingOption.formatSingleLineLabel(option);
desiredOptions.push(optionEl);
}
let el = null;
while ((el = this.dropdown.popupBox.querySelector(":scope > shipping-option"))) {
el.remove();
}
this.dropdown.popupBox.textContent = "";
for (let option of desiredOptions) {
this.dropdown.popupBox.appendChild(option);
}
// Update selectedness after the options are updated
let selectedShippingOption = state.selectedShippingOption;
let selectedOptionEl =
this.dropdown.getOptionByValue(selectedShippingOption);
this.dropdown.selectedOption = selectedOptionEl;
this.dropdown.value = selectedShippingOption;
if (selectedShippingOption && !selectedOptionEl) {
throw new Error(`Selected shipping option ${selectedShippingOption} ` +
`does not exist in option elements`);
if (selectedShippingOption && selectedShippingOption !== this.dropdown.popupBox.value) {
throw new Error(`The option ${selectedShippingOption} ` +
`does not exist in the shipping option picker`);
}
}
@ -68,8 +68,7 @@ export default class ShippingOptionPicker extends PaymentStateSubscriberMixin(HT
}
onChange(event) {
let select = event.target;
let selectedOptionId = select.selectedOption && select.selectedOption.value;
let selectedOptionId = this.dropdown.value;
this.requestStore.setState({
selectedShippingOption: selectedOptionId,
});

Просмотреть файл

@ -30,6 +30,7 @@ export let requestStore = new PaymentsStore({
previousId: null,
// onboardingWizard: true,
// error: "",
// selectedStateKey: "",
},
request: {
tabId: null,