Bug 1407568 - Add a spotlight indicator to a specific section and UI component. draft
authorEvan Tseng <evan@tseng.io>
Thu, 30 Nov 2017 17:42:40 +0800
changeset 706088 335b0f7929c6cac208b9cb94f1b8360f060e35e2
parent 706087 0e65cca6063d646f6ae09c326d2c7ec91734aa92
child 742568 50a943f3866dcf14e630e9dad6fb369cd60fa851
push id91699
push userbmo:evan@tseng.io
push dateFri, 01 Dec 2017 08:56:03 +0000
bugs1407568
milestone59.0a1
Bug 1407568 - Add a spotlight indicator to a specific section and UI component. MozReview-Commit-ID: 4AgAFq2r418
browser/components/preferences/in-content/preferences.js
browser/components/preferences/in-content/privacy.xul
browser/components/preferences/in-content/tests/browser.ini
browser/components/preferences/in-content/tests/browser_bug1020245_openPreferences_to_paneContent.js
browser/components/preferences/in-content/tests/browser_spotlight.js
browser/components/uitour/test/browser_openPreferences.js
browser/extensions/formautofill/FormAutofillPreferences.jsm
browser/themes/shared/incontentprefs/preferences.inc.css
--- a/browser/components/preferences/in-content/preferences.js
+++ b/browser/components/preferences/in-content/preferences.js
@@ -22,16 +22,18 @@ var Cr = Components.results;
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/AppConstants.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
                                   "resource://gre/modules/ExtensionSettingsStore.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "formAutofillParent",
