Bug 1520689 - Implement the class list panel in the new rules view. r=pbro
authorGabriel Luong <gabriel.luong@gmail.com>
Tue, 22 Jan 2019 11:06:39 -0500
changeset 514899 2e1923054d3b5103f13e257cd9d416d6679c1460
parent 514898 7a27f9d0f611a1c0baf420705a83b4258b93c7fe
child 514900 13e451a690836e7b38a6bdb7677e9247fdbb6fc3
push id1953
push userffxbld-merge
push dateMon, 11 Mar 2019 12:10:20 +0000
treeherdermozilla-release@9c35dcbaa899 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1520689
milestone66.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 1520689 - Implement the class list panel in the new rules view. r=pbro
devtools/client/inspector/reducers.js
devtools/client/inspector/rules/actions/class-list.js
devtools/client/inspector/rules/actions/index.js
devtools/client/inspector/rules/actions/moz.build
devtools/client/inspector/rules/components/ClassListPanel.js
devtools/client/inspector/rules/components/RulesApp.js
devtools/client/inspector/rules/components/Toolbar.js
devtools/client/inspector/rules/models/class-list.js
devtools/client/inspector/rules/models/moz.build
devtools/client/inspector/rules/new-rules.js
devtools/client/inspector/rules/reducers/class-list.js
devtools/client/inspector/rules/reducers/moz.build
devtools/client/inspector/rules/types.js
devtools/client/inspector/rules/views/class-list-previewer.js
--- a/devtools/client/inspector/reducers.js
+++ b/devtools/client/inspector/reducers.js
@@ -5,16 +5,17 @@
 "use strict";
 
 // This file exposes the Redux reducers of the box model, grid and grid highlighter
 // settings.
 
 exports.animations = require("devtools/client/inspector/animation/reducers/animations");
 exports.boxModel = require("devtools/client/inspector/boxmodel/reducers/box-model");
 exports.changes = require("devtools/client/inspector/changes/reducers/changes");
+exports.classList = require("devtools/client/inspector/rules/reducers/class-list");
 exports.extensionsSidebar = require("devtools/client/inspector/extensions/reducers/sidebar");
 exports.flexbox = require("devtools/client/inspector/flexbox/reducers/flexbox");
 exports.fontOptions = require("devtools/client/inspector/fonts/reducers/font-options");
 exports.fontData = require("devtools/client/inspector/fonts/reducers/fonts");
 exports.fontEditor = require("devtools/client/inspector/fonts/reducers/font-editor");
 exports.grids = require("devtools/client/inspector/grids/reducers/grids");
 exports.highlighterSettings = require("devtools/client/inspector/grids/reducers/highlighter-settings");
 exports.pseudoClasses = require("devtools/client/inspector/rules/reducers/pseudo-classes");
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/actions/class-list.js
@@ -0,0 +1,40 @@
+/* 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 {
+  UPDATE_CLASSES,
+  UPDATE_CLASS_PANEL_EXPANDED,
+} = require("./index");
+
+module.exports = {
+
+  /**
+   * Updates the entire class list state with the new list of classes.
+   *
+   * @param  {Array<Object>} classes
+   *         Array of CSS classes object applied to the element.
+   */
+  updateClasses(classes) {
+    return {
+      type: UPDATE_CLASSES,
+      classes,
+    };
+  },
+
+  /**
+   * Updates whether or not the class list panel is expanded.
+   *
+   * @param  {Boolean} isClassPanelExpanded
+   *         Whether or not the class list panel is expanded.
+   */
+  updateClassPanelExpanded(isClassPanelExpanded) {
+    return {
+      type: UPDATE_CLASS_PANEL_EXPANDED,
+      isClassPanelExpanded,
+    };
+  },
+
+};
--- a/devtools/client/inspector/rules/actions/index.js
+++ b/devtools/client/inspector/rules/actions/index.js
@@ -14,15 +14,21 @@ createEnum([
 
   // Sets the entire pseudo class state with the new list of applied pseudo-class
   // locks.
   "SET_PSEUDO_CLASSES",
 
   // Toggles on or off the given pseudo class value for the current selected element.
   "TOGGLE_PSEUDO_CLASS",
 
+  // Updates the entire class list state with the new list of classes.
+  "UPDATE_CLASSES",
+
+  // Updates whether or not the class list panel is expanded.
+  "UPDATE_CLASS_PANEL_EXPANDED",
+
   // Updates the highlighted selector.
   "UPDATE_HIGHLIGHTED_SELECTOR",
 
   // Updates the rules state with the new list of CSS rules for the selected element.
   "UPDATE_RULES",
 
 ], module.exports);
