--- 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 {