autocomplete-popup.js (20105B)
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 EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 9 loader.lazyRequireGetter( 10 this, 11 "HTMLTooltip", 12 "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js", 13 true 14 ); 15 loader.lazyRequireGetter( 16 this, 17 "colorUtils", 18 "resource://devtools/shared/css/color.js", 19 true 20 ); 21 22 const HTML_NS = "http://www.w3.org/1999/xhtml"; 23 let itemIdCounter = 0; 24 25 /** 26 * Autocomplete popup UI implementation. 27 */ 28 class AutocompletePopup extends EventEmitter { 29 /** 30 * @param {Document} toolboxDoc 31 * The toolbox document to attach the autocomplete popup panel. 32 * @param {object} options 33 * An object consiting any of the following options: 34 * - listId {String} The id for the list <UL> element. 35 * - position {String} The position for the tooltip ("top" or "bottom"). 36 * - useXulWrapper {Boolean} If the tooltip is hosted in a XUL document, use a 37 * XUL panel in order to use all the screen viewport available (defaults to false). 38 * - autoSelect {Boolean} Boolean to allow the first entry of the popup 39 * panel to be automatically selected when the popup shows. 40 * - onSelect {String} Callback called when the selected index is updated. 41 * - onClick {String} Callback called when the autocomplete popup receives a click 42 * event. The selectedIndex will already be updated if need be. 43 * - input {Element} Optional input element the popup will be bound to. If provided 44 * the event listeners for navigating the autocomplete list are going to be 45 * automatically added. 46 */ 47 constructor(toolboxDoc, options = {}) { 48 super(); 49 50 this.#document = toolboxDoc; 51 this.#autoSelect = options.autoSelect || false; 52 this.#listId = options.listId || null; 53 this.#position = options.position || "bottom"; 54 this.#useXulWrapper = options.useXulWrapper || false; 55 56 this.#onSelectCallback = options.onSelect; 57 this.#onClickCallback = options.onClick; 58 59 // Array of raw autocomplete items 60 this.items = []; 61 // Map of autocompleteItem to HTMLElement 62 this.elements = new WeakMap(); 63 64 this.selectedIndex = -1; 65 66 if (options.input) { 67 this.#input = options.input; 68 options.input.addEventListener("keydown", this.onInputKeyDown); 69 options.input.addEventListener("blur", this.onInputBlur); 70 } 71 } 72 73 #activeElement; 74 #autoSelect; 75 #document = null; 76 #input; 77 #list = null; 78 #listClone = null; 79 #listId; 80 #listPadding; 81 #onClickCallback; 82 #onSelectCallback; 83 #pendingShowPromise; 84 #position; 85 #tooltip; 86 #useXulWrapper; 87 88 get list() { 89 if (this.#list) { 90 return this.#list; 91 } 92 93 this.#list = this.#document.createElementNS(HTML_NS, "ul"); 94 this.#list.setAttribute("flex", "1"); 95 96 // The list clone will be inserted in the same document as the anchor, and will be a 97 // copy of the main list to allow screen readers to access the list. 98 this.#listClone = this.#list.cloneNode(); 99 this.#listClone.className = "devtools-autocomplete-list-aria-clone"; 100 101 if (this.#listId) { 102 this.#list.setAttribute("id", this.#listId); 103 } 104 105 this.#list.className = "devtools-autocomplete-listbox"; 106 107 // We need to retrieve the item padding in order to correct the offset of the popup. 108 const paddingPropertyName = "--autocomplete-item-padding-inline"; 109 const listPadding = this.#document.defaultView 110 .getComputedStyle(this.#list) 111 .getPropertyValue(paddingPropertyName) 112 .replace("px", ""); 113 114 this.#listPadding = 0; 115 if (!Number.isNaN(Number(listPadding))) { 116 this.#listPadding = Number(listPadding); 117 } 118 119 this.#list.addEventListener("click", this.onClick); 120 121 return this.#list; 122 } 123 124 get tooltip() { 125 if (this.#tooltip) { 126 return this.#tooltip; 127 } 128 129 this.#tooltip = new HTMLTooltip(this.#document, { 130 useXulWrapper: this.#useXulWrapper, 131 }); 132 133 this.#tooltip.panel.classList.add( 134 "devtools-autocomplete-popup", 135 "devtools-monospace" 136 ); 137 this.#tooltip.panel.appendChild(this.list); 138 this.#tooltip.setContentSize({ height: "auto" }); 139 140 return this.#tooltip; 141 } 142 143 onInputKeyDown = event => { 144 // Only handle the even if the popup is opened. 145 if (!this.isOpen) { 146 return; 147 } 148 149 if ( 150 this.selectedItem && 151 this.#onClickCallback && 152 (event.key === "Enter" || 153 (event.key === "ArrowRight" && !event.shiftKey) || 154 (event.key === "Tab" && !event.shiftKey)) 155 ) { 156 this.#onClickCallback(event, this.selectedItem); 157 158 // Prevent the associated keypress to be triggered. 159 event.preventDefault(); 160 event.stopPropagation(); 161 return; 162 } 163 164 // Close the popup when the user hit Left Arrow, but let the keypress be triggered 165 // so the cursor moves as the user wanted. 166 if (event.key === "ArrowLeft" && !event.shiftKey) { 167 this.clearItems(); 168 this.hidePopup(); 169 return; 170 } 171 172 // Close the popup when the user hit Escape. 173 if (event.key === "Escape") { 174 this.clearItems(); 175 this.hidePopup(); 176 // Prevent the associated keypress to be triggered. 177 event.preventDefault(); 178 event.stopPropagation(); 179 return; 180 } 181 182 if (event.key === "ArrowDown") { 183 this.selectNextItem(); 184 event.preventDefault(); 185 event.stopPropagation(); 186 return; 187 } 188 189 if (event.key === "ArrowUp") { 190 this.selectPreviousItem(); 191 event.preventDefault(); 192 event.stopPropagation(); 193 } 194 }; 195 196 onInputBlur = () => { 197 if (this.isOpen) { 198 this.clearItems(); 199 this.hidePopup(); 200 } 201 }; 202 203 onSelect(e) { 204 if (this.#onSelectCallback) { 205 this.#onSelectCallback(e); 206 } 207 } 208 209 onClick = e => { 210 const itemEl = e.target.closest(".autocomplete-item"); 211 const index = 212 typeof itemEl?.dataset?.index !== "undefined" 213 ? parseInt(itemEl.dataset.index, 10) 214 : null; 215 216 if (index !== null) { 217 this.selectItemAtIndex(index); 218 } 219 220 this.emit("popup-click"); 221 222 if (this.#onClickCallback) { 223 const item = index !== null ? this.items[index] : null; 224 this.#onClickCallback(e, item); 225 } 226 }; 227 228 /** 229 * Open the autocomplete popup panel. 230 * 231 * @param {Node} anchor 232 * Optional node to anchor the panel to. Will default to this.input if it exists. 233 * @param {number} xOffset 234 * Horizontal offset in pixels from the left of the node to the left 235 * of the popup. 236 * @param {number} yOffset 237 * Vertical offset in pixels from the top of the node to the starting 238 * of the popup. 239 * @param {number} index 240 * The position of item to select. 241 * @param {object} options: Check `selectItemAtIndex` for more information. 242 */ 243 async openPopup(anchor, xOffset = 0, yOffset = 0, index, options) { 244 if (!anchor && this.#input) { 245 anchor = this.#input; 246 } 247 248 // Retrieve the anchor's document active element to add accessibility metadata. 249 this.#activeElement = anchor.ownerDocument.activeElement; 250 251 // We want the autocomplete items to be perflectly lined-up with the string the 252 // user entered, so we need to remove the left-padding and the left-border from 253 // the xOffset. 254 const leftBorderSize = 1; 255 256 // If we have another call to openPopup while the previous one isn't over yet, we 257 // need to wait until it's settled to not be in a compromised state. 258 if (this.#pendingShowPromise) { 259 await this.#pendingShowPromise; 260 } 261 262 this.#pendingShowPromise = this.tooltip.show(anchor, { 263 x: xOffset - this.#listPadding - leftBorderSize, 264 y: yOffset, 265 position: this.#position, 266 }); 267 await this.#pendingShowPromise; 268 this.#pendingShowPromise = null; 269 270 if (this.#autoSelect) { 271 this.selectItemAtIndex(index, options); 272 } 273 274 this.emit("popup-opened"); 275 } 276 277 /** 278 * Select item at the provided index. 279 * 280 * @param {number} index 281 * The position of the item to select. 282 * @param {object} options: An object that can contain: 283 * - {Boolean} preventSelectCallback: true to not call this.onSelectCallback as 284 * during the initial autoSelect. 285 */ 286 selectItemAtIndex(index, options = {}) { 287 const { preventSelectCallback } = options; 288 289 if (!Number.isInteger(index)) { 290 // If no index was provided, select the first item. 291 index = 0; 292 } 293 const item = this.items[index]; 294 const element = this.elements.get(item); 295 296 const previousSelected = this.list.querySelector(".autocomplete-selected"); 297 if (previousSelected && previousSelected !== element) { 298 previousSelected.classList.remove("autocomplete-selected"); 299 } 300 301 if (element && !element.classList.contains("autocomplete-selected")) { 302 element.classList.add("autocomplete-selected"); 303 } 304 305 if (this.isOpen && item) { 306 this.#scrollElementIntoViewIfNeeded(element); 307 this.#setActiveDescendant(element.id); 308 } else { 309 this.#clearActiveDescendant(); 310 } 311 this.selectedIndex = index; 312 313 if ( 314 this.isOpen && 315 item && 316 this.#onSelectCallback && 317 !preventSelectCallback 318 ) { 319 // Call the user-defined select callback if defined. 320 this.#onSelectCallback(item); 321 } 322 } 323 324 /** 325 * Hide the autocomplete popup panel. 326 */ 327 hidePopup() { 328 this.#pendingShowPromise = null; 329 this.tooltip.once("hidden", () => { 330 this.emit("popup-closed"); 331 }); 332 333 this.#clearActiveDescendant(); 334 this.#activeElement = null; 335 this.tooltip.hide(); 336 } 337 338 /** 339 * Check if the autocomplete popup is open. 340 */ 341 get isOpen() { 342 return !!this.#tooltip && this.tooltip.isVisible(); 343 } 344 345 /** 346 * Destroy the object instance. Please note that the panel DOM elements remain 347 * in the DOM, because they might still be in use by other instances of the 348 * same code. It is the responsability of the client code to perform DOM 349 * cleanup. 350 */ 351 destroy() { 352 this.#pendingShowPromise = null; 353 if (this.isOpen) { 354 this.hidePopup(); 355 } 356 357 if (this.#list) { 358 this.#list.removeEventListener("click", this.onClick); 359 360 this.#list.remove(); 361 this.#listClone.remove(); 362 363 this.#list = null; 364 } 365 366 if (this.#tooltip) { 367 this.#tooltip.destroy(); 368 this.#tooltip = null; 369 } 370 371 if (this.#input) { 372 this.#input.addEventListener("keydown", this.onInputKeyDown); 373 this.#input.addEventListener("blur", this.onInputBlur); 374 this.#input = null; 375 } 376 377 this.#document = null; 378 } 379 380 /** 381 * Get the autocomplete items array. 382 * 383 * @param {number} index 384 * The index of the item what is wanted. 385 * 386 * @return {object} The autocomplete item at index index. 387 */ 388 getItemAtIndex(index) { 389 return this.items[index]; 390 } 391 392 /** 393 * Get the autocomplete items array. 394 * 395 * @return {Array} The array of autocomplete items. 396 */ 397 getItems() { 398 // Return a copy of the array to avoid side effects from the caller code. 399 return this.items.slice(0); 400 } 401 402 /** 403 * Set the autocomplete items list, in one go. 404 * 405 * @param {Array} items 406 * The list of items you want displayed in the popup list. 407 * @param {number} selectedIndex 408 * The position of the item to select. 409 * @param {object} options: An object that can contain: 410 * - {Boolean} preventSelectCallback: true to not call this.onSelectCallback as 411 * during the initial autoSelect. 412 */ 413 setItems(items, selectedIndex, options) { 414 this.clearItems(); 415 416 // If there is no new items, no need to do unecessary work. 417 if (items.length === 0) { 418 return; 419 } 420 421 if (!Number.isInteger(selectedIndex) && this.#autoSelect) { 422 selectedIndex = 0; 423 } 424 425 // Let's compute the max label length in the item list. This length will then be used 426 // to set the width of the popup. 427 let maxLabelLength = 0; 428 429 const fragment = this.#document.createDocumentFragment(); 430 items.forEach((item, i) => { 431 const selected = selectedIndex === i; 432 const listItem = this.#createListItem(item, i, selected); 433 this.items.push(item); 434 this.elements.set(item, listItem); 435 fragment.appendChild(listItem); 436 437 let { label, postLabel, count } = item; 438 if (count) { 439 label += count + ""; 440 } 441 442 if (postLabel) { 443 label += postLabel; 444 } 445 maxLabelLength = Math.max(label.length, maxLabelLength); 446 }); 447 448 // The popup should be as wide as its longest item. 449 // We need to account for the inline padding 450 const fragmentClone = fragment.cloneNode(true); 451 let width = `calc(${ 452 maxLabelLength + 3 453 }ch + 2 * var(--autocomplete-item-padding-inline, 0px))`; 454 // As well as add more space if we're displaying color swatches 455 if (fragment.querySelector(".autocomplete-colorswatch")) { 456 width = `calc(${width} + var(--autocomplete-item-color-swatch-size) + 2 * var(--autocomplete-item-color-swatch-margin-inline))`; 457 } 458 this.list.style.width = width; 459 this.list.appendChild(fragment); 460 // Update the clone content to match the current list content. 461 this.#listClone.appendChild(fragmentClone); 462 463 this.selectItemAtIndex(selectedIndex, options); 464 } 465 466 #scrollElementIntoViewIfNeeded(element) { 467 const quads = element.getBoxQuads({ 468 relativeTo: this.tooltip.panel, 469 createFramesForSuppressedWhitespace: false, 470 }); 471 if (!quads || !quads[0]) { 472 return; 473 } 474 475 const { top, height } = quads[0].getBounds(); 476 const containerHeight = this.tooltip.panel.getBoundingClientRect().height; 477 if (top < 0) { 478 // Element is above container. 479 element.scrollIntoView(true); 480 } else if (top + height > containerHeight) { 481 // Element is below container. 482 element.scrollIntoView(false); 483 } 484 } 485 486 /** 487 * Clear all the items from the autocomplete list. 488 */ 489 clearItems() { 490 if (this.#list) { 491 this.#list.innerHTML = ""; 492 } 493 if (this.#listClone) { 494 this.#listClone.innerHTML = ""; 495 } 496 497 this.items = []; 498 this.elements = new WeakMap(); 499 this.selectItemAtIndex(-1); 500 } 501 502 /** 503 * Getter for the selected item. 504 * 505 * @type Object 506 */ 507 get selectedItem() { 508 return this.items[this.selectedIndex]; 509 } 510 511 /** 512 * Setter for the selected item. 513 * 514 * @param {object} item 515 * The object you want selected in the list. 516 */ 517 set selectedItem(item) { 518 const index = this.items.indexOf(item); 519 if (index !== -1 && this.isOpen) { 520 this.selectItemAtIndex(index); 521 } 522 } 523 524 /** 525 * Update the aria-activedescendant attribute on the current active element for 526 * accessibility. 527 * 528 * @param {string} id 529 * The id (as in DOM id) of the currently selected autocomplete suggestion 530 */ 531 #setActiveDescendant(id) { 532 if (!this.#activeElement) { 533 return; 534 } 535 536 // Make sure the list clone is in the same document as the anchor. 537 const anchorDoc = this.#activeElement.ownerDocument; 538 if ( 539 !this.#listClone.parentNode || 540 this.#listClone.ownerDocument !== anchorDoc 541 ) { 542 anchorDoc.documentElement.appendChild(this.#listClone); 543 } 544 545 this.#activeElement.setAttribute("aria-activedescendant", id); 546 } 547 548 /** 549 * Clear the aria-activedescendant attribute on the current active element. 550 */ 551 #clearActiveDescendant() { 552 if (!this.#activeElement) { 553 return; 554 } 555 556 this.#activeElement.removeAttribute("aria-activedescendant"); 557 } 558 559 #createListItem(item, index, selected) { 560 const listItem = this.#document.createElementNS(HTML_NS, "li"); 561 // Items must have an id for accessibility. 562 listItem.setAttribute("id", "autocomplete-item-" + itemIdCounter++); 563 listItem.classList.add("autocomplete-item"); 564 if (selected) { 565 listItem.classList.add("autocomplete-selected"); 566 } 567 listItem.setAttribute("data-index", index); 568 569 if (this.direction) { 570 listItem.setAttribute("dir", this.direction); 571 } 572 573 const label = this.#document.createElementNS(HTML_NS, "span"); 574 label.textContent = item.label; 575 label.className = "autocomplete-value"; 576 577 if (item.preLabel) { 578 const preDesc = this.#document.createElementNS(HTML_NS, "span"); 579 preDesc.textContent = item.preLabel; 580 preDesc.className = "initial-value"; 581 listItem.appendChild(preDesc); 582 label.textContent = item.label.slice(item.preLabel.length); 583 } 584 585 listItem.appendChild(label); 586 587 if (item.postLabel) { 588 const postDesc = this.#document.createElementNS(HTML_NS, "span"); 589 postDesc.className = "autocomplete-postlabel"; 590 postDesc.textContent = item.postLabel; 591 // Determines if the postlabel is a valid colour or other value 592 if (this.#isValidColor(item.postLabel)) { 593 const colorSwatch = this.#document.createElementNS(HTML_NS, "span"); 594 colorSwatch.className = "autocomplete-swatch autocomplete-colorswatch"; 595 colorSwatch.style.cssText = "background-color: " + item.postLabel; 596 postDesc.insertBefore(colorSwatch, postDesc.childNodes[0]); 597 } 598 listItem.appendChild(postDesc); 599 } 600 601 if (item.count && item.count > 1) { 602 const countDesc = this.#document.createElementNS(HTML_NS, "span"); 603 countDesc.textContent = item.count; 604 countDesc.setAttribute("flex", "1"); 605 countDesc.className = "autocomplete-count"; 606 listItem.appendChild(countDesc); 607 } 608 609 return listItem; 610 } 611 612 /** 613 * Getter for the number of items in the popup. 614 * 615 * @type {number} 616 */ 617 get itemCount() { 618 return this.items.length; 619 } 620 621 /** 622 * Getter for the height of each item in the list. 623 * 624 * @type {number} 625 */ 626 get #itemsPerPane() { 627 if (this.items.length) { 628 const listHeight = this.tooltip.panel.clientHeight; 629 const element = this.elements.get(this.items[0]); 630 const elementHeight = element.getBoundingClientRect().height; 631 return Math.floor(listHeight / elementHeight); 632 } 633 return 0; 634 } 635 636 /** 637 * Select the next item in the list. 638 * 639 * @return {object} 640 * The newly selected item object. 641 */ 642 selectNextItem() { 643 if (this.selectedIndex < this.items.length - 1) { 644 this.selectItemAtIndex(this.selectedIndex + 1); 645 } else { 646 this.selectItemAtIndex(0); 647 } 648 return this.selectedItem; 649 } 650 651 /** 652 * Select the previous item in the list. 653 * 654 * @return {object} 655 * The newly-selected item object. 656 */ 657 selectPreviousItem() { 658 if (this.selectedIndex > 0) { 659 this.selectItemAtIndex(this.selectedIndex - 1); 660 } else { 661 this.selectItemAtIndex(this.items.length - 1); 662 } 663 664 return this.selectedItem; 665 } 666 667 /** 668 * Select the top-most item in the next page of items or 669 * the last item in the list. 670 * 671 * @return {object} 672 * The newly-selected item object. 673 */ 674 selectNextPageItem() { 675 const nextPageIndex = this.selectedIndex + this.#itemsPerPane + 1; 676 this.selectItemAtIndex(Math.min(nextPageIndex, this.itemCount - 1)); 677 return this.selectedItem; 678 } 679 680 /** 681 * Select the bottom-most item in the previous page of items, 682 * or the first item in the list. 683 * 684 * @return {object} 685 * The newly-selected item object. 686 */ 687 selectPreviousPageItem() { 688 const prevPageIndex = this.selectedIndex - this.#itemsPerPane - 1; 689 this.selectItemAtIndex(Math.max(prevPageIndex, 0)); 690 return this.selectedItem; 691 } 692 693 /** 694 * Determines if the specified colour object is a valid colour, and if 695 * it is not a "special value" 696 * 697 * @return {boolean} 698 * If the object represents a proper colour or not. 699 */ 700 #isValidColor(color) { 701 const colorObj = new colorUtils.CssColor(color); 702 return colorObj.valid && !colorObj.specialValue; 703 } 704 } 705 706 module.exports = AutocompletePopup;