--- a/devtools/client/inspector/rules/actions/moz.build
+++ b/devtools/client/inspector/rules/actions/moz.build
@@ -1,9 +1,10 @@
 # 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.js',
     'index.js',
     'pseudo-classes.js',
     'rules.js',
 )
--- a/devtools/client/inspector/rules/components/ClassListPanel.js
+++ b/devtools/client/inspector/rules/components/ClassListPanel.js
@@ -1,36 +1,108 @@
 /* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
+const { createRef, PureComponent } = require("devtools/client/shared/vendor/react");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
+const { KeyCodes } = require("devtools/client/shared/keycodes");
 
 const { getStr } = require("../utils/l10n");
+const Types = require("../types");
 
 class ClassListPanel extends PureComponent {
   static get propTypes() {
-    return {};
+    return {
+      classes: PropTypes.arrayOf(PropTypes.shape(Types.class)).isRequired,
+      onAddClass: PropTypes.func.isRequired,
+      onSetClassState: PropTypes.func.isRequired,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      // Class list panel input value.
+      value: "",
+    };
+
+    this.inputRef = createRef();
+
+    this.onInputChange = this.onInputChange.bind(this);
+    this.onInputKeyUp = this.onInputKeyUp.bind(this);
+    this.onToggleChange = this.onToggleChange.bind(this);
+  }
+
+  componentDidMount() {
+    this.inputRef.current.focus();
+  }
+
+  onInputChange({ target }) {
+    this.setState({ value: target.value });
+  }
+
+  onInputKeyUp({ target, keyCode }) {
+    // On Enter, submit the input.
+    if (keyCode === KeyCodes.DOM_VK_RETURN) {
+      this.props.onAddClass(target.value);
+      this.setState({ value: "" });
+    }
+  }
+
+  onToggleChange({ target }) {
+    this.props.onSetClassState(target.value, target.checked);
   }
 
   render() {
     return (
       dom.div(
         {
           id: "ruleview-class-panel",
           className: "ruleview-reveal-panel",
         },
         dom.input({
           className: "devtools-textinput add-class",
           placeholder: getStr("rule.classPanel.newClass.placeholder"),
+          onChange: this.onInputChange,
+          onKeyUp: this.onInputKeyUp,
+          ref: this.inputRef,
+          value: this.state.value,
         }),
         dom.div({ className: "classes" },
-          dom.p({ className: "no-classes" }, getStr("rule.classPanel.noClasses"))
+          this.props.classes.length ?
+            this.props.classes.map(({ name, isApplied }) => {
+              return (
+                dom.label(
+                  {
+                    key: name,
+                    title: name,
+                  },
+                  dom.input({
+                    checked: isApplied,
+                    onChange: this.onToggleChange,
+                    type: "checkbox",
+                    value: name,
+                  }),
+                  dom.span({}, name)
+                )
+              );
+            })
+            :
+            dom.p({ className: "no-classes" }, getStr("rule.classPanel.noClasses"))
         )
       )
     );
   }
 }
 
-module.exports = ClassListPanel;
+const mapStateToProps = state => {
+  return {
+    classes: state.classList.classes,
+  };
+};
+
+module.exports = connect(mapStateToProps)(ClassListPanel);
--- a/devtools/client/inspector/rules/components/RulesApp.js
+++ b/devtools/client/inspector/rules/components/RulesApp.js
@@ -23,16 +23,19 @@ const Toolbar = createFactory(require(".
 const { getStr } = require("../utils/l10n");
 const Types = require("../types");
 
 const SHOW_PSEUDO_ELEMENTS_PREF = "devtools.inspector.show_pseudo_elements";
 
 class RulesApp extends PureComponent {
   static get propTypes() {
     return {
+      onAddClass: PropTypes.func.isRequired,
+      onSetClassState: PropTypes.func.isRequired,
+      onToggleClassPanelExpanded: PropTypes.func.isRequired,
       onToggleDeclaration: PropTypes.func.isRequired,
       onTogglePseudoClass: PropTypes.func.isRequired,
       onToggleSelectorHighlighter: PropTypes.func.isRequired,
       rules: PropTypes.arrayOf(PropTypes.shape(Types.rule)).isRequired,
     };
   }
 
   renderInheritedRules(rules) {
@@ -157,16 +160,19 @@ class RulesApp extends PureComponent {
 
     return (
       dom.div(
         {
           id: "sidebar-panel-ruleview",
           className: "theme-sidebar inspector-tabpanel",
         },
         Toolbar({
+          onAddClass: this.props.onAddClass,
+          onSetClassState: this.props.onSetClassState,
+          onToggleClassPanelExpanded: this.props.onToggleClassPanelExpanded,
           onTogglePseudoClass: this.props.onTogglePseudoClass,
         }),
         dom.div(
           {
             id: "ruleview-container",
             className: "ruleview",
           },
           dom.div(
--- a/devtools/client/inspector/rules/components/Toolbar.js
+++ b/devtools/client/inspector/rules/components/Toolbar.js
@@ -2,84 +2,81 @@
  * 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 { createFactory, PureComponent } = require("devtools/client/shared/vendor/react");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
 
 const SearchBox = createFactory(require("./SearchBox"));
 
 loader.lazyGetter(this, "ClassListPanel", function() {
   return createFactory(require("./ClassListPanel"));
 });
 loader.lazyGetter(this, "PseudoClassPanel", function() {
   return createFactory(require("./PseudoClassPanel"));
 });
 
 const { getStr } = require("../utils/l10n");
 
 class Toolbar extends PureComponent {
   static get propTypes() {
     return {
+      isClassPanelExpanded: PropTypes.bool.isRequired,
+      onAddClass: PropTypes.func.isRequired,
+      onSetClassState: PropTypes.func.isRequired,
+      onToggleClassPanelExpanded: PropTypes.func.isRequired,
       onTogglePseudoClass: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.state = {
-      // Whether or not the class panel is expanded.
-      isClassPanelExpanded: false,
       // Whether or not the pseudo class panel is expanded.
       isPseudoClassPanelExpanded: false,
     };
 
     this.onClassPanelToggle = this.onClassPanelToggle.bind(this);
     this.onPseudoClassPanelToggle = this.onPseudoClassPanelToggle.bind(this);
   }
 
   onClassPanelToggle(event) {
     event.stopPropagation();
 
+    const isClassPanelExpanded = !this.props.isClassPanelExpanded;
+    this.props.onToggleClassPanelExpanded(isClassPanelExpanded);
     this.setState(prevState => {
-      const isClassPanelExpanded = !prevState.isClassPanelExpanded;
-      const isPseudoClassPanelExpanded = isClassPanelExpanded ?
-        false : prevState.isPseudoClassPanelExpanded;
-
       return {
-        isClassPanelExpanded,
-        isPseudoClassPanelExpanded,
+        isPseudoClassPanelExpanded: isClassPanelExpanded ?
+                                    false :
+                                    prevState.isPseudoClassPanelExpanded,
       };
     });
   }
 
   onPseudoClassPanelToggle(event) {
     event.stopPropagation();
 
-    this.setState(prevState => {
-      const isPseudoClassPanelExpanded = !prevState.isPseudoClassPanelExpanded;
-      const isClassPanelExpanded = isPseudoClassPanelExpanded ?
-        false : prevState.isClassPanelExpanded;
+    const isPseudoClassPanelExpanded = !this.state.isPseudoClassPanelExpanded;
 
-      return {
-        isClassPanelExpanded,
-        isPseudoClassPanelExpanded,
-      };
-    });
+    if (isPseudoClassPanelExpanded) {
+      this.props.onToggleClassPanelExpanded(false);
+    }
+
+    this.setState({ isPseudoClassPanelExpanded });
   }
 
   render() {
-    const {
-      isClassPanelExpanded,
-      isPseudoClassPanelExpanded,
-    } = this.state;
+    const { isClassPanelExpanded } = this.props;
+    const { isPseudoClassPanelExpanded } = this.state;
 
     return (
       dom.div(
         {
           id: "ruleview-toolbar-container",
           className: "devtools-toolbar",
         },
         dom.div({ id: "ruleview-toolbar" },
@@ -102,23 +99,32 @@ class Toolbar extends PureComponent {
               className: "devtools-button" +
                           (isClassPanelExpanded ? " checked" : ""),
               onClick: this.onClassPanelToggle,
               title: getStr("rule.classPanel.toggleClass.tooltip"),
             })
           )
         ),
         isClassPanelExpanded ?
-          ClassListPanel({})
+          ClassListPanel({
+            onAddClass: this.props.onAddClass,
+            onSetClassState: this.props.onSetClassState,
+          })
           :
           null,
         isPseudoClassPanelExpanded ?
           PseudoClassPanel({
             onTogglePseudoClass: this.props.onTogglePseudoClass,
           })
           :
           null
       )
     );
   }
 }
 
-module.exports = Toolbar;
+const mapStateToProps = state => {
+  return {
+    isClassPanelExpanded: state.classList.isClassPanelExpanded,
+  };
+};
+
+module.exports = connect(mapStateToProps)(Toolbar);
copy from devtools/client/inspector/rules/views/class-list-previewer.js
copy to devtools/client/inspector/rules/models/class-list.js
--- a/devtools/client/inspector/rules/views/class-list-previewer.js
+++ b/devtools/client/inspector/rules/models/class-list.js
@@ -1,18 +1,15 @@
 /* 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");
 
 // This serves as a local cache for the classes applied to each of the node we care about
 // here.
 // The map is indexed by NodeFront. Any time a new node is selected in the inspector, an
 // entry is added here, indexed by the corresponding NodeFront.
 // The value for each entry is an array of each of the class this node has. Items of this
 // array are objects like: { name, isApplied } where the name is the class itself, and
 // isApplied is a Boolean indicating if the class is applied on the node or not.
@@ -26,28 +23,28 @@ const CLASSES = new WeakMap();
  * 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) {
+function ClassList(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 = {
+ClassList.prototype = {
   destroy() {
     this.inspector.off("markupmutation", this.onMutations);
     this.inspector = null;
     this.classListProxyNode = null;
   },
 
   /**
    * The current node selection (which only returns if the node is an ELEMENT_NODE type
@@ -186,174 +183,9 @@ ClassListPreviewerModel.prototype = {
         if (target === this.currentNode) {
           this.emit("current-node-class-changed");
         }
       }
     }
   },
 };
 
-/**
- * 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.containerEl = containerEl;
-  this.model = new ClassListPreviewerModel(inspector);
-
-  this.onNewSelection = this.onNewSelection.bind(this);
-  this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
-  this.onKeyPress = this.onKeyPress.bind(this);
-  this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
-
-  // 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.classPanel.newClass.placeholder"));
-  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);
-
-  // Start listening for interesting events.
-  this.inspector.selection.on("new-node-front", this.onNewSelection);
-  this.containerEl.addEventListener("input", this.onCheckBoxChanged);
-  this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged);
-
-  this.onNewSelection();
-}
-
-ClassListPreviewer.prototype = {
-  destroy() {
-    this.inspector.selection.off("new-node-front", this.onNewSelection);
-    this.addEl.removeEventListener("keypress", this.onKeyPress);
-    this.containerEl.removeEventListener("input", this.onCheckBoxChanged);
-
-    this.containerEl.innerHTML = "";
-
-    this.model.destroy();
-    this.containerEl = null;
-    this.inspector = null;
-    this.addEl = null;
-    this.classesEl = null;
-  },
-
-  get doc() {
-    return this.containerEl.ownerDocument;
-  },
-
-  /**
-   * Render the content of the panel. You typically don't need to call this as the panel
-   * renders itself on inspector selection changes.
-   */
-  render() {
-    this.classesEl.innerHTML = "";
-
-    for (const { name, isApplied } of this.model.currentClasses) {
-      const checkBox = this.renderCheckBox(name, isApplied);
-      this.classesEl.appendChild(checkBox);
-    }
-
-    if (!this.model.currentClasses.length) {
-      this.classesEl.appendChild(this.renderNoClassesMessage());
-    }
-  },
-
-  /**
-   * Render a single checkbox for a given classname.
-   *
-   * @param {String} name
-   *        The name of this class.
-   * @param {Boolean} isApplied
-   *        Is this class currently applied on the DOM node.
-   * @return {DOMNode} The DOM element for this checkbox.
-   */
-  renderCheckBox(name, isApplied) {
-    const box = this.doc.createElement("input");
-    box.setAttribute("type", "checkbox");
-    if (isApplied) {
-      box.setAttribute("checked", "checked");
-    }
-    box.dataset.name = name;
-
-    const labelWrapper = this.doc.createElement("label");
-    labelWrapper.setAttribute("title", name);
-    labelWrapper.appendChild(box);
-
-    // A child element is required to do the ellipsis.
-    const label = this.doc.createElement("span");
-    label.textContent = name;
-    labelWrapper.appendChild(label);
-
-    return labelWrapper;
-  },
-
-  /**
-   * Render the message displayed in the panel when the current element has no classes.
-   *
-   * @return {DOMNode} The DOM element for the message.
-   */
-  renderNoClassesMessage() {
-    const msg = this.doc.createElement("p");
-    msg.classList.add("no-classes");
-    msg.textContent = L10N.getStr("inspector.classPanel.noClasses");
-    return msg;
-  },
-
-  /**
-   * Focus the add-class text field.
-   */
-  focusAddClassField() {
-    if (this.addEl) {
-      this.addEl.focus();
-    }
-  },
-
-  onCheckBoxChanged({ target }) {
-    if (!target.dataset.name) {
-      return;
-    }
-
-    this.model.setClassState(target.dataset.name, target.checked).catch(e => {
-      // Only log the error if the panel wasn't destroyed in the meantime.
-      if (this.containerEl) {
-        console.error(e);
-      }
-    });
-  },
-
-  onKeyPress(event) {
-    if (event.key !== "Enter" || this.addEl.value === "") {
-      return;
-    }
-
-    this.model.addClassName(this.addEl.value).then(() => {
-      this.render();
-      this.addEl.value = "";
-    }).catch(e => {
-      // Only log the error if the panel wasn't destroyed in the meantime.
-      if (this.containerEl) {
-        console.error(e);
-      }
-    });
-  },
-
-  onNewSelection() {
-    this.render();
-  },
-
-  onCurrentNodeClassChanged() {
-    this.render();
-  },
-};
-
-module.exports = ClassListPreviewer;
+module.exports = ClassList;
--- a/devtools/client/inspector/rules/models/moz.build
+++ b/devtools/client/inspector/rules/models/moz.build
@@ -1,12 +1,13 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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.js',
     'element-style.js',
     'rule.js',
     'text-property.js',
     'user-properties.js',
 )
