summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/rules/views/class-list-previewer.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/client/inspector/rules/views/class-list-previewer.js310
1 files changed, 310 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/views/class-list-previewer.js b/devtools/client/inspector/rules/views/class-list-previewer.js
new file mode 100644
index 0000000000..e4e99bedde
--- /dev/null
+++ b/devtools/client/inspector/rules/views/class-list-previewer.js
@@ -0,0 +1,310 @@
+/* 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 ClassList = require("resource://devtools/client/inspector/rules/models/class-list.js");
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const L10N = new LocalizationHelper(
+ "devtools/client/locales/inspector.properties"
+);
+const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js");
+const { debounce } = require("resource://devtools/shared/debounce.js");
+
+/**
+ * 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.
+ */
+class ClassListPreviewer {
+ /*
+ * @param {Inspector} inspector
+ * The current inspector instance.
+ * @param {DomNode} containerEl
+ * The element in the rule-view where the widget should go.
+ */
+ constructor(inspector, containerEl) {
+ this.inspector = inspector;
+ this.containerEl = containerEl;
+ this.model = new ClassList(inspector);
+
+ this.onNewSelection = this.onNewSelection.bind(this);
+ this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
+ this.onKeyDown = this.onKeyDown.bind(this);
+ this.onAddElementInputModified = debounce(
+ this.onAddElementInputModified,
+ 75,
+ this
+ );
+ this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
+ this.onNodeFrontWillUnset = this.onNodeFrontWillUnset.bind(this);
+ this.onAutocompleteClassHovered = debounce(
+ this.onAutocompleteClassHovered,
+ 75,
+ this
+ );
+ this.onAutocompleteClosed = this.onAutocompleteClosed.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("keydown", this.onKeyDown);
+ this.addEl.addEventListener("input", this.onAddElementInputModified);
+ 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);
+
+ // Create the autocomplete popup
+ this.autocompletePopup = new AutocompletePopup(this.inspector.toolbox.doc, {
+ listId: "inspector_classListPreviewer_autocompletePopupListBox",
+ position: "bottom",
+ autoSelect: true,
+ useXulWrapper: true,
+ input: this.addEl,
+ onClick: (e, item) => {
+ if (item) {
+ this.addEl.value = item.label;
+ this.autocompletePopup.hidePopup();
+ this.autocompletePopup.clearItems();
+ this.model.previewClass(item.label);
+ }
+ },
+ onSelect: item => {
+ if (item) {
+ this.onAutocompleteClassHovered(item?.label);
+ }
+ },
+ });
+
+ // Start listening for interesting events.
+ this.inspector.selection.on("new-node-front", this.onNewSelection);
+ this.inspector.selection.on(
+ "node-front-will-unset",
+ this.onNodeFrontWillUnset
+ );
+ this.containerEl.addEventListener("input", this.onCheckBoxChanged);
+ this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged);
+ this.autocompletePopup.on("popup-closed", this.onAutocompleteClosed);
+
+ this.onNewSelection();
+ }
+
+ destroy() {
+ this.inspector.selection.off("new-node-front", this.onNewSelection);
+ this.inspector.selection.off(
+ "node-front-will-unset",
+ this.onNodeFrontWillUnset
+ );
+ this.autocompletePopup.off("popup-closed", this.onAutocompleteClosed);
+ this.addEl.removeEventListener("keydown", this.onKeyDown);
+ this.addEl.removeEventListener("input", this.onAddElementInputModified);
+ this.containerEl.removeEventListener("input", this.onCheckBoxChanged);
+
+ this.autocompletePopup.destroy();
+
+ 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);
+ }
+ });
+ }
+
+ onKeyDown(event) {
+ // If the popup is already open, all the keyboard interaction are handled
+ // directly by the popup component.
+ if (this.autocompletePopup.isOpen) {
+ return;
+ }
+
+ // Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty)
+ if (
+ (this.addEl.value && event.key === " " && event.ctrlKey) ||
+ event.key === "ArrowDown"
+ ) {
+ this.onAddElementInputModified();
+ return;
+ }
+
+ if (this.addEl.value !== "" && event.key === "Enter") {
+ this.addClassName(this.addEl.value);
+ }
+ }
+
+ async onAddElementInputModified() {
+ const newValue = this.addEl.value;
+
+ // if the input is empty, let's close the popup, if it was open.
+ if (newValue === "") {
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.hidePopup();
+ this.autocompletePopup.clearItems();
+ } else {
+ this.model.previewClass("");
+ }
+ return;
+ }
+
+ // Otherwise, we need to update the popup items to match the new input.
+ let items = [];
+ try {
+ const classNames = await this.model.getClassNames(newValue);
+ if (!this.autocompletePopup.isOpen) {
+ this._previewClassesBeforeAutocompletion =
+ this.model.previewClasses.map(previewClass => previewClass.className);
+ }
+ items = classNames.map(className => {
+ return {
+ preLabel: className.substring(0, newValue.length),
+ label: className,
+ };
+ });
+ } catch (e) {
+ // If there was an error while retrieving the classNames, we'll simply NOT show the
+ // popup, which is okay.
+ console.warn("Error when calling getClassNames", e);
+ }
+
+ if (!items.length || (items.length == 1 && items[0].label === newValue)) {
+ this.autocompletePopup.clearItems();
+ await this.autocompletePopup.hidePopup();
+ this.model.previewClass(newValue);
+ } else {
+ this.autocompletePopup.setItems(items);
+ this.autocompletePopup.openPopup();
+ }
+ }
+
+ async addClassName(className) {
+ try {
+ await this.model.addClassName(className);
+ 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();
+ }
+
+ onNodeFrontWillUnset() {
+ this.model.eraseClassPreview();
+ this.addEl.value = "";
+ }
+
+ onAutocompleteClassHovered(autocompleteItemLabel = "") {
+ if (this.autocompletePopup.isOpen) {
+ this.model.previewClass(autocompleteItemLabel);
+ }
+ }
+
+ onAutocompleteClosed() {
+ const inputValue = this.addEl.value;
+ this.model.previewClass(inputValue);
+ }
+}
+
+module.exports = ClassListPreviewer;