Bug 1550526 - Create deck with tabs for HTML about:addons details r=robwu,jaws
authorMark Striemer <mstriemer@mozilla.com>
Wed, 15 May 2019 19:20:04 +0000
changeset 532943 6d47ff50d4ac244474605754fab7a39bb3180b40
parent 532942 b2867d2df9a3ddbd1bc3f1127eda13eed334f67d
child 532944 b7d165b8966e6a9ca0576fa6ee7c49a52d604d4c
push id11276
push userrgurzau@mozilla.com
push dateMon, 20 May 2019 13:11:24 +0000
treeherdermozilla-beta@847755a7c325 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrobwu, jaws
bugs1550526
milestone68.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 1550526 - Create deck with tabs for HTML about:addons details r=robwu,jaws Differential Revision: https://phabricator.services.mozilla.com/D29895
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/named-deck.js
toolkit/mozapps/extensions/jar.mn
toolkit/mozapps/extensions/test/browser/browser.ini
toolkit/mozapps/extensions/test/browser/browser_html_named_deck.js
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -3,16 +3,17 @@
   <head>
     <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
     <link rel="stylesheet" href="chrome://mozapps/content/extensions/aboutaddons.css">
 
     <link rel="localization" href="branding/brand.ftl">
     <link rel="localization" href="toolkit/about/aboutAddons.ftl">
     <link rel="localization" href="toolkit/about/abuseReports.ftl">
 
+    <script src="chrome://mozapps/content/extensions/named-deck.js"></script>
     <script src="chrome://mozapps/content/extensions/aboutaddonsCommon.js"></script>
     <script src="chrome://mozapps/content/extensions/message-bar.js"></script>
     <script src="chrome://mozapps/content/extensions/abuse-reports.js"></script>
     <script src="chrome://mozapps/content/extensions/aboutaddons.js"></script>
   </head>
   <body>
     <message-bar-stack id="abuse-reports-messages" reverse max-message-bar-count="3">
     </message-bar-stack>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/named-deck.js
