blob: 61d1cb206bfd3f88ce73e4b97322f6cc8c6eeb54 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
cr.define('cr.ui.pageManager', function() {
const PageManager = cr.ui.pageManager.PageManager;
/**
* Base class for pages that can be shown and hidden by PageManager. Each Page
* is like a node in a forest, corresponding to a particular div. At any
* point, one root Page is visible, and any visible Page can show a child Page
* as an overlay. The host of the root Page(s) should provide a container div
* for each nested level to enforce the stack order of overlays.
*/
class Page extends cr.EventTarget {
/**
* @param {string} name Page name.
* @param {string} title Page title, used for history.
* @param {string} pageDivName ID of the div corresponding to the page.
*/
constructor(name, title, pageDivName) {
super();
this.name = name;
this.title = title;
this.pageDivName = pageDivName;
this.pageDiv = getRequiredElement(this.pageDivName);
// |pageDiv.page| is set to the page object (this) when the page is
// visible to track which page is being shown when multiple pages can
// share the same underlying div.
this.pageDiv.page = null;
this.tab = null;
this.lastFocusedElement = null;
this.hash = '';
/**
* The parent page of this page; or null for root pages.
* @type {cr.ui.pageManager.Page}
*/
this.parentPage = null;
/**
* The section on the parent page that is associated with this page.
* Can be null.
* @type {Element}
*/
this.associatedSection = null;
/**
* An array of controls that are associated with this page. The first
* control should be located on a root page.
* @type {Array<Element>}
*/
this.associatedControls = null;
/**
* If true; this page should always be considered the top-most page when
* visible.
* @private {boolean}
*/
this.alwaysOnTop_ = false;
/**
* Set this to handle cancelling an overlay (and skip some typical steps).
* @see {cr.ui.PageManager.prototype.cancelOverlay}
* @type {?Function}
*/
this.handleCancel = null;
/** @type {boolean} */
this.isOverlay = false;
}
/**
* Initializes page content.
*/
initializePage() {}
/**
* Called by the PageManager when this.hash changes while the page is
* already visible. This is analogous to the hashchange DOM event.
*/
didChangeHash() {}
/**
* Sets focus on the first focusable element. Override for a custom focus
* strategy.
*/
focus() {
cr.ui.setInitialFocus(this.pageDiv);
}
/**
* Reverse any buttons strips in this page (only applies to overlays).
* @see cr.ui.reverseButtonStrips for an explanation of why this is
* necessary and when it's done.
*/
reverseButtonStrip() {
assert(this.isOverlay);
cr.ui.reverseButtonStrips(this.pageDiv);
}
/**
* Whether it should be possible to show the page.
* @return {boolean} True if the page should be shown.
*/
canShowPage() {
return true;
}
/**
* Updates the hash of the current page. If the page is topmost, the history
* state is updated.
* @param {string} hash The new hash value. Like location.hash, this
* should include the leading '#' if not empty.
*/
setHash(hash) {
if (this.hash == hash) {
return;
}
this.hash = hash;
PageManager.onPageHashChanged(this);
}
/**
* Called after the page has been shown.
*/
didShowPage() {}
/**
* Called before the page will be hidden, e.g., when a different root page
* will be shown.
*/
willHidePage() {}
/**
* Called after the overlay has been closed.
*/
didClosePage() {}
/**
* Gets the container div for this page if it is an overlay.
* @type {HTMLDivElement}
*/
get container() {
assert(this.isOverlay);
return this.pageDiv.parentNode;
}
/**
* Gets page visibility state.
* @type {boolean}
*/
get visible() {
// If this is an overlay dialog it is no longer considered visible while
// the overlay is fading out. See http://crbug.com/118629.
if (this.isOverlay && this.container.classList.contains('transparent')) {
return false;
}
if (this.pageDiv.hidden) {
return false;
}
return this.pageDiv.page == this;
}
/**
* Sets page visibility.
* @type {boolean}
*/
set visible(visible) {
if ((this.visible && visible) || (!this.visible && !visible)) {
return;
}
// If using an overlay, the visibility of the dialog is toggled at the
// same time as the overlay to show the dialog's out transition. This
// is handled in setOverlayVisible.
if (this.isOverlay) {
this.setOverlayVisible_(visible);
} else {
this.pageDiv.page = this;
this.pageDiv.hidden = !visible;
PageManager.onPageVisibilityChanged(this);
}
cr.dispatchPropertyChange(this, 'visible', visible, !visible);
}
/**
* Whether the page is considered 'sticky', such that it will remain a root
* page even if sub-pages change.
* @type {boolean} True if this page is sticky.
*/
get sticky() {
return false;
}
/**
* @type {boolean} True if this page should always be considered the
* top-most page when visible.
*/
get alwaysOnTop() {
return this.alwaysOnTop_;
}
/**
* @type {boolean} True if this page should always be considered the
* top-most page when visible. Only overlays can be always on top.
*/
set alwaysOnTop(value) {
assert(this.isOverlay);
this.alwaysOnTop_ = value;
}
/**
* Shows or hides an overlay (including any visible dialog).
* @param {boolean} visible Whether the overlay should be visible or not.
* @private
*/
setOverlayVisible_(visible) {
assert(this.isOverlay);
const pageDiv = this.pageDiv;
const container = this.container;
if (container.hidden != visible) {
if (visible) {
// If the container is set hidden and then immediately set visible
// again, the fadeCompleted_ callback would cause it to be erroneously
// hidden again. Removing the transparent tag avoids that.
container.classList.remove('transparent');
// Hide all dialogs in this container since a different one may have
// been previously visible before fading out.
const pages = container.querySelectorAll('.page');
for (let i = 0; i < pages.length; i++) {
pages[i].hidden = true;
}
// Show the new dialog.
pageDiv.hidden = false;
pageDiv.page = this;
}
return;
}
const self = this;
const loading = PageManager.isLoading();
if (!loading) {
// TODO(flackr): Use an event delegate to avoid having to subscribe and
// unsubscribe for transitionend events.
container.addEventListener('transitionend', function f(e) {
const propName = e.propertyName;
if (e.target != e.currentTarget ||
(propName && propName != 'opacity')) {
return;
}
container.removeEventListener('transitionend', f);
self.fadeCompleted_();
});
// transition is 200ms. Let's wait for 400ms.
ensureTransitionEndEvent(container, 400);
}
if (visible) {
container.hidden = false;
pageDiv.hidden = false;
pageDiv.page = this;
// NOTE: This is a hacky way to force the container to layout which
// will allow us to trigger the transition.
/** @suppress {uselessCode} */
container.scrollTop;
this.pageDiv.removeAttribute('aria-hidden');
if (this.parentPage) {
this.parentPage.pageDiv.parentElement.setAttribute(
'aria-hidden', true);
}
container.classList.remove('transparent');
PageManager.onPageVisibilityChanged(this);
} else {
// Kick change events for text fields.
if (pageDiv.contains(document.activeElement)) {
document.activeElement.blur();
}
container.classList.add('transparent');
}
if (loading) {
this.fadeCompleted_();
}
}
/**
* Called when a container opacity transition finishes.
* @private
*/
fadeCompleted_() {
if (this.container.classList.contains('transparent')) {
this.pageDiv.hidden = true;
this.container.hidden = true;
if (this.parentPage) {
this.parentPage.pageDiv.parentElement.removeAttribute('aria-hidden');
}
PageManager.onPageVisibilityChanged(this);
}
}
}
// Export
return {Page: Page};
});