--- a/devtools/client/inspector/rules/new-rules.js
+++ b/devtools/client/inspector/rules/new-rules.js
@@ -6,50 +6,60 @@
 
 const Services = require("Services");
 const ElementStyle = require("devtools/client/inspector/rules/models/element-style");
 const { createFactory, createElement } = require("devtools/client/shared/vendor/react");
 const { Provider } = require("devtools/client/shared/vendor/react-redux");
 const EventEmitter = require("devtools/shared/event-emitter");
 
 const {
+  updateClasses,
+  updateClassPanelExpanded,
+} = require("./actions/class-list");
+const {
   disableAllPseudoClasses,
   setPseudoClassLocks,
   togglePseudoClass,
 } = require("./actions/pseudo-classes");
 const {
   updateHighlightedSelector,
   updateRules,
 } = require("./actions/rules");
 
 const RulesApp = createFactory(require("./components/RulesApp"));
 
 const { LocalizationHelper } = require("devtools/shared/l10n");
 const INSPECTOR_L10N =
   new LocalizationHelper("devtools/client/locales/inspector.properties");
 
+loader.lazyRequireGetter(this, "ClassList", "devtools/client/inspector/rules/models/class-list");
+
 const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles";
 
 class RulesView {
   constructor(inspector, window) {
     this.cssProperties = inspector.cssProperties;
     this.doc = window.document;
     this.inspector = inspector;
     this.pageStyle = inspector.pageStyle;
     this.selection = inspector.selection;
     this.store = inspector.store;
     this.telemetry = inspector.telemetry;
     this.toolbox = inspector.toolbox;
 
     this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES);
 
+    this.onAddClass = this.onAddClass.bind(this);
     this.onSelection = this.onSelection.bind(this);
+    this.onSetClassState = this.onSetClassState.bind(this);
+    this.onToggleClassPanelExpanded = this.onToggleClassPanelExpanded.bind(this);
     this.onToggleDeclaration = this.onToggleDeclaration.bind(this);
     this.onTogglePseudoClass = this.onTogglePseudoClass.bind(this);
     this.onToggleSelectorHighlighter = this.onToggleSelectorHighlighter.bind(this);
+    this.updateClassList = this.updateClassList.bind(this);
     this.updateRules = this.updateRules.bind(this);
 
     this.inspector.sidebar.on("select", this.onSelection);
     this.selection.on("detached-front", this.onSelection);
     this.selection.on("new-node-front", this.onSelection);
 
     this.init();
 
@@ -57,16 +67,19 @@ class RulesView {
   }
 
   init() {
     if (!this.inspector) {
       return;
     }
 
     const rulesApp = RulesApp({
+      onAddClass: this.onAddClass,
+      onSetClassState: this.onSetClassState,
+      onToggleClassPanelExpanded: this.onToggleClassPanelExpanded,
       onToggleDeclaration: this.onToggleDeclaration,
       onTogglePseudoClass: this.onTogglePseudoClass,
       onToggleSelectorHighlighter: this.onToggleSelectorHighlighter,
     });
 
     const provider = createElement(Provider, {
       id: "ruleview",
       key: "ruleview",
@@ -78,16 +91,22 @@ class RulesView {
     this.provider = provider;
   }
 
   destroy() {
     this.inspector.sidebar.off("select", this.onSelection);
     this.selection.off("detached-front", this.onSelection);
     this.selection.off("new-node-front", this.onSelection);
 
+    if (this._classList) {
+      this._classList.off("current-node-class-changed", this.refreshClassList);
+      this._classList.destroy();
+      this._classList = null;
+    }
+
     if (this._selectHighlighter) {
       this._selectorHighlighter.finalize();
       this._selectorHighlighter = null;
     }
 
     if (this.elementStyle) {
       this.elementStyle.destroy();
     }
@@ -101,16 +120,29 @@ class RulesView {
     this.selection = null;
     this.showUserAgentStyles = null;
     this.store = null;
     this.telemetry = null;
     this.toolbox = null;
   }
 
   /**
+   * Get an instance of the ClassList model used to manage the list of CSS classes
+   * applied to the element.
+   *
+   * @return {ClassList} used to manage the list of CSS classes applied to the element.
+   */
+  get classList() {
+    if (!this._classList) {
+      this._classList = new ClassList(this.inspector);
+    }
+
+    return this._classList;
+  }
+  /**
    * Creates a dummy element in the document that helps get the computed style in
    * TextProperty.
    *
    * @return {Element} used to get the computed style for text properties.
    */
   get dummyElement() {
     // To figure out how shorthand properties are interpreted by the
     // engine, we will set properties on a dummy element and observe
@@ -162,16 +194,27 @@ class RulesView {
    */
   isPanelVisible() {
     return this.inspector && this.inspector.toolbox && this.inspector.sidebar &&
            this.inspector.toolbox.currentToolId === "inspector" &&
            this.inspector.sidebar.getCurrentTabID() === "newruleview";
   }
 
   /**
+   * Handler for adding the given CSS class value to the current element's class list.
+   *
+   * @param  {String} value
+   *         The string that contains all classes.
+   */
+  async onAddClass(value) {
+    await this.classList.addClassName(value);
+    this.updateClassList();
+  }
+
+  /**
    * Handler for selection events "detached-front" and "new-node-front" and inspector
    * sidbar "select" event. Updates the rules view with the selected node if the panel
    * is visible.
    */
   onSelection() {
     if (!this.isPanelVisible()) {
       return;
     }
@@ -181,16 +224,46 @@ class RulesView {
       this.update();
       return;
     }
 
     this.update(this.selection.nodeFront);
   }
 
   /**
+   * Handler for toggling a CSS class from the current element's class list. Sets the
+   * state of given CSS class name to the given checked value.
+   *
+   * @param  {String} name
+   *         The CSS class name.
+   * @param  {Boolean} checked
+   *         Whether or not the given CSS class is checked.
+   */
+  async onSetClassState(name, checked) {
+    await this.classList.setClassState(name, checked);
+    this.updateClassList();
+  }
+
+  /**
+   * Handler for toggling the expanded property of the class list panel.
+   *
+   * @param  {Boolean} isClassPanelExpanded
+   *         Whether or not the class list panel is expanded.
+   */
+  onToggleClassPanelExpanded(isClassPanelExpanded) {
+    if (isClassPanelExpanded) {
+      this.classList.on("current-node-class-changed", this.updateClassList);
+    } else {
+      this.classList.off("current-node-class-changed", this.updateClassList);
+    }
+
+    this.store.dispatch(updateClassPanelExpanded(isClassPanelExpanded));
+  }
+
+  /**
    * Handler for toggling the enabled property for a given CSS declaration.
    *
    * @param  {String} ruleId
    *         The Rule id of the given CSS declaration.
    * @param  {String} declarationId
    *         The TextProperty id for the CSS declaration.
    */
   onToggleDeclaration(ruleId, declarationId) {
@@ -257,30 +330,39 @@ class RulesView {
    * selection.
    *
    * @param  {NodeFront|null} element
    *         The NodeFront of the current selected element.
    */
   async update(element) {
     if (!element) {
       this.store.dispatch(disableAllPseudoClasses());
+      this.store.dispatch(updateClasses([]));
       this.store.dispatch(updateRules([]));
       return;
     }
 
     this.elementStyle = new ElementStyle(element, this, {}, this.pageStyle,
       this.showUserAgentStyles);
     this.elementStyle.onChanged = this.updateRules;
     await this.elementStyle.populate();
 
     this.store.dispatch(setPseudoClassLocks(this.elementStyle.element.pseudoClassLocks));
+    this.updateClassList();
     this.updateRules();
   }
 
   /**
+   * Updates the class list panel with the current list of CSS classes.
+   */
+  updateClassList() {
+    this.store.dispatch(updateClasses(this.classList.currentClasses));
+  }
+
+  /**
    * Updates the rules view by dispatching the current rules state. This is called from
    * the update() function, and from the ElementStyle's onChange() handler.
    */
   updateRules() {
     this.store.dispatch(updateRules(this.elementStyle.rules));
   }
 }
 
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/rules/reducers/class-list.js
@@ -0,0 +1,44 @@
+/* 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 {
+  UPDATE_CLASSES,
+  UPDATE_CLASS_PANEL_EXPANDED,
+} = require("../actions/index");
+
+const INITIAL_CLASS_LIST = {
+  // An array of objects containing the CSS class state that is applied to the current
+  // element.
+  classes: [],
+  // Whether or not the class list panel is expanded.
+  isClassPanelExpanded: false,
+};
+
+const reducers = {
+
+  [UPDATE_CLASSES](classList, { classes }) {
+    return {
+      ...classList,
+      classes: [...classes],
+    };
+  },
+
+  [UPDATE_CLASS_PANEL_EXPANDED](classList, { isClassPanelExpanded }) {
+    return {
+      ...classList,
+      isClassPanelExpanded,
+    };
+  },
+
+};
+
+module.exports = function(classList = INITIAL_CLASS_LIST, action) {
+  const reducer = reducers[action.type];
+  if (!reducer) {
+    return classList;
+  }
+  return reducer(classList, action);
+};
--- a/devtools/client/inspector/rules/reducers/moz.build
+++ b/devtools/client/inspector/rules/reducers/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.js',
     'pseudo-classes.js',
     'rules.js',
 )
--- a/devtools/client/inspector/rules/types.js
+++ b/devtools/client/inspector/rules/types.js
@@ -2,16 +2,27 @@
  * 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 PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 
 /**
+ * A CSS class.
+ */
+exports.classes = {
+  // The CSS class name.
+  name: PropTypes.string,
+
+  // Whether or not the CSS class is applied.
+  isApplied: PropTypes.bool,
+};
+
+/**
  * A CSS declaration.
  */
 const declaration = exports.declaration = {
   // Array of the computed properties for a CSS declaration.
   computedProperties: PropTypes.arrayOf(PropTypes.shape({
     // Whether or not the computed property is overridden.
     isOverridden: PropTypes.bool,
     // The computed property name.
--- a/devtools/client/inspector/rules/views/class-list-previewer.js
+++ b/devtools/client/inspector/rules/views/class-list-previewer.js
@@ -1,214 +1,32 @@
 /* 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 ClassList = require("devtools/client/inspector/rules/models/class-list");
 const {LocalizationHelper} = require("devtools/shared/l10n");
 
 const L10N = new LocalizationHelper("devtools/client/locales/inspector.properties");
 
-// This serves as a local cache for the classes applied to each of the node we care about
-// here.
-// The map is indexed by NodeFront. Any time a new node is selected in the inspector, an
-// entry is added here, indexed by the corresponding NodeFront.
-// The value for each entry is an array of each of the class this node has. Items of this
-// array are objects like: { name, isApplied } where the name is the class itself, and
-// isApplied is a Boolean indicating if the class is applied on the node or not.
-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 (which only returns if the node is an ELEMENT_NODE type
-   * since that's the only type this model can work with.)
-   */
-  get currentNode() {
-    if (this.inspector.selection.isElementNode() &&
-        !this.inspector.selection.isPseudoElementNode()) {
-      return this.inspector.selection.nodeFront;
-    }
-    return null;
-  },
-
-  /**
-   * The class states for the current node selection. See the documentation of the CLASSES
-   * constant.
-   */
-  get currentClasses() {
-    if (!this.currentNode) {
-      return [];
-    }
-
-    if (!CLASSES.has(this.currentNode)) {
-      // Use the proxy node to get a clean list of classes.
-      this.classListProxyNode.className = this.currentNode.className;
-      const 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.
-    const 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() {
-    // If there is no valid inspector selection, bail out silently. No need to report an
-    // error here.
-    if (!this.currentNode) {
-      return Promise.resolve();
-    }
-
-    // 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.
-    const mod = this.currentNode.startModifyingAttributes();
-    mod.setAttribute("class", this.currentClassesPreview);
-    return mod.apply();
-  },
-
-  onMutations(mutations) {
-    for (const {type, target, attributeName} of mutations) {
-      // Only care if this mutation is for the class attribute.
-      if (type !== "attributes" || attributeName !== "class") {
-        continue;
-      }
-
-      const 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 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.containerEl = containerEl;
-  this.model = new ClassListPreviewerModel(inspector);
+  this.model = new ClassList(inspector);
 
   this.onNewSelection = this.onNewSelection.bind(this);
   this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
   this.onKeyPress = this.onKeyPress.bind(this);
   this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
 
   // Create the add class text field.
   this.addEl = this.doc.createElement("input");