class-list-previewer.js (9424B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const ClassList = require("resource://devtools/client/inspector/rules/models/class-list.js"); 8 9 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 10 const L10N = new LocalizationHelper( 11 "devtools/client/locales/inspector.properties" 12 ); 13 const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); 14 const { debounce } = require("resource://devtools/shared/debounce.js"); 15 16 /** 17 * This UI widget shows a textfield and a series of checkboxes in the rule-view. It is 18 * used to toggle classes on the current node selection, and add new classes. 19 */ 20 class ClassListPreviewer { 21 /** 22 * @param {Inspector} inspector 23 * The current inspector instance. 24 * @param {DomNode} containerEl 25 * The element in the rule-view where the widget should go. 26 */ 27 constructor(inspector, containerEl) { 28 this.inspector = inspector; 29 this.containerEl = containerEl; 30 this.model = new ClassList(inspector); 31 32 this.onNewSelection = this.onNewSelection.bind(this); 33 this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this); 34 this.onKeyDown = this.onKeyDown.bind(this); 35 this.onAddElementInputModified = debounce( 36 this.onAddElementInputModified, 37 75, 38 this 39 ); 40 this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this); 41 this.onNodeFrontWillUnset = this.onNodeFrontWillUnset.bind(this); 42 this.onAutocompleteClassHovered = debounce( 43 this.onAutocompleteClassHovered, 44 75, 45 this 46 ); 47 this.onAutocompleteClosed = this.onAutocompleteClosed.bind(this); 48 49 // Create the add class text field. 50 this.addEl = this.doc.createElement("input"); 51 this.addEl.classList.add("devtools-textinput"); 52 this.addEl.classList.add("add-class"); 53 this.addEl.setAttribute( 54 "placeholder", 55 L10N.getStr("inspector.classPanel.newClass.placeholder") 56 ); 57 this.addEl.addEventListener("keydown", this.onKeyDown); 58 this.addEl.addEventListener("input", this.onAddElementInputModified); 59 this.containerEl.appendChild(this.addEl); 60 61 // Create the class checkboxes container. 62 this.classesEl = this.doc.createElement("div"); 63 this.classesEl.classList.add("classes"); 64 this.containerEl.appendChild(this.classesEl); 65 66 // Create the autocomplete popup 67 this.autocompletePopup = new AutocompletePopup(this.inspector.toolbox.doc, { 68 listId: "inspector_classListPreviewer_autocompletePopupListBox", 69 position: "bottom", 70 autoSelect: true, 71 useXulWrapper: true, 72 input: this.addEl, 73 onClick: (e, item) => { 74 if (item) { 75 this.addEl.value = item.label; 76 this.autocompletePopup.hidePopup(); 77 this.autocompletePopup.clearItems(); 78 this.model.previewClass(item.label); 79 } 80 }, 81 onSelect: item => { 82 if (item) { 83 this.onAutocompleteClassHovered(item?.label); 84 } 85 }, 86 }); 87 88 // Start listening for interesting events. 89 this.inspector.selection.on("new-node-front", this.onNewSelection); 90 this.inspector.selection.on( 91 "node-front-will-unset", 92 this.onNodeFrontWillUnset 93 ); 94 this.containerEl.addEventListener("input", this.onCheckBoxChanged); 95 this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged); 96 this.autocompletePopup.on("popup-closed", this.onAutocompleteClosed); 97 98 this.onNewSelection(); 99 } 100 101 destroy() { 102 this.inspector.selection.off("new-node-front", this.onNewSelection); 103 this.inspector.selection.off( 104 "node-front-will-unset", 105 this.onNodeFrontWillUnset 106 ); 107 this.autocompletePopup.off("popup-closed", this.onAutocompleteClosed); 108 this.addEl.removeEventListener("keydown", this.onKeyDown); 109 this.addEl.removeEventListener("input", this.onAddElementInputModified); 110 this.containerEl.removeEventListener("input", this.onCheckBoxChanged); 111 112 this.autocompletePopup.destroy(); 113 114 this.containerEl.innerHTML = ""; 115 116 this.model.destroy(); 117 this.containerEl = null; 118 this.inspector = null; 119 this.addEl = null; 120 this.classesEl = null; 121 } 122 123 get doc() { 124 return this.containerEl.ownerDocument; 125 } 126 127 /** 128 * Render the content of the panel. You typically don't need to call this as the panel 129 * renders itself on inspector selection changes. 130 */ 131 render() { 132 this.classesEl.innerHTML = ""; 133 134 for (const { name, isApplied } of this.model.currentClasses) { 135 const checkBox = this.renderCheckBox(name, isApplied); 136 this.classesEl.appendChild(checkBox); 137 } 138 139 if (!this.model.currentClasses.length) { 140 this.classesEl.appendChild(this.renderNoClassesMessage()); 141 } 142 } 143 144 /** 145 * Render a single checkbox for a given classname. 146 * 147 * @param {string} name 148 * The name of this class. 149 * @param {boolean} isApplied 150 * Is this class currently applied on the DOM node. 151 * @return {DOMNode} The DOM element for this checkbox. 152 */ 153 renderCheckBox(name, isApplied) { 154 const box = this.doc.createElement("input"); 155 box.setAttribute("type", "checkbox"); 156 if (isApplied) { 157 box.setAttribute("checked", "checked"); 158 } 159 box.dataset.name = name; 160 161 const labelWrapper = this.doc.createElement("label"); 162 labelWrapper.setAttribute("title", name); 163 labelWrapper.appendChild(box); 164 165 // A child element is required to do the ellipsis. 166 const label = this.doc.createElement("span"); 167 label.textContent = name; 168 labelWrapper.appendChild(label); 169 170 return labelWrapper; 171 } 172 173 /** 174 * Render the message displayed in the panel when the current element has no classes. 175 * 176 * @return {DOMNode} The DOM element for the message. 177 */ 178 renderNoClassesMessage() { 179 const msg = this.doc.createElement("p"); 180 msg.classList.add("no-classes"); 181 msg.textContent = L10N.getStr("inspector.classPanel.noClasses"); 182 return msg; 183 } 184 185 /** 186 * Focus the add-class text field. 187 */ 188 focusAddClassField() { 189 if (this.addEl) { 190 this.addEl.focus(); 191 } 192 } 193 194 onCheckBoxChanged({ target }) { 195 if (!target.dataset.name) { 196 return; 197 } 198 199 this.model.setClassState(target.dataset.name, target.checked).catch(e => { 200 // Only log the error if the panel wasn't destroyed in the meantime. 201 if (this.containerEl) { 202 console.error(e); 203 } 204 }); 205 } 206 207 onKeyDown(event) { 208 // If the popup is already open, all the keyboard interaction are handled 209 // directly by the popup component. 210 if (this.autocompletePopup.isOpen) { 211 return; 212 } 213 214 // Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty) 215 if ( 216 (this.addEl.value && event.key === " " && event.ctrlKey) || 217 event.key === "ArrowDown" 218 ) { 219 this.onAddElementInputModified(); 220 return; 221 } 222 223 if (this.addEl.value !== "" && event.key === "Enter") { 224 this.addClassName(this.addEl.value); 225 } 226 } 227 228 async onAddElementInputModified() { 229 const newValue = this.addEl.value; 230 231 // if the input is empty, let's close the popup, if it was open. 232 if (newValue === "") { 233 if (this.autocompletePopup.isOpen) { 234 this.autocompletePopup.hidePopup(); 235 this.autocompletePopup.clearItems(); 236 } else { 237 this.model.previewClass(""); 238 } 239 return; 240 } 241 242 // Otherwise, we need to update the popup items to match the new input. 243 let items = []; 244 try { 245 const classNames = await this.model.getClassNames(newValue); 246 if (!this.autocompletePopup.isOpen) { 247 this._previewClassesBeforeAutocompletion = 248 this.model.previewClasses.map(previewClass => previewClass.className); 249 } 250 items = classNames.map(className => { 251 return { 252 preLabel: className.substring(0, newValue.length), 253 label: className, 254 }; 255 }); 256 } catch (e) { 257 // If there was an error while retrieving the classNames, we'll simply NOT show the 258 // popup, which is okay. 259 console.warn("Error when calling getClassNames", e); 260 } 261 262 if (!items.length || (items.length == 1 && items[0].label === newValue)) { 263 this.autocompletePopup.clearItems(); 264 await this.autocompletePopup.hidePopup(); 265 this.model.previewClass(newValue); 266 } else { 267 this.autocompletePopup.setItems(items); 268 this.autocompletePopup.openPopup(); 269 } 270 } 271 272 async addClassName(className) { 273 try { 274 await this.model.addClassName(className); 275 this.render(); 276 this.addEl.value = ""; 277 } catch (e) { 278 // Only log the error if the panel wasn't destroyed in the meantime. 279 if (this.containerEl) { 280 console.error(e); 281 } 282 } 283 } 284 285 onNewSelection() { 286 this.render(); 287 } 288 289 onCurrentNodeClassChanged() { 290 this.render(); 291 } 292 293 onNodeFrontWillUnset() { 294 this.model.eraseClassPreview(); 295 this.addEl.value = ""; 296 } 297 298 onAutocompleteClassHovered(autocompleteItemLabel = "") { 299 if (this.autocompletePopup.isOpen) { 300 this.model.previewClass(autocompleteItemLabel); 301 } 302 } 303 304 onAutocompleteClosed() { 305 const inputValue = this.addEl.value; 306 this.model.previewClass(inputValue); 307 } 308 } 309 310 module.exports = ClassListPreviewer;