Bug 1213767 - Rule-view class toggle panel; r=jdescottes draft
authorPatrick Brosset <pbrosset@mozilla.com>
Fri, 03 Mar 2017 14:09:23 +0100
changeset 494065 acec16e30aed799ea6c1ebb24ae72e43c4c81cc5
parent 494064 b7f6812c6e765970796a2e5988fd21fc4b18fd32
child 547999 842471265f7057ef385dc1e816667f141facbab0
push id47922
push userbmo:pbrosset@mozilla.com
push dateMon, 06 Mar 2017 14:54:46 +0000
reviewersjdescottes
bugs1213767
milestone54.0a1
Bug 1213767 - Rule-view class toggle panel; r=jdescottes MozReview-Commit-ID: 2roKEm6Jr26
devtools/client/inspector/inspector.xhtml
devtools/client/inspector/rules/rules.js
devtools/client/inspector/rules/test/browser.ini
devtools/client/inspector/rules/test/browser_rules_class_panel_add.js
devtools/client/inspector/rules/test/browser_rules_class_panel_content.js
devtools/client/inspector/rules/test/browser_rules_class_panel_edit.js
devtools/client/inspector/rules/test/browser_rules_class_panel_mutation.js
devtools/client/inspector/rules/test/browser_rules_class_panel_state_preserved.js
devtools/client/inspector/rules/test/browser_rules_class_panel_toggle.js
devtools/client/inspector/rules/test/head.js
devtools/client/inspector/rules/views/class-list-previewer.js
devtools/client/inspector/rules/views/moz.build
devtools/client/locales/en-US/inspector.properties
devtools/client/themes/rules.css
--- a/devtools/client/inspector/inspector.xhtml
+++ b/devtools/client/inspector/inspector.xhtml
@@ -93,23 +93,25 @@
                           class="devtools-filterinput devtools-rule-searchbox"
                           type="search"
                           data-localization="placeholder=inspector.filterStyles.placeholder"/>
               <button id="ruleview-searchinput-clear" class="devtools-searchinput-clear"></button>
             </div>
             <div id="ruleview-command-toolbar">
               <button id="ruleview-add-rule-button" data-localization="title=inspector.addRule.tooltip" class="devtools-button"></button>
               <button id="pseudo-class-panel-toggle" data-localization="title=inspector.togglePseudo.tooltip" class="devtools-button"></button>
+              <button id="class-panel-toggle" data-localization="title=inspector.toggleClass.tooltip" class="devtools-button"></button>
             </div>
           </div>
-          <div id="pseudo-class-panel" hidden="true">
+          <div id="pseudo-class-panel" class="ruleview-reveal-panel" hidden="true">
             <label><input id="pseudo-hover-toggle" type="checkbox" value=":hover" tabindex="-1" />:hover</label>
             <label><input id="pseudo-active-toggle" type="checkbox" value=":active" tabindex="-1" />:active</label>
             <label><input id="pseudo-focus-toggle" type="checkbox" value=":focus" tabindex="-1" />:focus</label>
-        </div>
+          </div>
+          <div id="ruleview-class-panel" class="ruleview-reveal-panel" hidden="true"></div>
         </div>
 
         <div id="ruleview-container" class="ruleview">
           <div id="ruleview-container-focusable" tabindex="-1">
           </div>
         </div>
       </div>
 
