Bug 1463554 - Use native <option> UI with <rich-select> in the open state. r?MattN draft
authorprathiksha <prathikshaprasadsuman@gmail.com>
Thu, 31 May 2018 12:19:54 -0700
changeset 811637 f424385741cab09b0a1b7df9b543ff37e68d557d
parent 811302 9c7bb8874337c2d40aef3d9945b10490a5115188
child 811638 50ca6f9f585287bf16787c34b20e9e42fc9396bb
push id114380
push userbmo:prathikshaprasadsuman@gmail.com
push dateWed, 27 Jun 2018 23:25:31 +0000
reviewersMattN
bugs1463554
milestone63.0a1
Bug 1463554 - Use native <option> UI with <rich-select> in the open state. r?MattN MozReview-Commit-ID: KwuLdb6bz9L
browser/components/payments/res/components/address-option.js
browser/components/payments/res/components/basic-card-option.js
browser/components/payments/res/components/rich-select.css
browser/components/payments/res/components/rich-select.js
browser/components/payments/res/components/shipping-option.js
browser/components/payments/res/containers/address-picker.js
browser/components/payments/res/containers/payment-method-picker.js
browser/components/payments/res/containers/shipping-option-picker.js
--- a/browser/components/payments/res/components/address-option.js
+++ b/browser/components/payments/res/components/address-option.js
@@ -51,16 +51,20 @@ export default class AddressOption exten
 
   connectedCallback() {
     for (let name of ["name", "street-address", "email", "tel"]) {
       this.appendChild(this[`_${name}`]);
     }
     super.connectedCallback();
   }
 
+  static formatOptionLabel(address) {
+    return address.name + ", " + address["street-address"];
+  }
+
   render() {
     this._name.textContent = this.name;
     this["_street-address"].textContent = `${this.streetAddress} ` +
       `${this.addressLevel2} ${this.addressLevel1} ${this.postalCode} ${this.country}`;
     this._email.textContent = this.email;
     this._tel.textContent = this.tel;
   }
 }
--- a/browser/components/payments/res/components/basic-card-option.js
+++ b/browser/components/payments/res/components/basic-card-option.js
@@ -37,16 +37,20 @@ export default class BasicCardOption ext
 
   connectedCallback() {
     for (let name of ["cc-name", "cc-number", "cc-exp", "type"]) {
       this.appendChild(this[`_${name}`]);
     }
     super.connectedCallback();
   }
 
+  static formatOptionLabel(basicCard) {
+    return basicCard["cc-number"] + ", " + basicCard["cc-exp"] + ", " + basicCard["cc-name"];
+  }
+
   render() {
     this["_cc-name"].textContent = this.ccName;
     this["_cc-number"].textContent = this.ccNumber;
     this["_cc-exp"].textContent = this.ccExp;
     this._type.textContent = this.type;
   }
 }
 
--- a/browser/components/payments/res/components/rich-select.css
+++ b/browser/components/payments/res/components/rich-select.css
@@ -1,32 +1,18 @@
 /* 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/. */
 
 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;
+rich-select > select {
+  opacity: 0;
 }
 
 .rich-option {
   display: grid;
   border-bottom: 1px solid #ddd;
   background: #fff; /* TODO: system colors */
   padding: 8px;
 }
