Bug 1472748 - Convert the listbox in "editBookmarkPanel.inc.xul" to "richlistbox". r=mak draft
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Fri, 13 Jul 2018 15:37:31 +0100
changeset 817848 c07651f04f146eb25a1a700798d782cd2b4124cc
parent 817847 762819bac1b9efe0e939be07394bd7d293638c40
child 817913 e403c2d8cb01e3ef352ffb7e3e6023916d19d6e2
push id116184
push userpaolo.mozmail@amadzone.org
push dateFri, 13 Jul 2018 14:52:14 +0000
reviewersmak
bugs1472748
milestone63.0a1
Bug 1472748 - Convert the listbox in "editBookmarkPanel.inc.xul" to "richlistbox". r=mak This uses the same event handling as the "listbox" and "listitem-checkbox" bindings that are scheduled for removal, and copies the required styles locally. There is no need to preserve the scroll position explicitly anymore, because "richlistbox" handles scrolling like regular elements. MozReview-Commit-ID: 4gYhwlprPN7
browser/base/content/browser.css
browser/components/places/content/editBookmark.js
browser/components/places/content/editBookmarkPanel.inc.xul
browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js
browser/components/places/tests/browser/browser_editBookmark_tags_liveUpdate.js
browser/themes/linux/places/editBookmark.css
browser/themes/osx/places/editBookmark.css
browser/themes/windows/places/editBookmark.css
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -844,21 +844,16 @@ toolbarspring {
     image-rendering: -moz-crisp-edges;
   }
   /* Synced Tabs sidebar */
   html|*.tabs-container html|*.item-tabs-list html|*.item-icon-container {
     image-rendering: -moz-crisp-edges;
   }
 }
 
-#editBMPanel_tagsSelector {
-  /* override default listbox width from xul.css */
-  width: auto;
-}
-
 menupopup[emptyplacesresult="true"] > .hide-if-empty-places-result {
   display: none;
 }
 menuitem.spell-suggestion {
   font-weight: bold;
 }
 
 /* Hide extension toolbars that neglected to set the proper class */