--- a/devtools/client/inspector/rules/rules.js
+++ b/devtools/client/inspector/rules/rules.js
@@ -12,16 +12,17 @@ const {Task} = require("devtools/shared/
 const {Tools} = require("devtools/client/definitions");
 const {l10n} = require("devtools/shared/inspector/css-logic");
 const {ELEMENT_STYLE} = require("devtools/shared/specs/styles");
 const OutputParser = require("devtools/client/shared/output-parser");
 const {PrefObserver} = require("devtools/client/shared/prefs");
 const ElementStyle = require("devtools/client/inspector/rules/models/element-style");
 const Rule = require("devtools/client/inspector/rules/models/rule");
 const RuleEditor = require("devtools/client/inspector/rules/views/rule-editor");
+const ClassListPreviewer = require("devtools/client/inspector/rules/views/class-list-previewer");
 const {gDevTools} = require("devtools/client/framework/devtools");
 const {getCssProperties} = require("devtools/shared/fronts/css-properties");
 const {
   VIEW_NODE_SELECTOR_TYPE,
   VIEW_NODE_PROPERTY_TYPE,
   VIEW_NODE_VALUE_TYPE,
   VIEW_NODE_IMAGE_URL_TYPE,
   VIEW_NODE_LOCATION_TYPE,
@@ -115,24 +116,27 @@ function CssRuleView(inspector, document
 
   this._onAddRule = this._onAddRule.bind(this);
   this._onContextMenu = this._onContextMenu.bind(this);
   this._onCopy = this._onCopy.bind(this);
   this._onFilterStyles = this._onFilterStyles.bind(this);
   this._onClearSearch = this._onClearSearch.bind(this);
   this._onTogglePseudoClassPanel = this._onTogglePseudoClassPanel.bind(this);
   this._onTogglePseudoClass = this._onTogglePseudoClass.bind(this);
+  this._onToggleClassPanel = this._onToggleClassPanel.bind(this);
 
   let doc = this.styleDocument;
   this.element = doc.getElementById("ruleview-container-focusable");
   this.addRuleButton = doc.getElementById("ruleview-add-rule-button");
   this.searchField = doc.getElementById("ruleview-searchbox");
   this.searchClearButton = doc.getElementById("ruleview-searchinput-clear");
   this.pseudoClassPanel = doc.getElementById("pseudo-class-panel");
   this.pseudoClassToggle = doc.getElementById("pseudo-class-panel-toggle");
+  this.classPanel = doc.getElementById("ruleview-class-panel");
+  this.classToggle = doc.getElementById("class-panel-toggle");
   this.hoverCheckbox = doc.getElementById("pseudo-hover-toggle");
   this.activeCheckbox = doc.getElementById("pseudo-active-toggle");
   this.focusCheckbox = doc.getElementById("pseudo-focus-toggle");
 
   this.searchClearButton.hidden = true;
 
   this.shortcuts = new KeyShortcuts({ window: this.styleWindow });
   this._onShortcut = this._onShortcut.bind(this);
@@ -141,18 +145,18 @@ function CssRuleView(inspector, document
   this.shortcuts.on("Space", this._onShortcut);
   this.shortcuts.on("CmdOrCtrl+F", this._onShortcut);
   this.element.addEventListener("copy", this._onCopy);
   this.element.addEventListener("contextmenu", this._onContextMenu);
   this.addRuleButton.addEventListener("click", this._onAddRule);
   this.searchField.addEventListener("input", this._onFilterStyles);
   this.searchField.addEventListener("contextmenu", this.inspector.onTextBoxContextMenu);
   this.searchClearButton.addEventListener("click", this._onClearSearch);
-  this.pseudoClassToggle.addEventListener("click",
-                                          this._onTogglePseudoClassPanel);
+  this.pseudoClassToggle.addEventListener("click", this._onTogglePseudoClassPanel);
+  this.classToggle.addEventListener("click", this._onToggleClassPanel);
   this.hoverCheckbox.addEventListener("click", this._onTogglePseudoClass);
   this.activeCheckbox.addEventListener("click", this._onTogglePseudoClass);
   this.focusCheckbox.addEventListener("click", this._onTogglePseudoClass);
 
   this._handlePrefChange = this._handlePrefChange.bind(this);
   this._onSourcePrefChanged = this._onSourcePrefChanged.bind(this);
 
   this._prefObserver = new PrefObserver("devtools.");
@@ -176,16 +180,18 @@ function CssRuleView(inspector, document
   this._contextmenu = new StyleInspectorMenu(this, { isRuleView: true });
 
   // Add the tooltips and highlighters to the view
   this.tooltips = new TooltipsOverlay(this);
   this.tooltips.addToView();
 
   this.highlighters.addToView(this);
 
+  this.classListPreviewer = new ClassListPreviewer(this.inspector, this.classPanel);
+
   EventEmitter.decorate(this);
 }
 
 CssRuleView.prototype = {
   // The element that we're inspecting.
   _viewedElement: null,
 
   // Used for cancelling timeouts in the style filter.
@@ -668,36 +674,39 @@ CssRuleView.prototype = {
     // Remove context menu
     if (this._contextmenu) {
       this._contextmenu.destroy();
       this._contextmenu = null;
     }
 
     this.tooltips.destroy();
     this.highlighters.removeFromView(this);
+    this.classListPreviewer.destroy();
 
     // Remove bound listeners
     this.shortcuts.destroy();
     this.element.removeEventListener("copy", this._onCopy);
     this.element.removeEventListener("contextmenu", this._onContextMenu);
     this.addRuleButton.removeEventListener("click", this._onAddRule);
     this.searchField.removeEventListener("input", this._onFilterStyles);
     this.searchField.removeEventListener("contextmenu",
       this.inspector.onTextBoxContextMenu);
     this.searchClearButton.removeEventListener("click", this._onClearSearch);
-    this.pseudoClassToggle.removeEventListener("click",
-      this._onTogglePseudoClassPanel);
+    this.pseudoClassToggle.removeEventListener("click", this._onTogglePseudoClassPanel);
+    this.classToggle.removeEventListener("click", this._onToggleClassPanel);
     this.hoverCheckbox.removeEventListener("click", this._onTogglePseudoClass);
     this.activeCheckbox.removeEventListener("click", this._onTogglePseudoClass);
     this.focusCheckbox.removeEventListener("click", this._onTogglePseudoClass);
 
     this.searchField = null;
     this.searchClearButton = null;
     this.pseudoClassPanel = null;
     this.pseudoClassToggle = null;
+    this.classPanel = null;
+    this.classToggle = null;
     this.hoverCheckbox = null;
     this.activeCheckbox = null;
     this.focusCheckbox = null;
 
     this.inspector = null;
     this.highlighters = null;
     this.styleDocument = null;
     this.styleWindow = null;
@@ -1367,40 +1376,76 @@ CssRuleView.prototype = {
   },
 
   /**
    * Called when the pseudo class panel button is clicked and toggles
    * the display of the pseudo class panel.
    */
   _onTogglePseudoClassPanel: function () {
     if (this.pseudoClassPanel.hidden) {
-      this.pseudoClassToggle.classList.add("checked");
-      this.hoverCheckbox.setAttribute("tabindex", "0");
-      this.activeCheckbox.setAttribute("tabindex", "0");
-      this.focusCheckbox.setAttribute("tabindex", "0");
+      this.showPseudoClassPanel();
     } else {
-      this.pseudoClassToggle.classList.remove("checked");
-      this.hoverCheckbox.setAttribute("tabindex", "-1");
-      this.activeCheckbox.setAttribute("tabindex", "-1");
-      this.focusCheckbox.setAttribute("tabindex", "-1");
+      this.hidePseudoClassPanel();
     }
+  },
 
-    this.pseudoClassPanel.hidden = !this.pseudoClassPanel.hidden;
+  showPseudoClassPanel: function () {
+    this.hideClassPanel();
+
+    this.pseudoClassToggle.classList.add("checked");
+    this.hoverCheckbox.setAttribute("tabindex", "0");
+    this.activeCheckbox.setAttribute("tabindex", "0");
+    this.focusCheckbox.setAttribute("tabindex", "0");
+
+    this.pseudoClassPanel.hidden = false;
+  },
+
+  hidePseudoClassPanel: function () {
+    this.pseudoClassToggle.classList.remove("checked");
+    this.hoverCheckbox.setAttribute("tabindex", "-1");
+    this.activeCheckbox.setAttribute("tabindex", "-1");
+    this.focusCheckbox.setAttribute("tabindex", "-1");
+
+    this.pseudoClassPanel.hidden = true;
   },
 
   /**
    * Called when a pseudo class checkbox is clicked and toggles
    * the pseudo class for the current selected element.
    */
   _onTogglePseudoClass: function (event) {
     let target = event.currentTarget;
     this.inspector.togglePseudoClass(target.value);
   },
 
   /**
+   * Called when the class panel button is clicked and toggles the display of the class
+   * panel.
+   */
+  _onToggleClassPanel: function () {
+    if (this.classPanel.hidden) {
+      this.showClassPanel();
+    } else {
+      this.hideClassPanel();
+    }
+  },
+
+  showClassPanel: function () {
+    this.hidePseudoClassPanel();
+
+    this.classToggle.classList.add("checked");
+    this.classPanel.hidden = false;
+  },
+
+  hideClassPanel: function () {
+    this.classToggle.classList.remove("checked");
+    this.classPanel.hidden = true;
+  },
+
+  /**
    * Handle the keypress event in the rule view.
    */
   _onShortcut: function (name, event) {
     if (!event.target.closest("#sidebar-panel-ruleview")) {
       return;
     }
 
     if (name === "CmdOrCtrl+F") {
--- a/devtools/client/inspector/rules/test/browser.ini
+++ b/devtools/client/inspector/rules/test/browser.ini
@@ -56,16 +56,22 @@ support-files =
 [browser_rules_add-rule-pseudo-class.js]
 [browser_rules_add-rule-then-property-edit-selector.js]
 [browser_rules_add-rule-with-menu.js]
 [browser_rules_add-rule.js]
 [browser_rules_authored.js]
 [browser_rules_authored_color.js]
 [browser_rules_authored_override.js]
 [browser_rules_blob_stylesheet.js]
+[browser_rules_class_panel_add.js]
+[browser_rules_class_panel_content.js]
+[browser_rules_class_panel_edit.js]
+[browser_rules_class_panel_mutation.js]
+[browser_rules_class_panel_state_preserved.js]
+[browser_rules_class_panel_toggle.js]
 [browser_rules_colorpicker-and-image-tooltip_01.js]
 [browser_rules_colorpicker-and-image-tooltip_02.js]
 [browser_rules_colorpicker-appears-on-swatch-click.js]
 [browser_rules_colorpicker-commit-on-ENTER.js]
 [browser_rules_colorpicker-edit-gradient.js]
 [browser_rules_colorpicker-hides-on-tooltip.js]
 [browser_rules_colorpicker-multiple-changes.js]
 [browser_rules_colorpicker-release-outside-frame.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_add.js
@@ -0,0 +1,91 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that classes can be added in the class panel
+
+// This array contains the list of test cases. Each test case contains these properties:
+// - {String} textEntered The text to be entered in the field
+// - {Boolean} expectNoMutation Set to true if we shouldn't wait for a DOM mutation
+// - {Array} expectedClasses The expected list of classes to be applied to the DOM and to
+//   be found in the class panel
+const TEST_ARRAY = [{
+  textEntered: "",
+  expectNoMutation: true,
+  expectedClasses: []
+}, {
+  textEntered: "class",
+  expectedClasses: ["class"]
+}, {
+  textEntered: "class",
+  expectNoMutation: true,
+  expectedClasses: ["class"]
+}, {
+  textEntered: "a a a a a a a a a a",
+  expectedClasses: ["class", "a"]
+}, {
+  textEntered: "class2 class3",
+  expectedClasses: ["class", "a", "class2", "class3"]
+}, {
+  textEntered: "                       ",
+  expectNoMutation: true,
+  expectedClasses: ["class", "a", "class2", "class3"]
+}, {
+  textEntered: "          class4",
+  expectedClasses: ["class", "a", "class2", "class3", "class4"]
+}, {
+  textEntered: "    \t      class5      \t \t\t             ",
+  expectedClasses: ["class", "a", "class2", "class3", "class4", "class5"]
+}];
+
+add_task(function* () {
+  yield addTab("data:text/html;charset=utf-8,");
+  let {testActor, inspector, view} = yield openRuleView();
+
+  info("Open the class panel");
+  view.showClassPanel();
+
+  const textField = inspector.panelDoc.querySelector("#ruleview-class-panel .add-class");
+  ok(textField, "The input field exists in the class panel");
+
+  textField.focus();
+
+  let onMutation;
+  for (let {textEntered, expectNoMutation, expectedClasses} of TEST_ARRAY) {
+    if (!expectNoMutation) {
+      onMutation = inspector.once("markupmutation");
+    }
+
+    info(`Enter the test string in the field: ${textEntered}`);
+    for (let key of textEntered.split("")) {
+      EventUtils.synthesizeKey(key, {}, view.styleWindow);
+    }
+
+    info("Submit the change and wait for the textfield to become empty");
+    let onEmpty = waitForFieldToBeEmpty(textField);
+    EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow);
+
+    if (!expectNoMutation) {
+      info("Wait for the DOM to change");
+      yield onMutation;
+    }
+
+    yield onEmpty;
+
+    info("Check the state of the DOM node");
+    let className = yield testActor.getAttribute("body", "class");
+    let expectedClassName = expectedClasses.length ? expectedClasses.join(" ") : null;
+    is(className, expectedClassName, "The DOM node has the right className");
+
+    info("Check the content of the class panel");
+    checkClassPanelContent(view, expectedClasses.map(name => {
+      return {name, state: true};
+    }));
+  }
+});
+
+function waitForFieldToBeEmpty(textField) {
+  return waitForSuccess(() => !textField.value);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_content.js
@@ -0,0 +1,65 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that class panel shows the right content when selecting various nodes.
+
+// This array contains the list of test cases. Each test case contains these properties:
+// - {String} inputClassName The className on a node
+// - {Array} expectedClasses The expected list of classes in the class panel
+const TEST_ARRAY = [{
+  inputClassName: "",
+  expectedClasses: []
+}, {
+  inputClassName: "         a a a a           a a a      a   a",
+  expectedClasses: ["a"]
+}, {
+  inputClassName: "c1 c2 c3 c4 c5",
+  expectedClasses: ["c1", "c2", "c3", "c4", "c5"]
+}, {
+  inputClassName: "a a b b c c a a b b c c",
+  expectedClasses: ["a", "b", "c"]
+}, {
+  inputClassName: "ajdhfkasjhdkjashdkjghaskdgkauhkbdhvliashdlghaslidghasldgliashdglhasli",
+  expectedClasses: [
+    "ajdhfkasjhdkjashdkjghaskdgkauhkbdhvliashdlghaslidghasldgliashdglhasli"
+  ]
+}, {
+  inputClassName: "c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 " +
+                  "c10 c11 c12 c13 c14 c15 c16 c17 c18 c19 " +
+                  "c20 c21 c22 c23 c24 c25 c26 c27 c28 c29 " +
+                  "c30 c31 c32 c33 c34 c35 c36 c37 c38 c39 " +
+                  "c40 c41 c42 c43 c44 c45 c46 c47 c48 c49",
+  expectedClasses: ["c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9",
+                    "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19",
+                    "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29",
+                    "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39",
+                    "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49"]
+}, {
+  inputClassName: "  \n  \n class1  \t   class2 \t\tclass3\t",
+  expectedClasses: ["class1", "class2", "class3"]
+}];
+
+add_task(function* () {
+  yield addTab("data:text/html;charset=utf-8,<div>");
+  let {testActor, inspector, view} = yield openRuleView();
+
+  yield selectNode("div", inspector);
+
+  info("Open the class panel");
+  view.showClassPanel();
+
+  for (let {inputClassName, expectedClasses} of TEST_ARRAY) {
+    info(`Apply the '${inputClassName}' className to the node`);
+    const onMutation = inspector.once("markupmutation");
+    yield testActor.setAttribute("div", "class", inputClassName);
+    yield onMutation;
+
+    info("Check the content of the class panel");
+    checkClassPanelContent(view, expectedClasses.map(name => {
+      return {name, state: true};
+    }));
+  }
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_edit.js
@@ -0,0 +1,51 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that classes can be toggled in the class panel
+
+add_task(function* () {
+  yield addTab("data:text/html;charset=utf-8,<body class='class1 class2'>");
+  let {view, testActor} = yield openRuleView();
+
+  info("Open the class panel");
+  view.showClassPanel();
+
+  info("Click on class1 and check that the checkbox is unchecked and the DOM is updated");
+  yield toggleClassPanelCheckBox(view, "class1");
+  checkClassPanelContent(view, [
+    {name: "class1", state: false},
+    {name: "class2", state: true}
+  ]);
+  let newClassName = yield testActor.getAttribute("body", "class");
+  is(newClassName, "class2", "The class attribute has been updated in the DOM");
+
+  info("Click on class2 and check the same thing");
+  yield toggleClassPanelCheckBox(view, "class2");
+  checkClassPanelContent(view, [
+    {name: "class1", state: false},
+    {name: "class2", state: false}
+  ]);
+  newClassName = yield testActor.getAttribute("body", "class");
+  is(newClassName, "", "The class attribute has been updated in the DOM");
+
+  info("Click on class2 and checks that the class is added again");
+  yield toggleClassPanelCheckBox(view, "class2");
+  checkClassPanelContent(view, [
+    {name: "class1", state: false},
+    {name: "class2", state: true}
+  ]);
+  newClassName = yield testActor.getAttribute("body", "class");
+  is(newClassName, "class2", "The class attribute has been updated in the DOM");
+
+  info("And finally, click on class1 again and checks it is added again");
+  yield toggleClassPanelCheckBox(view, "class1");
+  checkClassPanelContent(view, [
+    {name: "class1", state: true},
+    {name: "class2", state: true}
+  ]);
+  newClassName = yield testActor.getAttribute("body", "class");
+  is(newClassName, "class1 class2", "The class attribute has been updated in the DOM");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_mutation.js
@@ -0,0 +1,74 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that class panel updates on markup mutations
+
+add_task(function* () {
+  yield addTab("data:text/html;charset=utf-8,<div class='c1 c2'>");
+  let {inspector, view, testActor} = yield openRuleView();
+
+  yield selectNode("div", inspector);
+
+  info("Open the class panel");
+  view.showClassPanel();
+
+  info("Trigger an unrelated mutation on the div (id attribute change)");
+  let onMutation = view.inspector.once("markupmutation");
+  yield testActor.setAttribute("div", "id", "test-id");
+  yield onMutation;
+
+  info("Check that the panel still contains the right classes");
+  checkClassPanelContent(view, [
+    {name: "c1", state: true},
+    {name: "c2", state: true}
+  ]);
+
+  info("Trigger a class mutation on a different, unknown, node");
+  onMutation = view.inspector.once("markupmutation");
+  yield testActor.setAttribute("body", "class", "test-class");
+  yield onMutation;
+
+  info("Check that the panel still contains the right classes");
+  checkClassPanelContent(view, [
+    {name: "c1", state: true},
+    {name: "c2", state: true}
+  ]);
+
+  info("Trigger a class mutation on the current node");
+  onMutation = view.inspector.once("markupmutation");
+  yield testActor.setAttribute("div", "class", "c3 c4");
+  yield onMutation;
+
+  info("Check that the panel now contains the new classes");
+  checkClassPanelContent(view, [
+    {name: "c3", state: true},
+    {name: "c4", state: true}
+  ]);
+
+  info("Change the state of one of the new classes");
+  yield toggleClassPanelCheckBox(view, "c4");
+  checkClassPanelContent(view, [
+    {name: "c3", state: true},
+    {name: "c4", state: false}
+  ]);
+
+  info("Select another node");
+  yield selectNode("body", inspector);
+
+  info("Trigger a class mutation on the div");
+  onMutation = view.inspector.once("markupmutation");
+  yield testActor.setAttribute("div", "class", "c5 c6 c7");
+  yield onMutation;
+
+  info("Go back to the previous node and check the content of the class panel." +
+       "Even if hidden, it should have refreshed when we changed the DOM");
+  yield selectNode("div", inspector);
+  checkClassPanelContent(view, [
+    {name: "c5", state: true},
+    {name: "c6", state: true},
+    {name: "c7", state: true}
+  ]);
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_state_preserved.js
@@ -0,0 +1,37 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that class states are preserved when switching to other nodes
+
+add_task(function* () {
+  yield addTab("data:text/html;charset=utf-8,<body class='class1 class2 class3'><div>");
+  let {inspector, view} = yield openRuleView();
+
+  info("Open the class panel");
+  view.showClassPanel();
+
+  info("With the <body> selected, uncheck class2 and class3 in the panel");
+  yield toggleClassPanelCheckBox(view, "class2");
+  yield toggleClassPanelCheckBox(view, "class3");
+
+  info("Now select the <div> so the panel gets refreshed");
+  yield selectNode("div", inspector);
+  is(view.classPanel.querySelectorAll("[type=checkbox]").length, 0,
+     "The panel content doesn't contain any checkboxes anymore");
+
+  info("Select the <body> again");
+  yield selectNode("body", inspector);
+  const checkBoxes = view.classPanel.querySelectorAll("[type=checkbox]");
+
+  is(checkBoxes[0].dataset.name, "class1", "The first checkbox is class1");
+  is(checkBoxes[0].checked, true, "The first checkbox is still checked");
+
+  is(checkBoxes[1].dataset.name, "class2", "The second checkbox is class2");
+  is(checkBoxes[1].checked, false, "The second checkbox is still unchecked");
+
+  is(checkBoxes[2].dataset.name, "class3", "The third checkbox is class3");
+  is(checkBoxes[2].checked, false, "The third checkbox is still unchecked");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/test/browser_rules_class_panel_toggle.js
@@ -0,0 +1,45 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the class panel can be toggled.
+
+add_task(function* () {
+  yield addTab("data:text/html;charset=utf-8,<body class='class1 class2'>");
+  let {inspector, view} = yield openRuleView();
+
+  info("Check that the toggle button exists");
+  const button = inspector.panelDoc.querySelector("#class-panel-toggle");
+  ok(button, "The class panel toggle button exists");
+  is(view.classToggle, button, "The rule-view refers to the right element");
+
+  info("Check that the panel exists and is hidden by default");
+  const panel = inspector.panelDoc.querySelector("#ruleview-class-panel");
+  ok(panel, "The class panel exists");
+  is(view.classPanel, panel, "The rule-view refers to the right element");
+  ok(panel.hasAttribute("hidden"), "The panel is hidden");
+
+  info("Click on the button to show the panel");
+  button.click();
+  ok(!panel.hasAttribute("hidden"), "The panel is shown");
+  ok(button.classList.contains("checked"), "The button is checked");
+
+  info("Click again to hide the panel");
+  button.click();
+  ok(panel.hasAttribute("hidden"), "The panel is hidden");
+  ok(!button.classList.contains("checked"), "The button is unchecked");
+
+  info("Open the pseudo-class panel first, then the class panel");
+  view.pseudoClassToggle.click();
+  ok(!view.pseudoClassPanel.hasAttribute("hidden"), "The pseudo-class panel is shown");
+  button.click();
+  ok(!panel.hasAttribute("hidden"), "The panel is shown");
+  ok(view.pseudoClassPanel.hasAttribute("hidden"), "The pseudo-class panel is hidden");
+
+  info("Click again on the pseudo-class button");
+  view.pseudoClassToggle.click();
+  ok(panel.hasAttribute("hidden"), "The panel is hidden");
+  ok(!view.pseudoClassPanel.hasAttribute("hidden"), "The pseudo-class panel is shown");
+});
--- a/devtools/client/inspector/rules/test/head.js
+++ b/devtools/client/inspector/rules/test/head.js
@@ -501,8 +501,45 @@ function* clickSelectorIcon(icon, view) 
  * Make sure window is properly focused before sending a key event.
  * @param {Window} win
  * @param {Event} key
  */
 function focusAndSendKey(win, key) {
   win.document.documentElement.focus();
   EventUtils.sendKey(key, win);
 }
+
+/**
+ * Toggle one of the checkboxes inside the class-panel. Resolved after the DOM mutation
+ * has been recorded.
+ * @param {CssRuleView} view The rule-view instance.
+ * @param {String} name The class name to find the checkbox.
+ */
+function* toggleClassPanelCheckBox(view, name) {
+  info(`Clicking on checkbox for class ${name}`);
+  const checkBox = [...view.classPanel.querySelectorAll("[type=checkbox]")].find(box => {
+    return box.dataset.name === name;
+  });
+
+  const onMutation = view.inspector.once("markupmutation");
+  checkBox.click();
+  info("Waiting for a markupmutation as a result of toggling this class");
+  yield onMutation;
+}
+
+/**
+ * Verify the content of the class-panel.
+ * @param {CssRuleView} view The rule-view isntance
+ * @param {Array} classes The list of expected classes. Each item in this array is an
+ * object with the following properties: {name: {String}, state: {Boolean}}
+ */
+function checkClassPanelContent(view, classes) {
+  const checkBoxNodeList = view.classPanel.querySelectorAll("[type=checkbox]");
+  is(checkBoxNodeList.length, classes.length,
+     "The panel contains the expected number of checkboxes");
+
+  for (let i = 0; i < classes.length; i ++) {
+    is(checkBoxNodeList[i].dataset.name, classes[i].name,
+       `Checkbox ${i} has the right class name`);
+    is(checkBoxNodeList[i].checked, classes[i].state,
+       `Checkbox ${i} has the right state`);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/views/class-list-previewer.js
@@ -0,0 +1,270 @@
+/* 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/. */
+
+"use strict";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const {LocalizationHelper} = require("devtools/shared/l10n");
+
+const L10N = new LocalizationHelper("devtools/client/locales/inspector.properties");
+
+const CLASSES = new WeakMap();
+
+/**
+ * Manages the list classes per DOM elements we care about.
+ * The actual list is stored in the CLASSES const, indexed by NodeFront objects.
+ * The responsibility of this class is to be the source of truth for anyone who wants to
+ * know which classes a given NodeFront has, and which of these are enabled and which are
+ * disabled.
+ * It also reacts to DOM mutations so the list of classes is up to date with what is in
+ * the DOM.
+ * It can also be used to enable/disable a given class, or add classes.
+ * @param {Inspector} inspector The current inspector instance.
+ */
+function ClassListPreviewerModel(inspector) {
+  EventEmitter.decorate(this);
+
+  this.inspector = inspector;
+
+  this.onMutations = this.onMutations.bind(this);
+  this.inspector.on("markupmutation", this.onMutations);
+
+  this.classListProxyNode = this.inspector.panelDoc.createElement("div");
+}
+
+ClassListPreviewerModel.prototype = {
+  destroy() {
+    this.inspector.off("markupmutation", this.onMutations);
+    this.inspector = null;
+    this.classListProxyNode = null;
+  },
+
+  /**
+   * The current node selection.
+   */
+  get currentNode() {
+    return this.inspector.selection.nodeFront;
+  },
+
+  /**
+   * The class states for the current node selection.
+   */
+  get currentClasses() {
+    if (!CLASSES.has(this.currentNode)) {
+      // Use the proxy node to get a clean list of classes.
+      this.classListProxyNode.className = this.currentNode.className;
+      let nodeClasses = [...new Set([...this.classListProxyNode.classList])].map(name => {
+        return { name, isApplied: true };
+      });
+
+      CLASSES.set(this.currentNode, nodeClasses);
+    }
+
+    return CLASSES.get(this.currentNode);
+  },
+
+  /**
+   * Same as currentClasses, but returns it in the form of a className string, where only
+   * enabled classes are added.
+   */
+  get currentClassesPreview() {
+    return this.currentClasses.filter(({ isApplied }) => isApplied)
+                              .map(({ name }) => name)
+                              .join(" ");
+  },
+
+  /**
+   * Set the state for a given class on the current node.
+   * @param {String} name The class which state should be changed.
+   * @param {Boolean} isApplied True if the class should be enabled, false otherwise.
+   * @return {Promise} Resolves when the change has been made in the DOM.
+   */
+  setClassState(name, isApplied) {
+    // Do the change in our local model.
+    let nodeClasses = this.currentClasses;
+    nodeClasses.find(({ name: cName }) => cName === name).isApplied = isApplied;
+
+    return this.applyClassState();
+  },
+
+  /**
+   * Add several classes to the current node at once.
+   * @param {String} classNameString The string that contains all classes.
+   * @return {Promise} Resolves when the change has been made in the DOM.
+   */
+  addClassName(classNameString) {
+    this.classListProxyNode.className = classNameString;
+    return Promise.all([...new Set([...this.classListProxyNode.classList])].map(name => {
+      return this.addClass(name);
+    }));
+  },
+
+  /**
+   * Add a class to the current node at once.
+   * @param {String} name The class to be added.
+   * @return {Promise} Resolves when the change has been made in the DOM.
+   */
+  addClass(name) {
+    // Avoid adding the same class again.
+    if (this.currentClasses.some(({ name: cName }) => cName === name)) {
+      return Promise.resolve();
+    }
+
+    // Change the local model, so we retain the state of the existing classes.
+    this.currentClasses.push({ name, isApplied: true });
+
+    return this.applyClassState();
+  },
+
+  /**
+   * Used internally by other functions like addClass or setClassState. Actually applies
+   * the class change to the DOM.
+   * @return {Promise} Resolves when the change has been made in the DOM.
+   */
+  applyClassState() {
+    // Remember which node we changed and the className we applied, so we can filter out
+    // dom mutations that are caused by us in onMutations.
+    this.lastStateChange = {
+      node: this.currentNode,
+      className: this.currentClassesPreview
+    };
+
+    // Apply the change to the node.
+    let mod = this.currentNode.startModifyingAttributes();
+    mod.setAttribute("class", this.currentClassesPreview);
+    return mod.apply();
+  },
+
+  onMutations(e, mutations) {
+    for (let {type, target, attributeName} of mutations) {
+      // Only care if this mutation is for the class attribute.
+      if (type !== "attributes" || attributeName !== "class") {
+        continue;
+      }
+
+      let isMutationForOurChange = this.lastStateChange &&
+                                   target === this.lastStateChange.node &&
+                                   target.className === this.lastStateChange.className;
+
+      if (!isMutationForOurChange) {
+        CLASSES.delete(target);
+        if (target === this.currentNode) {
+          this.emit("current-node-class-changed", this.currentClasses);
+        }
+      }
+    }
+  }
+};
+
+/**
+ * This UI widget shows a textfield and a series of checkboxes in the rule-view. It is
+ * used to toggle classes on the current node selection, and add new classes.
+ * @param {Inspector} inspector The current inspector instance.
+ * @param {DomNode} containerEl The element in the rule-view where the widget should go.
+ */
+function ClassListPreviewer(inspector, containerEl) {
+  this.inspector = inspector;
+  this.onNewSelection = this.onNewSelection.bind(this);
+  this.inspector.selection.on("new-node-front", this.onNewSelection);
+
+  this.containerEl = containerEl;
+  this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
+  this.containerEl.addEventListener("input", this.onCheckBoxChanged);
+
+  // Create the add class text field.
+  this.addEl = this.doc.createElement("input");
+  this.addEl.classList.add("devtools-textinput");
+  this.addEl.classList.add("add-class");
+  this.addEl.setAttribute("placeholder", L10N.getStr("inspector.newClass.placeholder"));
+  this.onKeyPress = this.onKeyPress.bind(this);
+  this.addEl.addEventListener("keypress", this.onKeyPress);
+  this.containerEl.appendChild(this.addEl);
+
+  // Create the class checkboxes container.
+  this.classesEl = this.doc.createElement("div");
+  this.classesEl.classList.add("classes");
+  this.containerEl.appendChild(this.classesEl);
+
+  this.model = new ClassListPreviewerModel(inspector);
+
+  this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
+  this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged);
+}
+
+ClassListPreviewer.prototype = {
+  destroy() {
+    this.inspector.selection.off("new-node-front", this.onNewSelection);
+    this.inspector = null;
+
+    this.model.destroy();
+
+    this.addEl.removeEventListener("keypress", this.onKeyPress);
+    this.addEl = null;
+
+    this.classesEl = null;
+
+    this.containerEl.removeEventListener("input", this.onCheckBoxChanged);
+    this.containerEl.innerHTML = "";
+    this.containerEl = null;
+  },
+
+  get doc() {
+    return this.containerEl.ownerDocument;
+  },
+
+  render() {
+    this.classesEl.innerHTML = "";
+
+    for (let { name, isApplied } of this.model.currentClasses) {
+      let checkBox = this.renderCheckBox(name, isApplied);
+      this.classesEl.appendChild(checkBox);
+    }
+  },
+
+  renderCheckBox(name, isApplied) {
+    let box = this.doc.createElement("input");
+    box.setAttribute("type", "checkbox");
+    if (isApplied) {
+      box.setAttribute("checked", "checked");
+    }
+    box.dataset.name = name;
+
+    let labelWrapper = this.doc.createElement("label");
+    labelWrapper.setAttribute("title", name);
+    labelWrapper.appendChild(box);
+
+    // A child element is required to do the ellipsis.
+    let label = this.doc.createElement("span");
+    label.textContent = name;
+    labelWrapper.appendChild(label);
+
+    return labelWrapper;
+  },
+
+  onCheckBoxChanged(event) {
+    if (event.target.dataset.name) {
+      this.model.setClassState(event.target.dataset.name, event.target.checked)
+                .catch(e => console.error(e));
+    }
+  },
+
+  onKeyPress(event) {
+    if (event.key === "Enter" && this.addEl.value !== "") {
+      this.model.addClassName(this.addEl.value).then(() => {
+        this.render();
+        this.addEl.value = "";
+      }, e => console.error(e));
+    }
+  },
+
+  onNewSelection() {
+    this.render();
+  },
+
+  onCurrentNodeClassChanged() {
+    this.render();
+  }
+};
+
+module.exports = ClassListPreviewer;
--- a/devtools/client/inspector/rules/views/moz.build
+++ b/devtools/client/inspector/rules/views/moz.build
@@ -1,8 +1,9 @@
 # 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/.
 
 DevToolsModules(
+    'class-list-previewer.js',
     'rule-editor.js',
     'text-property-editor.js',
 )
--- a/devtools/client/locales/en-US/inspector.properties
+++ b/devtools/client/locales/en-US/inspector.properties
@@ -372,12 +372,21 @@ inspector.filterStyles.placeholder=Filte
 # match ruleView.contextmenu.addNewRule in styleinspector.properties
 inspector.addRule.tooltip=Add new rule
 
 # LOCALIZATION NOTE (inspector.togglePseudo.tooltip): This is the tooltip
 # shown when hovering over the `Toggle Pseudo Class Panel` button in the
 # rule view toolbar.
 inspector.togglePseudo.tooltip=Toggle pseudo-classes
 
+# LOCALIZATION NOTE (inspector.toggleClass.tooltip): This is the tooltip
+# shown when hovering over the `Toggle Class Panel` button in the
+# rule view toolbar.
+inspector.toggleClass.tooltip=Toggle classes
+
+# LOCALIZATION NOTE (inspector.newClass.placeholder): This is the placeholder
+# shown inside the text field used to add a new class in the rule-view.
+inspector.newClass.placeholder=Add new class
+
 # LOCALIZATION NOTE (inspector.noProperties): In the case where there are no CSS
 # properties to display e.g. due to search criteria this message is
 # displayed.
 inspector.noProperties=No CSS properties found.
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -45,34 +45,72 @@
 #ruleview-toolbar > .devtools-searchbox:first-child {
   padding-inline-start: 0px;
 }
 
 #ruleview-command-toolbar {
   display: flex;
 }
 
-#pseudo-class-panel {
+.ruleview-reveal-panel {
   display: flex;
   height: 24px;
   overflow: hidden;
   transition: height 150ms ease;
 }
 
-#pseudo-class-panel[hidden] {
+.ruleview-reveal-panel[hidden] {
   height: 0px;
 }
 
-#pseudo-class-panel > label {
+.ruleview-reveal-panel label {
   -moz-user-select: none;
   flex-grow: 1;
   display: flex;
   align-items: center;
 }
 
+/* Class toggle panel */
+#ruleview-class-panel:not([hidden]) {
+  /* The class panel can contain 0 to N classes, can't hardcode a height here.
+     Unfortunately, that means we don't get the height transition when toggling the
+     panel */
+  height: unset;
+  flex-direction: column;
+}
+
+#ruleview-class-panel .add-class {
+  margin: 0;
+  border-width: 0 0 1px 0;
+  padding: 2px 6px;
+  border-radius: 0;
+}
+
+#ruleview-class-panel .classes {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+}
+
+#ruleview-class-panel .classes {
+  max-height: 100px;
+  overflow-y: auto;
+}
+
+#ruleview-class-panel .classes label {
+  flex: 0 0;
+  max-width: 50%;
+}
+
+#ruleview-class-panel .classes label span {
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+
 /* Rule View Container */
 
 #ruleview-container {
   -moz-user-select: text;
   overflow: auto;
   flex: auto;
   height: 100%;
 }
@@ -554,16 +592,20 @@
   background-size: cover;
 }
 
 #pseudo-class-panel-toggle::before {
   background-image: url("chrome://devtools/skin/images/pseudo-class.svg");
   background-size: cover;
 }
 
+#class-panel-toggle::before {
+  content: ".cls";
+}
+
 .ruleview-overridden-rule-filter {
   opacity: 0.8;
 }
 .ruleview-overridden-rule-filter:hover {
   opacity: 1;
 }
 
 .theme-firebug .ruleview-overridden {