@@ -0,0 +1,221 @@
+/* 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/. */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+/**
+ * This element is for use with the <named-deck> element. Set the target
+ * <named-deck>'s ID in the "deck" attribute and the button's selected state
+ * will reflect the deck's state. When the button is clicked, it will set the
+ * view in the <named-deck> to the button's "name" attribute.
+ *
+ * NOTE: This does not observe changes to the "deck" or "name" attributes, so
+ * changing them likely won't work properly.
+ *
+ * <named-deck-button deck="pet-deck" name="dogs">Dogs</named-deck-button>
+ * <named-deck id="pet-deck">
+ *   <p name="cats">I like cats.</p>
+ *   <p name="dogs">I like dogs.</p>
+ * </named-deck>
+ *
+ * let btn = document.querySelector("named-deck-button");
+ * let deck = document.querySelector("named-deck");
+ * deck.selectedViewName == "cats";
+ * btn.selected == false; // Selected was pulled from the related deck.
+ * btn.click();
+ * deck.selectedViewName == "dogs";
+ * btn.selected == true; // Selected updated when view changed.
+ */
+class NamedDeckButton extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({mode: "open"});
+    // Include styles inline to avoid a FOUC.
+    let style = document.createElement("style");
+    style.textContent = `
+      button {
+        -moz-appearance: none;
+        border: none;
+        border-top: 2px solid transparent;
+        border-bottom: 2px solid transparent;
+        background: white;
+        font-size: 14px;
+        line-height: 20px;
+        padding: 4px 16px;
+        color: var(--grey-90);
+      }
+
+      button:hover {
+        background-color: var(--grey-90-a10);
+        border-top-color: var(--grey-90-a20);
+      }
+
+      button:hover:active {
+        background-color: var(--grey-90-a20);
+      }
+
+      :host([selected]) button {
+        border-top-color: var(--blue-60);
+        color: var(--blue-60);
+      }
+    `;
+    this.shadowRoot.appendChild(style);
+
+    let button = document.createElement("button");
+    button.appendChild(document.createElement("slot"));
+    this.shadowRoot.appendChild(button);
+
+    this.addEventListener("click", this);
+  }
+
+  connectedCallback() {
+    this.setSelectedFromDeck();
+    document.addEventListener("view-changed", this, {capture: true});
+  }
+
+  disconnectedCallback() {
+    document.removeEventListener("view-changed", this, {capture: true});
+  }
+
+  get deckId() {
+    return this.getAttribute("deck");
+  }
+
+  set deckId(val) {
+    this.setAttribute("deck", val);
+  }
+
+  get deck() {
+    return document.getElementById(this.deckId);
+  }
+
+  handleEvent(e) {
+    if (e.type == "view-changed" && e.target.id == this.deckId) {
+      this.setSelectedFromDeck();
+    } else if (e.type == "click") {
+      let {deck} = this;
+      if (deck) {
+        deck.selectedViewName = this.name;
+      }
+    }
+  }
+
+  get name() {
+    return this.getAttribute("name");
+  }
+
+  get selected() {
+    return this.hasAttribute("selected");
+  }
+
+  set selected(val) {
+    this.toggleAttribute("selected", !!val);
+  }
+
+  setSelectedFromDeck() {
+    let {deck} = this;
+    this.selected = deck && deck.selectedViewName == this.name;
+  }
+}
+customElements.define("named-deck-button", NamedDeckButton);
+
+/**
+ * A deck that is indexed by the "name" attribute of its children. The
+ * <named-deck-button> element is a companion element that can update its state
+ * and change the view of a <named-deck>.
+ *
+ * When the deck is connected it will set the first child as the selected view
+ * if a view is not already selected.
+ *
+ * The deck is implemented using a named slot. Setting a slot directly on a
+ * child element of the deck is not supported.
+ *
+ * You can get or set the selected view by name with the `selectedViewName`
+ * property or by setting the "selected-view" attribute.
+ *
+ * <named-deck>
+ *   <section name="cats">Some info about cats.</section>
+ *   <section name="dogs">Some dog stuff.</section>
+ * </named-deck>
+ *
+ * let deck = document.querySelector("named-deck");
+ * deck.selectedViewName == "cats"; // Cat info is shown.
+ * deck.selectedViewName = "dogs";
+ * deck.selectedViewName == "dogs"; // Dog stuff is shown.
+ * deck.setAttribute("selected-view", "cats");
+ * deck.selectedViewName == "cats"; // Cat info is shown.
+ */
+class NamedDeck extends HTMLElement {
+  static get observedAttributes() {
+    return ["selected-view"];
+  }
+
+  constructor() {
+    super();
+    this.attachShadow({mode: "open"});
+
+    // Create a slot for the visible content.
+    let selectedSlot = document.createElement("slot");
+    selectedSlot.setAttribute("name", "selected");
+    this.shadowRoot.appendChild(selectedSlot);
+
+    this.observer = new MutationObserver(() => {
+      this._setSelectedViewAttributes();
+    });
+  }
+
+  connectedCallback() {
+    if (this.selectedViewName) {
+      // Make sure the selected view is shown.
+      this._setSelectedViewAttributes();
+    } else {
+      // If there's no selected view, default to the first.
+      let firstView = this.firstElementChild;
+      if (firstView) {
+        // This will trigger showing the first view.
+        this.selectedViewName = firstView.getAttribute("name");
+      }
+    }
+    this.observer.observe(this, {childList: true});
+  }
+
+  disconnectedCallback() {
+    this.observer.disconnect();
+  }
+
+  attributeChangedCallback(attr, oldVal, newVal) {
+    if (attr == "selected-view" && oldVal != newVal) {
+      // Update the slot attribute on the views.
+      this._setSelectedViewAttributes();
+
+      // Notify that the selected view changed.
+      this.dispatchEvent(new CustomEvent("view-changed"));
+    }
+  }
+
+  get selectedViewName() {
+    return this.getAttribute("selected-view");
+  }
+
+  set selectedViewName(name) {
+    this.setAttribute("selected-view", name);
+  }
+
+  /**
+   * Set the slot attribute on all of the views to ensure only the selected view
+   * is shown.
+   */
+  _setSelectedViewAttributes() {
+    let {selectedViewName} = this;
+    for (let view of this.children) {
+      if (view.getAttribute("name") == selectedViewName) {
+        view.slot = "selected";
+      } else {
+        view.slot = "";
+      }
+    }
+  }
+}
+customElements.define("named-deck", NamedDeck);
--- a/toolkit/mozapps/extensions/jar.mn
+++ b/toolkit/mozapps/extensions/jar.mn
@@ -24,12 +24,13 @@ toolkit.jar:
   content/mozapps/extensions/aboutaddons.css                    (content/aboutaddons.css)
   content/mozapps/extensions/abuse-reports.js                   (content/abuse-reports.js)
   content/mozapps/extensions/abuse-report-frame.html            (content/abuse-report-frame.html)
   content/mozapps/extensions/abuse-report-frame.js              (content/abuse-report-frame.js)
   content/mozapps/extensions/abuse-report-panel.css             (content/abuse-report-panel.css)
   content/mozapps/extensions/abuse-report-panel.js              (content/abuse-report-panel.js)
   content/mozapps/extensions/message-bar.css                    (content/message-bar.css)
   content/mozapps/extensions/message-bar.js                     (content/message-bar.js)