--- a/browser/components/places/content/editBookmark.js
+++ b/browser/components/places/content/editBookmark.js
@@ -742,46 +742,42 @@ var gEditItemOverlay = {
   },
 
   _rebuildTagsSelectorList() {
     let tagsSelector = this._element("tagsSelector");
     let tagsSelectorRow = this._element("tagsSelectorRow");
     if (tagsSelectorRow.collapsed)
       return;
 
-    // Save the current scroll position and restore it after the rebuild.
-    let firstIndex = tagsSelector.getIndexOfFirstVisibleRow();
     let selectedIndex = tagsSelector.selectedIndex;
     let selectedTag = selectedIndex >= 0 ? tagsSelector.selectedItem.label
                                          : null;
 
     while (tagsSelector.hasChildNodes()) {
       tagsSelector.removeChild(tagsSelector.lastChild);
     }
 
     let tagsInField = this._getTagsArrayFromTagsInputField();
     let allTags = PlacesUtils.tagging.allTags;
-    for (let tag of allTags) {
-      let elt = document.createElement("listitem");
-      elt.setAttribute("type", "checkbox");
-      elt.setAttribute("label", tag);
+    let fragment = document.createDocumentFragment();
+    for (var i = 0; i < allTags.length; i++) {
+      let tag = allTags[i];
+      let elt = document.createElement("richlistitem");
+      elt.appendChild(document.createElement("image"));
+      let label = document.createElement("label");
+      label.setAttribute("value", tag);
+      elt.appendChild(label);
       if (tagsInField.includes(tag))
         elt.setAttribute("checked", "true");
-      tagsSelector.appendChild(elt);
+      fragment.appendChild(elt);
       if (selectedTag === tag)
-        selectedIndex = tagsSelector.getIndexOfItem(elt);
+        selectedIndex = i;
     }
+    tagsSelector.appendChild(fragment);
 
-    // Restore position.
-    // The listbox allows to scroll only if the required offset doesn't
-    // overflow its capacity, thus need to adjust the index for removals.
-    firstIndex =
-      Math.min(firstIndex,
-               tagsSelector.itemCount - tagsSelector.getNumberOfVisibleRows());
-    tagsSelector.scrollToIndex(firstIndex);
     if (selectedIndex >= 0 && tagsSelector.itemCount > 0) {
       selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1);
       tagsSelector.selectedIndex = selectedIndex;
       tagsSelector.ensureIndexIsVisible(selectedIndex);
     }
   },
 
   toggleTagsSelector() {
@@ -791,22 +787,27 @@ var gEditItemOverlay = {
     if (tagsSelectorRow.collapsed) {
       expander.className = "expander-up";
       expander.setAttribute("tooltiptext",
                             expander.getAttribute("tooltiptextup"));
       tagsSelectorRow.collapsed = false;
       this._rebuildTagsSelectorList();
 
       // This is a no-op if we've added the listener.
-      tagsSelector.addEventListener("CheckboxStateChange", this);
+      tagsSelector.addEventListener("mousedown", this);
+      tagsSelector.addEventListener("keypress", this);
     } else {
       expander.className = "expander-down";
       expander.setAttribute("tooltiptext",
                             expander.getAttribute("tooltiptextdown"));
       tagsSelectorRow.collapsed = true;
+
+      // This is a no-op if we've removed the listener.
+      tagsSelector.removeEventListener("mousedown", this);
+      tagsSelector.removeEventListener("keypress", this);
     }
   },
 
   /**
    * Splits "tagsField" element value, returning an array of valid tag strings.
    *
    * @return Array of tag strings found in the field value.
    */
@@ -840,42 +841,62 @@ var gEditItemOverlay = {
     this._folderTree.selectItems([ip.guid]);
     PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true;
     this._folderTree.selectItems([guid]);
     this._folderTree.startEditing(this._folderTree.view.selection.currentIndex,
                                   this._folderTree.columns.getFirstColumn());
   },
 
   // EventListener
-  handleEvent(aEvent) {
-    switch (aEvent.type) {
-    case "CheckboxStateChange":
-      // Update the tags field when items are checked/unchecked in the listbox
-      let tags = this._getTagsArrayFromTagsInputField();
-      let tagCheckbox = aEvent.target;
-
-      let curTagIndex = tags.indexOf(tagCheckbox.label);
-      let tagsSelector = this._element("tagsSelector");
-      tagsSelector.selectedItem = tagCheckbox;
-
-      if (tagCheckbox.checked) {
-        if (curTagIndex == -1)
-          tags.push(tagCheckbox.label);
-      } else if (curTagIndex != -1) {
-        tags.splice(curTagIndex, 1);
+  handleEvent(event) {
+    switch (event.type) {
+    case "mousedown":
+      if (event.button == 0) {
+        // Make sure the event is triggered on an item and not the empty space.
+        let item = event.target.closest("richlistbox,richlistitem");
+        if (item.localName == "richlistitem") {
+          this.toggleItemCheckbox(item);
+        }
       }
-      this._element("tagsField").value = tags.join(", ");
-      this._updateTags();
+      break;
+    case "keypress":
+      if (event.key == " ") {
+        let item = event.target.currentItem;
+        if (item) {
+          this.toggleItemCheckbox(item);
+        }
+      }
       break;
     case "unload":
       this.uninitPanel(false);
       break;
     }
   },
 
+  toggleItemCheckbox(item) {
+    // Update the tags field when items are checked/unchecked in the listbox
+    let tags = this._getTagsArrayFromTagsInputField();
+
+    let curTagIndex = tags.indexOf(item.label);
+    let tagsSelector = this._element("tagsSelector");
+    tagsSelector.selectedItem = item;
+
+    if (!item.hasAttribute("checked")) {
+      item.setAttribute("checked", "true");
+      if (curTagIndex == -1)
+        tags.push(item.label);
+    } else {
+      item.removeAttribute("checked");
+      if (curTagIndex != -1)
+        tags.splice(curTagIndex, 1);
+    }
+    this._element("tagsField").value = tags.join(", ");
+    this._updateTags();
+  },
+
   _initTagsField() {
     let tags;
     if (this._paneInfo.isURI)
       tags = PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
     else if (this._paneInfo.bulkTagging)
       tags = this._getCommonTags();
     else
       throw new Error("_promiseTagsStr called unexpectedly");
--- a/browser/components/places/content/editBookmarkPanel.inc.xul
+++ b/browser/components/places/content/editBookmarkPanel.inc.xul
@@ -102,19 +102,20 @@
                 tooltiptext="&editBookmarkOverlay.tagsExpanderDown.tooltip;"
                 tooltiptextdown="&editBookmarkOverlay.tagsExpanderDown.tooltip;"
                 tooltiptextup="&editBookmarkOverlay.expanderUp.tooltip;"
                 oncommand="gEditItemOverlay.toggleTagsSelector();"/>
       </hbox>
     </vbox>
 
     <vbox id="editBMPanel_tagsSelectorRow"
-         collapsed="true">
-      <listbox id="editBMPanel_tagsSelector"
-               height="150"/>
+          collapsed="true">
+      <richlistbox id="editBMPanel_tagsSelector"
+                   styled="true"
+                   height="150"/>
     </vbox>
 
     <vbox id="editBMPanel_keywordRow"
           collapsed="true">
       <label value="&editBookmarkOverlay.keyword.label;"
              accesskey="&editBookmarkOverlay.keyword.accesskey;"
              control="editBMPanel_keywordField"/>
       <textbox id="editBMPanel_keywordField"
--- a/browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js
+++ b/browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js
@@ -1,15 +1,24 @@
  /**
   * This test checks that editing tags doesn't scroll the tags selector
   * listbox to wrong positions.
   */
 
 const TEST_URL = "about:buildconfig";
 
+function scrolledIntoView(item, parentItem) {
+  let itemRect = item.getBoundingClientRect();
+  let parentItemRect = parentItem.getBoundingClientRect();
+  let pointInView = y => parentItemRect.top < y && y < parentItemRect.bottom;
+
+  // Partially visible items are also considered visible.
+  return pointInView(itemRect.top) || pointInView(itemRect.bottom);
+}
+
 add_task(async function() {
   await PlacesUtils.bookmarks.eraseEverything();
   let tags = ["a", "b", "c", "d", "e", "f", "g",
               "h", "i", "l", "m", "n", "o", "p"];
 
   // Add a bookmark and tag it.
   let uri1 = Services.io.newURI(TEST_URL);
   let bm1 = await PlacesUtils.bookmarks.insert({
@@ -58,76 +67,70 @@ add_task(async function() {
 
   // Go by two so there is some untouched tag in the middle.
   for (let i = 8; i < tags.length; i += 2) {
     tagsSelector.selectedIndex = i;
     let listItem = tagsSelector.selectedItem;
     isnot(listItem, null, "Valid listItem found");
 
     tagsSelector.ensureElementIsVisible(listItem);
-    let visibleIndex = tagsSelector.getIndexOfFirstVisibleRow();
+    let scrollTop = tagsSelector.scrollTop;
 
-    ok(listItem.checked, "Item is checked " + i);
+    ok(listItem.hasAttribute("checked"), "Item is checked " + i);
     let selectedTag = listItem.label;
 
     // Uncheck the tag.
     let promiseNotification = PlacesTestUtils.waitForNotification(
       "onItemChanged", (id, property) => property == "tags");
-    listItem.checked = false;
+    EventUtils.synthesizeMouseAtCenter(listItem.firstChild, {});
     await promiseNotification;
-    is(visibleIndex, tagsSelector.getIndexOfFirstVisibleRow(),
-       "Scroll position did not change");
+    is(scrollTop, tagsSelector.scrollTop, "Scroll position did not change");
 
     // The listbox is rebuilt, so we have to get the new element.
     let newItem = tagsSelector.selectedItem;
     isnot(newItem, null, "Valid new listItem found");
-    ok(!newItem.checked, "New listItem is unchecked " + i);
+    ok(!newItem.hasAttribute("checked"), "New listItem is unchecked " + i);
     is(newItem.label, selectedTag, "Correct tag is still selected");
 
     // Check the tag.
     promiseNotification = PlacesTestUtils.waitForNotification(
       "onItemChanged", (id, property) => property == "tags");
-    newItem.checked = true;
+    EventUtils.synthesizeMouseAtCenter(newItem.firstChild, {});
     await promiseNotification;
-    is(visibleIndex, tagsSelector.getIndexOfFirstVisibleRow(),
-       "Scroll position did not change");
+    is(scrollTop, tagsSelector.scrollTop, "Scroll position did not change");
   }
 
   // Remove the second bookmark, then nuke some of the tags.
   await PlacesUtils.bookmarks.remove(bm2);
 
   // Doing this backwords tests more interesting paths.
   for (let i = tags.length - 1; i >= 0 ; i -= 2) {
     tagsSelector.selectedIndex = i;
     let listItem = tagsSelector.selectedItem;
     isnot(listItem, null, "Valid listItem found");
 
     tagsSelector.ensureElementIsVisible(listItem);
-    let firstVisibleTag = tags[tagsSelector.getIndexOfFirstVisibleRow()];
+    let items = [...tagsSelector.children];
+    let topTag = items.find(e => scrolledIntoView(e, tagsSelector)).label;
 
-    ok(listItem.checked, "Item is checked " + i);
+    ok(listItem.hasAttribute("checked"), "Item is checked " + i);
 
     // Uncheck the tag.
     let promiseNotification = PlacesTestUtils.waitForNotification(
       "onItemChanged", (id, property) => property == "tags");
-    listItem.checked = false;
+    EventUtils.synthesizeMouseAtCenter(listItem.firstChild, {});
     await promiseNotification;
 
-    // Ensure the first visible tag is still visible in the list.
-    let firstVisibleIndex = tagsSelector.getIndexOfFirstVisibleRow();
-    let lastVisibleIndex = firstVisibleIndex + tagsSelector.getNumberOfVisibleRows() - 1;
-    let expectedTagIndex = tags.indexOf(firstVisibleTag);
-    ok(expectedTagIndex >= firstVisibleIndex &&
-       expectedTagIndex <= lastVisibleIndex,
-       "Scroll position is correct");
+    // The listbox is rebuilt, so we have to get the new element.
+    let topItem = [...tagsSelector.children].find(e => e.label == topTag);
+    ok(scrolledIntoView(topItem, tagsSelector), "Scroll position is correct");
 
-    // The listbox is rebuilt, so we have to get the new element.
     let newItem = tagsSelector.selectedItem;
     isnot(newItem, null, "Valid new listItem found");
-    ok(newItem.checked, "New listItem is checked " + i);
+    ok(newItem.hasAttribute("checked"), "New listItem is checked " + i);
     is(tagsSelector.selectedItem.label,
        tags[Math.min(i + 1, tags.length - 2)],
        "The next tag is now selected");
   }
 
   let hiddenPromise = promisePopupHidden(bookmarkPanel);
   let doneButton = document.getElementById("editBookmarkPanelDoneButton");
   doneButton.click();
--- a/browser/components/places/tests/browser/browser_editBookmark_tags_liveUpdate.js
+++ b/browser/components/places/tests/browser/browser_editBookmark_tags_liveUpdate.js
@@ -4,17 +4,17 @@ function checkTagsSelector(aAvailableTag
   is(PlacesUtils.tagging.allTags.length, aAvailableTags.length,
      "tagging service is in sync.");
   let tagsSelector = document.getElementById("editBMPanel_tagsSelector");
   let children = tagsSelector.childNodes;
   is(children.length, aAvailableTags.length,
       "Found expected number of tags in the tags selector");
 
   Array.prototype.forEach.call(children, function(aChild) {
-    let tag = aChild.getAttribute("label");
+    let tag = aChild.querySelector("label").getAttribute("value");
     ok(true, "Found tag '" + tag + "' in the selector");
     ok(aAvailableTags.includes(tag), "Found expected tag");
     let checked = aChild.getAttribute("checked") == "true";
     is(checked, aCheckedTags.includes(tag),
        "Tag is correctly marked");
   });
 }
 
--- a/browser/themes/linux/places/editBookmark.css
+++ b/browser/themes/linux/places/editBookmark.css
@@ -53,16 +53,24 @@
  * leaving only the comment column visible. This is
  * so that only the tag being edited is shown in the
  * popup.
  */
 #editBMPanel_tagsField #treecolAutoCompleteValue {
   visibility: collapse;
 }
 
+#editBMPanel_tagsSelector > richlistitem > image {
+  -moz-appearance: checkbox;
+  -moz-box-align: center;
+  margin: 0 2px;
+  min-width: 13px;
+  min-height: 13px;
+}
+
 
 /* Bookmark panel dropdown menu items */
 #editBMPanel_folderMenuList[selectedIndex="0"],
 #editBMPanel_toolbarFolderItem {
   list-style-image: url("chrome://browser/skin/places/bookmarksToolbar.svg") !important;
 }
 
 #editBMPanel_folderMenuList[selectedIndex="1"],
--- a/browser/themes/osx/places/editBookmark.css
+++ b/browser/themes/osx/places/editBookmark.css
@@ -55,16 +55,30 @@
  * leaving only the comment column visible. This is
  * so that only the tag being edited is shown in the
  * popup.
  */
 #editBMPanel_tagsField #treecolAutoCompleteValue {
   visibility: collapse;
 }
 
+#editBMPanel_tagsSelector > richlistitem > image {
+  -moz-appearance: checkbox;
+  -moz-box-align: center;
+  margin: 0px 2px;
+  border: 1px solid -moz-DialogText;
+  min-width: 13px;
+  min-height: 13px;
+  background: -moz-Field no-repeat 50% 50%;
+}
+
+#editBMPanel_tagsSelector > richlistitem[checked="true"] > image {
+  background-image: url("chrome://global/skin/checkbox/cbox-check.gif");
+}
+
 
 /* ----- BOOKMARK PANEL DROPDOWN MENU ITEMS ----- */
 
 #editBMPanel_folderMenuList[selectedIndex="0"],
 #editBMPanel_toolbarFolderItem {
   list-style-image: url("chrome://browser/skin/places/bookmarksToolbar.svg") !important;
 }
 
--- a/browser/themes/windows/places/editBookmark.css
+++ b/browser/themes/windows/places/editBookmark.css
@@ -58,16 +58,26 @@
  * leaving only the comment column visible. This is
  * so that only the tag being edited is shown in the
  * popup.
  */
 #editBMPanel_tagsField #treecolAutoCompleteValue {
   visibility: collapse;
 }
 
+#editBMPanel_tagsSelector > richlistitem > image {
+  -moz-appearance: checkbox;
+  -moz-box-align: center;
+  margin: 0px 2px;
+  border: 1px solid -moz-DialogText;
+  min-width: 13px;
+  min-height: 13px;
+  background: -moz-Field no-repeat 50% 50%;
+}
+
 
 /* ::::: bookmark panel dropdown icons ::::: */
 
 #editBMPanel_folderMenuList[selectedIndex="0"],
 #editBMPanel_toolbarFolderItem {
   list-style-image: url("chrome://browser/skin/places/bookmarksToolbar.svg") !important;
 }