--- a/browser/components/payments/res/components/rich-select.js
+++ b/browser/components/payments/res/components/rich-select.js
@@ -4,210 +4,78 @@
 
 import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
 import RichOption from "./rich-option.js";
 
 /**
  * <rich-select>
  *  <rich-option></rich-option>
  * </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",
     ];
   }
 
   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]");
+  get value() {
+    return this.popupBox.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;
-    }
-
+  set value(guid) {
+    this.popupBox.value = guid;
     this.render();
   }
 
   getOptionByValue(value) {
     return this.popupBox.querySelector(`:scope > [value="${CSS.escape(value)}"]`);
   }
 
   handleEvent(event) {
     switch (event.type) {
-      case "blur": {
-        this.onBlur(event);
-        break;
-      }
-      case "click": {
-        this.onClick(event);
-        break;
-      }
-      case "keydown": {
-        this.onKeyDown(event);
+      case "change": {
+        this.value = event.target.value;
         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;
+  render() {
+    let selectedRichOption = this.querySelector(":scope > .rich-select-selected-clone");
+    if (selectedRichOption) {
+      selectedRichOption.remove();
     }
 
-    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;
-  }
+    if (this.value) {
+      let optionType = this.getAttribute("optionType");
+      selectedRichOption = document.createElement(optionType);
 
-  render() {
-    let selectedChild;
-    for (let child of this.popupBox.children) {
-      if (child.selected) {
-        selectedChild = child;
-        break;
+      let option = this.getOptionByValue(this.value);
+      let attributes = option.attributes;
+      for (let attribute of attributes) {
+        selectedRichOption.setAttribute(attribute.name, attribute.value);
       }
-    }
-
-    let selectedClone = this.querySelector(":scope > .rich-select-selected-clone");
-    if (this._optionsAreEquivalent(selectedClone, selectedChild)) {
-      return;
+    } else {
+      selectedRichOption = new RichOption();
+      selectedRichOption.textContent = "(None selected)";
     }
-
-    if (selectedClone) {
-      selectedClone.remove();
-    }
-
-    if (selectedChild) {
-      selectedClone = selectedChild.cloneNode(false);
-      selectedClone.removeAttribute("id");
-      selectedClone.removeAttribute("selected");
-    } else {
-      selectedClone = new RichOption();
-      selectedClone.textContent = "(None selected)";
-    }
-    selectedClone.classList.add("rich-select-selected-clone");
-    selectedClone = this.appendChild(selectedClone);
+    selectedRichOption.classList.add("rich-select-selected-clone");
+    selectedRichOption = this.appendChild(selectedRichOption);
   }
 }
 
 customElements.define("rich-select", RichSelect);
--- a/browser/components/payments/res/components/shipping-option.js
+++ b/browser/components/payments/res/components/shipping-option.js
@@ -33,16 +33,20 @@ export default class ShippingOption exte
 
   connectedCallback() {
     this.appendChild(this._currencyAmount);
     this.append(" ");
     this.appendChild(this._label);
     super.connectedCallback();
   }
 
+  static formatOptionLabel(option) {
+    return option.label;
+  }
+
   render() {
     this._label.textContent = this.label;
     this._currencyAmount.currency = this.amountCurrency;
     this._currencyAmount.value = this.amountValue;
     // Need to call render after setting these properties
     // if we want the amount to get displayed in the same
     // render pass as the label.
     this._currencyAmount.render();
--- a/browser/components/payments/res/containers/address-picker.js
+++ b/browser/components/payments/res/containers/address-picker.js
@@ -17,16 +17,17 @@ export default class AddressPicker exten
   static get observedAttributes() {
     return ["address-fields"];
   }
 
   constructor() {
     super();
     this.dropdown = new RichSelect();
     this.dropdown.addEventListener("change", this);
+    this.dropdown.setAttribute("optionType", "address-option");
     this.addLink = document.createElement("a");
     this.addLink.className = "add-link";
     this.addLink.href = "javascript:void(0)";
     this.addLink.textContent = this.dataset.addLinkLabel;
     this.addLink.addEventListener("click", this);
     this.editLink = document.createElement("a");
     this.editLink.className = "edit-link";
     this.editLink.href = "javascript:void(0)";
@@ -98,49 +99,43 @@ export default class AddressPicker exten
         fieldNames = names;
       }
     }
     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;
       }
 
       for (let key of AddressOption.recordAttributes) {
         let val = address[key];
         if (val) {
           optionEl.setAttribute(key, val);
         } else {
           optionEl.removeAttribute(key);
         }
       }
 
+      optionEl.textContent = AddressOption.formatOptionLabel(address);
       desiredOptions.push(optionEl);
     }
 
-    let el = null;
-    while ((el = this.dropdown.popupBox.querySelector(":scope > address-option"))) {
+    for (let el of this.dropdown.popupBox.options) {
       el.remove();
     }
     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;
-
-    if (selectedAddressGUID && !optionWithGUID) {
-      throw new Error(`${this.selectedStateKey} option ${selectedAddressGUID}` +
-                      `does not exist in options`);
-    }
+    this.dropdown.value = selectedAddressGUID;
   }
 
   get selectedStateKey() {
     return this.getAttribute("selected-state-key");
   }
 
   handleEvent(event) {
     switch (event.type) {
@@ -150,21 +145,20 @@ export default class AddressPicker exten
       }
       case "click": {
         this.onClick(event);
       }
     }
   }
 
   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,
       });
     }
   }
 
   onClick({target}) {
     let nextState = {
       page: {
         id: "address-page",
--- a/browser/components/payments/res/containers/payment-method-picker.js
+++ b/browser/components/payments/res/containers/payment-method-picker.js
@@ -13,16 +13,17 @@ import paymentRequest from "../paymentRe
  * <basic-card-option> listening to savedBasicCards.
  */
 
 export default class PaymentMethodPicker extends PaymentStateSubscriberMixin(HTMLElement) {
   constructor() {
     super();
     this.dropdown = new RichSelect();
     this.dropdown.addEventListener("change", this);
+    this.dropdown.setAttribute("optionType", "basic-card-option");
     this.spacerText = document.createTextNode(" ");
     this.securityCodeInput = document.createElement("input");
     this.securityCodeInput.autocomplete = "off";
     this.securityCodeInput.size = 3;
     this.securityCodeInput.addEventListener("change", this);
     this.addLink = document.createElement("a");
     this.addLink.className = "add-link";
     this.addLink.href = "javascript:void(0)";
@@ -46,46 +47,43 @@ export default class PaymentMethodPicker
   }
 
   render(state) {
     let basicCards = paymentRequest.getBasicCards(state);
     let desiredOptions = [];
     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) {
           optionEl.setAttribute(key, val);
         } else {
           optionEl.removeAttribute(key);
         }
       }
