blob: a1cc1dcd393bc428eac7523c940435e2f58c51af [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001/**
2 * @license
3 * Copyright 2015 Google Inc. All Rights Reserved.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18(function() {
19 'use strict';
20
21 /**
22 * Class constructor for Layout MDL component.
23 * Implements MDL component design pattern defined at:
24 * https://github.com/jasonmayes/mdl-component-design-pattern
25 *
26 * @constructor
27 * @param {HTMLElement} element The element that will be upgraded.
28 */
29 var MaterialLayout = function MaterialLayout(element) {
30 this.element_ = element;
31
32 // Initialize instance.
33 this.init();
34 };
35 window['MaterialLayout'] = MaterialLayout;
36
37 /**
38 * Store constants in one place so they can be updated easily.
39 *
40 * @enum {string | number}
41 * @private
42 */
43 MaterialLayout.prototype.Constant_ = {
44 MAX_WIDTH: '(max-width: 1024px)',
45 TAB_SCROLL_PIXELS: 100,
46 RESIZE_TIMEOUT: 100,
47
48 MENU_ICON: '',
49 CHEVRON_LEFT: 'chevron_left',
50 CHEVRON_RIGHT: 'chevron_right'
51 };
52
53 /**
54 * Keycodes, for code readability.
55 *
56 * @enum {number}
57 * @private
58 */
59 MaterialLayout.prototype.Keycodes_ = {
60 ENTER: 13,
61 ESCAPE: 27,
62 SPACE: 32
63 };
64
65 /**
66 * Modes.
67 *
68 * @enum {number}
69 * @private
70 */
71 MaterialLayout.prototype.Mode_ = {
72 STANDARD: 0,
73 SEAMED: 1,
74 WATERFALL: 2,
75 SCROLL: 3
76 };
77
78 /**
79 * Store strings for class names defined by this component that are used in
80 * JavaScript. This allows us to simply change it in one place should we
81 * decide to modify at a later date.
82 *
83 * @enum {string}
84 * @private
85 */
86 MaterialLayout.prototype.CssClasses_ = {
87 CONTAINER: 'mdl-layout__container',
88 HEADER: 'mdl-layout__header',
89 DRAWER: 'mdl-layout__drawer',
90 CONTENT: 'mdl-layout__content',
91 DRAWER_BTN: 'mdl-layout__drawer-button',
92
93 ICON: 'material-icons',
94
95 JS_RIPPLE_EFFECT: 'mdl-js-ripple-effect',
96 RIPPLE_CONTAINER: 'mdl-layout__tab-ripple-container',
97 RIPPLE: 'mdl-ripple',
98 RIPPLE_IGNORE_EVENTS: 'mdl-js-ripple-effect--ignore-events',
99
100 HEADER_SEAMED: 'mdl-layout__header--seamed',
101 HEADER_WATERFALL: 'mdl-layout__header--waterfall',
102 HEADER_SCROLL: 'mdl-layout__header--scroll',
103
104 FIXED_HEADER: 'mdl-layout--fixed-header',
105 OBFUSCATOR: 'mdl-layout__obfuscator',
106
107 TAB_BAR: 'mdl-layout__tab-bar',
108 TAB_CONTAINER: 'mdl-layout__tab-bar-container',
109 TAB: 'mdl-layout__tab',
110 TAB_BAR_BUTTON: 'mdl-layout__tab-bar-button',
111 TAB_BAR_LEFT_BUTTON: 'mdl-layout__tab-bar-left-button',
112 TAB_BAR_RIGHT_BUTTON: 'mdl-layout__tab-bar-right-button',
113 TAB_MANUAL_SWITCH: 'mdl-layout__tab-manual-switch',
114 PANEL: 'mdl-layout__tab-panel',
115
116 HAS_DRAWER: 'has-drawer',
117 HAS_TABS: 'has-tabs',
118 HAS_SCROLLING_HEADER: 'has-scrolling-header',
119 CASTING_SHADOW: 'is-casting-shadow',
120 IS_COMPACT: 'is-compact',
121 IS_SMALL_SCREEN: 'is-small-screen',
122 IS_DRAWER_OPEN: 'is-visible',
123 IS_ACTIVE: 'is-active',
124 IS_UPGRADED: 'is-upgraded',
125 IS_ANIMATING: 'is-animating',
126
127 ON_LARGE_SCREEN: 'mdl-layout--large-screen-only',
128 ON_SMALL_SCREEN: 'mdl-layout--small-screen-only'
129
130 };
131
132 /**
133 * Handles scrolling on the content.
134 *
135 * @private
136 */
137 MaterialLayout.prototype.contentScrollHandler_ = function() {
138 if (this.header_.classList.contains(this.CssClasses_.IS_ANIMATING)) {
139 return;
140 }
141
142 var headerVisible =
143 !this.element_.classList.contains(this.CssClasses_.IS_SMALL_SCREEN) ||
144 this.element_.classList.contains(this.CssClasses_.FIXED_HEADER);
145
146 if (this.content_.scrollTop > 0 &&
147 !this.header_.classList.contains(this.CssClasses_.IS_COMPACT)) {
148 this.header_.classList.add(this.CssClasses_.CASTING_SHADOW);
149 this.header_.classList.add(this.CssClasses_.IS_COMPACT);
150 if (headerVisible) {
151 this.header_.classList.add(this.CssClasses_.IS_ANIMATING);
152 }
153 } else if (this.content_.scrollTop <= 0 &&
154 this.header_.classList.contains(this.CssClasses_.IS_COMPACT)) {
155 this.header_.classList.remove(this.CssClasses_.CASTING_SHADOW);
156 this.header_.classList.remove(this.CssClasses_.IS_COMPACT);
157 if (headerVisible) {
158 this.header_.classList.add(this.CssClasses_.IS_ANIMATING);
159 }
160 }
161 };
162
163 /**
164 * Handles a keyboard event on the drawer.
165 *
166 * @param {Event} evt The event that fired.
167 * @private
168 */
169 MaterialLayout.prototype.keyboardEventHandler_ = function(evt) {
170 // Only react when the drawer is open.
171 if (evt.keyCode === this.Keycodes_.ESCAPE &&
172 this.drawer_.classList.contains(this.CssClasses_.IS_DRAWER_OPEN)) {
173 this.toggleDrawer();
174 }
175 };
176
177 /**
178 * Handles changes in screen size.
179 *
180 * @private
181 */
182 MaterialLayout.prototype.screenSizeHandler_ = function() {
183 if (this.screenSizeMediaQuery_.matches) {
184 this.element_.classList.add(this.CssClasses_.IS_SMALL_SCREEN);
185 } else {
186 this.element_.classList.remove(this.CssClasses_.IS_SMALL_SCREEN);
187 // Collapse drawer (if any) when moving to a large screen size.
188 if (this.drawer_) {
189 this.drawer_.classList.remove(this.CssClasses_.IS_DRAWER_OPEN);
190 this.obfuscator_.classList.remove(this.CssClasses_.IS_DRAWER_OPEN);
191 }
192 }
193 };
194
195 /**
196 * Handles events of drawer button.
197 *
198 * @param {Event} evt The event that fired.
199 * @private
200 */
201 MaterialLayout.prototype.drawerToggleHandler_ = function(evt) {
202 if (evt && (evt.type === 'keydown')) {
203 if (evt.keyCode === this.Keycodes_.SPACE || evt.keyCode === this.Keycodes_.ENTER) {
204 // prevent scrolling in drawer nav
205 evt.preventDefault();
206 } else {
207 // prevent other keys
208 return;
209 }
210 }
211
212 this.toggleDrawer();
213 };
214
215 /**
216 * Handles (un)setting the `is-animating` class
217 *
218 * @private
219 */
220 MaterialLayout.prototype.headerTransitionEndHandler_ = function() {
221 this.header_.classList.remove(this.CssClasses_.IS_ANIMATING);
222 };
223
224 /**
225 * Handles expanding the header on click
226 *
227 * @private
228 */
229 MaterialLayout.prototype.headerClickHandler_ = function() {
230 if (this.header_.classList.contains(this.CssClasses_.IS_COMPACT)) {
231 this.header_.classList.remove(this.CssClasses_.IS_COMPACT);
232 this.header_.classList.add(this.CssClasses_.IS_ANIMATING);
233 }
234 };
235
236 /**
237 * Reset tab state, dropping active classes
238 *
239 * @private
240 */
241 MaterialLayout.prototype.resetTabState_ = function(tabBar) {
242 for (var k = 0; k < tabBar.length; k++) {
243 tabBar[k].classList.remove(this.CssClasses_.IS_ACTIVE);
244 }
245 };
246
247 /**
248 * Reset panel state, droping active classes
249 *
250 * @private
251 */
252 MaterialLayout.prototype.resetPanelState_ = function(panels) {
253 for (var j = 0; j < panels.length; j++) {
254 panels[j].classList.remove(this.CssClasses_.IS_ACTIVE);
255 }
256 };
257
258 /**
259 * Toggle drawer state
260 *
261 * @public
262 */
263 MaterialLayout.prototype.toggleDrawer = function() {
264 var drawerButton = this.element_.querySelector('.' + this.CssClasses_.DRAWER_BTN);
265 this.drawer_.classList.toggle(this.CssClasses_.IS_DRAWER_OPEN);
266 this.obfuscator_.classList.toggle(this.CssClasses_.IS_DRAWER_OPEN);
267
268 // Set accessibility properties.
269 if (this.drawer_.classList.contains(this.CssClasses_.IS_DRAWER_OPEN)) {
270 this.drawer_.setAttribute('aria-hidden', 'false');
271 drawerButton.setAttribute('aria-expanded', 'true');
272 } else {
273 this.drawer_.setAttribute('aria-hidden', 'true');
274 drawerButton.setAttribute('aria-expanded', 'false');
275 }
276 };
277 MaterialLayout.prototype['toggleDrawer'] =
278 MaterialLayout.prototype.toggleDrawer;
279
280 /**
281 * Initialize element.
282 */
283 MaterialLayout.prototype.init = function() {
284 if (this.element_) {
285 var container = document.createElement('div');
286 container.classList.add(this.CssClasses_.CONTAINER);
287
288 var focusedElement = this.element_.querySelector(':focus');
289
290 this.element_.parentElement.insertBefore(container, this.element_);
291 this.element_.parentElement.removeChild(this.element_);
292 container.appendChild(this.element_);
293
294 if (focusedElement) {
295 focusedElement.focus();
296 }
297
298 var directChildren = this.element_.childNodes;
299 var numChildren = directChildren.length;
300 for (var c = 0; c < numChildren; c++) {
301 var child = directChildren[c];
302 if (child.classList &&
303 child.classList.contains(this.CssClasses_.HEADER)) {
304 this.header_ = child;
305 }
306
307 if (child.classList &&
308 child.classList.contains(this.CssClasses_.DRAWER)) {
309 this.drawer_ = child;
310 }
311
312 if (child.classList &&
313 child.classList.contains(this.CssClasses_.CONTENT)) {
314 this.content_ = child;
315 }
316 }
317
318 window.addEventListener('pageshow', function(e) {
319 if (e.persisted) { // when page is loaded from back/forward cache
320 // trigger repaint to let layout scroll in safari
321 this.element_.style.overflowY = 'hidden';
322 requestAnimationFrame(function() {
323 this.element_.style.overflowY = '';
324 }.bind(this));
325 }
326 }.bind(this), false);
327
328 if (this.header_) {
329 this.tabBar_ = this.header_.querySelector('.' + this.CssClasses_.TAB_BAR);
330 }
331
332 var mode = this.Mode_.STANDARD;
333
334 if (this.header_) {
335 if (this.header_.classList.contains(this.CssClasses_.HEADER_SEAMED)) {
336 mode = this.Mode_.SEAMED;
337 } else if (this.header_.classList.contains(
338 this.CssClasses_.HEADER_WATERFALL)) {
339 mode = this.Mode_.WATERFALL;
340 this.header_.addEventListener('transitionend',
341 this.headerTransitionEndHandler_.bind(this));
342 this.header_.addEventListener('click',
343 this.headerClickHandler_.bind(this));
344 } else if (this.header_.classList.contains(
345 this.CssClasses_.HEADER_SCROLL)) {
346 mode = this.Mode_.SCROLL;
347 container.classList.add(this.CssClasses_.HAS_SCROLLING_HEADER);
348 }
349
350 if (mode === this.Mode_.STANDARD) {
351 this.header_.classList.add(this.CssClasses_.CASTING_SHADOW);
352 if (this.tabBar_) {
353 this.tabBar_.classList.add(this.CssClasses_.CASTING_SHADOW);
354 }
355 } else if (mode === this.Mode_.SEAMED || mode === this.Mode_.SCROLL) {
356 this.header_.classList.remove(this.CssClasses_.CASTING_SHADOW);
357 if (this.tabBar_) {
358 this.tabBar_.classList.remove(this.CssClasses_.CASTING_SHADOW);
359 }
360 } else if (mode === this.Mode_.WATERFALL) {
361 // Add and remove shadows depending on scroll position.
362 // Also add/remove auxiliary class for styling of the compact version of
363 // the header.
364 this.content_.addEventListener('scroll',
365 this.contentScrollHandler_.bind(this));
366 this.contentScrollHandler_();
367 }
368 }
369
370 // Add drawer toggling button to our layout, if we have an openable drawer.
371 if (this.drawer_) {
372 var drawerButton = this.element_.querySelector('.' +
373 this.CssClasses_.DRAWER_BTN);
374 if (!drawerButton) {
375 drawerButton = document.createElement('div');
376 drawerButton.setAttribute('aria-expanded', 'false');
377 drawerButton.setAttribute('role', 'button');
378 drawerButton.setAttribute('tabindex', '0');
379 drawerButton.classList.add(this.CssClasses_.DRAWER_BTN);
380
381 var drawerButtonIcon = document.createElement('i');
382 drawerButtonIcon.classList.add(this.CssClasses_.ICON);
383 drawerButtonIcon.innerHTML = this.Constant_.MENU_ICON;
384 drawerButton.appendChild(drawerButtonIcon);
385 }
386
387 if (this.drawer_.classList.contains(this.CssClasses_.ON_LARGE_SCREEN)) {
388 //If drawer has ON_LARGE_SCREEN class then add it to the drawer toggle button as well.
389 drawerButton.classList.add(this.CssClasses_.ON_LARGE_SCREEN);
390 } else if (this.drawer_.classList.contains(this.CssClasses_.ON_SMALL_SCREEN)) {
391 //If drawer has ON_SMALL_SCREEN class then add it to the drawer toggle button as well.
392 drawerButton.classList.add(this.CssClasses_.ON_SMALL_SCREEN);
393 }
394
395 drawerButton.addEventListener('click',
396 this.drawerToggleHandler_.bind(this));
397
398 drawerButton.addEventListener('keydown',
399 this.drawerToggleHandler_.bind(this));
400
401 // Add a class if the layout has a drawer, for altering the left padding.
402 // Adds the HAS_DRAWER to the elements since this.header_ may or may
403 // not be present.
404 this.element_.classList.add(this.CssClasses_.HAS_DRAWER);
405
406 // If we have a fixed header, add the button to the header rather than
407 // the layout.
408 if (this.element_.classList.contains(this.CssClasses_.FIXED_HEADER)) {
409 this.header_.insertBefore(drawerButton, this.header_.firstChild);
410 } else {
411 this.element_.insertBefore(drawerButton, this.content_);
412 }
413
414 var obfuscator = document.createElement('div');
415 obfuscator.classList.add(this.CssClasses_.OBFUSCATOR);
416 this.element_.appendChild(obfuscator);
417 obfuscator.addEventListener('click',
418 this.drawerToggleHandler_.bind(this));
419 this.obfuscator_ = obfuscator;
420
421 this.drawer_.addEventListener('keydown', this.keyboardEventHandler_.bind(this));
422 this.drawer_.setAttribute('aria-hidden', 'true');
423 }
424
425 // Keep an eye on screen size, and add/remove auxiliary class for styling
426 // of small screens.
427 this.screenSizeMediaQuery_ = window.matchMedia(
428 /** @type {string} */ (this.Constant_.MAX_WIDTH));
429 this.screenSizeMediaQuery_.addListener(this.screenSizeHandler_.bind(this));
430 this.screenSizeHandler_();
431
432 // Initialize tabs, if any.
433 if (this.header_ && this.tabBar_) {
434 this.element_.classList.add(this.CssClasses_.HAS_TABS);
435
436 var tabContainer = document.createElement('div');
437 tabContainer.classList.add(this.CssClasses_.TAB_CONTAINER);
438 this.header_.insertBefore(tabContainer, this.tabBar_);
439 this.header_.removeChild(this.tabBar_);
440
441 var leftButton = document.createElement('div');
442 leftButton.classList.add(this.CssClasses_.TAB_BAR_BUTTON);
443 leftButton.classList.add(this.CssClasses_.TAB_BAR_LEFT_BUTTON);
444 var leftButtonIcon = document.createElement('i');
445 leftButtonIcon.classList.add(this.CssClasses_.ICON);
446 leftButtonIcon.textContent = this.Constant_.CHEVRON_LEFT;
447 leftButton.appendChild(leftButtonIcon);
448 leftButton.addEventListener('click', function() {
449 this.tabBar_.scrollLeft -= this.Constant_.TAB_SCROLL_PIXELS;
450 }.bind(this));
451
452 var rightButton = document.createElement('div');
453 rightButton.classList.add(this.CssClasses_.TAB_BAR_BUTTON);
454 rightButton.classList.add(this.CssClasses_.TAB_BAR_RIGHT_BUTTON);
455 var rightButtonIcon = document.createElement('i');
456 rightButtonIcon.classList.add(this.CssClasses_.ICON);
457 rightButtonIcon.textContent = this.Constant_.CHEVRON_RIGHT;
458 rightButton.appendChild(rightButtonIcon);
459 rightButton.addEventListener('click', function() {
460 this.tabBar_.scrollLeft += this.Constant_.TAB_SCROLL_PIXELS;
461 }.bind(this));
462
463 tabContainer.appendChild(leftButton);
464 tabContainer.appendChild(this.tabBar_);
465 tabContainer.appendChild(rightButton);
466
467 // Add and remove tab buttons depending on scroll position and total
468 // window size.
469 var tabUpdateHandler = function() {
470 if (this.tabBar_.scrollLeft > 0) {
471 leftButton.classList.add(this.CssClasses_.IS_ACTIVE);
472 } else {
473 leftButton.classList.remove(this.CssClasses_.IS_ACTIVE);
474 }
475
476 if (this.tabBar_.scrollLeft <
477 this.tabBar_.scrollWidth - this.tabBar_.offsetWidth) {
478 rightButton.classList.add(this.CssClasses_.IS_ACTIVE);
479 } else {
480 rightButton.classList.remove(this.CssClasses_.IS_ACTIVE);
481 }
482 }.bind(this);
483
484 this.tabBar_.addEventListener('scroll', tabUpdateHandler);
485 tabUpdateHandler();
486
487 // Update tabs when the window resizes.
488 var windowResizeHandler = function() {
489 // Use timeouts to make sure it doesn't happen too often.
490 if (this.resizeTimeoutId_) {
491 clearTimeout(this.resizeTimeoutId_);
492 }
493 this.resizeTimeoutId_ = setTimeout(function() {
494 tabUpdateHandler();
495 this.resizeTimeoutId_ = null;
496 }.bind(this), /** @type {number} */ (this.Constant_.RESIZE_TIMEOUT));
497 }.bind(this);
498
499 window.addEventListener('resize', windowResizeHandler);
500
501 if (this.tabBar_.classList.contains(this.CssClasses_.JS_RIPPLE_EFFECT)) {
502 this.tabBar_.classList.add(this.CssClasses_.RIPPLE_IGNORE_EVENTS);
503 }
504
505 // Select element tabs, document panels
506 var tabs = this.tabBar_.querySelectorAll('.' + this.CssClasses_.TAB);
507 var panels = this.content_.querySelectorAll('.' + this.CssClasses_.PANEL);
508
509 // Create new tabs for each tab element
510 for (var i = 0; i < tabs.length; i++) {
511 new MaterialLayoutTab(tabs[i], tabs, panels, this);
512 }
513 }
514
515 this.element_.classList.add(this.CssClasses_.IS_UPGRADED);
516 }
517 };
518
519 /**
520 * Constructor for an individual tab.
521 *
522 * @constructor
523 * @param {HTMLElement} tab The HTML element for the tab.
524 * @param {!Array<HTMLElement>} tabs Array with HTML elements for all tabs.
525 * @param {!Array<HTMLElement>} panels Array with HTML elements for all panels.
526 * @param {MaterialLayout} layout The MaterialLayout object that owns the tab.
527 */
528 function MaterialLayoutTab(tab, tabs, panels, layout) {
529
530 /**
531 * Auxiliary method to programmatically select a tab in the UI.
532 */
533 function selectTab() {
534 var href = tab.href.split('#')[1];
535 var panel = layout.content_.querySelector('#' + href);
536 layout.resetTabState_(tabs);
537 layout.resetPanelState_(panels);
538 tab.classList.add(layout.CssClasses_.IS_ACTIVE);
539 panel.classList.add(layout.CssClasses_.IS_ACTIVE);
540 }
541
542 if (layout.tabBar_.classList.contains(
543 layout.CssClasses_.JS_RIPPLE_EFFECT)) {
544 var rippleContainer = document.createElement('span');
545 rippleContainer.classList.add(layout.CssClasses_.RIPPLE_CONTAINER);
546 rippleContainer.classList.add(layout.CssClasses_.JS_RIPPLE_EFFECT);
547 var ripple = document.createElement('span');
548 ripple.classList.add(layout.CssClasses_.RIPPLE);
549 rippleContainer.appendChild(ripple);
550 tab.appendChild(rippleContainer);
551 }
552
553 if (!layout.tabBar_.classList.contains(
554 layout.CssClasses_.TAB_MANUAL_SWITCH)) {
555 tab.addEventListener('click', function(e) {
556 if (tab.getAttribute('href').charAt(0) === '#') {
557 e.preventDefault();
558 selectTab();
559 }
560 });
561 }
562
563 tab.show = selectTab;
564 }
565 window['MaterialLayoutTab'] = MaterialLayoutTab;
566
567 // The component registers itself. It can assume componentHandler is available
568 // in the global scope.
569 componentHandler.register({
570 constructor: MaterialLayout,
571 classAsString: 'MaterialLayout',
572 cssClass: 'mdl-js-layout'
573 });
574})();