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 var PageManager = cr.ui.pageManager.PageManager; 7 8 /** 9 * Base class for pages that can be shown and hidden by PageManager. Each Page 10 * is like a node in a forest, corresponding to a particular div. At any 11 * point, one root Page is visible, and any visible Page can show a child Page 12 * as an overlay. The host of the root Page(s) should provide a container div 13 * for each nested level to enforce the stack order of overlays. 14 * @constructor 15 * @param {string} name Page name. 16 * @param {string} title Page title, used for history. 17 * @param {string} pageDivName ID of the div corresponding to the page. 18 * @extends {cr.EventTarget} 19 */ 20 function Page(name, title, pageDivName) { 21 this.name = name; 22 this.title = title; 23 this.pageDivName = pageDivName; 24 this.pageDiv = $(this.pageDivName); 25 // |pageDiv.page| is set to the page object (this) when the page is visible 26 // to track which page is being shown when multiple pages can share the same 27 // underlying div. 28 this.pageDiv.page = null; 29 this.tab = null; 30 this.lastFocusedElement = null; 31 this.hash = ''; 32 } 33 34 Page.prototype = { 35 __proto__: cr.EventTarget.prototype, 36 37 /** 38 * The parent page of this page, or null for root pages. 39 * @type {cr.ui.pageManager.Page} 40 */ 41 parentPage: null, 42 43 /** 44 * The section on the parent page that is associated with this page. 45 * Can be null. 46 * @type {Element} 47 */ 48 associatedSection: null, 49 50 /** 51 * An array of controls that are associated with this page. The first 52 * control should be located on a root page. 53 * @type {Array.<Element>} 54 */ 55 associatedControls: null, 56 57 /** 58 * If true, this page should always be considered the top-most page when 59 * visible. 60 * @type {boolean} 61 */ 62 alwaysOnTop_: false, 63 64 /** 65 * Initializes page content. 66 */ 67 initializePage: function() {}, 68 69 /** 70 * Called by the PageManager when this.hash changes while the page is 71 * already visible. This is analogous to the hashchange DOM event. 72 */ 73 didChangeHash: function() {}, 74 75 /** 76 * Sets focus on the first focusable element. Override for a custom focus 77 * strategy. 78 */ 79 focus: function() { 80 // Do not change focus if any control on this page is already focused. 81 if (this.pageDiv.contains(document.activeElement)) 82 return; 83 84 var elements = this.pageDiv.querySelectorAll( 85 'input, list, select, textarea, button'); 86 for (var i = 0; i < elements.length; i++) { 87 var element = elements[i]; 88 // Try to focus. If fails, then continue. 89 element.focus(); 90 if (document.activeElement == element) 91 return; 92 } 93 }, 94 95 /** 96 * Reverses the child elements of this overlay's button strip if it hasn't 97 * already been reversed. This is necessary because WebKit does not alter 98 * the tab order for elements that are visually reversed using 99 * flex-direction: reverse, and the button order is reversed for views. 100 * See http://webk.it/62664 for more information. 101 */ 102 reverseButtonStrip: function() { 103 assert(this.isOverlay); 104 var buttonStrips = 105 this.pageDiv.querySelectorAll('.button-strip:not([reversed])'); 106 107 // Reverse all button-strips in the overlay. 108 for (var j = 0; j < buttonStrips.length; j++) { 109 var buttonStrip = buttonStrips[j]; 110 111 var childNodes = buttonStrip.childNodes; 112 for (var i = childNodes.length - 1; i >= 0; i--) 113 buttonStrip.appendChild(childNodes[i]); 114 115 buttonStrip.setAttribute('reversed', ''); 116 } 117 }, 118 119 /** 120 * Whether it should be possible to show the page. 121 * @return {boolean} True if the page should be shown. 122 */ 123 canShowPage: function() { 124 return true; 125 }, 126 127 /** 128 * Updates the hash of the current page. If the page is topmost, the history 129 * state is updated. 130 * @param {string} hash The new hash value. Like location.hash, this 131 * should include the leading '#' if not empty. 132 */ 133 setHash: function(hash) { 134 if (this.hash == hash) 135 return; 136 this.hash = hash; 137 PageManager.onPageHashChanged(this); 138 }, 139 140 /** 141 * Called after the page has been shown. 142 */ 143 didShowPage: function() {}, 144 145 /** 146 * Called before the page will be hidden, e.g., when a different root page 147 * will be shown. 148 */ 149 willHidePage: function() {}, 150 151 /** 152 * Called after the overlay has been closed. 153 */ 154 didClosePage: function() {}, 155 156 /** 157 * Gets the container div for this page if it is an overlay. 158 * @type {HTMLDivElement} 159 */ 160 get container() { 161 assert(this.isOverlay); 162 return this.pageDiv.parentNode; 163 }, 164 165 /** 166 * Gets page visibility state. 167 * @type {boolean} 168 */ 169 get visible() { 170 // If this is an overlay dialog it is no longer considered visible while 171 // the overlay is fading out. See http://crbug.com/118629. 172 if (this.isOverlay && 173 this.container.classList.contains('transparent')) { 174 return false; 175 } 176 if (this.pageDiv.hidden) 177 return false; 178 return this.pageDiv.page == this; 179 }, 180 181 /** 182 * Sets page visibility. 183 * @type {boolean} 184 */ 185 set visible(visible) { 186 if ((this.visible && visible) || (!this.visible && !visible)) 187 return; 188 189 // If using an overlay, the visibility of the dialog is toggled at the 190 // same time as the overlay to show the dialog's out transition. This 191 // is handled in setOverlayVisible. 192 if (this.isOverlay) { 193 this.setOverlayVisible_(visible); 194 } else { 195 this.pageDiv.page = this; 196 this.pageDiv.hidden = !visible; 197 PageManager.onPageVisibilityChanged(this); 198 } 199 200 cr.dispatchPropertyChange(this, 'visible', visible, !visible); 201 }, 202 203 /** 204 * Whether the page is considered 'sticky', such that it will remain a root 205 * page even if sub-pages change. 206 * @type {boolean} True if this page is sticky. 207 */ 208 get sticky() { 209 return false; 210 }, 211 212 /** 213 * @type {boolean} True if this page should always be considered the 214 * top-most page when visible. 215 */ 216 get alwaysOnTop() { 217 return this.alwaysOnTop_; 218 }, 219 220 /** 221 * @type {boolean} True if this page should always be considered the 222 * top-most page when visible. Only overlays can be always on top. 223 */ 224 set alwaysOnTop(value) { 225 assert(this.isOverlay); 226 this.alwaysOnTop_ = value; 227 }, 228 229 /** 230 * Shows or hides an overlay (including any visible dialog). 231 * @param {boolean} visible Whether the overlay should be visible or not. 232 * @private 233 */ 234 setOverlayVisible_: function(visible) { 235 assert(this.isOverlay); 236 var pageDiv = this.pageDiv; 237 var container = this.container; 238 239 if (container.hidden != visible) { 240 if (visible) { 241 // If the container is set hidden and then immediately set visible 242 // again, the fadeCompleted_ callback would cause it to be erroneously 243 // hidden again. Removing the transparent tag avoids that. 244 container.classList.remove('transparent'); 245 246 // Hide all dialogs in this container since a different one may have 247 // been previously visible before fading out. 248 var pages = container.querySelectorAll('.page'); 249 for (var i = 0; i < pages.length; i++) 250 pages[i].hidden = true; 251 // Show the new dialog. 252 pageDiv.hidden = false; 253 pageDiv.page = this; 254 } 255 return; 256 } 257 258 var self = this; 259 var loading = PageManager.isLoading(); 260 if (!loading) { 261 // TODO(flackr): Use an event delegate to avoid having to subscribe and 262 // unsubscribe for webkitTransitionEnd events. 263 container.addEventListener('webkitTransitionEnd', function f(e) { 264 var propName = e.propertyName; 265 if (e.target != e.currentTarget || 266 (propName && propName != 'opacity')) { 267 return; 268 } 269 container.removeEventListener('webkitTransitionEnd', f); 270 self.fadeCompleted_(); 271 }); 272 // -webkit-transition is 200ms. Let's wait for 400ms. 273 ensureTransitionEndEvent(container, 400); 274 } 275 276 if (visible) { 277 container.hidden = false; 278 pageDiv.hidden = false; 279 pageDiv.page = this; 280 // NOTE: This is a hacky way to force the container to layout which 281 // will allow us to trigger the webkit transition. 282 /** @suppress {uselessCode} */ 283 container.scrollTop; 284 285 this.pageDiv.removeAttribute('aria-hidden'); 286 if (this.parentPage) { 287 this.parentPage.pageDiv.parentElement.setAttribute('aria-hidden', 288 true); 289 } 290 container.classList.remove('transparent'); 291 PageManager.onPageVisibilityChanged(this); 292 } else { 293 // Kick change events for text fields. 294 if (pageDiv.contains(document.activeElement)) 295 document.activeElement.blur(); 296 container.classList.add('transparent'); 297 } 298 299 if (loading) 300 this.fadeCompleted_(); 301 }, 302 303 /** 304 * Called when a container opacity transition finishes. 305 * @private 306 */ 307 fadeCompleted_: function() { 308 if (this.container.classList.contains('transparent')) { 309 this.pageDiv.hidden = true; 310 this.container.hidden = true; 311 312 if (this.parentPage) 313 this.parentPage.pageDiv.parentElement.removeAttribute('aria-hidden'); 314 315 PageManager.onPageVisibilityChanged(this); 316 } 317 }, 318 }; 319 320 // Export 321 return { 322 Page: Page 323 }; 324}); 325