+
+      optionEl.textContent = BasicCardOption.formatOptionLabel(basicCard);
       desiredOptions.push(optionEl);
     }
-    let el = null;
-    while ((el = this.dropdown.popupBox.querySelector(":scope > basic-card-option"))) {
+
+    for (let el of this.dropdown.popupBox.options) {
       el.remove();
     }
     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;
-
-    if (selectedPaymentCardGUID && !optionWithGUID) {
-      throw new Error(`${this.selectedStateKey} option ${selectedPaymentCardGUID}` +
-                      `does not exist in options`);
-    }
+    this.dropdown.value = selectedPaymentCardGUID;
   }
 
   get selectedStateKey() {
     return this.getAttribute("selected-state-key");
   }
 
   handleEvent(event) {
     switch (event.type) {
@@ -104,18 +102,18 @@ export default class PaymentMethodPicker
     let selectedKey = this.selectedStateKey;
     let stateChange = {};
 
     if (!selectedKey) {
       return;
     }
 
     switch (target) {
-      case this.dropdown: {
-        stateChange[selectedKey] = target.selectedOption && target.selectedOption.guid;
+      case this.dropdown.popupBox: {
+        stateChange[selectedKey] = this.dropdown.value;
         // 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();
         break;
       }
       case this.securityCodeInput: {
         stateChange[selectedKey + "SecurityCode"] = this.securityCodeInput.value;
         break;
--- a/browser/components/payments/res/containers/shipping-option-picker.js
+++ b/browser/components/payments/res/containers/shipping-option-picker.js
@@ -4,76 +4,72 @@
 
 import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
 import RichSelect from "../components/rich-select.js";
 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) {
   constructor() {
     super();
     this.dropdown = new RichSelect();
     this.dropdown.addEventListener("change", this);
+    this.dropdown.setAttribute("optionType", "shipping-option");
   }
 
   connectedCallback() {
     this.appendChild(this.dropdown);
     super.connectedCallback();
   }
 
   render(state) {
     let {shippingOptions} = state.request.paymentDetails;
     let desiredOptions = [];
     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.formatOptionLabel(option);
       desiredOptions.push(optionEl);
     }
-    let el = null;
-    while ((el = this.dropdown.popupBox.querySelector(":scope > shipping-option"))) {
+
+    for (let el of this.dropdown.popupBox.options) {
       el.remove();
     }
     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;
-
-    if (selectedShippingOption && !selectedOptionEl) {
-      throw new Error(`Selected shipping option ${selectedShippingOption} ` +
-                      `does not exist in option elements`);
-    }
+    this.dropdown.value = selectedShippingOption;
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "change": {
         this.onChange(event);
         break;
       }
     }
   }
 
   onChange(event) {
-    let select = event.target;
-    let selectedOptionId = select.selectedOption && select.selectedOption.value;
+    let selectedOptionId = this.dropdown.value;
     this.requestStore.setState({
       selectedShippingOption: selectedOptionId,
     });
   }
 }
 
 customElements.define("shipping-option-picker", ShippingOptionPicker);