blob: 9ed2e1374f3d2b2405922370c8bcbf04ee93d677 [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001/**
2 * @license
3 * Copyright 2016 Leif Olsen. 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 * This code is built with Google Material Design Lite,
18 * which is Licensed under the Apache License, Version 2.0
19 */
20
21/**
22 * A sticky header makes site navigation easily accessible anywhere on the page and saves content space at the same.
23 * The header should auto-hide, i.e. hiding the header automatically when a user starts scrolling down the page and
24 * bringing the header back when a user might need it: they reach the bottom of the page or start scrolling up.
25 */
26
27import fullThrottle from '../utils/full-throttle';
28import { jsonStringToObject } from '../utils/json-utils';
29import {
30 IS_UPGRADED
31} from '../utils/constants';
32
33
34(function() {
35 'use strict';
36 const MDL_LAYOUT_CONTENT = 'mdl-layout__content';
37 const IS_SCROLL_CLASS = 'mdlext-is-scroll';
38
39
40 /**
41 * @constructor
42 * @param {Element} element The element that will be upgraded.
43 */
44 const MaterialExtStickyHeader = function MaterialExtStickyHeader(element) {
45 // Stores the element.
46 this.header_ = element;
47
48 // Heder listens to scroll events from content
49 this.content_ = null;
50 this.lastScrollTop_ = 0;
51
52 // Default config
53 this.config_ = {
54 visibleAtScrollEnd: false
55 };
56
57 this.mutationObserver_ = null;
58
59 this.drawing_ = false;
60
61 // Initialize instance.
62 this.init();
63 };
64
65 window['MaterialExtStickyHeader'] = MaterialExtStickyHeader;
66
67
68 /**
69 * Update header width
70 * @private
71 */
72 MaterialExtStickyHeader.prototype.recalcWidth_ = function() {
73 this.header_.style.width = `${this.content_.clientWidth}px`;
74 };
75
76 const throttleResize = fullThrottle(self => self.recalcWidth_() );
77
78 /**
79 * Adjust header width when window resizes or oreientation changes
80 * @param event
81 * @private
82 */
83 MaterialExtStickyHeader.prototype.resizeHandler_ = function( /* event */ ) {
84 throttleResize(this);
85 };
86
87
88 /**
89 * Update header position
90 * @private
91 */
92 MaterialExtStickyHeader.prototype.reposition_ = function() {
93
94 const currentContentScrollTop = this.content_.scrollTop;
95 const scrollDiff = this.lastScrollTop_ - currentContentScrollTop;
96
97 if(currentContentScrollTop <= 0) {
98 // Scrolled to the top. Header sticks to the top
99 this.header_.style.top = '0';
100 this.header_.classList.remove(IS_SCROLL_CLASS);
101 }
102 else if(scrollDiff > 0) {
103
104 if(scrollDiff >= this.header_.offsetHeight) {
105
106 // Scrolled up. Header slides in
107 const headerTop = (parseInt( window.getComputedStyle( this.header_ ).getPropertyValue( 'top' ) ) || 0);
108 if(headerTop != 0) {
109 this.header_.style.top = '0';
110 this.header_.classList.add(IS_SCROLL_CLASS);
111 }
112 this.lastScrollTop_ = currentContentScrollTop;
113 }
114 return;
115 }
116 else if(scrollDiff < 0) {
117 // Scrolled down
118 this.header_.classList.add(IS_SCROLL_CLASS);
119 let headerTop = (parseInt( window.getComputedStyle( this.header_ ).getPropertyValue( 'top' ) ) || 0);
120
121 if (this.content_.scrollHeight - this.content_.scrollTop <= this.content_.offsetHeight) {
122 // Bottom of content
123 if(headerTop != 0) {
124 this.header_.style.top = this.config_.visibleAtScrollEnd ? '0' : `-${this.header_.offsetHeight}px`;
125 }
126 }
127 else {
128 headerTop += scrollDiff;
129 const offsetHeight = this.header_.offsetHeight;
130 this.header_.style.top = `${( Math.abs( headerTop ) > offsetHeight ? -offsetHeight : headerTop )}px`;
131 }
132 }
133
134 this.lastScrollTop_ = currentContentScrollTop;
135 };
136
137
138 const throttleScroll = fullThrottle((self) => self.reposition_());
139
140 /**
141 * Scroll header when content scrolls
142 * @param event
143 * @private
144 */
145 MaterialExtStickyHeader.prototype.scrollHandler_ = function( /* event */ ) {
146 throttleScroll(this);
147 };
148
149 /**
150 * Init header position
151 * @private
152 */
153 MaterialExtStickyHeader.prototype.updatePosition_ = function( /* event */ ) {
154 this.recalcWidth_();
155 this.reposition_();
156 };
157
158 /**
159 * Add mutation observer
160 * @private
161 */
162 MaterialExtStickyHeader.prototype.addMutationObserver_ = function() {
163
164 // jsdom does not support MutationObserver - so this is not testable
165 /* istanbul ignore next */
166 this.mutationObserver_ = new MutationObserver( ( /*mutations*/ ) => {
167 // Adjust header width if content changes (e.g. in a SPA)
168 this.updatePosition_();
169 });
170
171 this.mutationObserver_.observe( this.content_, {
172 attributes: false,
173 childList: true,
174 characterData: false,
175 subtree: true
176 });
177 };
178
179 /**
180 * Removes event listeners
181 * @private
182 */
183 MaterialExtStickyHeader.prototype.removeListeners_ = function() {
184
185 window.removeEventListener('resize', this.resizeHandler_);
186 window.removeEventListener('orientationchange', this.resizeHandler_);
187
188 if(this.content_) {
189 this.content_.removeEventListener('scroll', this.scrollHandler_);
190 }
191
192 if(this.mutationObserver_) {
193 this.mutationObserver_.disconnect();
194 this.mutationObserver_ = null;
195 }
196 };
197
198 /**
199 * Initialize component
200 */
201 MaterialExtStickyHeader.prototype.init = function() {
202
203 if (this.header_) {
204
205 this.removeListeners_();
206
207 if(this.header_.hasAttribute('data-config')) {
208 this.config_ = jsonStringToObject(this.header_.getAttribute('data-config'));
209 }
210
211 this.content_ = this.header_.parentNode.querySelector(`.${MDL_LAYOUT_CONTENT}`) || null;
212
213 if(this.content_) {
214 this.content_.style.paddingTop = `${this.header_.offsetHeight}px`; // Make room for sticky header
215 this.lastScrollTop_ = this.content_.scrollTop;
216
217 this.content_.addEventListener('scroll', this.scrollHandler_.bind(this));
218 window.addEventListener('resize', this.resizeHandler_.bind(this));
219 window.addEventListener('orientationchange', this.resizeHandler_.bind(this));
220
221 this.addMutationObserver_();
222 this.updatePosition_();
223
224 // Set upgraded flag
225 this.header_.classList.add(IS_UPGRADED);
226 }
227 }
228 };
229
230 /*
231 * Downgrade component
232 * E.g remove listeners and clean up resources
233 *
234 * Nothing to clean
235 *
236 MaterialExtStickyHeader.prototype.mdlDowngrade_ = function() {
237 'use strict';
238 console.log('***** MaterialExtStickyHeader.prototype.mdlDowngrade_');
239 };
240 */
241
242
243 // The component registers itself. It can assume componentHandler is available
244 // in the global scope.
245 /* eslint no-undef: 0 */
246 componentHandler.register({
247 constructor: MaterialExtStickyHeader,
248 classAsString: 'MaterialExtStickyHeader',
249 cssClass: 'mdlext-js-sticky-header'
250 });
251})();