1// Copyright 2014 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5cr.define('cr.ui.pageManager', function() { 6 /** @const */ var FocusOutlineManager = cr.ui.FocusOutlineManager; 7 8 /** 9 * PageManager contains a list of root Page and overlay Page objects and 10 * handles "navigation" by showing and hiding these pages and overlays. On 11 * initial load, PageManager can use the path to open the correct hierarchy 12 * of pages and overlay(s). Handlers for user events, like pressing buttons, 13 * can call into PageManager to open a particular overlay or cancel an 14 * existing overlay. 15 */ 16 var PageManager = { 17 /** 18 * True if page is served from a dialog. 19 * @type {boolean} 20 */ 21 isDialog: false, 22 23 /** 24 * Offset of page container in pixels. Uber pages that use the side menu 25 * can override this with the setter. 26 * The default (23) comes from -webkit-margin-start in uber_shared.css. 27 * @type {number} 28 */ 29 horizontalOffset_: 23, 30 31 /** 32 * Root pages. Maps lower-case page names to the respective page object. 33 * @type {!Object.<string, !cr.ui.pageManager.Page>} 34 */ 35 registeredPages: {}, 36 37 /** 38 * Pages which are meant to behave like modal dialogs. Maps lower-case 39 * overlay names to the respective overlay object. 40 * @type {!Object.<string, !cr.ui.pageManager.Page>} 41 * @private 42 */ 43 registeredOverlayPages: {}, 44 45 /** 46 * Observers will be notified when opening and closing overlays. 47 * @type {!Array.<!cr.ui.pageManager.PageManager.Observer>} 48 */ 49 observers_: [], 50 51 /** 52 * Initializes the complete page. 53 * @param {cr.ui.pageManager.Page} defaultPage The page to be shown when no 54 * page is specified in the path. 55 */ 56 initialize: function(defaultPage) { 57 this.defaultPage_ = defaultPage; 58 59 FocusOutlineManager.forDocument(document); 60 document.addEventListener('scroll', this.handleScroll_.bind(this)); 61 62 // Trigger the scroll handler manually to set the initial state. 63 this.handleScroll_(); 64 65 // Shake the dialog if the user clicks outside the dialog bounds. 66 var containers = document.querySelectorAll('body > .overlay'); 67 for (var i = 0; i < containers.length; i++) { 68 var overlay = containers[i]; 69 cr.ui.overlay.setupOverlay(overlay); 70 overlay.addEventListener('cancelOverlay', 71 this.cancelOverlay.bind(this)); 72 } 73 74 cr.ui.overlay.globalInitialization(); 75 }, 76 77 /** 78 * Registers new page. 79 * @param {!cr.ui.pageManager.Page} page Page to register. 80 */ 81 register: function(page) { 82 this.registeredPages[page.name.toLowerCase()] = page; 83 page.initializePage(); 84 }, 85 86 /** 87 * Registers a new Overlay page. 88 * @param {!cr.ui.pageManager.Page} overlay Overlay to register. 89 * @param {cr.ui.pageManager.Page} parentPage Associated parent page for 90 * this overlay. 91 * @param {Array} associatedControls Array of control elements associated 92 * with this page. 93 */ 94 registerOverlay: function(overlay, 95 parentPage, 96 associatedControls) { 97 this.registeredOverlayPages[overlay.name.toLowerCase()] = overlay; 98 overlay.parentPage = parentPage; 99 if (associatedControls) { 100 overlay.associatedControls = associatedControls; 101 if (associatedControls.length) { 102 overlay.associatedSection = 103 this.findSectionForNode_(associatedControls[0]); 104 } 105 106 // Sanity check. 107 for (var i = 0; i < associatedControls.length; ++i) { 108 assert(associatedControls[i], 'Invalid element passed.'); 109 } 110 } 111 112 overlay.tab = undefined; 113 overlay.isOverlay = true; 114 115 // Reverse the button strip for Windows and CrOS. See the documentation of 116 // cr.ui.pageManager.Page.reverseButtonStrip() for an explanation of why 117 // this is done. 118 if (cr.isWindows || cr.isChromeOS) 119 overlay.reverseButtonStrip(); 120 121 overlay.initializePage(); 122 }, 123 124 /** 125 * Shows the default page. 126 * @param {boolean=} opt_updateHistory If we should update the history after 127 * showing the page (defaults to true). 128 */ 129 showDefaultPage: function(opt_updateHistory) { 130 assert(this.defaultPage_ instanceof cr.ui.pageManager.Page, 131 'PageManager must be initialized with a default page.'); 132 this.showPageByName(this.defaultPage_.name, opt_updateHistory); 133 }, 134 135 /** 136 * Shows a registered page. This handles both root and overlay pages. 137 * @param {string} pageName Page name. 138 * @param {boolean=} opt_updateHistory If we should update the history after 139 * showing the page (defaults to true). 140 * @param {Object=} opt_propertyBag An optional bag of properties including 141 * replaceState (if history state should be replaced instead of pushed). 142 * hash (a hash state to attach to the page). 143 */ 144 showPageByName: function(pageName, 145 opt_updateHistory, 146 opt_propertyBag) { 147 opt_updateHistory = opt_updateHistory !== false; 148 opt_propertyBag = opt_propertyBag || {}; 149 150 // If a bubble is currently being shown, hide it. 151 this.hideBubble(); 152 153 // Find the currently visible root-level page. 154 var rootPage = null; 155 for (var name in this.registeredPages) { 156 var page = this.registeredPages[name]; 157 if (page.visible && !page.parentPage) { 158 rootPage = page; 159 break; 160 } 161 } 162 163 // Find the target page. 164 var targetPage = this.registeredPages[pageName.toLowerCase()]; 165 if (!targetPage || !targetPage.canShowPage()) { 166 // If it's not a page, try it as an overlay. 167 var hash = opt_propertyBag.hash || ''; 168 if (!targetPage && this.showOverlay_(pageName, hash, rootPage)) { 169 if (opt_updateHistory) 170 this.updateHistoryState_(!!opt_propertyBag.replaceState); 171 this.updateTitle_(); 172 return; 173 } 174 targetPage = this.defaultPage_; 175 } 176 177 pageName = targetPage.name.toLowerCase(); 178 var targetPageWasVisible = targetPage.visible; 179 180 // Determine if the root page is 'sticky', meaning that it 181 // shouldn't change when showing an overlay. This can happen for special 182 // pages like Search. 183 var isRootPageLocked = 184 rootPage && rootPage.sticky && targetPage.parentPage; 185 186 // Notify pages if they will be hidden. 187 this.forEachPage_(!isRootPageLocked, function(page) { 188 if (page.name != pageName && !this.isAncestorOfPage(page, targetPage)) 189 page.willHidePage(); 190 }); 191 192 // Update the page's hash. 193 targetPage.hash = opt_propertyBag.hash || ''; 194 195 // Update visibilities to show only the hierarchy of the target page. 196 this.forEachPage_(!isRootPageLocked, function(page) { 197 page.visible = page.name == pageName || 198 this.isAncestorOfPage(page, targetPage); 199 }); 200 201 // Update the history and current location. 202 if (opt_updateHistory) 203 this.updateHistoryState_(!!opt_propertyBag.replaceState); 204 205 // Update focus if any other control was focused on the previous page, 206 // or the previous page is not known. 207 if (document.activeElement != document.body && 208 (!rootPage || rootPage.pageDiv.contains(document.activeElement))) { 209 targetPage.focus(); 210 } 211 212 // Notify pages if they were shown. 213 this.forEachPage_(!isRootPageLocked, function(page) { 214 if (!targetPageWasVisible && 215 (page.name == pageName || 216 this.isAncestorOfPage(page, targetPage))) { 217 page.didShowPage(); 218 } 219 }); 220 221 // If the target page was already visible, notify it that its hash 222 // changed externally. 223 if (targetPageWasVisible) 224 targetPage.didChangeHash(); 225 226 // Update the document title. Do this after didShowPage was called, in 227 // case a page decides to change its title. 228 this.updateTitle_(); 229 }, 230 231 /** 232 * Returns the name of the page from the current path. 233 * @return {string} Name of the page specified by the current path. 234 */ 235 getPageNameFromPath: function() { 236 var path = location.pathname; 237 if (path.length <= 1) 238 return this.defaultPage_.name; 239 240 // Skip starting slash and remove trailing slash (if any). 241 return path.slice(1).replace(/\/$/, ''); 242 }, 243 244 /** 245 * Gets the level of the page. Root pages (e.g., BrowserOptions) are at 246 * level 0. 247 * @return {number} How far down this page is from the root page. 248 */ 249 getNestingLevel: function(page) { 250 var level = 0; 251 var parent = page.parentPage; 252 while (parent) { 253 level++; 254 parent = parent.parentPage; 255 } 256 return level; 257 }, 258 259 /** 260 * Checks whether one page is an ancestor of the other page in terms of 261 * subpage nesting. 262 * @param {cr.ui.pageManager.Page} potentialAncestor Potential ancestor. 263 * @param {cr.ui.pageManager.Page} potentialDescendent Potential descendent. 264 * @return {boolean} True if |potentialDescendent| is nested under 265 * |potentialAncestor|. 266 */ 267 isAncestorOfPage: function(potentialAncestor, potentialDescendent) { 268 var parent = potentialDescendent.parentPage; 269 while (parent) { 270 if (parent == potentialAncestor) 271 return true; 272 parent = parent.parentPage; 273 } 274 return false; 275 }, 276 277 /** 278 * Returns true if the page is a direct descendent of a root page, or if 279 * the page is considered always on top. Doesn't consider visibility. 280 * @param {cr.ui.pageManager.Page} page Page to check. 281 * @return {boolean} True if |page| is a top-level overlay. 282 */ 283 isTopLevelOverlay: function(page) { 284 return page.isOverlay && 285 (page.alwaysOnTop || this.getNestingLevel(page) == 1); 286 }, 287 288 /** 289 * Called when an page is shown or hidden to update the root page 290 * based on the page's new visibility. 291 * @param {cr.ui.pageManager.Page} page The page being made visible or 292 * invisible. 293 */ 294 onPageVisibilityChanged: function(page) { 295 this.updateRootPageFreezeState(); 296 297 for (var i = 0; i < this.observers_.length; ++i) 298 this.observers_[i].onPageVisibilityChanged(page); 299 300 if (!page.visible && this.isTopLevelOverlay(page)) 301 this.updateScrollPosition_(); 302 }, 303 304 /** 305 * Called when a page's hash changes. If the page is the topmost visible 306 * page, the history state is updated. 307 * @param {cr.ui.pageManager.Page} page The page whose hash has changed. 308 */ 309 onPageHashChanged: function(page) { 310 if (page == this.getTopmostVisiblePage()) 311 this.updateHistoryState_(false); 312 }, 313 314 /** 315 * Returns the topmost visible page, or null if no page is visible. 316 * @return {cr.ui.pageManager.Page} The topmost visible page. 317 */ 318 getTopmostVisiblePage: function() { 319 // Check overlays first since they're top-most if visible. 320 return this.getVisibleOverlay_() || 321 this.getTopmostVisibleNonOverlayPage_(); 322 }, 323 324 /** 325 * Closes the visible overlay. Updates the history state after closing the 326 * overlay. 327 */ 328 closeOverlay: function() { 329 var overlay = this.getVisibleOverlay_(); 330 if (!overlay) 331 return; 332 333 overlay.visible = false; 334 overlay.didClosePage(); 335 336 this.updateHistoryState_(false); 337 this.updateTitle_(); 338 339 this.restoreLastFocusedElement_(); 340 }, 341 342 /** 343 * Closes all overlays and updates the history after each closed overlay. 344 */ 345 closeAllOverlays: function() { 346 while (this.isOverlayVisible_()) { 347 this.closeOverlay(); 348 } 349 }, 350 351 /** 352 * Cancels (closes) the overlay, due to the user pressing <Esc>. 353 */ 354 cancelOverlay: function() { 355 // Blur the active element to ensure any changed pref value is saved. 356 document.activeElement.blur(); 357 var overlay = this.getVisibleOverlay_(); 358 if (!overlay) 359 return; 360 // Let the overlay handle the <Esc> if it wants to. 361 if (overlay.handleCancel) { 362 overlay.handleCancel(); 363 this.restoreLastFocusedElement_(); 364 } else { 365 this.closeOverlay(); 366 } 367 }, 368 369 /** 370 * Shows an informational bubble displaying |content| and pointing at the 371 * |target| element. If |content| has focusable elements, they join the 372 * current page's tab order as siblings of |domSibling|. 373 * @param {HTMLDivElement} content The content of the bubble. 374 * @param {HTMLElement} target The element at which the bubble points. 375 * @param {HTMLElement} domSibling The element after which the bubble is 376 * added to the DOM. 377 * @param {cr.ui.ArrowLocation} location The arrow location. 378 */ 379 showBubble: function(content, target, domSibling, location) { 380 this.hideBubble(); 381 382 var bubble = new cr.ui.AutoCloseBubble; 383 bubble.anchorNode = target; 384 bubble.domSibling = domSibling; 385 bubble.arrowLocation = location; 386 bubble.content = content; 387 bubble.show(); 388 this.bubble_ = bubble; 389 }, 390 391 /** 392 * Hides the currently visible bubble, if any. 393 */ 394 hideBubble: function() { 395 if (this.bubble_) 396 this.bubble_.hide(); 397 }, 398 399 /** 400 * Returns the currently visible bubble, or null if no bubble is visible. 401 * @return {cr.ui.AutoCloseBubble} The bubble currently being shown. 402 */ 403 getVisibleBubble: function() { 404 var bubble = this.bubble_; 405 return bubble && !bubble.hidden ? bubble : null; 406 }, 407 408 /** 409 * Callback for window.onpopstate to handle back/forward navigations. 410 * @param {string} pageName The current page name. 411 * @param {string} hash The hash to pass into the page. 412 * @param {Object} data State data pushed into history. 413 */ 414 setState: function(pageName, hash, data) { 415 var currentOverlay = this.getVisibleOverlay_(); 416 var lowercaseName = pageName.toLowerCase(); 417 var newPage = this.registeredPages[lowercaseName] || 418 this.registeredOverlayPages[lowercaseName] || 419 this.defaultPage_; 420 if (currentOverlay && !this.isAncestorOfPage(currentOverlay, newPage)) { 421 currentOverlay.visible = false; 422 currentOverlay.didClosePage(); 423 } 424 this.showPageByName(pageName, false, {hash: hash}); 425 }, 426 427 428 /** 429 * Whether the page is still loading (i.e. onload hasn't finished running). 430 * @return {boolean} Whether the page is still loading. 431 */ 432 isLoading: function() { 433 return document.documentElement.classList.contains('loading'); 434 }, 435 436 /** 437 * Callback for window.onbeforeunload. Used to notify overlays that they 438 * will be closed. 439 */ 440 willClose: function() { 441 var overlay = this.getVisibleOverlay_(); 442 if (overlay) 443 overlay.didClosePage(); 444 }, 445 446 /** 447 * Freezes/unfreezes the scroll position of the root page based on the 448 * current page stack. 449 */ 450 updateRootPageFreezeState: function() { 451 var topPage = this.getTopmostVisiblePage(); 452 if (topPage) 453 this.setRootPageFrozen_(topPage.isOverlay); 454 }, 455 456 /** 457 * Change the horizontal offset used to reposition elements while showing an 458 * overlay from the default. 459 */ 460 set horizontalOffset(value) { 461 this.horizontalOffset_ = value; 462 }, 463 464 /** 465 * @param {!cr.ui.pageManager.PageManager.Observer} observer The observer to 466 * register. 467 */ 468 addObserver: function(observer) { 469 this.observers_.push(observer); 470 }, 471 472 /** 473 * Shows a registered overlay page. Does not update history. 474 * @param {string} overlayName Page name. 475 * @param {string} hash The hash state to associate with the overlay. 476 * @param {cr.ui.pageManager.Page} rootPage The currently visible root-level 477 * page. 478 * @return {boolean} Whether we showed an overlay. 479 * @private 480 */ 481 showOverlay_: function(overlayName, hash, rootPage) { 482 var overlay = this.registeredOverlayPages[overlayName.toLowerCase()]; 483 if (!overlay || !overlay.canShowPage()) 484 return false; 485 486 // Save the currently focused element in the page for restoration later. 487 var currentPage = this.getTopmostVisiblePage(); 488 if (currentPage) 489 currentPage.lastFocusedElement = document.activeElement; 490 491 if ((!rootPage || !rootPage.sticky) && 492 overlay.parentPage && 493 !overlay.parentPage.visible) { 494 this.showPageByName(overlay.parentPage.name, false); 495 } 496 497 overlay.hash = hash; 498 if (!overlay.visible) { 499 overlay.visible = true; 500 overlay.didShowPage(); 501 } else { 502 overlay.didChangeHash(); 503 } 504 505 // Change focus to the overlay if any other control was focused by 506 // keyboard before. Otherwise, no one should have focus. 507 if (document.activeElement != document.body) { 508 if (FocusOutlineManager.forDocument(document).visible) { 509 overlay.focus(); 510 } else if (!overlay.pageDiv.contains(document.activeElement)) { 511 document.activeElement.blur(); 512 } 513 } 514 515 if ($('search-field') && $('search-field').value == '') { 516 var section = overlay.associatedSection; 517 if (section) 518 options.BrowserOptions.scrollToSection(section); 519 } 520 521 return true; 522 }, 523 524 /** 525 * Returns whether or not an overlay is visible. 526 * @return {boolean} True if an overlay is visible. 527 * @private 528 */ 529 isOverlayVisible_: function() { 530 return this.getVisibleOverlay_() != null; 531 }, 532 533 /** 534 * Returns the currently visible overlay, or null if no page is visible. 535 * @return {cr.ui.pageManager.Page} The visible overlay. 536 * @private 537 */ 538 getVisibleOverlay_: function() { 539 var topmostPage = null; 540 for (var name in this.registeredOverlayPages) { 541 var page = this.registeredOverlayPages[name]; 542 if (!page.visible) 543 continue; 544 545 if (page.alwaysOnTop) 546 return page; 547 548 if (!topmostPage || 549 this.getNestingLevel(page) > this.getNestingLevel(topmostPage)) { 550 topmostPage = page; 551 } 552 } 553 return topmostPage; 554 }, 555 556 /** 557 * Returns the topmost visible page (overlays excluded). 558 * @return {cr.ui.pageManager.Page} The topmost visible page aside from any 559 * overlays. 560 * @private 561 */ 562 getTopmostVisibleNonOverlayPage_: function() { 563 for (var name in this.registeredPages) { 564 var page = this.registeredPages[name]; 565 if (page.visible) 566 return page; 567 } 568 569 return null; 570 }, 571 572 /** 573 * Scrolls the page to the correct position (the top when opening an 574 * overlay, or the old scroll position a previously hidden overlay 575 * becomes visible). 576 * @private 577 */ 578 updateScrollPosition_: function() { 579 var container = $('page-container'); 580 var scrollTop = container.oldScrollTop || 0; 581 container.oldScrollTop = undefined; 582 window.scroll(scrollLeftForDocument(document), scrollTop); 583 }, 584 585 /** 586 * Updates the title to the title of the current page, or of the topmost 587 * visible page with a non-empty title. 588 * @private 589 */ 590 updateTitle_: function() { 591 var page = this.getTopmostVisiblePage(); 592 while (page) { 593 if (page.title) { 594 for (var i = 0; i < this.observers_.length; ++i) { 595 this.observers_[i].updateTitle(page.title); 596 } 597 return; 598 } 599 page = page.parentPage; 600 } 601 }, 602 603 /** 604 * Constructs a new path to push onto the history stack, using observers 605 * to update the history. 606 * @param {boolean} replace If true, handlers should replace the current 607 * history event rather than create new ones. 608 * @private 609 */ 610 updateHistoryState_: function(replace) { 611 if (this.isDialog) 612 return; 613 614 var page = this.getTopmostVisiblePage(); 615 var path = window.location.pathname + window.location.hash; 616 if (path) { 617 // Remove trailing slash. 618 path = path.slice(1).replace(/\/(?:#|$)/, ''); 619 } 620 621 // If the page is already in history (the user may have clicked the same 622 // link twice, or this is the initial load), do nothing. 623 var newPath = (page == this.defaultPage_ ? '' : page.name) + page.hash; 624 if (path == newPath) 625 return; 626 627 for (var i = 0; i < this.observers_.length; ++i) { 628 this.observers_[i].updateHistory(newPath, replace); 629 } 630 }, 631 632 /** 633 * Restores the last focused element on a given page. 634 * @private 635 */ 636 restoreLastFocusedElement_: function() { 637 var currentPage = this.getTopmostVisiblePage(); 638 if (currentPage.lastFocusedElement) 639 currentPage.lastFocusedElement.focus(); 640 }, 641 642 /** 643 * Find an enclosing section for an element if it exists. 644 * @param {Node} node Element to search. 645 * @return {Node} The section element, or null. 646 * @private 647 */ 648 findSectionForNode_: function(node) { 649 while (node = node.parentNode) { 650 if (node.nodeName == 'SECTION') 651 return node; 652 } 653 return null; 654 }, 655 656 /** 657 * Freezes/unfreezes the scroll position of the root page container. 658 * @param {boolean} freeze Whether the page should be frozen. 659 * @private 660 */ 661 setRootPageFrozen_: function(freeze) { 662 var container = $('page-container'); 663 if (container.classList.contains('frozen') == freeze) 664 return; 665 666 if (freeze) { 667 // Lock the width, since auto width computation may change. 668 container.style.width = window.getComputedStyle(container).width; 669 container.oldScrollTop = scrollTopForDocument(document); 670 container.classList.add('frozen'); 671 var verticalPosition = 672 container.getBoundingClientRect().top - container.oldScrollTop; 673 container.style.top = verticalPosition + 'px'; 674 this.updateFrozenElementHorizontalPosition_(container); 675 } else { 676 container.classList.remove('frozen'); 677 container.style.top = ''; 678 container.style.left = ''; 679 container.style.right = ''; 680 container.style.width = ''; 681 } 682 }, 683 684 /** 685 * Called when the page is scrolled; moves elements that are position:fixed 686 * but should only behave as if they are fixed for vertical scrolling. 687 * @private 688 */ 689 handleScroll_: function() { 690 this.updateAllFrozenElementPositions_(); 691 }, 692 693 /** 694 * Updates all frozen pages to match the horizontal scroll position. 695 * @private 696 */ 697 updateAllFrozenElementPositions_: function() { 698 var frozenElements = document.querySelectorAll('.frozen'); 699 for (var i = 0; i < frozenElements.length; i++) 700 this.updateFrozenElementHorizontalPosition_(frozenElements[i]); 701 }, 702 703 /** 704 * Updates the given frozen element to match the horizontal scroll position. 705 * @param {HTMLElement} e The frozen element to update. 706 * @private 707 */ 708 updateFrozenElementHorizontalPosition_: function(e) { 709 if (isRTL()) { 710 e.style.right = this.horizontalOffset + 'px'; 711 } else { 712 var scrollLeft = scrollLeftForDocument(document); 713 e.style.left = this.horizontalOffset - scrollLeft + 'px'; 714 } 715 }, 716 717 /** 718 * Calls the given callback with each registered page. 719 * @param {boolean} includeRootPages Whether the callback should be called 720 * for the root pages. 721 * @param {function(cr.ui.pageManager.Page)} callback The callback. 722 * @private 723 */ 724 forEachPage_: function(includeRootPages, callback) { 725 var pageNames = Object.keys(this.registeredOverlayPages); 726 if (includeRootPages) 727 pageNames = Object.keys(this.registeredPages).concat(pageNames); 728 729 pageNames.forEach(function(name) { 730 callback.call(this, this.registeredOverlayPages[name] || 731 this.registeredPages[name]); 732 }, this); 733 }, 734 }; 735 736 /** 737 * An observer of PageManager. 738 * @interface 739 */ 740 PageManager.Observer = function() {} 741 742 PageManager.Observer.prototype = { 743 /** 744 * Called when a page is being shown or has been hidden. 745 * @param {cr.ui.pageManager.Page} page The page being shown or hidden. 746 */ 747 onPageVisibilityChanged: function(page) {}, 748 749 /** 750 * Called when a new title should be set. 751 * @param {string} title The title to set. 752 */ 753 updateTitle: function(title) {}, 754 755 /** 756 * Called when a page is navigated to. 757 * @param {string} path The path of the page being visited. 758 * @param {boolean} replace If true, allow no history events to be created. 759 */ 760 updateHistory: function(path, replace) {}, 761 }; 762 763 // Export 764 return { 765 PageManager: PageManager 766 }; 767}); 768