+  content/mozapps/extensions/named-deck.js                      (content/named-deck.js)
   content/mozapps/extensions/panel-list.css                     (content/panel-list.css)
   content/mozapps/extensions/panel-item.css                     (content/panel-item.css)
   content/mozapps/extensions/rating-star.css                    (content/rating-star.css)
 #endif
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -80,16 +80,17 @@ skip-if = true # Bug 1449071 - Frequent 
 skip-if = os == 'linux' && !debug # Bug 1398766
 [browser_html_abuse_report.js]
 [browser_html_detail_view.js]
 [browser_html_discover_view.js]
 [browser_html_discover_view_clientid.js]
 [browser_html_discover_view_prefs.js]
 [browser_html_list_view.js]
 [browser_html_message_bar.js]
+[browser_html_named_deck.js]
 [browser_html_options_ui_in_tab.js]
 [browser_html_plugins.js]
 skip-if = (os == 'win' && processor == 'aarch64') # aarch64 has no plugin support, bug 1525174 and 1547495
 [browser_html_recent_updates.js]
 [browser_html_updates.js]
 [browser_inlinesettings_browser.js]
 skip-if = os == 'mac' || os == 'linux' # Bug 1483347
 [browser_installssl.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_named_deck.js
@@ -0,0 +1,187 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+add_task(async function enableHtmlViews() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.htmlaboutaddons.enabled", true]],
+  });
+});
+
+const DEFAULT_SECTION_NAMES = ["one", "two", "three"];
+
+function makeButton({doc, name, deckId}) {
+  let button = doc.createElement("named-deck-button");
+  button.setAttribute("name", name);
+  button.deckId = deckId;
+  button.textContent = name.toUpperCase();
+  return button;
+}
+
+function makeSection({doc, name}) {
+  let view = doc.createElement("section");
+  view.setAttribute("name", name);
+  view.textContent = name + name;
+  return view;
+}
+
+function addSection({name, deck, buttons}) {
+  let doc = deck.ownerDocument;
+  let button = makeButton({doc, name, deckId: deck.id});
+  buttons.appendChild(button);
+  let view = makeSection({doc, name});
+  deck.appendChild(view);
+  return {button, view};
+}
+
+async function runTests({deck, buttons}) {
+  const selectedSlot = deck.shadowRoot.querySelector('slot[name="selected"]');
+  const getButtonByName = name => buttons.querySelector(`[name="${name}"]`);
+
+  function checkState(name, count, empty = false) {
+    // Check that the right view is selected.
+    is(deck.selectedViewName, name, "The right view is selected");
+
+    // Verify there's one element in the slot.
+    let slottedEls = selectedSlot.assignedElements();
+    if (empty) {
+      is(slottedEls.length, 0, "The deck is empty");
+    } else {
+      is(slottedEls.length, 1, "There's one visible view");
+      is(slottedEls[0].getAttribute("name"), name,
+        "The correct view is in the slot");
+    }
+
+    // Check that the hidden properties are set.
+    let sections = deck.querySelectorAll("section");
+    is(sections.length, count, "There are the right number of sections");
+    for (let section of sections) {
+      let sectionName = section.getAttribute("name");
+      if (sectionName == name) {
+        is(section.slot, "selected", `${sectionName} is visible`);
+      } else {
+        is(section.slot, "", `${sectionName} is hidden`);
+      }
+    }
+
+    // Check the right button is selected.
+    is(buttons.children.length, count, "There are the right number of buttons");
+    for (let button of buttons.children) {
+      let buttonName = button.getAttribute("name");
+      let selected = buttonName == name;
+      is(button.hasAttribute("selected"), selected,
+         `${buttonName} is ${selected ? "selected" : "not selected"}`);
+    }
+  }
+
+  // Check that the first view is selected by default.
+  checkState("one", 3);
+
+  // Switch to the third view.
+  info("Switch to section three");
+  getButtonByName("three").click();
+  checkState("three", 3);
+
+  // Add a new section, nothing changes.
+  info("Add section last");
+  let last = addSection({name: "last", deck, buttons});
+  checkState("three", 4);
+
+  // We can switch to the new section.
+  last.button.click();
+  info("Switch to section last");
+  checkState("last", 4);
+
+  info("Switch view with selectedViewName");
+  let shown = BrowserTestUtils.waitForEvent(deck, "view-changed");
+  deck.selectedViewName = "two";
+  await shown;
+  checkState("two", 4);
+
+  info("Switch back to the last view to test removing selected view");
+  shown = BrowserTestUtils.waitForEvent(deck, "view-changed");
+  deck.setAttribute("selected-view", "last");
+  await shown;
+  checkState("last", 4);
+
+  // Removing the selected section leaves the selected slot empty.
+  info("Remove section last");
+  last.button.remove();
+  last.view.remove();
+
+  info("Should not have any selected views");
+  checkState("last", 3, true);
+
+  // Setting a missing view will give a "view-changed" event.
+  info("Set view to a missing name");
+  let hidden = BrowserTestUtils.waitForEvent(deck, "view-changed");
+  deck.selectedViewName = "missing";
+  await hidden;
+  checkState("missing", 3, true);
+
+  // Adding the view won't trigger "view-changed", but the view will slotted.
+  info("Add the missing view, it should be shown");
+  shown = BrowserTestUtils.waitForEvent(selectedSlot, "slotchange");
+  let viewChangedEvent = false;
+  let viewChangedFn = () => { viewChangedEvent = true; };
+  deck.addEventListener("view-changed", viewChangedFn);
+  addSection({name: "missing", deck, buttons});
+  await shown;
+  deck.removeEventListener("view-changed", viewChangedFn);
+  ok(!viewChangedEvent, "The view-changed event didn't fire");
+  checkState("missing", 4);
+}
+
+async function setup({doc, beAsync, first}) {
+  const deckId = `${first}-first-${beAsync}`;
+
+  // Make the deck and buttons.
+  const deck = doc.createElement("named-deck");
+  deck.id = deckId;
+  for (let name of DEFAULT_SECTION_NAMES) {
+    deck.appendChild(makeSection({doc, name}));
+  }
+  const buttons = doc.createElement("div");
+  for (let name of DEFAULT_SECTION_NAMES) {
+    buttons.appendChild(makeButton({doc, name, deckId}));
+  }
+
+  let ordered;
+  if (first == "deck") {
+    ordered = [deck, buttons];
+  } else if (first == "buttons") {
+    ordered = [buttons, deck];
+  } else {
+    throw new Error("Invalid order");
+  }
+
+  // Insert them in the specified order, possibly async.
+  doc.body.appendChild(ordered.shift());
+  if (beAsync) {
+    await new Promise(resolve => requestAnimationFrame(resolve));
+  }
+  doc.body.appendChild(ordered.shift());
+
+  return {deck, buttons};
+}
+
+add_task(async function testNamedDeckAndButtons() {
+  const win = await loadInitialView("extension");
+  const doc = win.document;
+
+  // Check adding the deck first.
+  dump("Running deck first tests synchronously");
+  await runTests(await setup({doc, beAsync: false, first: "deck"}));
+  dump("Running deck first tests asynchronously");
+  await runTests(await setup({doc, beAsync: true, first: "deck"}));
+
+  // Check adding the buttons first.
+  dump("Running buttons first tests synchronously");
+  await runTests(await setup({doc, beAsync: false, first: "buttons"}));
+  dump("Running buttons first tests asynchronously");
+  await runTests(await setup({doc, beAsync: true, first: "buttons"}));
+
+  await closeView(win);
+});