PanelMultiView.sys.mjs (70007B)
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 const lazy = {}; 6 ChromeUtils.defineESModuleGetters(lazy, { 7 CustomizableUI: 8 "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs", 9 }); 10 11 ChromeUtils.defineLazyGetter(lazy, "gBundle", function () { 12 return Services.strings.createBundle( 13 "chrome://browser/locale/browser.properties" 14 ); 15 }); 16 17 /** 18 * Safety timeout after which asynchronous events will be canceled if any of the 19 * registered blockers does not return. 20 */ 21 const BLOCKERS_TIMEOUT_MS = 10000; 22 23 const TRANSITION_PHASES = Object.freeze({ 24 START: 1, 25 PREPARE: 2, 26 TRANSITION: 3, 27 }); 28 29 let gNodeToObjectMap = new WeakMap(); 30 let gWindowsWithUnloadHandler = new WeakSet(); 31 32 /** 33 * Allows associating an object to a node lazily using a weak map. 34 * 35 * Classes deriving from this one may be easily converted to Custom Elements, 36 * although they would lose the ability of being associated lazily. 37 */ 38 var AssociatedToNode = class { 39 constructor(node) { 40 /** 41 * Node associated to this object. 42 */ 43 this.node = node; 44 45 /** 46 * This promise is resolved when the current set of blockers set by event 47 * handlers have all been processed. 48 */ 49 this._blockersPromise = Promise.resolve(); 50 } 51 52 /** 53 * Retrieves the instance associated with the given node, constructing a new 54 * one if necessary. When the last reference to the node is released, the 55 * object instance will be garbage collected as well. 56 * 57 * @param {DOMNode} node 58 * The node to retrieve or construct the AssociatedToNode instance for. 59 * @returns {AssociatedToNode} 60 */ 61 static forNode(node) { 62 let associatedToNode = gNodeToObjectMap.get(node); 63 if (!associatedToNode) { 64 associatedToNode = new this(node); 65 gNodeToObjectMap.set(node, associatedToNode); 66 } 67 return associatedToNode; 68 } 69 70 /** 71 * A shortcut to the document that the node belongs to. 72 * 73 * @returns {Document} 74 */ 75 get document() { 76 return this.node.ownerDocument; 77 } 78 79 /** 80 * A shortcut to the window global that the node belongs to. 81 * 82 * @returns {DOMWindow} 83 */ 84 get window() { 85 return this.node.ownerGlobal; 86 } 87 88 /** 89 * A shortcut to windowUtils.getBoundsWithoutFlushing for the window global 90 * associated with the node. 91 * 92 * This is a pseudo-private method using an `_` because we want it to be 93 * used by subclasses. Please don't use outside of this module. 94 * 95 * @param {DOMNode} element 96 * The element to retrieve the bounds for without flushing layout. 97 * @returns {DOMRect} 98 * The bounding rect of the element. 99 */ 100 _getBoundsWithoutFlushing(element) { 101 return this.window.windowUtils.getBoundsWithoutFlushing(element); 102 } 103 104 /** 105 * Dispatches a custom event on this element. 106 * 107 * @param {string} eventName 108 * Name of the event to dispatch. 109 * @param {object|undefined} [detail] 110 * Event detail object. Optional. 111 * @param {boolean} cancelable 112 * True if the event can be canceled. 113 * @returns {boolean} 114 * True if the event was canceled by an event handler, false otherwise. 115 */ 116 dispatchCustomEvent(eventName, detail, cancelable = false) { 117 let event = new this.window.CustomEvent(eventName, { 118 detail, 119 bubbles: true, 120 cancelable, 121 }); 122 this.node.dispatchEvent(event); 123 return event.defaultPrevented; 124 } 125 126 /** 127 * Dispatches a custom event on this element and waits for any blocking 128 * promises registered using the "addBlocker" function on the details object. 129 * If this function is called again, the event is only dispatched after all 130 * the previously registered blockers have returned. 131 * 132 * The event can be canceled either by resolving any blocking promise to the 133 * boolean value "false" or by calling preventDefault on the event. Rejections 134 * and exceptions will be reported and will cancel the event. 135 * 136 * Blocking should be used sporadically because it slows down the interface. 137 * Also, non-reentrancy is not strictly guaranteed because a safety timeout of 138 * BLOCKERS_TIMEOUT_MS is implemented, after which the event will be canceled. 139 * This helps to prevent deadlocks if any of the event handlers does not 140 * resolve a blocker promise. 141 * 142 * Note: 143 * Since there is no use case for dispatching different asynchronous 144 * events in parallel for the same element, this function will also wait 145 * for previous blockers when the event name is different. 146 * 147 * @param {string} eventName 148 * Name of the custom event to dispatch. 149 * @returns {Promise<boolean>} 150 * Resolves to true if the event was canceled by a handler, false otherwise. 151 */ 152 async dispatchAsyncEvent(eventName) { 153 // Wait for all the previous blockers before dispatching the event. 154 let blockersPromise = this._blockersPromise.catch(() => {}); 155 return (this._blockersPromise = blockersPromise.then(async () => { 156 let blockers = new Set(); 157 let cancel = this.dispatchCustomEvent( 158 eventName, 159 { 160 addBlocker(promise) { 161 // Any exception in the blocker will cancel the operation. 162 blockers.add( 163 promise.catch(ex => { 164 console.error(ex); 165 return true; 166 }) 167 ); 168 }, 169 }, 170 true 171 ); 172 if (blockers.size) { 173 let timeoutPromise = new Promise((resolve, reject) => { 174 this.window.setTimeout(reject, BLOCKERS_TIMEOUT_MS); 175 }); 176 try { 177 let results = await Promise.race([ 178 Promise.all(blockers), 179 timeoutPromise, 180 ]); 181 cancel = cancel || results.some(result => result === false); 182 } catch (ex) { 183 console.error( 184 new Error(`One of the blockers for ${eventName} timed out.`) 185 ); 186 return true; 187 } 188 } 189 return cancel; 190 })); 191 } 192 }; 193 194 /** 195 * This is associated to <panelmultiview> elements. 196 */ 197 export var PanelMultiView = class extends AssociatedToNode { 198 /** 199 * Tries to open the specified <panel> and displays the main view specified 200 * with the "mainViewId" attribute on the <panelmultiview> node it contains. 201 * 202 * If the panel does not contain a <panelmultiview>, it is opened directly. 203 * This allows consumers like page actions to accept different panel types. 204 * 205 * See the non-static openPopup method for details. 206 * 207 * @static 208 * @memberof PanelMultiView 209 * @param {DOMNode} panelNode 210 * The <panel> node that is to be opened. 211 * @param {...*} args 212 * Additional arguments to be forwarded to the openPopup method of the 213 * panel. 214 * @returns {Promise<boolean, Exception>|boolean} 215 * Returns a Promise that resolves to `true` if the panel successfully 216 * opened, or rejects if the panel cannot open. May also return `true` 217 * immediately if the panel does not contain a <panelmultiview>. 218 */ 219 static async openPopup(panelNode, ...args) { 220 let panelMultiViewNode = panelNode.querySelector("panelmultiview"); 221 if (panelMultiViewNode) { 222 return this.forNode(panelMultiViewNode).openPopup(...args); 223 } 224 panelNode.openPopup(...args); 225 return true; 226 } 227 228 /** 229 * Closes the specified <panel> which contains a <panelmultiview> node. 230 * 231 * If the panel does not contain a <panelmultiview>, it is closed directly. 232 * This allows consumers like page actions to accept different panel types. 233 * 234 * See the non-static hidePopup method for details. 235 * 236 * @static 237 * @memberof PanelMultiView 238 * @param {DOMNode} panelNode 239 * The <panel> node. 240 * @param {boolean} [animate=false] 241 * Whether to show a fade animation. 242 */ 243 static hidePopup(panelNode, animate = false) { 244 let panelMultiViewNode = panelNode.querySelector("panelmultiview"); 245 if (panelMultiViewNode) { 246 this.forNode(panelMultiViewNode).hidePopup(animate); 247 } else { 248 panelNode.hidePopup(animate); 249 } 250 } 251 252 /** 253 * Removes the specified <panel> from the document, ensuring that any 254 * <panelmultiview> node it contains is destroyed properly. 255 * 256 * If the viewCacheId attribute is present on the <panelmultiview> element, 257 * imported subviews will be moved out again to the element it specifies, so 258 * that the panel element can be removed safely. 259 * 260 * If the panel does not contain a <panelmultiview>, it is removed directly. 261 * This allows consumers like page actions to accept different panel types. 262 * 263 * @param {DOMNode} panelNode 264 * The <panel> to remove. 265 */ 266 static removePopup(panelNode) { 267 try { 268 let panelMultiViewNode = panelNode.querySelector("panelmultiview"); 269 if (panelMultiViewNode) { 270 let panelMultiView = this.forNode(panelMultiViewNode); 271 panelMultiView._moveOutKids(); 272 panelMultiView.disconnect(); 273 } 274 } finally { 275 // Make sure to remove the panel element even if disconnecting fails. 276 panelNode.remove(); 277 } 278 } 279 280 /** 281 * Returns the element with the given id. 282 * For nodes that are lazily loaded and not yet in the DOM, the node should 283 * be retrieved from the view cache template. 284 * 285 * @param {Document} doc 286 * The document to retrieve the node for. 287 * @param {string} id 288 * The ID of the element to retrieve from the DOM (or the 289 * appMenu-viewCache). 290 * @returns {DOMNode|null} 291 * The found DOMNode or null if no node was found with that ID. 292 */ 293 static getViewNode(doc, id) { 294 let viewCacheTemplate = doc.getElementById("appMenu-viewCache"); 295 296 return ( 297 doc.getElementById(id) || 298 viewCacheTemplate?.content.querySelector("#" + id) 299 ); 300 } 301 302 /** 303 * Ensures that when the specified window is closed all the <panelmultiview> 304 * node it contains are destroyed properly. 305 * 306 * @param {DOMWindow} window 307 * The window to add the unload handler to. 308 */ 309 static ensureUnloadHandlerRegistered(window) { 310 if (gWindowsWithUnloadHandler.has(window)) { 311 return; 312 } 313 314 window.addEventListener( 315 "unload", 316 () => { 317 for (let panelMultiViewNode of window.document.querySelectorAll( 318 "panelmultiview" 319 )) { 320 this.forNode(panelMultiViewNode).disconnect(); 321 } 322 }, 323 { once: true } 324 ); 325 326 gWindowsWithUnloadHandler.add(window); 327 } 328 329 /** 330 * Returns the parent element of the <panelmultiview>, which should be a 331 * <panel>. 332 */ 333 get #panel() { 334 return this.node.parentNode; 335 } 336 337 /** 338 * Sets the `transitioning` attribute of the <panelmultiview>. 339 * 340 * @param {boolean} val 341 * If true, sets the attribute to `"true"`. If false, removes the attribute. 342 */ 343 set #transitioning(val) { 344 if (val) { 345 this.node.setAttribute("transitioning", "true"); 346 } else { 347 this.node.removeAttribute("transitioning"); 348 } 349 } 350 351 constructor(node) { 352 super(node); 353 this._openPopupPromise = Promise.resolve(false); 354 } 355 356 /** 357 * Binds this PanelMultiView class to the underlying <panelmultiview> element. 358 * Also creates the appropriate <panelmultview> child elements, like the 359 * viewcontainer and the viewstack. Sets up popup event handlers for the 360 * panel. 361 */ 362 connect() { 363 this.connected = true; 364 365 PanelMultiView.ensureUnloadHandlerRegistered(this.window); 366 367 let viewContainer = (this._viewContainer = 368 this.document.createXULElement("box")); 369 viewContainer.classList.add("panel-viewcontainer"); 370 371 let viewStack = (this._viewStack = this.document.createXULElement("box")); 372 viewStack.classList.add("panel-viewstack"); 373 viewContainer.append(viewStack); 374 375 let offscreenViewContainer = this.document.createXULElement("box"); 376 offscreenViewContainer.classList.add("panel-viewcontainer", "offscreen"); 377 378 let offscreenViewStack = (this._offscreenViewStack = 379 this.document.createXULElement("box")); 380 offscreenViewStack.classList.add("panel-viewstack"); 381 offscreenViewContainer.append(offscreenViewStack); 382 383 this.node.prepend(offscreenViewContainer); 384 this.node.prepend(viewContainer); 385 386 this.openViews = []; 387 388 this.#panel.addEventListener("popupshowing", this); 389 this.#panel.addEventListener("popuphidden", this); 390 this.#panel.addEventListener("popupshown", this); 391 392 // Proxy these public properties and methods, as used elsewhere by various 393 // parts of the browser, to this instance. 394 ["goBack", "showSubView"].forEach(method => { 395 Object.defineProperty(this.node, method, { 396 enumerable: true, 397 value: (...args) => this[method](...args), 398 }); 399 }); 400 } 401 402 /** 403 * Disconnects this PanelMultiView instance from a <panelmultiview> element. 404 * This does not remove any of the nodes created in `connect`, but does clean 405 * up the event listeners that were set up. 406 */ 407 disconnect() { 408 // Guard against re-entrancy. 409 if (!this.node || !this.connected) { 410 return; 411 } 412 413 this.#panel.removeEventListener("mousemove", this); 414 this.#panel.removeEventListener("popupshowing", this); 415 this.#panel.removeEventListener("popupshown", this); 416 this.#panel.removeEventListener("popuphidden", this); 417 this.document.documentElement.removeEventListener("keydown", this, true); 418 this.node = 419 this._openPopupPromise = 420 this._openPopupCancelCallback = 421 this._viewContainer = 422 this._viewStack = 423 this._transitionDetails = 424 null; 425 } 426 427 /** 428 * Tries to open the panel associated with this PanelMultiView, and displays 429 * the main view specified with the "mainViewId" attribute. 430 * 431 * The hidePopup method can be called while the operation is in progress to 432 * prevent the panel from being displayed. View events may also cancel the 433 * operation, so there is no guarantee that the panel will become visible. 434 * 435 * The "popuphidden" event will be fired either when the operation is canceled 436 * or when the popup is closed later. This event can be used for example to 437 * reset the "open" state of the anchor or tear down temporary panels. 438 * 439 * If this method is called again before the panel is shown, the result 440 * depends on the operation currently in progress. If the operation was not 441 * canceled, the panel is opened using the arguments from the previous call, 442 * and this call is ignored. If the operation was canceled, it will be 443 * retried again using the arguments from this call. 444 * 445 * It's not necessary for the <panelmultiview> binding to be connected when 446 * this method is called, but the containing panel must have its display 447 * turned on, for example it shouldn't have the "hidden" attribute. 448 * 449 * @instance 450 * @memberof PanelMultiView# 451 * @param {DOMNode} anchor 452 * The node to anchor the popup to. 453 * @param {StringOrOpenPopupOptions} options 454 * Either options to use or a string position. This is forwarded to 455 * the openPopup method of the panel. 456 * @param {...*} args 457 * Additional arguments to be forwarded to the openPopup method of the 458 * panel. 459 * @returns {Promise<boolean, Exception>} 460 * Resolves with true as soon as the request to display the panel has been 461 * sent, or with false if the operation was canceled. The state of 462 * the panel at this point is not guaranteed. It may be still 463 * showing, completely shown, or completely hidden. 464 * 465 * Rejects if an exception is thrown at any point in the process before the 466 * request to display the panel is sent. 467 */ 468 async openPopup(anchor, options, ...args) { 469 // Set up the function that allows hidePopup or a second call to showPopup 470 // to cancel the specific panel opening operation that we're starting below. 471 // This function must be synchronous, meaning we can't use Promise.race, 472 // because hidePopup wants to dispatch the "popuphidden" event synchronously 473 // even if the panel has not been opened yet. 474 let canCancel = true; 475 let cancelCallback = (this._openPopupCancelCallback = () => { 476 // If the cancel callback is called and the panel hasn't been prepared 477 // yet, cancel showing it. Setting canCancel to false will prevent the 478 // popup from opening. If the panel has opened by the time the cancel 479 // callback is called, canCancel will be false already, and we will not 480 // fire the "popuphidden" event. 481 if (canCancel && this.node) { 482 canCancel = false; 483 this.dispatchCustomEvent("popuphidden"); 484 } 485 if (cancelCallback == this._openPopupCancelCallback) { 486 // If still current, let go of the cancel callback since it will capture 487 // the entire scope and tie it to the main window. 488 delete this._openPopupCancelCallback; 489 } 490 }); 491 492 // Create a promise that is resolved with the result of the last call to 493 // this method, where errors indicate that the panel was not opened. 494 let openPopupPromise = this._openPopupPromise.catch(() => { 495 return false; 496 }); 497 498 // Make the preparation done before showing the panel non-reentrant. The 499 // promise created here will be resolved only after the panel preparation is 500 // completed, even if a cancellation request is received in the meantime. 501 return (this._openPopupPromise = openPopupPromise.then(async wasShown => { 502 // The panel may have been destroyed in the meantime. 503 if (!this.node) { 504 return false; 505 } 506 // If the panel has been already opened there is nothing more to do. We 507 // check the actual state of the panel rather than setting some state in 508 // our handler of the "popuphidden" event because this has a lower chance 509 // of locking indefinitely if events aren't raised in the expected order. 510 if (wasShown && ["open", "showing"].includes(this.#panel.state)) { 511 if (cancelCallback == this._openPopupCancelCallback) { 512 // If still current, let go of the cancel callback since it will 513 // capture the entire scope and tie it to the main window. 514 delete this._openPopupCancelCallback; 515 } 516 return true; 517 } 518 try { 519 if (!this.connected) { 520 this.connect(); 521 } 522 // Allow any of the ViewShowing handlers to prevent showing the main view. 523 if (!(await this.#showMainView())) { 524 cancelCallback(); 525 } 526 } catch (ex) { 527 cancelCallback(); 528 throw ex; 529 } 530 // If a cancellation request was received there is nothing more to do. 531 if (!canCancel || !this.node) { 532 return false; 533 } 534 // We have to set canCancel to false before opening the popup because the 535 // hidePopup method of PanelMultiView can be re-entered by event handlers. 536 // If the openPopup call fails, however, we still have to dispatch the 537 // "popuphidden" event even if canCancel was set to false. 538 try { 539 canCancel = false; 540 this.#panel.openPopup(anchor, options, ...args); 541 if (cancelCallback == this._openPopupCancelCallback) { 542 // If still current, let go of the cancel callback since it will 543 // capture the entire scope and tie it to the main window. 544 delete this._openPopupCancelCallback; 545 } 546 // Set an attribute on the popup to let consumers style popup elements - 547 // for example, the anchor arrow is styled to match the color of the header 548 // in the Protections Panel main view. 549 this.#panel.setAttribute("mainviewshowing", true); 550 551 // On Windows, if another popup is hiding while we call openPopup, the 552 // call won't fail but the popup won't open. In this case, we have to 553 // dispatch an artificial "popuphidden" event to reset our state. 554 if (this.#panel.state == "closed" && this.openViews.length) { 555 this.dispatchCustomEvent("popuphidden"); 556 return false; 557 } 558 559 if ( 560 options && 561 typeof options == "object" && 562 options.triggerEvent && 563 (options.triggerEvent.type == "keypress" || 564 options.triggerEvent.type == "keydown" || 565 options.triggerEvent?.inputSource == 566 MouseEvent.MOZ_SOURCE_KEYBOARD) && 567 this.openViews.length 568 ) { 569 // This was opened via the keyboard, so focus the first item. 570 this.openViews[0].focusWhenActive = true; 571 } 572 573 return true; 574 } catch (ex) { 575 this.dispatchCustomEvent("popuphidden"); 576 throw ex; 577 } 578 })); 579 } 580 581 /** 582 * Closes the panel associated with this PanelMultiView. 583 * 584 * If the openPopup method was called but the panel has not been displayed 585 * yet, the operation is canceled and the panel will not be displayed, but the 586 * "popuphidden" event is fired synchronously anyways. 587 * 588 * This means that by the time this method returns all the operations handled 589 * by the "popuphidden" event are completed, for example resetting the "open" 590 * state of the anchor, and the panel is already invisible. 591 * 592 * Note: 593 * The value of animate could be changed to true by default, in both 594 * this and the static method above. (see bug 1769813) 595 * 596 * @instance 597 * @memberof PanelMultiView# 598 * @param {boolean} [animate=false] 599 * Whether to show a fade animation. Optional. 600 */ 601 hidePopup(animate = false) { 602 if (!this.node || !this.connected) { 603 return; 604 } 605 606 // If we have already reached the #panel.openPopup call in the openPopup 607 // method, we can call hidePopup. Otherwise, we have to cancel the latest 608 // request to open the panel, which will have no effect if the request has 609 // been canceled already. 610 if (["open", "showing"].includes(this.#panel.state)) { 611 this.#panel.hidePopup(animate); 612 } else { 613 this._openPopupCancelCallback?.(); 614 } 615 616 // We close all the views synchronously, so that they are ready to be opened 617 // in other PanelMultiView instances. The "popuphidden" handler may also 618 // call this function, but the second time openViews will be empty. 619 this.closeAllViews(); 620 } 621 622 /** 623 * Move any child subviews into the element defined by "viewCacheId" to make 624 * sure they will not be removed together with the <panelmultiview> element. 625 * 626 * This is "pseudo-private" with the underscore so that the static 627 * removePopup method can call it. 628 */ 629 _moveOutKids() { 630 // this.node may have been set to null by a call to disconnect(). 631 let viewCacheId = this.node?.getAttribute("viewCacheId"); 632 if (!viewCacheId) { 633 return; 634 } 635 636 // Node.children and Node.children is live to DOM changes like the 637 // ones we're about to do, so iterate over a static copy: 638 let subviews = Array.from(this._viewStack.children); 639 let viewCache = this.document.getElementById("appMenu-viewCache"); 640 for (let subview of subviews) { 641 viewCache.appendChild(subview); 642 } 643 } 644 645 /** 646 * Slides in the specified view as a subview. This returns synchronously, 647 * but may eventually log an error if showing the subview fails for some 648 * reason. 649 * 650 * @param {string|DOMNode} viewIdOrNode 651 * DOM element or string ID of the <panelview> to display. 652 * @param {DOMNode} anchor 653 * DOM element that triggered the subview, which will be highlighted 654 * and whose "label" attribute will be used for the title of the 655 * subview when a "title" attribute is not specified. 656 */ 657 showSubView(viewIdOrNode, anchor) { 658 this.#showSubView(viewIdOrNode, anchor).catch(console.error); 659 } 660 661 /** 662 * The asynchronous private helper method for showSubView that does most of 663 * the heavy lifting. 664 * 665 * @param {string|DOMNode} viewIdOrNode 666 * DOM element or string ID of the <panelview> to display. 667 * @param {DOMNode} anchor 668 * DOM element that triggered the subview, which will be highlighted 669 * and whose "label" attribute will be used for the title of the 670 * subview when a "title" attribute is not specified. 671 * @returns {Promise<undefined>} 672 * Returns a Promise that resolves when attempting to show the subview 673 * completes. 674 */ 675 async #showSubView(viewIdOrNode, anchor) { 676 let viewNode = 677 typeof viewIdOrNode == "string" 678 ? PanelMultiView.getViewNode(this.document, viewIdOrNode) 679 : viewIdOrNode; 680 if (!viewNode) { 681 console.error(new Error(`Subview ${viewIdOrNode} doesn't exist.`)); 682 return; 683 } 684 685 if (!this.openViews.length) { 686 console.error(new Error(`Cannot show a subview in a closed panel.`)); 687 return; 688 } 689 690 let prevPanelView = this.openViews[this.openViews.length - 1]; 691 let nextPanelView = PanelView.forNode(viewNode); 692 if (this.openViews.includes(nextPanelView)) { 693 console.error(new Error(`Subview ${viewNode.id} is already open.`)); 694 return; 695 } 696 697 // Do not re-enter the process if navigation is already in progress. Since 698 // there is only one active view at any given time, we can do this check 699 // safely, even considering that during the navigation process the actual 700 // view to which prevPanelView refers will change. 701 if (!prevPanelView.active) { 702 return; 703 } 704 // If prevPanelView._doingKeyboardActivation is true, it will be reset to 705 // false synchronously. Therefore, we must capture it before we use any 706 // "await" statements. 707 let doingKeyboardActivation = prevPanelView._doingKeyboardActivation; 708 // Marking the view that is about to scrolled out of the visible area as 709 // inactive will prevent re-entrancy and also disable keyboard navigation. 710 // From this point onwards, "await" statements can be used safely. 711 prevPanelView.active = false; 712 713 // Provide visual feedback while navigation is in progress, starting before 714 // the transition starts and ending when the previous view is invisible. 715 anchor?.setAttribute("open", "true"); 716 try { 717 // If the ViewShowing event cancels the operation we have to re-enable 718 // keyboard navigation, but this must be avoided if the panel was closed. 719 if (!(await this.#openView(nextPanelView))) { 720 if (prevPanelView.isOpenIn(this)) { 721 // We don't raise a ViewShown event because nothing actually changed. 722 // Technically we should use a different state flag just because there 723 // is code that could check the "active" property to determine whether 724 // to wait for a ViewShown event later, but this only happens in 725 // regression tests and is less likely to be a technique used in 726 // production code, where use of ViewShown is less common. 727 prevPanelView.active = true; 728 } 729 return; 730 } 731 732 prevPanelView.captureKnownSize(); 733 734 // The main view of a panel can be a subview in another one. Make sure to 735 // reset all the properties that may be set on a subview. 736 nextPanelView.mainview = false; 737 // The header may be set by a Fluent message with a title attribute 738 // that has changed immediately before showing the panelview, 739 // and so is not reflected in the DOM yet. 740 let title; 741 const l10nId = viewNode.getAttribute("data-l10n-id"); 742 if (l10nId) { 743 const l10nArgs = viewNode.getAttribute("data-l10n-args"); 744 const args = l10nArgs ? JSON.parse(l10nArgs) : undefined; 745 const [msg] = await viewNode.ownerDocument.l10n.formatMessages([ 746 { id: l10nId, args }, 747 ]); 748 title = msg.attributes.find(a => a.name === "title")?.value; 749 } 750 // If not set by Fluent, the header may change based on how the subview was opened. 751 title ??= viewNode.getAttribute("title") || anchor?.getAttribute("label"); 752 nextPanelView.headerText = title; 753 // The constrained width of subviews may also vary between panels. 754 nextPanelView.minMaxWidth = prevPanelView.knownWidth; 755 let lockPanelVertical = 756 this.openViews[0].node.getAttribute("lockpanelvertical") == "true"; 757 nextPanelView.minMaxHeight = lockPanelVertical 758 ? prevPanelView.knownHeight 759 : 0; 760 761 if (anchor) { 762 viewNode.classList.add("PanelUI-subView"); 763 } 764 765 await this.#transitionViews(prevPanelView.node, viewNode, false); 766 } finally { 767 anchor?.removeAttribute("open"); 768 } 769 770 nextPanelView.focusWhenActive = doingKeyboardActivation; 771 this.#activateView(nextPanelView); 772 } 773 774 /** 775 * Navigates backwards by sliding out the most recent subview. 776 */ 777 goBack() { 778 this.#goBack().catch(console.error); 779 } 780 781 /** 782 * The asynchronous helper method for goBack that does most of the heavy 783 * lifting. 784 * 785 * @returns {Promise<undefined>} 786 * Resolves when attempting to go back completes. 787 */ 788 async #goBack() { 789 if (this.openViews.length < 2) { 790 // This may be called by keyboard navigation or external code when only 791 // the main view is open. 792 return; 793 } 794 795 let prevPanelView = this.openViews[this.openViews.length - 1]; 796 let nextPanelView = this.openViews[this.openViews.length - 2]; 797 798 // Like in the showSubView method, do not re-enter navigation while it is 799 // in progress, and make the view inactive immediately. From this point 800 // onwards, "await" statements can be used safely. 801 if (!prevPanelView.active) { 802 return; 803 } 804 prevPanelView.active = false; 805 806 prevPanelView.captureKnownSize(); 807 await this.#transitionViews(prevPanelView.node, nextPanelView.node, true); 808 809 this.#closeLatestView(); 810 811 this.#activateView(nextPanelView); 812 } 813 814 /** 815 * Prepares the main view before showing the panel. 816 * 817 * @returns {boolean} 818 * Returns true if showing the main view succeeds. 819 */ 820 async #showMainView() { 821 let nextPanelView = PanelView.forNode( 822 PanelMultiView.getViewNode( 823 this.document, 824 this.node.getAttribute("mainViewId") 825 ) 826 ); 827 828 // If the view is already open in another panel, close the panel first. 829 let oldPanelMultiViewNode = nextPanelView.node.panelMultiView; 830 if (oldPanelMultiViewNode) { 831 PanelMultiView.forNode(oldPanelMultiViewNode).hidePopup(); 832 // Wait for a layout flush after hiding the popup, otherwise the view may 833 // not be displayed correctly for some time after the new panel is opened. 834 // This is filed as bug 1441015. 835 await this.window.promiseDocumentFlushed(() => {}); 836 } 837 838 if (!(await this.#openView(nextPanelView))) { 839 return false; 840 } 841 842 // The main view of a panel can be a subview in another one. Make sure to 843 // reset all the properties that may be set on a subview. 844 nextPanelView.mainview = true; 845 nextPanelView.headerText = ""; 846 nextPanelView.minMaxWidth = 0; 847 nextPanelView.minMaxHeight = 0; 848 849 // Ensure the view will be visible once the panel is opened. 850 nextPanelView.visible = true; 851 852 return true; 853 } 854 855 /** 856 * Opens the specified PanelView and dispatches the ViewShowing event, which 857 * can be used to populate the subview or cancel the operation. 858 * 859 * This also clears all the attributes and styles that may be left by a 860 * transition that was interrupted. 861 * 862 * @param {DOMNode} panelView 863 * The <panelview> element to show. 864 * @returns {Promise<boolean>} 865 * Resolves with true if the view was opened, false otherwise. 866 */ 867 async #openView(panelView) { 868 if (panelView.node.parentNode != this._viewStack) { 869 this._viewStack.appendChild(panelView.node); 870 } 871 872 panelView.node.panelMultiView = this.node; 873 this.openViews.push(panelView); 874 875 // Panels could contain out-pf-process <browser> elements, that need to be 876 // supported with a remote attribute on the panel in order to display properly. 877 // See bug https://bugzilla.mozilla.org/show_bug.cgi?id=1365660 878 if (panelView.node.getAttribute("remote") == "true") { 879 this.#panel.setAttribute("remote", "true"); 880 } 881 882 let canceled = await panelView.dispatchAsyncEvent("ViewShowing"); 883 884 // The panel can be hidden while we are processing the ViewShowing event. 885 // This results in all the views being closed synchronously, and at this 886 // point the ViewHiding event has already been dispatched for all of them. 887 if (!this.openViews.length) { 888 return false; 889 } 890 891 // Check if the event requested cancellation but the panel is still open. 892 if (canceled) { 893 // Handlers for ViewShowing can't know if a different handler requested 894 // cancellation, so this will dispatch a ViewHiding event to give a chance 895 // to clean up. 896 this.#closeLatestView(); 897 return false; 898 } 899 900 // Clean up all the attributes and styles related to transitions. We do this 901 // here rather than when the view is closed because we are likely to make 902 // other DOM modifications soon, which isn't the case when closing. 903 let { style } = panelView.node; 904 style.removeProperty("outline"); 905 style.removeProperty("width"); 906 907 return true; 908 } 909 910 /** 911 * Activates the specified view and raises the ViewShown event, unless the 912 * view was closed in the meantime. 913 * 914 * @param {DOMNode} panelView 915 * The <panelview> to be activated. 916 */ 917 #activateView(panelView) { 918 if (panelView.isOpenIn(this)) { 919 panelView.active = true; 920 if (panelView.focusWhenActive) { 921 panelView.focusFirstNavigableElement(false, true); 922 panelView.focusWhenActive = false; 923 } 924 panelView.dispatchCustomEvent("ViewShown"); 925 } 926 } 927 928 /** 929 * Closes the most recent PanelView and raises the ViewHiding event. 930 * 931 * Note: 932 * The ViewHiding event is not cancelable and should probably be renamed 933 * to ViewHidden or ViewClosed instead, see bug 1438507. 934 */ 935 #closeLatestView() { 936 let panelView = this.openViews.pop(); 937 panelView.clearNavigation(); 938 panelView.dispatchCustomEvent("ViewHiding"); 939 panelView.node.panelMultiView = null; 940 // Views become invisible synchronously when they are closed, and they won't 941 // become visible again until they are opened. When this is called at the 942 // end of backwards navigation, the view is already invisible. 943 panelView.visible = false; 944 } 945 946 /** 947 * Closes all the views that are currently open. 948 */ 949 closeAllViews() { 950 // Raise ViewHiding events for open views in reverse order. 951 while (this.openViews.length) { 952 this.#closeLatestView(); 953 } 954 } 955 956 /** 957 * Apply a transition to 'slide' from the currently active view to the next 958 * one. 959 * Sliding the next subview in means that the previous panelview stays where 960 * it is and the active panelview slides in from the left in LTR mode, right 961 * in RTL mode. 962 * 963 * @param {DOMNode} previousViewNode 964 * The panelview node that is currently displayed, but is about to be 965 * transitioned away. This must be already inactive at this point. 966 * @param {DOMNode} viewNode 967 * The panelview node that will becode the active view after the transition 968 * has finished. 969 * @param {boolean} reverse 970 * Whether we're navigation back to a previous view or forward to a next 971 * view. 972 */ 973 async #transitionViews(previousViewNode, viewNode, reverse) { 974 const { window } = this; 975 976 let nextPanelView = PanelView.forNode(viewNode); 977 let prevPanelView = PanelView.forNode(previousViewNode); 978 979 let details = (this._transitionDetails = { 980 phase: TRANSITION_PHASES.START, 981 }); 982 983 // Set the viewContainer dimensions to make sure only the current view is 984 // visible. 985 let olderView = reverse ? nextPanelView : prevPanelView; 986 this._viewContainer.style.minHeight = olderView.knownHeight + "px"; 987 this._viewContainer.style.height = prevPanelView.knownHeight + "px"; 988 this._viewContainer.style.width = prevPanelView.knownWidth + "px"; 989 // Lock the dimensions of the window that hosts the popup panel. 990 let rect = this._getBoundsWithoutFlushing(this.#panel); 991 this.#panel.style.width = rect.width + "px"; 992 this.#panel.style.height = rect.height + "px"; 993 994 let viewRect; 995 if (reverse) { 996 // Use the cached size when going back to a previous view, but not when 997 // reopening a subview, because its contents may have changed. 998 viewRect = { 999 width: nextPanelView.knownWidth, 1000 height: nextPanelView.knownHeight, 1001 }; 1002 nextPanelView.visible = true; 1003 } else if (viewNode.customRectGetter) { 1004 // We use a customRectGetter for WebExtensions panels, because they need 1005 // to query the size from an embedded browser. The presence of this 1006 // getter also provides an indication that the view node shouldn't be 1007 // moved around, otherwise the state of the browser would get disrupted. 1008 let width = prevPanelView.knownWidth; 1009 let height = prevPanelView.knownHeight; 1010 viewRect = Object.assign({ height, width }, viewNode.customRectGetter()); 1011 nextPanelView.visible = true; 1012 // Until the header is visible, it has 0 height. 1013 // Wait for layout before measuring it 1014 let header = viewNode.firstElementChild; 1015 if (header && header.classList.contains("panel-header")) { 1016 viewRect.height += await window.promiseDocumentFlushed(() => { 1017 return this._getBoundsWithoutFlushing(header).height; 1018 }); 1019 } 1020 // Bail out if the panel was closed in the meantime. 1021 if (!nextPanelView.isOpenIn(this)) { 1022 return; 1023 } 1024 } else { 1025 this._offscreenViewStack.style.minHeight = olderView.knownHeight + "px"; 1026 this._offscreenViewStack.appendChild(viewNode); 1027 nextPanelView.visible = true; 1028 1029 viewRect = await window.promiseDocumentFlushed(() => { 1030 return this._getBoundsWithoutFlushing(viewNode); 1031 }); 1032 // Bail out if the panel was closed in the meantime. 1033 if (!nextPanelView.isOpenIn(this)) { 1034 return; 1035 } 1036 1037 // Place back the view after all the other views that are already open in 1038 // order for the transition to work as expected. 1039 this._viewStack.appendChild(viewNode); 1040 1041 this._offscreenViewStack.style.removeProperty("min-height"); 1042 } 1043 1044 this.#transitioning = true; 1045 details.phase = TRANSITION_PHASES.PREPARE; 1046 1047 // The 'magic' part: build up the amount of pixels to move right or left. 1048 let moveToLeft = 1049 (this.window.RTL_UI && !reverse) || (!this.window.RTL_UI && reverse); 1050 let deltaX = prevPanelView.knownWidth; 1051 let deepestNode = reverse ? previousViewNode : viewNode; 1052 1053 // With a transition when navigating backwards - user hits the 'back' 1054 // button - we need to make sure that the views are positioned in a way 1055 // that a translateX() unveils the previous view from the right direction. 1056 if (reverse) { 1057 this._viewStack.style.marginInlineStart = "-" + deltaX + "px"; 1058 } 1059 1060 // Set the transition style and listen for its end to clean up and make sure 1061 // the box sizing becomes dynamic again. 1062 // Somehow, putting these properties in PanelUI.css doesn't work for newly 1063 // shown nodes in a XUL parent node. 1064 this._viewStack.style.transition = 1065 "transform var(--animation-easing-function)" + 1066 " var(--panelui-subview-transition-duration)"; 1067 this._viewStack.style.willChange = "transform"; 1068 // Use an outline instead of a border so that the size is not affected. 1069 deepestNode.style.outline = "1px solid var(--panel-separator-color)"; 1070 1071 // Now that all the elements are in place for the start of the transition, 1072 // give the layout code a chance to set the initial values. 1073 await window.promiseDocumentFlushed(() => {}); 1074 // Bail out if the panel was closed in the meantime. 1075 if (!nextPanelView.isOpenIn(this)) { 1076 return; 1077 } 1078 1079 // Now set the viewContainer dimensions to that of the new view, which 1080 // kicks of the height animation. 1081 this._viewContainer.style.height = viewRect.height + "px"; 1082 this._viewContainer.style.width = viewRect.width + "px"; 1083 this.#panel.style.removeProperty("width"); 1084 this.#panel.style.removeProperty("height"); 1085 // We're setting the width property to prevent flickering during the 1086 // sliding animation with smaller views. 1087 viewNode.style.width = viewRect.width + "px"; 1088 1089 // Kick off the transition! 1090 details.phase = TRANSITION_PHASES.TRANSITION; 1091 1092 // If we're going to show the main view, we can remove the 1093 // min-height property on the view container. It's also time 1094 // to set the mainviewshowing attribute on the popup. 1095 if (viewNode.getAttribute("mainview")) { 1096 this._viewContainer.style.removeProperty("min-height"); 1097 this.#panel.setAttribute("mainviewshowing", true); 1098 } else { 1099 this.#panel.removeAttribute("mainviewshowing"); 1100 } 1101 1102 // Avoid transforming element if the user has prefers-reduced-motion set 1103 if ( 1104 this.window.matchMedia("(prefers-reduced-motion: no-preference)") 1105 .matches && 1106 !viewNode.getAttribute("no-panelview-transition") 1107 ) { 1108 this._viewStack.style.transform = 1109 "translateX(" + (moveToLeft ? "" : "-") + deltaX + "px)"; 1110 1111 await new Promise(resolve => { 1112 details.resolve = resolve; 1113 this._viewContainer.addEventListener( 1114 "transitionend", 1115 (details.listener = ev => { 1116 // It's quite common that `height` on the view container doesn't need 1117 // to transition, so we make sure to do all the work on the transform 1118 // transition-end, because that is guaranteed to happen. 1119 if ( 1120 ev.target != this._viewStack || 1121 ev.propertyName != "transform" 1122 ) { 1123 return; 1124 } 1125 this._viewContainer.removeEventListener( 1126 "transitionend", 1127 details.listener 1128 ); 1129 delete details.listener; 1130 resolve(); 1131 }) 1132 ); 1133 this._viewContainer.addEventListener( 1134 "transitioncancel", 1135 (details.cancelListener = ev => { 1136 if (ev.target != this._viewStack) { 1137 return; 1138 } 1139 this._viewContainer.removeEventListener( 1140 "transitioncancel", 1141 details.cancelListener 1142 ); 1143 delete details.cancelListener; 1144 resolve(); 1145 }) 1146 ); 1147 }); 1148 } 1149 1150 // Bail out if the panel was closed during the transition. 1151 if (!nextPanelView.isOpenIn(this)) { 1152 return; 1153 } 1154 prevPanelView.visible = false; 1155 1156 // This will complete the operation by removing any transition properties. 1157 nextPanelView.node.style.removeProperty("width"); 1158 deepestNode.style.removeProperty("outline"); 1159 this.#cleanupTransitionPhase(); 1160 // Ensure the newly-visible view has been through a layout flush before we 1161 // attempt to focus anything in it. 1162 // See https://firefox-source-docs.mozilla.org/performance/bestpractices.html#detecting-and-avoiding-synchronous-reflow 1163 // for more information. 1164 await this.window.promiseDocumentFlushed(() => {}); 1165 nextPanelView.focusSelectedElement(); 1166 } 1167 1168 /** 1169 * Attempt to clean up the attributes and properties set by `#transitionViews` 1170 * above. Which attributes and properties depends on the phase the transition 1171 * was left from. 1172 */ 1173 #cleanupTransitionPhase() { 1174 if (!this._transitionDetails) { 1175 return; 1176 } 1177 1178 let { phase, resolve, listener, cancelListener } = this._transitionDetails; 1179 this._transitionDetails = null; 1180 1181 if (phase >= TRANSITION_PHASES.START) { 1182 this.#panel.removeAttribute("width"); 1183 this.#panel.removeAttribute("height"); 1184 this._viewContainer.style.removeProperty("height"); 1185 this._viewContainer.style.removeProperty("width"); 1186 } 1187 if (phase >= TRANSITION_PHASES.PREPARE) { 1188 this.#transitioning = false; 1189 this._viewStack.style.removeProperty("margin-inline-start"); 1190 this._viewStack.style.removeProperty("transition"); 1191 } 1192 if (phase >= TRANSITION_PHASES.TRANSITION) { 1193 this._viewStack.style.removeProperty("transform"); 1194 if (listener) { 1195 this._viewContainer.removeEventListener("transitionend", listener); 1196 } 1197 if (cancelListener) { 1198 this._viewContainer.removeEventListener( 1199 "transitioncancel", 1200 cancelListener 1201 ); 1202 } 1203 if (resolve) { 1204 resolve(); 1205 } 1206 } 1207 } 1208 1209 /** 1210 * Centralized event handler for the associated <panelmultiview>. 1211 * 1212 * @param {Event} aEvent 1213 * The event being handled. 1214 */ 1215 handleEvent(aEvent) { 1216 // Only process actual popup events from the panel or events we generate 1217 // ourselves, but not from menus being shown from within the panel. 1218 if ( 1219 aEvent.type.startsWith("popup") && 1220 aEvent.target != this.#panel && 1221 aEvent.target != this.node 1222 ) { 1223 return; 1224 } 1225 switch (aEvent.type) { 1226 case "keydown": { 1227 // Since we start listening for the "keydown" event when the popup is 1228 // already showing and stop listening when the panel is hidden, we 1229 // always have at least one view open. 1230 let currentView = this.openViews[this.openViews.length - 1]; 1231 currentView.keyNavigation(aEvent); 1232 break; 1233 } 1234 case "mousemove": { 1235 this.openViews.forEach(panelView => { 1236 if (!panelView.ignoreMouseMove) { 1237 panelView.clearNavigation(); 1238 } 1239 }); 1240 break; 1241 } 1242 case "popupshowing": { 1243 this._viewContainer.setAttribute("panelopen", "true"); 1244 if (!this.node.hasAttribute("disablekeynav")) { 1245 // We add the keydown handler on the root so that it handles key 1246 // presses when a panel appears but doesn't get focus, as happens 1247 // when a button to open a panel is clicked with the mouse. 1248 // However, this means the listener is on an ancestor of the panel, 1249 // which means that handlers such as ToolbarKeyboardNavigator are 1250 // deeper in the tree. Therefore, this must be a capturing listener 1251 // so we get the event first. 1252 this.document.documentElement.addEventListener("keydown", this, true); 1253 this.#panel.addEventListener("mousemove", this); 1254 } 1255 break; 1256 } 1257 case "popupshown": { 1258 // The main view is always open and visible when the panel is first 1259 // shown, so we can check the height of the description elements it 1260 // contains and notify consumers using the ViewShown event. In order to 1261 // minimize flicker we need to allow synchronous reflows, and we still 1262 // make sure the ViewShown event is dispatched synchronously. 1263 let mainPanelView = this.openViews[0]; 1264 this.#activateView(mainPanelView); 1265 break; 1266 } 1267 case "popuphidden": { 1268 // WebExtensions consumers can hide the popup from viewshowing, or 1269 // mid-transition, which disrupts our state: 1270 this.#transitioning = false; 1271 this._viewContainer.removeAttribute("panelopen"); 1272 this.#cleanupTransitionPhase(); 1273 this.document.documentElement.removeEventListener( 1274 "keydown", 1275 this, 1276 true 1277 ); 1278 this.#panel.removeEventListener("mousemove", this); 1279 this.closeAllViews(); 1280 1281 // Clear the main view size caches. The dimensions could be different 1282 // when the popup is opened again, e.g. through touch mode sizing. 1283 this._viewContainer.style.removeProperty("min-height"); 1284 this._viewStack.style.removeProperty("max-height"); 1285 this._viewContainer.style.removeProperty("width"); 1286 this._viewContainer.style.removeProperty("height"); 1287 1288 this.dispatchCustomEvent("PanelMultiViewHidden"); 1289 break; 1290 } 1291 } 1292 } 1293 }; 1294 1295 /** 1296 * This is associated to <panelview> elements. 1297 */ 1298 export var PanelView = class extends AssociatedToNode { 1299 constructor(node) { 1300 super(node); 1301 1302 /** 1303 * Indicates whether the view is active. When this is false, consumers can 1304 * wait for the ViewShown event to know when the view becomes active. 1305 */ 1306 this.active = false; 1307 1308 /** 1309 * Specifies whether the view should be focused when active. When this 1310 * is true, the first navigable element in the view will be focused 1311 * when the view becomes active. This should be set to true when the view 1312 * is activated from the keyboard. It will be set to false once the view 1313 * is active. 1314 */ 1315 this.focusWhenActive = false; 1316 } 1317 1318 /** 1319 * Indicates whether the view is open in the specified PanelMultiView object. 1320 * 1321 * @param {PanelMultiView} panelMultiView 1322 * The PanelMultiView instance to check. 1323 * @returns {boolean} 1324 * True if the PanelView is open in the specified PanelMultiView object. 1325 */ 1326 isOpenIn(panelMultiView) { 1327 return this.node.panelMultiView == panelMultiView.node; 1328 } 1329 1330 /** 1331 * The "mainview" attribute is set before the panel is opened when this view 1332 * is displayed as the main view, and is removed before the <panelview> is 1333 * displayed as a subview. The same view element can be displayed as a main 1334 * view and as a subview at different times. 1335 * 1336 * @param {boolean} value 1337 * True to set the `"mainview"` attribute to `true`, otherwise removes the 1338 * attribute. 1339 */ 1340 set mainview(value) { 1341 if (value) { 1342 this.node.setAttribute("mainview", true); 1343 } else { 1344 this.node.removeAttribute("mainview"); 1345 } 1346 } 1347 1348 /** 1349 * Determines whether the view is visible. Setting this to false also resets 1350 * the "active" property. 1351 * 1352 * @param {boolean} value 1353 * True to set the `"visible"` attribute to `true`, otherwise removes the 1354 * attribute, and sets active and focusWhenActive to `false`. 1355 */ 1356 set visible(value) { 1357 if (value) { 1358 this.node.setAttribute("visible", true); 1359 } else { 1360 this.node.removeAttribute("visible"); 1361 this.active = false; 1362 this.focusWhenActive = false; 1363 } 1364 } 1365 1366 /** 1367 * Constrains the width of this view using the "min-width" and "max-width" 1368 * styles. Setting this to zero removes the constraints. 1369 * 1370 * @param {number} value 1371 * Sets the min and max width of the element to ${value}px. 1372 */ 1373 set minMaxWidth(value) { 1374 let style = this.node.style; 1375 if (value) { 1376 style.minWidth = style.maxWidth = value + "px"; 1377 } else { 1378 style.removeProperty("min-width"); 1379 style.removeProperty("max-width"); 1380 } 1381 } 1382 1383 /** 1384 * Constrains the height of this view using the "min-height" and "max-height" 1385 * styles. Setting this to zero removes the constraints. 1386 * 1387 * @param {number} value 1388 * Sets the min and max height of the element to ${value}px. 1389 */ 1390 set minMaxHeight(value) { 1391 let style = this.node.style; 1392 if (value) { 1393 style.minHeight = style.maxHeight = value + "px"; 1394 } else { 1395 style.removeProperty("min-height"); 1396 style.removeProperty("max-height"); 1397 } 1398 } 1399 1400 /** 1401 * Adds a header with the given title, or removes it if the title is empty. 1402 * 1403 * If an element matching `.panel-header` is found in the PanelView, then 1404 * this method will attempt to set the textContent of the first `h1 > span` 1405 * underneath that `.panel-header` to `value`. 1406 * 1407 * Otherwise, this will attempt to insert a header element and a separator 1408 * beneath it, with the text of the header set to `value`. 1409 * 1410 * If `value` is null, then these elements are cleared and removed. 1411 * 1412 * @param {string} value 1413 * The header to set. 1414 */ 1415 set headerText(value) { 1416 let ensureHeaderSeparator = headerNode => { 1417 if (headerNode.nextSibling.tagName != "toolbarseparator") { 1418 let separator = this.document.createXULElement("toolbarseparator"); 1419 this.node.insertBefore(separator, headerNode.nextSibling); 1420 } 1421 }; 1422 1423 // If the header already exists, update or remove it as requested. 1424 let isMainView = this.node.getAttribute("mainview"); 1425 let header = this.node.querySelector(".panel-header"); 1426 if (header) { 1427 let headerBackButton = header.querySelector(".subviewbutton-back"); 1428 if (isMainView) { 1429 if (headerBackButton) { 1430 // A back button should not appear in a mainview. 1431 // This codepath can be reached if a user enters a panelview in 1432 // the overflow panel (like the Profiler), and then unpins it back to the toolbar. 1433 headerBackButton.remove(); 1434 } 1435 } 1436 if (value) { 1437 if ( 1438 !isMainView && 1439 !headerBackButton && 1440 !this.node.getAttribute("no-back-button") 1441 ) { 1442 // Add a back button when not in mainview (if it doesn't exist already), 1443 // also when a panelview specifies it doesn't want a back button, 1444 // like the Report Broken Site (sent) panelview. 1445 header.prepend(this.createHeaderBackButton()); 1446 } 1447 // Set the header title based on the value given. 1448 header.querySelector(".panel-header > h1 > span").textContent = value; 1449 ensureHeaderSeparator(header); 1450 } else if ( 1451 !this.node.getAttribute("has-custom-header") && 1452 !this.node.getAttribute("mainview-with-header") 1453 ) { 1454 // No value supplied, and the panelview doesn't have a certain requirement 1455 // for any kind of header, so remove it and the following toolbarseparator. 1456 if (header.nextSibling.tagName == "toolbarseparator") { 1457 header.nextSibling.remove(); 1458 } 1459 header.remove(); 1460 return; 1461 } 1462 // Either the header exists and has been adjusted accordingly by now, 1463 // or it doesn't (or shouldn't) exist. Bail out to not create a duplicate header. 1464 return; 1465 } 1466 1467 // The header doesn't and shouldn't exist, only create it if needed. 1468 if (!value) { 1469 return; 1470 } 1471 1472 header = this.document.createXULElement("box"); 1473 header.classList.add("panel-header"); 1474 1475 if (!isMainView) { 1476 let backButton = this.createHeaderBackButton(); 1477 header.append(backButton); 1478 } 1479 1480 let h1 = this.document.createElement("h1"); 1481 let span = this.document.createElement("span"); 1482 span.textContent = value; 1483 h1.appendChild(span); 1484 1485 header.append(h1); 1486 this.node.prepend(header); 1487 1488 ensureHeaderSeparator(header); 1489 } 1490 1491 /** 1492 * Creates and returns a panel header back toolbarbutton. 1493 */ 1494 createHeaderBackButton() { 1495 let backButton = this.document.createXULElement("toolbarbutton"); 1496 backButton.className = 1497 "subviewbutton subviewbutton-iconic subviewbutton-back"; 1498 backButton.setAttribute("closemenu", "none"); 1499 backButton.setAttribute("tabindex", "0"); 1500 backButton.setAttribute( 1501 "aria-label", 1502 lazy.gBundle.GetStringFromName("panel.back") 1503 ); 1504 backButton.addEventListener("command", () => { 1505 // The panelmultiview element may change if the view is reused. 1506 this.node.panelMultiView.goBack(); 1507 backButton.blur(); 1508 }); 1509 return backButton; 1510 } 1511 1512 /** 1513 * Dispatches a custom event on the PanelView, and also makes sure that the 1514 * correct method is called on CustomizableWidget if applicable. 1515 * 1516 * @see AssociatedToNode.dispatchCustomEvent 1517 * @param {...*} args 1518 * Additional arguments to be forwarded to the dispatchCustomEvent method of 1519 * AssociatedToNode. 1520 */ 1521 dispatchCustomEvent(...args) { 1522 lazy.CustomizableUI.ensureSubviewListeners(this.node); 1523 return super.dispatchCustomEvent(...args); 1524 } 1525 1526 /** 1527 * Populates the "knownWidth" and "knownHeight" properties with the current 1528 * dimensions of the view. These may be zero if the view is invisible. 1529 * 1530 * These values are relevant during transitions and are retained for backwards 1531 * navigation if the view is still open but is invisible. 1532 */ 1533 captureKnownSize() { 1534 let rect = this._getBoundsWithoutFlushing(this.node); 1535 this.knownWidth = rect.width; 1536 this.knownHeight = rect.height; 1537 } 1538 1539 /** 1540 * Determine whether an element can only be navigated to with tab/shift+tab, 1541 * not the arrow keys. 1542 * 1543 * @param {DOMNode} element 1544 * The element to check for navigation with tab only. 1545 * @returns {boolean} 1546 * True if the element can only be navigated to with tab/shift+tab. 1547 */ 1548 #isNavigableWithTabOnly(element) { 1549 let tag = element.localName; 1550 return ( 1551 tag == "menulist" || 1552 tag == "select" || 1553 tag == "radiogroup" || 1554 tag == "input" || 1555 tag == "textarea" || 1556 // Allow tab to reach embedded documents. 1557 tag == "browser" || 1558 tag == "iframe" || 1559 // This is currently needed for the unified extensions panel to allow 1560 // users to use up/down arrow to more quickly move between the extension 1561 // items. See Bug 1784118 1562 element.dataset?.navigableWithTabOnly === "true" 1563 ); 1564 } 1565 1566 /** 1567 * Make a TreeWalker for keyboard navigation. 1568 * 1569 * @param {boolean} arrowKey 1570 * If `true`, elements only navigable with tab are excluded. 1571 * @returns {TreeWalker} 1572 * The created TreeWalker instance. 1573 */ 1574 #makeNavigableTreeWalker(arrowKey) { 1575 let filter = node => { 1576 if (node.disabled) { 1577 return NodeFilter.FILTER_REJECT; 1578 } 1579 let bounds = this._getBoundsWithoutFlushing(node); 1580 if (bounds.width == 0 || bounds.height == 0) { 1581 return NodeFilter.FILTER_REJECT; 1582 } 1583 let isNavigableWithTabOnly = this.#isNavigableWithTabOnly(node); 1584 // Early return when the node is navigable with tab only and we are using 1585 // arrow keys so that nodes like button, toolbarbutton, checkbox, etc. 1586 // can also be marked as "navigable with tab only", otherwise the next 1587 // condition will unconditionally make them focusable. 1588 if (arrowKey && isNavigableWithTabOnly) { 1589 return NodeFilter.FILTER_REJECT; 1590 } 1591 let localName = node.localName.toLowerCase(); 1592 if ( 1593 localName == "button" || 1594 localName == "toolbarbutton" || 1595 localName == "checkbox" || 1596 localName == "a" || 1597 localName == "moz-button" || 1598 localName == "moz-box-button" || 1599 localName == "moz-toggle" || 1600 node.classList.contains("text-link") || 1601 (!arrowKey && isNavigableWithTabOnly) || 1602 node.dataset?.capturesFocus === "true" 1603 ) { 1604 // Set the tabindex attribute to make sure the node is focusable. 1605 // Don't do this for browser and iframe elements because this breaks 1606 // tabbing behavior. They're already focusable anyway. 1607 if ( 1608 localName != "browser" && 1609 localName != "iframe" && 1610 !node.hasAttribute("tabindex") && 1611 node.dataset?.capturesFocus !== "true" 1612 ) { 1613 node.setAttribute("tabindex", "-1"); 1614 } 1615 return NodeFilter.FILTER_ACCEPT; 1616 } 1617 return NodeFilter.FILTER_SKIP; 1618 }; 1619 return this.document.createTreeWalker( 1620 this.node, 1621 NodeFilter.SHOW_ELEMENT, 1622 filter 1623 ); 1624 } 1625 1626 /** 1627 * Get a TreeWalker which finds elements navigable with tab/shift+tab. 1628 * 1629 * This is currently pseudo private with the underscore because 1630 * AccessibilityUtils.js appears to be accessing this. 1631 * 1632 * @returns {TreeWalker} 1633 * The TreeWalker for tab/shift+tab navigable elements. 1634 */ 1635 #_tabNavigableWalker = null; 1636 get _tabNavigableWalker() { 1637 if (!this.#_tabNavigableWalker) { 1638 this.#_tabNavigableWalker = this.#makeNavigableTreeWalker(false); 1639 } 1640 return this.#_tabNavigableWalker; 1641 } 1642 1643 /** 1644 * Get a TreeWalker which finds elements navigable with up/down arrow keys. 1645 * 1646 * @returns {TreeWalker} 1647 * The TreeWalker for arrow key navigable elements. 1648 */ 1649 #_arrowNavigableWalker = null; 1650 get #arrowNavigableWalker() { 1651 if (!this.#_arrowNavigableWalker) { 1652 this.#_arrowNavigableWalker = this.#makeNavigableTreeWalker(true); 1653 } 1654 return this.#_arrowNavigableWalker; 1655 } 1656 1657 /** 1658 * Element that is currently selected with the keyboard, or null if no element 1659 * is selected. Since the reference is held weakly, it can become null or 1660 * undefined at any time. 1661 * 1662 * @type {DOMNode|null} 1663 * The selected element, or null if no element is selected. 1664 */ 1665 get selectedElement() { 1666 return this._selectedElement && this._selectedElement.get(); 1667 } 1668 1669 set selectedElement(value) { 1670 if (!value) { 1671 delete this._selectedElement; 1672 } else { 1673 this._selectedElement = Cu.getWeakReference(value); 1674 } 1675 } 1676 1677 /** 1678 * Focuses and moves keyboard selection to the first navigable element. 1679 * This is a no-op if there are no navigable elements. 1680 * 1681 * @param {boolean} [homeKey=false] 1682 * True if this is for the home key. 1683 * @param {boolean} [skipBack=false] 1684 * True if the Back button should be skipped. 1685 */ 1686 focusFirstNavigableElement(homeKey = false, skipBack = false) { 1687 // The home key is conceptually similar to the up/down arrow keys. 1688 let walker = homeKey 1689 ? this.#arrowNavigableWalker 1690 : this._tabNavigableWalker; 1691 walker.currentNode = walker.root; 1692 this.selectedElement = walker.firstChild(); 1693 if ( 1694 skipBack && 1695 walker.currentNode && 1696 walker.currentNode.classList.contains("subviewbutton-back") && 1697 walker.nextNode() 1698 ) { 1699 this.selectedElement = walker.currentNode; 1700 } 1701 this.focusSelectedElement(/* byKey */ true); 1702 } 1703 1704 /** 1705 * Focuses and moves keyboard selection to the last navigable element. 1706 * This is a no-op if there are no navigable elements. 1707 * 1708 * @param {boolean} [endKey=false] 1709 * True if this is for the end key. 1710 */ 1711 focusLastNavigableElement(endKey = false) { 1712 // The end key is conceptually similar to the up/down arrow keys. 1713 let walker = endKey ? this.#arrowNavigableWalker : this._tabNavigableWalker; 1714 walker.currentNode = walker.root; 1715 this.selectedElement = walker.lastChild(); 1716 this.focusSelectedElement(/* byKey */ true); 1717 } 1718 1719 /** 1720 * Based on going up or down, select the previous or next focusable element. 1721 * 1722 * @param {boolean} isDown 1723 * True if the selection is going down, false if going up. 1724 * @param {boolean} [arrowKey=false] 1725 * True if this is for the up/down arrow keys. 1726 * @returns {DOMNode} the element we selected. 1727 */ 1728 moveSelection(isDown, arrowKey = false) { 1729 let walker = arrowKey 1730 ? this.#arrowNavigableWalker 1731 : this._tabNavigableWalker; 1732 let oldSel = this.selectedElement; 1733 let newSel; 1734 if (oldSel) { 1735 walker.currentNode = oldSel; 1736 newSel = isDown ? walker.nextNode() : walker.previousNode(); 1737 } 1738 // If we couldn't find something, select the first or last item: 1739 if (!newSel) { 1740 walker.currentNode = walker.root; 1741 newSel = isDown ? walker.firstChild() : walker.lastChild(); 1742 } 1743 this.selectedElement = newSel; 1744 return newSel; 1745 } 1746 1747 /** 1748 * Allow for navigating subview buttons using the arrow keys and the Enter key. 1749 * The Up and Down keys can be used to navigate the list up and down and the 1750 * Enter, Right or Left - depending on the text direction - key can be used to 1751 * simulate a click on the currently selected button. 1752 * The Right or Left key - depending on the text direction - can be used to 1753 * navigate to the previous view, functioning as a shortcut for the view's 1754 * back button. 1755 * Thus, in LTR mode: 1756 * - The Right key functions the same as the Enter key, simulating a click 1757 * - The Left key triggers a navigation back to the previous view. 1758 * 1759 * Key navigation is only enabled while the view is active, meaning that this 1760 * method will return early if it is invoked during a sliding transition. 1761 * 1762 * @param {KeyEvent} event 1763 * The KeyEvent to potentially perform navigation for. 1764 */ 1765 keyNavigation(event) { 1766 if (!this.active) { 1767 return; 1768 } 1769 1770 let focus = this.document.activeElement; 1771 // Make sure the focus is actually inside the panel. (It might not be if 1772 // the panel was opened with the mouse.) If it isn't, we don't care 1773 // about it for our purposes. 1774 // We use Node.compareDocumentPosition because Node.contains doesn't 1775 // behave as expected for anonymous content; e.g. the input inside a 1776 // textbox. 1777 if ( 1778 focus && 1779 !( 1780 this.node.compareDocumentPosition(focus) & 1781 Node.DOCUMENT_POSITION_CONTAINED_BY 1782 ) 1783 ) { 1784 focus = null; 1785 } 1786 1787 // Some panels contain embedded documents or need to capture focus events. 1788 // We can't manage keyboard navigation within those. 1789 if ( 1790 focus && 1791 (focus.tagName == "browser" || 1792 focus.tagName == "iframe" || 1793 focus.dataset?.capturesFocus === "true") 1794 ) { 1795 return; 1796 } 1797 1798 let stop = () => { 1799 event.stopPropagation(); 1800 event.preventDefault(); 1801 }; 1802 1803 // If the focused element is only navigable with tab, it wants the arrow 1804 // keys, etc. We shouldn't handle any keys except tab and shift+tab. 1805 // We make a function for this for performance reasons: we only want to 1806 // check this for keys we potentially care about, not *all* keys. 1807 let tabOnly = () => { 1808 // We use the real focus rather than this.selectedElement because focus 1809 // might have been moved without keyboard navigation (e.g. mouse click) 1810 // and this.selectedElement is only updated for keyboard navigation. 1811 return focus && this.#isNavigableWithTabOnly(focus); 1812 }; 1813 1814 // If a context menu is open, we must let it handle all keys. 1815 // Normally, this just happens, but because we have a capturing root 1816 // element keydown listener, our listener takes precedence. 1817 // Again, we only want to do this check on demand for performance. 1818 let isContextMenuOpen = () => { 1819 if (!focus) { 1820 return false; 1821 } 1822 let contextNode = focus.closest("[context]"); 1823 if (!contextNode) { 1824 return false; 1825 } 1826 let context = contextNode.getAttribute("context"); 1827 if (!context) { 1828 return false; 1829 } 1830 let popup = this.document.getElementById(context); 1831 return popup && popup.state == "open"; 1832 }; 1833 1834 this.ignoreMouseMove = false; 1835 1836 let keyCode = event.code; 1837 switch (keyCode) { 1838 case "ArrowDown": 1839 case "ArrowUp": 1840 if (tabOnly()) { 1841 break; 1842 } 1843 // Fall-through... 1844 case "Tab": { 1845 if ( 1846 isContextMenuOpen() || 1847 // Tab in an open menulist should close it. 1848 (focus && focus.localName == "menulist" && focus.open) 1849 ) { 1850 break; 1851 } 1852 stop(); 1853 let isDown = 1854 keyCode == "ArrowDown" || (keyCode == "Tab" && !event.shiftKey); 1855 let button = this.moveSelection(isDown, keyCode != "Tab"); 1856 button.focus(); 1857 break; 1858 } 1859 case "Home": 1860 if (tabOnly() || isContextMenuOpen()) { 1861 break; 1862 } 1863 stop(); 1864 this.focusFirstNavigableElement(true); 1865 break; 1866 case "End": 1867 if (tabOnly() || isContextMenuOpen()) { 1868 break; 1869 } 1870 stop(); 1871 this.focusLastNavigableElement(true); 1872 break; 1873 case "ArrowLeft": 1874 case "ArrowRight": { 1875 if (tabOnly() || isContextMenuOpen()) { 1876 break; 1877 } 1878 stop(); 1879 if ( 1880 (!this.window.RTL_UI && keyCode == "ArrowLeft") || 1881 (this.window.RTL_UI && keyCode == "ArrowRight") 1882 ) { 1883 this.node.panelMultiView.goBack(); 1884 break; 1885 } 1886 // If the current button is _not_ one that points to a subview, pressing 1887 // the arrow key shouldn't do anything. 1888 let button = this.selectedElement; 1889 if ( 1890 !button || 1891 !( 1892 button.classList.contains("subviewbutton-nav") || 1893 button.classList.contains("moz-button-subviewbutton-nav") 1894 ) 1895 ) { 1896 break; 1897 } 1898 } 1899 // Fall-through... 1900 case "Space": 1901 case "NumpadEnter": 1902 case "Enter": { 1903 if (tabOnly() || isContextMenuOpen()) { 1904 break; 1905 } 1906 let button = this.selectedElement; 1907 if (!button || button?.localName == "moz-toggle") { 1908 break; 1909 } 1910 stop(); 1911 1912 this._doingKeyboardActivation = true; 1913 const details = { 1914 bubbles: true, 1915 ctrlKey: event.ctrlKey, 1916 altKey: event.altKey, 1917 shiftKey: event.shiftKey, 1918 metaKey: event.metaKey, 1919 }; 1920 // The a11y-checks want the target to be accessible. For moz-button the 1921 // focus is really on the inner button which is accessible, but we check 1922 // a11y against the event target (moz-button) which fails. Dispatch from 1923 // the inner button element instead. 1924 let target = button; 1925 if ( 1926 button.localName == "moz-button" || 1927 button.localName == "moz-box-button" 1928 ) { 1929 target = button.buttonEl; 1930 details.composed = true; 1931 } 1932 let dispEvent = new event.target.ownerGlobal.MouseEvent( 1933 "mousedown", 1934 details 1935 ); 1936 target.dispatchEvent(dispEvent); 1937 // This event will trigger a command event too. 1938 dispEvent = new event.target.ownerGlobal.PointerEvent("click", details); 1939 target.dispatchEvent(dispEvent); 1940 this._doingKeyboardActivation = false; 1941 break; 1942 } 1943 } 1944 } 1945 1946 /** 1947 * Focus the last selected element in the view, if any. 1948 * 1949 * @param {boolean} [byKey=false] 1950 * True if focus was moved by the user pressing a key. Needed to ensure we 1951 * show focus styles in the right cases. 1952 */ 1953 focusSelectedElement(byKey = false) { 1954 let selected = this.selectedElement; 1955 if (selected) { 1956 let flag = byKey ? Services.focus.FLAG_BYKEY : 0; 1957 Services.focus.setFocus(selected, flag); 1958 } 1959 } 1960 1961 /** 1962 * Clear all traces of keyboard navigation happening right now. 1963 */ 1964 clearNavigation() { 1965 let selected = this.selectedElement; 1966 if (selected) { 1967 selected.blur(); 1968 this.selectedElement = null; 1969 } 1970 } 1971 };