+                                  "resource://formautofill/FormAutofillParent.jsm");
 
 var gLastHash = "";
 
 var gCategoryInits = new Map();
 function init_category_if_required(category) {
   let categoryInfo = gCategoryInits.get(category);
   if (!categoryInfo) {
     throw "Unknown in-content prefs category! Can't init " + category;
@@ -169,46 +171,41 @@ function gotoPref(aCategory) {
   // the categories 'select' event will re-enter the gotoPref codepath.
   gLastHash = category;
   if (item) {
     categories.selectedItem = item;
   } else {
     categories.clearSelection();
   }
   window.history.replaceState(category, document.title);
-  search(category, "data-category", subcategory, "data-subcategory");
+  search(category, "data-category");
 
   let mainContent = document.querySelector(".main-content");
   mainContent.scrollTop = 0;
 
+  spotlight(subcategory);
+
   Services.telemetry
           .getHistogramById("FX_PREFERENCES_CATEGORY_OPENED_V2")
           .add(telemetryBucketForCategory(friendlyName));
 }
 
-function search(aQuery, aAttribute, aSubquery, aSubAttribute) {
+function search(aQuery, aAttribute) {
   let mainPrefPane = document.getElementById("mainPrefPane");
   let elements = mainPrefPane.children;
   for (let element of elements) {
     // If the "data-hidden-from-search" is "true", the
     // element will not get considered during search. This
     // should only be used when an element is still under
     // development and should not be shown for any reason.
     if (element.getAttribute("data-hidden-from-search") != "true" ||
         element.getAttribute("data-subpanel") == "true") {
       let attributeValue = element.getAttribute(aAttribute);
       if (attributeValue == aQuery) {
-        if (!element.classList.contains("header") &&
-            element.localName !== "preferences" &&
-            aSubquery && aSubAttribute) {
-          let subAttributeValue = element.getAttribute(aSubAttribute);
-          element.hidden = subAttributeValue != aSubquery;
-        } else {
-          element.hidden = false;
-        }
+        element.hidden = false;
       } else {
         element.hidden = true;
       }
     }
     element.classList.remove("visually-hidden");
   }
 
   let keysets = mainPrefPane.getElementsByTagName("keyset");
@@ -216,16 +213,116 @@ function search(aQuery, aAttribute, aSub
     let attributeValue = element.getAttribute(aAttribute);
     if (attributeValue == aQuery)
       element.removeAttribute("disabled");
     else
       element.setAttribute("disabled", true);
   }
 }
 
+async function spotlight(subcategory) {
+  let highlightedElements = document.querySelectorAll(".spotlight");
+  if (highlightedElements.length) {
+    for (let element of highlightedElements) {
+      element.classList.remove("spotlight");
+    }
+  }
+  if (subcategory) {
+    if (!gSearchResultsPane.categoriesInitialized) {
+      await waitForSystemAddonInjectionsFinished([{
+        isGoingToInject: formAutofillParent.initialized,
+        elementId: "formAutofillGroup",
+      }]);
+    }
+    scrollAndHighlight(subcategory);
+  }
+
+  /**
+   * Wait for system addons finished their dom injections.
+   * @param {Array} addons - The system addon information array.
+   * For example, the element is looked like
+   * { isGoingToInject: true, elementId: "formAutofillGroup" }.
+   * The `isGoingToInject` means the system addon will be visible or not,
+   * and the `elementId` means the id of the element will be injected into the dom
+   * if the `isGoingToInject` is true.
+   * @returns {Promise} Will resolve once all injections are finished.
+   */
+  function waitForSystemAddonInjectionsFinished(addons) {
+    return new Promise(resolve => {
+      let elementIdSet = new Set();
+      for (let addon of addons) {
+        if (addon.isGoingToInject) {
+          elementIdSet.add(addon.elementId);
+        }
+      }
+      if (elementIdSet.size) {
+        let observer = new MutationObserver(mutations => {
+          for (let mutation of mutations) {
+            for (let node of mutation.addedNodes) {
+              elementIdSet.delete(node.id);
+              if (elementIdSet.size === 0) {
+                observer.disconnect();
+                resolve();
+              }
+            }
+          }
+        });
+        let mainContent = document.querySelector(".main-content");
+        observer.observe(mainContent, {childList: true, subtree: true});
+        // Disconnect the mutation observer once there is any user input.
+        mainContent.addEventListener("scroll", disconnectMutationObserver);
+        window.addEventListener("mousedown", disconnectMutationObserver);
+        window.addEventListener("keydown", disconnectMutationObserver);
+        function disconnectMutationObserver() {
+          mainContent.removeEventListener("scroll", disconnectMutationObserver);
+          window.removeEventListener("mousedown", disconnectMutationObserver);
+          window.removeEventListener("keydown", disconnectMutationObserver);
+          observer.disconnect();
+        }
+      } else {
+        resolve();
+      }
+    });
+  }
+}
+
+function scrollAndHighlight(subcategory) {
+  let element = document.querySelector(`[data-subcategory="${subcategory}"]`);
+  if (element) {
+    let header = getClosestDisplayedHeader(element);
+    scrollContentTo(header);
+    element.classList.add("spotlight");
+  }
+}
+
+/**
+ * If there is no visible second level header it will return first level header,
+ * otherwise return second level header.
+ * @returns {Element} - The closest displayed header.
+ */
+function getClosestDisplayedHeader(element) {
+  let header = element.closest("groupbox");
+  let searchHeader = header.querySelector("caption.search-header");
+  if (searchHeader && searchHeader.hidden &&
+      header.previousSibling.classList.contains("subcategory")) {
+    header = header.previousSibling;
+  }
+  return header;
+}
+
+function scrollContentTo(element) {
+  const SEARCH_CONTAINER_HEIGHT = document.querySelector(".search-container").clientHeight;
+  let mainContent = document.querySelector(".main-content");
+  let top = element.getBoundingClientRect().top - SEARCH_CONTAINER_HEIGHT;
+  mainContent.scroll({
+    top,
+    behavior: "smooth",
+  });
+}
+
 function helpButtonCommand() {
   let pane = history.state;
   let categories = document.getElementById("categories");
   let helpTopic = categories.querySelector(".category[value=" + pane + "]")
                             .getAttribute("helpTopic");
   openHelpLink(helpTopic);
 }
 
--- a/browser/components/preferences/in-content/privacy.xul
+++ b/browser/components/preferences/in-content/privacy.xul
@@ -702,52 +702,52 @@
               value="&a11yPrivacy.learnmore.label;"></label>
     </hbox>
   </vbox>
 </groupbox>
 
 <hbox id="dataCollectionCategory"
       class="subcategory"
       hidden="true"
-      data-category="panePrivacy"
-      data-subcategory="reports">
+      data-category="panePrivacy">
   <label class="header-name" flex="1">&dataCollection.label;</label>
 </hbox>
 
 <!-- Firefox Data Collection and Use -->
 #ifdef MOZ_DATA_REPORTING
-<groupbox id="dataCollectionGroup" data-category="panePrivacy" data-subcategory="reports" hidden="true">
+<groupbox id="dataCollectionGroup" data-category="panePrivacy" hidden="true">
   <caption class="search-header" hidden="true"><label>&dataCollection.label;</label></caption>
 
-  <vbox>
-    <description>
-      <label class="tail-with-learn-more">&dataCollectionDesc.label;</label><label id="dataCollectionPrivacyNotice" class="learnMore text-link">&dataCollectionPrivacyNotice.label;</label>
-    </description>
+  <description>
+    <label class="tail-with-learn-more">&dataCollectionDesc.label;</label><label id="dataCollectionPrivacyNotice" class="learnMore text-link">&dataCollectionPrivacyNotice.label;</label>
+  </description>
+  <vbox data-subcategory="reports">
     <description flex="1">
       <checkbox id="submitHealthReportBox" label="&enableHealthReport2.label;"
                 class="tail-with-learn-more"
                 accesskey="&enableHealthReport2.accesskey;"/>
       <label id="FHRLearnMore"
              class="learnMore text-link">&healthReportLearnMore.label;</label>
     </description>
 #ifndef MOZ_TELEMETRY_REPORTING
     <description id="TelemetryDisabledDesc" class="indent tip-caption" control="telemetryGroup">&healthReportingDisabled.label;</description>
 #endif
-  </vbox>
+
 #ifdef MOZ_CRASHREPORTER
-  <hbox align="center">
-    <checkbox id="automaticallySubmitCrashesBox"
-              class="tail-with-learn-more"
-              preference="browser.crashReports.unsubmittedCheck.autoSubmit"
-              label="&alwaysSubmitCrashReports1.label;"
-              accesskey="&alwaysSubmitCrashReports1.accesskey;"/>
-    <label id="crashReporterLearnMore"
-           class="learnMore text-link">&crashReporterLearnMore.label;</label>
-  </hbox>
+    <hbox align="center">
+      <checkbox id="automaticallySubmitCrashesBox"
+                class="tail-with-learn-more"
+                preference="browser.crashReports.unsubmittedCheck.autoSubmit"
+                label="&alwaysSubmitCrashReports1.label;"
+                accesskey="&alwaysSubmitCrashReports1.accesskey;"/>
+      <label id="crashReporterLearnMore"
+             class="learnMore text-link">&crashReporterLearnMore.label;</label>
+    </hbox>
 #endif
+  </vbox>
 </groupbox>
 #endif
 
 <hbox id="securityCategory"
       class="subcategory"
       hidden="true"
       data-category="panePrivacy">
   <label class="header-name" flex="1">&security.label;</label>
--- a/browser/components/preferences/in-content/tests/browser.ini
+++ b/browser/components/preferences/in-content/tests/browser.ini
@@ -68,16 +68,17 @@ skip-if = e10s
 [browser_privacypane_8.js]
 [browser_sanitizeOnShutdown_prefLocked.js]
 [browser_searchsuggestions.js]
 [browser_security-1.js]
 [browser_security-2.js]
 [browser_siteData.js]
 [browser_siteData2.js]
 [browser_siteData3.js]
+[browser_spotlight.js]
 [browser_site_login_exceptions.js]
 [browser_permissions_dialog.js]
 [browser_cookies_dialog.js]
 [browser_subdialogs.js]
 support-files =
   subdialog.xul
   subdialog2.xul
 [browser_telemetry.js]
--- a/browser/components/preferences/in-content/tests/browser_bug1020245_openPreferences_to_paneContent.js
+++ b/browser/components/preferences/in-content/tests/browser_bug1020245_openPreferences_to_paneContent.js
@@ -20,17 +20,18 @@ add_task(async function() {
   prefs = await openPreferencesViaHash("nonexistant-category");
   is(prefs.selectedPane, "paneGeneral", "General pane is selected when hash is a nonexistant-category");
   prefs = await openPreferencesViaHash();
   is(prefs.selectedPane, "paneGeneral", "General pane is selected by default");
   prefs = await openPreferencesViaOpenPreferencesAPI("privacy-reports", {leaveOpen: true});
   is(prefs.selectedPane, "panePrivacy", "Privacy pane is selected by default");
   let doc = gBrowser.contentDocument;
   is(doc.location.hash, "#privacy", "The subcategory should be removed from the URI");
-  ok(doc.querySelector("#locationBarGroup").hidden, "Location Bar prefs should be hidden when only Reports are requested");
+  await TestUtils.waitForCondition(() => doc.querySelector(".spotlight"), "Wait for the reports section is spotlighted.");
+  is(doc.querySelector(".spotlight").getAttribute("data-subcategory"), "reports", "The reports section is spotlighted.");
   await BrowserTestUtils.removeTab(gBrowser.selectedTab);
 });
 
 // Test opening Preferences with subcategory on an existing Preferences tab. See bug 1358475.
 add_task(async function() {
   let prefs = await openPreferencesViaOpenPreferencesAPI("general", {leaveOpen: true});
   is(prefs.selectedPane, "paneGeneral", "General pane is selected by default");
   let doc = gBrowser.contentDocument;
@@ -38,17 +39,18 @@ add_task(async function() {
   // The reasons that here just call the `openPreferences` API without the helping function are
   //   - already opened one about:preferences tab up there and
   //   - the goal is to test on the existing tab and
   //   - using `openPreferencesViaOpenPreferencesAPI` would introduce more handling of additional about:blank and unneccessary event
   openPreferences("privacy-reports");
   let selectedPane = gBrowser.contentWindow.history.state;
   is(selectedPane, "panePrivacy", "Privacy pane should be selected");
   is(doc.location.hash, "#privacy", "The subcategory should be removed from the URI");
-  ok(doc.querySelector("#locationBarGroup").hidden, "Location Bar prefs should be hidden when only Reports are requested");
+  await TestUtils.waitForCondition(() => doc.querySelector(".spotlight"), "Wait for the reports section is spotlighted.");
+  is(doc.querySelector(".spotlight").getAttribute("data-subcategory"), "reports", "The reports section is spotlighted.");
   await BrowserTestUtils.removeTab(gBrowser.selectedTab);
 });
 
 // Test opening to a subcategory displays the correct values for preferences
 add_task(async function() {
   // Skip if crash reporting isn't enabled since the checkbox will be missing.
   if (!AppConstants.MOZ_CRASHREPORTER) {
     return;
new file mode 100644
--- /dev/null
+++ b/browser/components/preferences/in-content/tests/browser_spotlight.js
@@ -0,0 +1,37 @@
+/* eslint-disable mozilla/no-cpows-in-tests */
+
+add_task(async function test_reports_section() {
+  let prefs = await openPreferencesViaOpenPreferencesAPI("privacy-reports", {leaveOpen: true});
+  is(prefs.selectedPane, "panePrivacy", "Privacy pane is selected by default");
+  let doc = gBrowser.contentDocument;
+  is(doc.location.hash, "#privacy", "The subcategory should be removed from the URI");
+  await TestUtils.waitForCondition(() => doc.querySelector(".spotlight"),
+    "Wait for the reports section is spotlighted.");
+  is(doc.querySelector(".spotlight").getAttribute("data-subcategory"), "reports",
+    "The reports section is spotlighted.");
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function test_address_autofill_section() {
+  let prefs = await openPreferencesViaOpenPreferencesAPI("privacy-address-autofill", {leaveOpen: true});
+  is(prefs.selectedPane, "panePrivacy", "Privacy pane is selected by default");
+  let doc = gBrowser.contentDocument;
+  is(doc.location.hash, "#privacy", "The subcategory should be removed from the URI");
+  await TestUtils.waitForCondition(() => doc.querySelector(".spotlight"),
+    "Wait for the ddress-autofill section is spotlighted.");
+  is(doc.querySelector(".spotlight").getAttribute("data-subcategory"), "address-autofill",
+    "The ddress-autofill section is spotlighted.");
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function test_credit_card_autofill_section() {
+  let prefs = await openPreferencesViaOpenPreferencesAPI("privacy-credit-card-autofill", {leaveOpen: true});
+  is(prefs.selectedPane, "panePrivacy", "Privacy pane is selected by default");
+  let doc = gBrowser.contentDocument;
+  is(doc.location.hash, "#privacy", "The subcategory should be removed from the URI");
+  await TestUtils.waitForCondition(() => doc.querySelector(".spotlight"),
+    "Wait for the credit-card-autofill section is spotlighted.");
+  is(doc.querySelector(".spotlight").getAttribute("data-subcategory"), "credit-card-autofill",
+    "The credit-card-autofill section is spotlighted.");
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
--- a/browser/components/uitour/test/browser_openPreferences.js
+++ b/browser/components/uitour/test/browser_openPreferences.js
@@ -40,13 +40,14 @@ add_UITour_task(async function test_open
       !(AppConstants.MOZ_DATA_REPORTING && AppConstants.MOZ_CRASHREPORTER)) {
     return;
   }
   let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, "about:preferences#privacy-reports");
   await gContentAPI.openPreferences("privacy-reports");
   let tab = await promiseTabOpened;
   await BrowserTestUtils.waitForEvent(gBrowser.selectedBrowser, "Initialized");
   let doc = gBrowser.selectedBrowser.contentDocument;
-  let reports = doc.querySelector("groupbox[data-subcategory='reports']");
   is(doc.location.hash, "#privacy", "Should not display the reports subcategory in the location hash.");
-  is(reports.hidden, false, "Should open to the reports subcategory in the privacy pane in the new Preferences.");
+  await TestUtils.waitForCondition(() => doc.querySelector(".spotlight"),
+    "Wait for the reports section is spotlighted.");
+  is(doc.querySelector(".spotlight").getAttribute("data-subcategory"), "reports", "The reports section is spotlighted.");
   await BrowserTestUtils.removeTab(tab);
 });
--- a/browser/extensions/formautofill/FormAutofillPreferences.jsm
+++ b/browser/extensions/formautofill/FormAutofillPreferences.jsm
@@ -82,16 +82,17 @@ FormAutofillPreferences.prototype = {
     savedAddressesBtn.className = "accessory-button";
     addressAutofillCheckbox.className = "tail-with-learn-more";
     addressAutofillLearnMore.className = "learnMore text-link";
 
     formAutofillGroup.id = "formAutofillGroup";
     addressAutofill.id = "addressAutofill";
     addressAutofillLearnMore.id = "addressAutofillLearnMore";
 
+    addressAutofill.setAttribute("data-subcategory", "address-autofill");
     addressAutofillLearnMore.setAttribute("value", this.bundle.GetStringFromName("learnMoreLabel"));
     addressAutofillCheckbox.setAttribute("label", this.bundle.GetStringFromName("autofillAddressesCheckbox"));
     savedAddressesBtn.setAttribute("label", this.bundle.GetStringFromName("savedAddressesBtnLabel"));
 
     addressAutofillLearnMore.setAttribute("href", learnMoreURL);
 
     // Add preferences search support
     savedAddressesBtn.setAttribute("searchkeywords", MANAGE_ADDRESSES_KEYWORDS.concat(EDIT_ADDRESS_KEYWORDS)
@@ -125,16 +126,17 @@ FormAutofillPreferences.prototype = {
       let savedCreditCardsBtn = document.createElementNS(XUL_NS, "button");
       savedCreditCardsBtn.className = "accessory-button";
       creditCardAutofillCheckbox.className = "tail-with-learn-more";
       creditCardAutofillLearnMore.className = "learnMore text-link";
 
       creditCardAutofill.id = "creditCardAutofill";
       creditCardAutofillLearnMore.id = "creditCardAutofillLearnMore";
 
+      creditCardAutofill.setAttribute("data-subcategory", "credit-card-autofill");
       creditCardAutofillLearnMore.setAttribute("value", this.bundle.GetStringFromName("learnMoreLabel"));
       creditCardAutofillCheckbox.setAttribute("label", this.bundle.GetStringFromName("autofillCreditCardsCheckbox"));
       savedCreditCardsBtn.setAttribute("label", this.bundle.GetStringFromName("savedCreditCardsBtnLabel"));
 
       creditCardAutofillLearnMore.setAttribute("href", learnMoreURL);
 
       // Add preferences search support
       savedCreditCardsBtn.setAttribute("searchkeywords", MANAGE_CREDITCARDS_KEYWORDS.concat(EDIT_CREDITCARD_KEYWORDS)
--- a/browser/themes/shared/incontentprefs/preferences.inc.css
+++ b/browser/themes/shared/incontentprefs/preferences.inc.css
@@ -90,16 +90,34 @@ button > hbox > label {
 }
 
 .accessory-button {
   height: 30px;
   min-width: 150px;
   margin: 4px 0;
 }
 
+.spotlight {
+  background-color: rgba(0,200,215,0.3);
+  /* Show the border to spotlight the components in high-contrast mode. */
+  border: 1px solid transparent;
+  border-radius: 2px;
+}
+
+[data-subcategory] {
+  margin-left: -4px;
+  margin-right: -4px;
+  padding-left: 4px;
+  padding-right: 4px;
+}
+
+[data-subcategory] > .groupbox-title {
+  padding-inline-start: 4px;
+}
+
 #searchInput {
   border-radius: 0;
 }
 
 /* Subcategory title */
 
 /**
  * The first subcategory title for each category should not have margin-top.
@@ -720,19 +738,24 @@ button > hbox > label {
   margin-inline-end: 8px;
   margin-top: 5px;
   margin-bottom: 5px;
 }
 
 .search-container {
   position: sticky;
   background-color: var(--in-content-page-background);
-  width: 100%;
   top: 0;
   z-index: 1;
+  /* The search-container should have the capability to cover all spotlight area. */
+  width: calc(100% + 8px);
+  margin-left: -4px;
+  margin-right: -4px;
+  padding-left: 4px;
+  padding-right: 4px;
 }
 
 #searchInput {
   margin: 20px 0 30px 0;
 }
 
 #searchInput .textbox-search-icons:not([selectedIndex="1"]) {
   display: none;