Javascript  |  1007行  |  31.7 KB

// Copyright (c) 2012 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('options', function() {
  /** @const */ var FocusOutlineManager = cr.ui.FocusOutlineManager;

  /////////////////////////////////////////////////////////////////////////////
  // OptionsPage class:

  /**
   * Base class for options page.
   * @constructor
   * @param {string} name Options page name.
   * @param {string} title Options page title, used for history.
   * @extends {EventTarget}
   */
  function OptionsPage(name, title, pageDivName) {
    this.name = name;
    this.title = title;
    this.pageDivName = pageDivName;
    this.pageDiv = $(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 is the absolute difference maintained between standard and
   * fixed-width font sizes. Refer http://crbug.com/91922.
   * @const
   */
  OptionsPage.SIZE_DIFFERENCE_FIXED_STANDARD = 3;

  /**
   * Offset of page container in pixels, to allow room for side menu.
   * Simplified settings pages can override this if they don't use the menu.
   * The default (155) comes from -webkit-margin-start in uber_shared.css
   * @private
   */
  OptionsPage.horizontalOffset = 155;

  /**
   * Main level option pages. Maps lower-case page names to the respective page
   * object.
   * @protected
   */
  OptionsPage.registeredPages = {};

  /**
   * Pages which are meant to behave like modal dialogs. Maps lower-case overlay
   * names to the respective overlay object.
   * @protected
   */
  OptionsPage.registeredOverlayPages = {};

  /**
   * Gets the default page (to be shown on initial load).
   */
  OptionsPage.getDefaultPage = function() {
    return BrowserOptions.getInstance();
  };

  /**
   * Shows the default page.
   */
  OptionsPage.showDefaultPage = function() {
    this.navigateToPage(this.getDefaultPage().name);
  };

  /**
   * "Navigates" to a page, meaning that the page will be shown and the
   * appropriate entry is placed in the history.
   * @param {string} pageName Page name.
   */
  OptionsPage.navigateToPage = function(pageName) {
    this.showPageByName(pageName, true);
  };

  /**
   * Shows a registered page. This handles both top-level and overlay pages.
   * @param {string} pageName Page name.
   * @param {boolean} updateHistory True if we should update the history after
   *     showing the page.
   * @param {Object=} opt_propertyBag An optional bag of properties including
   *     replaceState (if history state should be replaced instead of pushed).
   * @private
   */
  OptionsPage.showPageByName = function(pageName,
                                        updateHistory,
                                        opt_propertyBag) {
    // If |opt_propertyBag| is non-truthy, homogenize to object.
    opt_propertyBag = opt_propertyBag || {};

    // If a bubble is currently being shown, hide it.
    this.hideBubble();

    // Find the currently visible root-level page.
    var rootPage = null;
    for (var name in this.registeredPages) {
      var page = this.registeredPages[name];
      if (page.visible && !page.parentPage) {
        rootPage = page;
        break;
      }
    }

    // Find the target page.
    var targetPage = this.registeredPages[pageName.toLowerCase()];
    if (!targetPage || !targetPage.canShowPage()) {
      // If it's not a page, try it as an overlay.
      if (!targetPage && this.showOverlay_(pageName, rootPage)) {
        if (updateHistory)
          this.updateHistoryState_(!!opt_propertyBag.replaceState);
        return;
      } else {
        targetPage = this.getDefaultPage();
      }
    }

    pageName = targetPage.name.toLowerCase();
    var targetPageWasVisible = targetPage.visible;

    // Determine if the root page is 'sticky', meaning that it
    // shouldn't change when showing an overlay. This can happen for special
    // pages like Search.
    var isRootPageLocked =
        rootPage && rootPage.sticky && targetPage.parentPage;

    var allPageNames = Array.prototype.concat.call(
        Object.keys(this.registeredPages),
        Object.keys(this.registeredOverlayPages));

    // Notify pages if they will be hidden.
    for (var i = 0; i < allPageNames.length; ++i) {
      var name = allPageNames[i];
      var page = this.registeredPages[name] ||
                 this.registeredOverlayPages[name];
      if (!page.parentPage && isRootPageLocked)
        continue;
      if (page.willHidePage && name != pageName &&
          !page.isAncestorOfPage(targetPage)) {
        page.willHidePage();
      }
    }

    // Update visibilities to show only the hierarchy of the target page.
    for (var i = 0; i < allPageNames.length; ++i) {
      var name = allPageNames[i];
      var page = this.registeredPages[name] ||
                 this.registeredOverlayPages[name];
      if (!page.parentPage && isRootPageLocked)
        continue;
      page.visible = name == pageName || page.isAncestorOfPage(targetPage);
    }

    // Update the history and current location.
    if (updateHistory)
      this.updateHistoryState_(!!opt_propertyBag.replaceState);

    // Update tab title.
    this.setTitle_(targetPage.title);

    // Update focus if any other control was focused on the previous page,
    // or the previous page is not known.
    if (document.activeElement != document.body &&
        (!rootPage || rootPage.pageDiv.contains(document.activeElement))) {
      targetPage.focus();
    }

    // Notify pages if they were shown.
    for (var i = 0; i < allPageNames.length; ++i) {
      var name = allPageNames[i];
      var page = this.registeredPages[name] ||
                 this.registeredOverlayPages[name];
      if (!page.parentPage && isRootPageLocked)
        continue;
      if (!targetPageWasVisible && page.didShowPage &&
          (name == pageName || page.isAncestorOfPage(targetPage))) {
        page.didShowPage();
      }
    }
  };

  /**
   * Sets the title of the page. This is accomplished by calling into the
   * parent page API.
   * @param {string} title The title string.
   * @private
   */
  OptionsPage.setTitle_ = function(title) {
    uber.invokeMethodOnParent('setTitle', {title: title});
  };

  /**
   * Scrolls the page to the correct position (the top when opening an overlay,
   * or the old scroll position a previously hidden overlay becomes visible).
   * @private
   */
  OptionsPage.updateScrollPosition_ = function() {
    var container = $('page-container');
    var scrollTop = container.oldScrollTop || 0;
    container.oldScrollTop = undefined;
    window.scroll(scrollLeftForDocument(document), scrollTop);
  };

  /**
   * Pushes the current page onto the history stack, overriding the last page
   * if it is the generic chrome://settings/.
   * @param {boolean} replace If true, allow no history events to be created.
   * @param {object=} opt_params A bag of optional params, including:
   *     {boolean} ignoreHash Whether to include the hash or not.
   * @private
   */
  OptionsPage.updateHistoryState_ = function(replace, opt_params) {
    var page = this.getTopmostVisiblePage();
    var path = window.location.pathname + window.location.hash;
    if (path)
      path = path.slice(1).replace(/\/(?:#|$)/, '');  // Remove trailing slash.

    // Update tab title.
    this.setTitle_(page.title);

    // The page is already in history (the user may have clicked the same link
    // twice). Do nothing.
    if (path == page.name && !OptionsPage.isLoading())
      return;

    var hash = opt_params && opt_params.ignoreHash ? '' : window.location.hash;

    // If settings are embedded, tell the outer page to set its "path" to the
    // inner frame's path.
    var outerPath = (page == this.getDefaultPage() ? '' : page.name) + hash;
    uber.invokeMethodOnParent('setPath', {path: outerPath});

    // If there is no path, the current location is chrome://settings/.
    // Override this with the new page.
    var historyFunction = path && !replace ? window.history.pushState :
                                             window.history.replaceState;
    historyFunction.call(window.history,
                         {pageName: page.name},
                         page.title,
                         '/' + page.name + hash);
  };

  /**
   * Shows a registered Overlay page. Does not update history.
   * @param {string} overlayName Page name.
   * @param {OptionPage} rootPage The currently visible root-level page.
   * @return {boolean} whether we showed an overlay.
   */
  OptionsPage.showOverlay_ = function(overlayName, rootPage) {
    var overlay = this.registeredOverlayPages[overlayName.toLowerCase()];
    if (!overlay || !overlay.canShowPage())
      return false;

    // Save the currently focused element in the page for restoration later.
    var currentPage = this.getTopmostVisiblePage();
    if (currentPage)
      currentPage.lastFocusedElement = document.activeElement;

    if ((!rootPage || !rootPage.sticky) &&
        overlay.parentPage &&
        !overlay.parentPage.visible) {
      this.showPageByName(overlay.parentPage.name, false);
    }

    if (!overlay.visible) {
      overlay.visible = true;
      if (overlay.didShowPage) overlay.didShowPage();
    }

    // Update tab title.
    this.setTitle_(overlay.title);

    // Change focus to the overlay if any other control was focused by keyboard
    // before. Otherwise, no one should have focus.
    if (document.activeElement != document.body) {
      if (FocusOutlineManager.forDocument(document).visible) {
        overlay.focus();
      } else if (!overlay.pageDiv.contains(document.activeElement)) {
        document.activeElement.blur();
      }
    }

    if ($('search-field').value == '') {
      var section = overlay.associatedSection;
      if (section)
        options.BrowserOptions.scrollToSection(section);
    }

    return true;
  };

  /**
   * Returns whether or not an overlay is visible.
   * @return {boolean} True if an overlay is visible.
   * @private
   */
  OptionsPage.isOverlayVisible_ = function() {
    return this.getVisibleOverlay_() != null;
  };

  /**
   * Returns the currently visible overlay, or null if no page is visible.
   * @return {OptionPage} The visible overlay.
   */
  OptionsPage.getVisibleOverlay_ = function() {
    var topmostPage = null;
    for (var name in this.registeredOverlayPages) {
      var page = this.registeredOverlayPages[name];
      if (page.visible &&
          (!topmostPage || page.nestingLevel > topmostPage.nestingLevel)) {
        topmostPage = page;
      }
    }
    return topmostPage;
  };

  /**
   * Restores the last focused element on a given page.
   */
  OptionsPage.restoreLastFocusedElement_ = function() {
    var currentPage = this.getTopmostVisiblePage();
    if (currentPage.lastFocusedElement)
      currentPage.lastFocusedElement.focus();
  };

  /**
   * Closes the visible overlay. Updates the history state after closing the
   * overlay.
   */
  OptionsPage.closeOverlay = function() {
    var overlay = this.getVisibleOverlay_();
    if (!overlay)
      return;

    overlay.visible = false;

    if (overlay.didClosePage) overlay.didClosePage();
    this.updateHistoryState_(false, {ignoreHash: true});

    this.restoreLastFocusedElement_();
  };

  /**
   * Cancels (closes) the overlay, due to the user pressing <Esc>.
   */
  OptionsPage.cancelOverlay = function() {
    // Blur the active element to ensure any changed pref value is saved.
    document.activeElement.blur();
    var overlay = this.getVisibleOverlay_();
    // Let the overlay handle the <Esc> if it wants to.
    if (overlay.handleCancel) {
      overlay.handleCancel();
      this.restoreLastFocusedElement_();
    } else {
      this.closeOverlay();
    }
  };

  /**
   * Hides the visible overlay. Does not affect the history state.
   * @private
   */
  OptionsPage.hideOverlay_ = function() {
    var overlay = this.getVisibleOverlay_();
    if (overlay)
      overlay.visible = false;
  };

  /**
   * Returns the pages which are currently visible, ordered by nesting level
   * (ascending).
   * @return {Array.OptionPage} The pages which are currently visible, ordered
   * by nesting level (ascending).
   */
  OptionsPage.getVisiblePages_ = function() {
    var visiblePages = [];
    for (var name in this.registeredPages) {
      var page = this.registeredPages[name];
      if (page.visible)
        visiblePages[page.nestingLevel] = page;
    }
    return visiblePages;
  };

  /**
   * Returns the topmost visible page (overlays excluded).
   * @return {OptionPage} The topmost visible page aside any overlay.
   * @private
   */
  OptionsPage.getTopmostVisibleNonOverlayPage_ = function() {
    var topPage = null;
    for (var name in this.registeredPages) {
      var page = this.registeredPages[name];
      if (page.visible &&
          (!topPage || page.nestingLevel > topPage.nestingLevel))
        topPage = page;
    }

    return topPage;
  };

  /**
   * Returns the topmost visible page, or null if no page is visible.
   * @return {OptionPage} The topmost visible page.
   */
  OptionsPage.getTopmostVisiblePage = function() {
    // Check overlays first since they're top-most if visible.
    return this.getVisibleOverlay_() || this.getTopmostVisibleNonOverlayPage_();
  };

  /**
   * Returns the currently visible bubble, or null if no bubble is visible.
   * @return {AutoCloseBubble} The bubble currently being shown.
   */
  OptionsPage.getVisibleBubble = function() {
    var bubble = OptionsPage.bubble_;
    return bubble && !bubble.hidden ? bubble : null;
  };

  /**
   * Shows an informational bubble displaying |content| and pointing at the
   * |target| element. If |content| has focusable elements, they join the
   * current page's tab order as siblings of |domSibling|.
   * @param {HTMLDivElement} content The content of the bubble.
   * @param {HTMLElement} target The element at which the bubble points.
   * @param {HTMLElement} domSibling The element after which the bubble is added
   *                      to the DOM.
   * @param {cr.ui.ArrowLocation} location The arrow location.
   */
  OptionsPage.showBubble = function(content, target, domSibling, location) {
    OptionsPage.hideBubble();

    var bubble = new cr.ui.AutoCloseBubble;
    bubble.anchorNode = target;
    bubble.domSibling = domSibling;
    bubble.arrowLocation = location;
    bubble.content = content;
    bubble.show();
    OptionsPage.bubble_ = bubble;
  };

  /**
   * Hides the currently visible bubble, if any.
   */
  OptionsPage.hideBubble = function() {
    if (OptionsPage.bubble_)
      OptionsPage.bubble_.hide();
  };

  /**
   * Shows the tab contents for the given navigation tab.
   * @param {!Element} tab The tab that the user clicked.
   */
  OptionsPage.showTab = function(tab) {
    // Search parents until we find a tab, or the nav bar itself. This allows
    // tabs to have child nodes, e.g. labels in separately-styled spans.
    while (tab && !tab.classList.contains('subpages-nav-tabs') &&
           !tab.classList.contains('tab')) {
      tab = tab.parentNode;
    }
    if (!tab || !tab.classList.contains('tab'))
      return;

    // Find tab bar of the tab.
    var tabBar = tab;
    while (tabBar && !tabBar.classList.contains('subpages-nav-tabs')) {
      tabBar = tabBar.parentNode;
    }
    if (!tabBar)
      return;

    if (tabBar.activeNavTab != null) {
      tabBar.activeNavTab.classList.remove('active-tab');
      $(tabBar.activeNavTab.getAttribute('tab-contents')).classList.
          remove('active-tab-contents');
    }

    tab.classList.add('active-tab');
    $(tab.getAttribute('tab-contents')).classList.add('active-tab-contents');
    tabBar.activeNavTab = tab;
  };

  /**
   * Registers new options page.
   * @param {OptionsPage} page Page to register.
   */
  OptionsPage.register = function(page) {
    this.registeredPages[page.name.toLowerCase()] = page;
    page.initializePage();
  };

  /**
   * Find an enclosing section for an element if it exists.
   * @param {Element} element Element to search.
   * @return {OptionPage} The section element, or null.
   * @private
   */
  OptionsPage.findSectionForNode_ = function(node) {
    while (node = node.parentNode) {
      if (node.nodeName == 'SECTION')
        return node;
    }
    return null;
  };

  /**
   * Registers a new Overlay page.
   * @param {OptionsPage} overlay Overlay to register.
   * @param {OptionsPage} parentPage Associated parent page for this overlay.
   * @param {Array} associatedControls Array of control elements associated with
   *   this page.
   */
  OptionsPage.registerOverlay = function(overlay,
                                         parentPage,
                                         associatedControls) {
    this.registeredOverlayPages[overlay.name.toLowerCase()] = overlay;
    overlay.parentPage = parentPage;
    if (associatedControls) {
      overlay.associatedControls = associatedControls;
      if (associatedControls.length) {
        overlay.associatedSection =
            this.findSectionForNode_(associatedControls[0]);
      }

      // Sanity check.
      for (var i = 0; i < associatedControls.length; ++i) {
        assert(associatedControls[i], 'Invalid element passed.');
      }
    }

    // Reverse the button strip for views. See the documentation of
    // reverseButtonStripIfNecessary_() for an explanation of why this is done.
    if (cr.isViews)
      this.reverseButtonStripIfNecessary_(overlay);

    overlay.tab = undefined;
    overlay.isOverlay = true;
    overlay.initializePage();
  };

  /**
   * Reverses the child elements of a button strip if it hasn't already been
   * reversed. This is necessary because WebKit does not alter the tab order for
   * elements that are visually reversed using -webkit-box-direction: reverse,
   * and the button order is reversed for views. See http://webk.it/62664 for
   * more information.
   * @param {Object} overlay The overlay containing the button strip to reverse.
   * @private
   */
  OptionsPage.reverseButtonStripIfNecessary_ = function(overlay) {
    var buttonStrips =
        overlay.pageDiv.querySelectorAll('.button-strip:not([reversed])');

    // Reverse all button-strips in the overlay.
    for (var j = 0; j < buttonStrips.length; j++) {
      var buttonStrip = buttonStrips[j];

      var childNodes = buttonStrip.childNodes;
      for (var i = childNodes.length - 1; i >= 0; i--)
        buttonStrip.appendChild(childNodes[i]);

      buttonStrip.setAttribute('reversed', '');
    }
  };

  /**
   * Callback for window.onpopstate to handle back/forward navigations.
   * @param {Object} data State data pushed into history.
   */
  OptionsPage.setState = function(data) {
    if (data && data.pageName) {
      var currentOverlay = this.getVisibleOverlay_();
      var lowercaseName = data.pageName.toLowerCase();
      var newPage = this.registeredPages[lowercaseName] ||
                    this.registeredOverlayPages[lowercaseName] ||
                    this.getDefaultPage();
      if (currentOverlay && !currentOverlay.isAncestorOfPage(newPage)) {
        currentOverlay.visible = false;
        if (currentOverlay.didClosePage) currentOverlay.didClosePage();
      }
      this.showPageByName(data.pageName, false);
    }
  };

  /**
   * Callback for window.onbeforeunload. Used to notify overlays that they will
   * be closed.
   */
  OptionsPage.willClose = function() {
    var overlay = this.getVisibleOverlay_();
    if (overlay && overlay.didClosePage)
      overlay.didClosePage();
  };

  /**
   * Freezes/unfreezes the scroll position of the root page container.
   * @param {boolean} freeze Whether the page should be frozen.
   * @private
   */
  OptionsPage.setRootPageFrozen_ = function(freeze) {
    var container = $('page-container');
    if (container.classList.contains('frozen') == freeze)
      return;

    if (freeze) {
      // Lock the width, since auto width computation may change.
      container.style.width = window.getComputedStyle(container).width;
      container.oldScrollTop = scrollTopForDocument(document);
      container.classList.add('frozen');
      var verticalPosition =
          container.getBoundingClientRect().top - container.oldScrollTop;
      container.style.top = verticalPosition + 'px';
      this.updateFrozenElementHorizontalPosition_(container);
    } else {
      container.classList.remove('frozen');
      container.style.top = '';
      container.style.left = '';
      container.style.right = '';
      container.style.width = '';
    }
  };

  /**
   * Freezes/unfreezes the scroll position of the root page based on the current
   * page stack.
   */
  OptionsPage.updateRootPageFreezeState = function() {
    var topPage = OptionsPage.getTopmostVisiblePage();
    if (topPage)
      this.setRootPageFrozen_(topPage.isOverlay);
  };

  /**
   * Initializes the complete options page.  This will cause all C++ handlers to
   * be invoked to do final setup.
   */
  OptionsPage.initialize = function() {
    chrome.send('coreOptionsInitialize');
    uber.onContentFrameLoaded();
    FocusOutlineManager.forDocument(document);
    document.addEventListener('scroll', this.handleScroll_.bind(this));

    // Trigger the scroll handler manually to set the initial state.
    this.handleScroll_();

    // Shake the dialog if the user clicks outside the dialog bounds.
    var containers = [$('overlay-container-1'), $('overlay-container-2')];
    for (var i = 0; i < containers.length; i++) {
      var overlay = containers[i];
      cr.ui.overlay.setupOverlay(overlay);
      overlay.addEventListener('cancelOverlay',
                               OptionsPage.cancelOverlay.bind(OptionsPage));
    }

    cr.ui.overlay.globalInitialization();
  };

  /**
   * Does a bounds check for the element on the given x, y client coordinates.
   * @param {Element} e The DOM element.
   * @param {number} x The client X to check.
   * @param {number} y The client Y to check.
   * @return {boolean} True if the point falls within the element's bounds.
   * @private
   */
  OptionsPage.elementContainsPoint_ = function(e, x, y) {
    var clientRect = e.getBoundingClientRect();
    return x >= clientRect.left && x <= clientRect.right &&
        y >= clientRect.top && y <= clientRect.bottom;
  };

  /**
   * Called when the page is scrolled; moves elements that are position:fixed
   * but should only behave as if they are fixed for vertical scrolling.
   * @private
   */
  OptionsPage.handleScroll_ = function() {
    this.updateAllFrozenElementPositions_();
  };

  /**
   * Updates all frozen pages to match the horizontal scroll position.
   * @private
   */
  OptionsPage.updateAllFrozenElementPositions_ = function() {
    var frozenElements = document.querySelectorAll('.frozen');
    for (var i = 0; i < frozenElements.length; i++)
      this.updateFrozenElementHorizontalPosition_(frozenElements[i]);
  };

  /**
   * Updates the given frozen element to match the horizontal scroll position.
   * @param {HTMLElement} e The frozen element to update.
   * @private
   */
  OptionsPage.updateFrozenElementHorizontalPosition_ = function(e) {
    if (isRTL()) {
      e.style.right = OptionsPage.horizontalOffset + 'px';
    } else {
      var scrollLeft = scrollLeftForDocument(document);
      e.style.left = OptionsPage.horizontalOffset - scrollLeft + 'px';
    }
  };

  /**
   * Change the horizontal offset used to reposition elements while showing an
   * overlay from the default.
   */
  OptionsPage.setHorizontalOffset = function(value) {
    OptionsPage.horizontalOffset = value;
  };

  OptionsPage.setClearPluginLSODataEnabled = function(enabled) {
    if (enabled) {
      document.documentElement.setAttribute(
          'flashPluginSupportsClearSiteData', '');
    } else {
      document.documentElement.removeAttribute(
          'flashPluginSupportsClearSiteData');
    }
    if (navigator.plugins['Shockwave Flash'])
      document.documentElement.setAttribute('hasFlashPlugin', '');
  };

  OptionsPage.setPepperFlashSettingsEnabled = function(enabled) {
    if (enabled) {
      document.documentElement.setAttribute(
          'enablePepperFlashSettings', '');
    } else {
      document.documentElement.removeAttribute(
          'enablePepperFlashSettings');
    }
  };

  OptionsPage.setIsSettingsApp = function() {
    document.documentElement.classList.add('settings-app');
  };

  OptionsPage.isSettingsApp = function() {
    return document.documentElement.classList.contains('settings-app');
  };

  /**
   * Whether the page is still loading (i.e. onload hasn't finished running).
   * @return {boolean} Whether the page is still loading.
   */
  OptionsPage.isLoading = function() {
    return document.documentElement.classList.contains('loading');
  };

  OptionsPage.prototype = {
    __proto__: cr.EventTarget.prototype,

    /**
     * The parent page of this option page, or null for top-level pages.
     * @type {OptionsPage}
     */
    parentPage: null,

    /**
     * The section on the parent page that is associated with this page.
     * Can be null.
     * @type {Element}
     */
    associatedSection: null,

    /**
     * An array of controls that are associated with this page.  The first
     * control should be located on a top-level page.
     * @type {OptionsPage}
     */
    associatedControls: null,

    /**
     * Initializes page content.
     */
    initializePage: function() {},

    /**
     * Sets focus on the first focusable element. Override for a custom focus
     * strategy.
     */
    focus: function() {
      // Do not change focus if any control on this page is already focused.
      if (this.pageDiv.contains(document.activeElement))
        return;

      var elements = this.pageDiv.querySelectorAll(
          'input, list, select, textarea, button');
      for (var i = 0; i < elements.length; i++) {
        var element = elements[i];
        // Try to focus. If fails, then continue.
        element.focus();
        if (document.activeElement == element)
          return;
      }
    },

    /**
     * Gets the container div for this page if it is an overlay.
     * @type {HTMLElement}
     */
    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;
        this.onVisibilityChanged_();
      }

      cr.dispatchPropertyChange(this, 'visible', visible, !visible);
    },

    /**
     * Shows or hides an overlay (including any visible dialog).
     * @param {boolean} visible Whether the overlay should be visible or not.
     * @private
     */
    setOverlayVisible_: function(visible) {
      assert(this.isOverlay);
      var pageDiv = this.pageDiv;
      var container = this.container;

      if (visible)
        uber.invokeMethodOnParent('beginInterceptingEvents');

      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.
          var pages = container.querySelectorAll('.page');
          for (var i = 0; i < pages.length; i++)
            pages[i].hidden = true;
          // Show the new dialog.
          pageDiv.hidden = false;
          pageDiv.page = this;
        }
        return;
      }

      var self = this;
      var loading = OptionsPage.isLoading();
      if (!loading) {
        // TODO(flackr): Use an event delegate to avoid having to subscribe and
        // unsubscribe for webkitTransitionEnd events.
        container.addEventListener('webkitTransitionEnd', function f(e) {
            if (e.target != e.currentTarget || e.propertyName != 'opacity')
              return;
            container.removeEventListener('webkitTransitionEnd', f);
            self.fadeCompleted_();
        });
      }

      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 webkit transition.
        container.scrollTop;

        this.pageDiv.removeAttribute('aria-hidden');
        if (this.parentPage) {
          this.parentPage.pageDiv.parentElement.setAttribute('aria-hidden',
                                                             true);
        }
        container.classList.remove('transparent');
        this.onVisibilityChanged_();
      } 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_: function() {
      if (this.container.classList.contains('transparent')) {
        this.pageDiv.hidden = true;
        this.container.hidden = true;

        if (this.parentPage)
          this.parentPage.pageDiv.parentElement.removeAttribute('aria-hidden');

        if (this.nestingLevel == 1)
          uber.invokeMethodOnParent('stopInterceptingEvents');

        this.onVisibilityChanged_();
      }
    },

    /**
     * Called when a page is shown or hidden to update the root options page
     * based on this page's visibility.
     * @private
     */
    onVisibilityChanged_: function() {
      OptionsPage.updateRootPageFreezeState();

      if (this.isOverlay && !this.visible)
        OptionsPage.updateScrollPosition_();
    },

    /**
     * The nesting level of this page.
     * @type {number} The nesting level of this page (0 for top-level page)
     */
    get nestingLevel() {
      var level = 0;
      var parent = this.parentPage;
      while (parent) {
        level++;
        parent = parent.parentPage;
      }
      return level;
    },

    /**
     * Whether the page is considered 'sticky', such that it will
     * remain a top-level page even if sub-pages change.
     * @type {boolean} True if this page is sticky.
     */
    get sticky() {
      return false;
    },

    /**
     * Checks whether this page is an ancestor of the given page in terms of
     * subpage nesting.
     * @param {OptionsPage} page The potential descendent of this page.
     * @return {boolean} True if |page| is nested under this page.
     */
    isAncestorOfPage: function(page) {
      var parent = page.parentPage;
      while (parent) {
        if (parent == this)
          return true;
        parent = parent.parentPage;
      }
      return false;
    },

    /**
     * Whether it should be possible to show the page.
     * @return {boolean} True if the page should be shown.
     */
    canShowPage: function() {
      return true;
    },
  };

  // Export
  return {
    OptionsPage: OptionsPage
  };
});