1 | /**
|
2 | * @module component/Sticky
|
3 | */
|
4 | define(
|
5 | [
|
6 | 'domReady', //
|
7 | 'lib/defineClass', //
|
8 | 'lib/enforceFactory', //
|
9 | 'lib/queries', //
|
10 | 'lib/throttle' //
|
11 | ], //--
|
12 | function( //--
|
13 | domReady, //
|
14 | defineClass, //
|
15 | enforceFactory, //
|
16 | q, //
|
17 | throttle //
|
18 | ) {
|
19 | /**
|
20 | * CSS classes used by the Sticky component.
|
21 | *
|
22 | * @namespace
|
23 | * @readonly
|
24 | * @property {string} AT_BOTTOM - Sticks to the scroll bottom.
|
25 | * @property {string} AT_TOP - Sticks to the scroll top.
|
26 | * @property {string} PLACEHOLDER - Placeholder element that maintains layout and flow.
|
27 | * @property {string} READY - Component has been initialized.
|
28 | * @property {string} SEMI -
|
29 | * @property {string} STUCK - Component is in the "stuck" state.
|
30 | * @property {string} TO_CONTAINER - Sticks inside a specified container.
|
31 | * @property {string} TO_PAGE - Sticks inside the full viewport.
|
32 | */
|
33 | var $CLASS = {
|
34 | AT_BOTTOM: "sticky--stick-at-bottom",
|
35 | AT_TOP: "sticky--stick-at-top",
|
36 | PLACEHOLDER: "sticky__placeholder",
|
37 | READY: "sticky--ready",
|
38 | SEMI: "sticky--semi-stuck",
|
39 | STUCK: "sticky--stuck",
|
40 | TO_CONTAINER: "sticky--stick-to-container",
|
41 | TO_PAGE: "sticky--stick-to-page"
|
42 | };
|
43 |
|
44 |
|
45 | /**
|
46 | * Static messages.
|
47 | *
|
48 | * @namespace
|
49 | * @readonly
|
50 | * @property {string} EX_BAD_KEY - For exception on invalid param to {@link Sticky.getInstance}.
|
51 | * @property {string} EX_BAD_MODE - For exception on invalid mode.
|
52 | * @property {string} EX_CONTAINER_NOT_FOUND - For exception on failure to find container.
|
53 | * @property {string} EX_CONTAINER_SELECTOR - For exception on missing container selector.
|
54 | * @property {string} EX_MIXED_CONTAINER - For exception on mixed stick-to- modifiers.
|
55 | * @property {string} EX_MIXED_MODE - For exception on mixed stick-at- modifiers.
|
56 | * @property {string} EX_PARENT - For exception on missing parent node.
|
57 | */
|
58 | var $MSG = {
|
59 | EX_BAD_KEY: "Param to getInstance must be either a number or a string",
|
60 | EX_BAD_MODE: "Mode must be one of 'bottom' or 'top'",
|
61 | EX_CONTAINER_NOT_FOUND: "Container element not found",
|
62 | EX_CONTAINER_SELECTOR: "Need to specify container selector in data-sticky attribute",
|
63 | EX_MIXED_CONTAINER: (
|
64 | "Cannot have both of the " + $CLASS.AT_BOTTOM +
|
65 | " and " + $CLASS.AT_TOP +
|
66 | " modifiers"
|
67 | ),
|
68 | EX_MIXED_MODE: (
|
69 | "Cannot have both of the " + $CLASS.TO_CONTAINER +
|
70 | " and " + $CLASS.TO_PAGE +
|
71 | " modifiers"
|
72 | ),
|
73 | EX_PARENT: "Component element has no parent node"
|
74 | };
|
75 |
|
76 |
|
77 | /**
|
78 | * A StickyException is thrown when something goes wrong either in the initialization or
|
79 | * operation of a Sticky component.
|
80 | *
|
81 | * @class
|
82 | */
|
83 | var StickyException = defineClass(
|
84 | /**
|
85 | * Creates a new StickyException.
|
86 | *
|
87 | * @constructs
|
88 | * @param {string} message - A short human-friendly message describing the nature of
|
89 | * the exception.
|
90 | */
|
91 | function StickyException(message) { this.message = message; },
|
92 |
|
93 |
|
94 | /** @lends StickyException.prototype */
|
95 | {
|
96 | /**
|
97 | * Returns a string representation of the exception.
|
98 | *
|
99 | * @returns {string}
|
100 | */
|
101 | toString: function() { return "StickyException: " + this.message; }
|
102 | }
|
103 | );
|
104 |
|
105 |
|
106 | /**
|
107 | * Enforce that the given expression is not false, null, or undefined. If so, throw a
|
108 | * {@link StickyException} with the provided message, otherwise return the value of the expression.
|
109 | *
|
110 | * @function
|
111 | * @param expression - Expression to test and, if valid, return.
|
112 | * @param {string} message - Message to give the potential exception.
|
113 | * @returns The value of the provided expression.
|
114 | */
|
115 | var enforce = enforceFactory(StickyException);
|
116 |
|
117 |
|
118 | /**
|
119 | * An array of all instances of Sticky.
|
120 | *
|
121 | * @type {Sticky[]}
|
122 | */
|
123 | var instances = [];
|
124 |
|
125 |
|
126 | /**
|
127 | * A mapping of Sticky ID's to instances.
|
128 | *
|
129 | * @type {Object.<string, Sticky>}
|
130 | */
|
131 | var instancesById = {};
|
132 |
|
133 |
|
134 | /**
|
135 | * Determines the configuration of a Sticky component, based on the classes and possible
|
136 | * data attribute of the component element.
|
137 | *
|
138 | * @param {HTMLElement} sticky - The sticky component.
|
139 | */
|
140 | function loadConfig(sticky) {
|
141 | var element = sticky.element;
|
142 | var classList = element.classList;
|
143 |
|
144 | // determine the mode
|
145 | var hasAtBottom = classList.contains($CLASS.AT_BOTTOM);
|
146 | var hasAtTop = classList.contains($CLASS.AT_TOP);
|
147 | enforce(hasAtBottom != hasAtTop, $MSG.EX_MIXED_CONTAINER);
|
148 | sticky.mode = (hasAtBottom ? "bottom" : "top");
|
149 |
|
150 | // determine the container
|
151 | var hasToContainer = classList.contains($CLASS.TO_CONTAINER);
|
152 | var hasToPage = classList.contains($CLASS.TO_PAGE);
|
153 | enforce(hasToContainer != hasToPage, $MSG.EX_MIXED_MODE);
|
154 | if (hasToContainer) {
|
155 | var containerSelector = enforce(element.getAttribute("data-sticky"), $MSG.EX_CONTAINER_SELECTOR);
|
156 | sticky.container = enforce(q.first(containerSelector), $MSG.EX_CONTAINER_NOT_FOUND);
|
157 | sticky.containerIsBody = false;
|
158 | } else {
|
159 | sticky.container = document.body;
|
160 | sticky.containerIsBody = true;
|
161 | }
|
162 | }
|
163 |
|
164 |
|
165 | /**
|
166 | * Handles custom scroll:sticky events (throttled scroll events). Calls
|
167 | * {@link Sticky#update} on all live instances.
|
168 | *
|
169 | * @param {CustomEvent} event
|
170 | */
|
171 | function onStickyUpdateEvent(event) {
|
172 | for (var i = 0, len = instances.length; i < len; ++i) {
|
173 | var sticky = instances[i];
|
174 | if (null != sticky && !sticky.ignoreEvents) {
|
175 | sticky.update();
|
176 | }
|
177 | }
|
178 | }
|
179 | throttle(window, "scroll", "component:sticky:update", { type: "animation" });
|
180 | throttle(window, "resize", "component:sticky:update", { type: "animation" });
|
181 | window.addEventListener("component:sticky:update", onStickyUpdateEvent);
|
182 | domReady(function() { window.dispatchEvent(new CustomEvent("scroll")); });
|
183 |
|
184 |
|
185 | /**
|
186 | * @class
|
187 | * @alias module:component/Sticky
|
188 | */
|
189 | var Sticky = defineClass(
|
190 | /**
|
191 | * @constructs
|
192 | * @param {HTMLElement} element - The sticky component element.
|
193 | */
|
194 | function Sticky(element, ignoreEvents) {
|
195 | this.element = element;
|
196 | this.ignoreEvents = ignoreEvents || false;
|
197 |
|
198 | // load config options from element attributes
|
199 | loadConfig(this);
|
200 |
|
201 | // create the placeholder and make the sticky element its child
|
202 | var parent = enforce(element.parentNode, $MSG.EX_PARENT);
|
203 | var placeholder = this.placeholder = document.createElement("div");
|
204 | var rect = element.getBoundingClientRect();
|
205 | var height = rect.height || (rect.bottom - rect.top);
|
206 | var width = rect.width || (rect.right - rect.left);
|
207 | placeholder.classList.add($CLASS.PLACEHOLDER);
|
208 | placeholder.setAttribute("style", "width: " + width + "px; height: " + height + "px;");
|
209 | parent.replaceChild(placeholder, element);
|
210 | placeholder.appendChild(element);
|
211 |
|
212 | // set ID attribute (if not already set) and register instance
|
213 | var id = element.id;
|
214 | var instanceIndex = this.instanceIndex = instances.length;
|
215 | if (null == id) {
|
216 | id = element.id = "sticky-" + instanceIndex;
|
217 | }
|
218 | enforce(instancesById[id] == null, "Instance has duplicate ID: #" + id);
|
219 | instancesById[id] = this;
|
220 | instances.push(this);
|
221 |
|
222 | // mark component as initialized
|
223 | element.classList.add($CLASS.READY);
|
224 | },
|
225 |
|
226 |
|
227 | /** @lends Sticky.prototype */
|
228 | {
|
229 | /**
|
230 | *
|
231 | */
|
232 | clear: function(destruct, deregister) {
|
233 | if (destruct) {
|
234 | var element = this.element;
|
235 | var placeholder = this.placeholder;
|
236 | var parent = this.placeholder.parentNode;
|
237 |
|
238 | this.unstick();
|
239 | placeholder.removeChild(element);
|
240 | parent.replaceChild(element, placeholder);
|
241 | element.classList.remove($CLASS.READY);
|
242 | }
|
243 | if (deregister) {
|
244 | instances[this.instanceIndex] = null;
|
245 | delete instancesById[this.id];
|
246 | }
|
247 | },
|
248 |
|
249 |
|
250 | /**
|
251 | * Get the ID attribute of the component element.
|
252 | *
|
253 | * @returns {string}
|
254 | */
|
255 | getId: function() { return this.element.id; },
|
256 |
|
257 |
|
258 | /**
|
259 | * Get whether this component is currently in the "stuck" state.
|
260 | *
|
261 | * @returns {boolean}
|
262 | */
|
263 | isStuck: function() { return this.element.classList.contains($CLASS.STUCK); },
|
264 |
|
265 |
|
266 | /**
|
267 | * Programmatically put this component into the "stuck" state.
|
268 | *
|
269 | * @returns {Sticky} This component instance.
|
270 | */
|
271 | stick: function() { return this.toggleStuck(true); },
|
272 |
|
273 |
|
274 | /**
|
275 | *
|
276 | */
|
277 | threshold: 1,
|
278 |
|
279 |
|
280 | /**
|
281 | * Programmatically toggle the "stuck" state of this component. If the optional
|
282 | * condition parameter is given, then stick on true or unstick on false. Otherwise,
|
283 | * invert the current state.
|
284 | *
|
285 | * @param [condition]
|
286 | * @returns {Sticky} This component instance.
|
287 | */
|
288 | toggleStuck: function(condition, semi) {
|
289 | if (undefined === condition) {
|
290 | condition = !this.isStuck();
|
291 | }
|
292 | this.element.classList.toggle($CLASS.STUCK, condition);
|
293 | this.placeholder.classList.toggle($CLASS.STUCK, condition);
|
294 | this.element.classList.toggle($CLASS.SEMI, !!semi);
|
295 | return this;
|
296 | },
|
297 |
|
298 |
|
299 | /**
|
300 | * Programmatically take this component out of the "stuck" state.
|
301 | *
|
302 | * @returns {Sticky} This component instance.
|
303 | */
|
304 | unstick: function(skipCheck) { return this.toggleStuck(false); },
|
305 |
|
306 |
|
307 | /**
|
308 | *
|
309 | *
|
310 | * @returns {Sticky} This component instance.
|
311 | */
|
312 | update: function() {
|
313 | var mode = this.mode;
|
314 | var rect = this.placeholder.getBoundingClientRect();
|
315 | var position = enforce(rect[mode], $MSG.EX_BAD_MODE);
|
316 | if ("bottom" === mode) {
|
317 | position = window.innerHeight - position;
|
318 | }
|
319 | if (this.containerIsBody) {
|
320 | this.toggleStuck(position <= this.threshold, false);
|
321 | } else {
|
322 | var container = this.container;
|
323 | var contRect = container.getBoundingClientRect();
|
324 | var height = rect.height || (rect.bottom - rect.top);
|
325 | if ("top" === mode && 0 >= position) {
|
326 | this.toggleStuck(true, contRect.bottom <= height);
|
327 | } else if ("bottom" === mode && 0 <= contRect.top) {
|
328 | this.toggleStuck(true, (window.innerHeight - contRect.top) <= height);
|
329 | } else if (this.isStuck()) {
|
330 | this.toggleStuck(false, false);
|
331 | }
|
332 | }
|
333 | return this;
|
334 | }
|
335 | },
|
336 |
|
337 |
|
338 | /** @lends Sticky */
|
339 | {
|
340 | /**
|
341 | *
|
342 | */
|
343 | clearInstances: function(destruct) {
|
344 | if (destruct) {
|
345 | for (var i = 0, len = instances.length; i < len; ++i) {
|
346 | instaces[i].clear(destruct);
|
347 | }
|
348 | }
|
349 | instances = [];
|
350 | instancesById = {};
|
351 | },
|
352 |
|
353 |
|
354 | /**
|
355 | *
|
356 | */
|
357 | getInstance: function(key) {
|
358 | switch (typeof key) {
|
359 | case "number": return instances[key];
|
360 | case "string": return instancesById[key];
|
361 | default : throw new StickyException($MSG.EX_BAD_KEY);
|
362 | }
|
363 | },
|
364 |
|
365 |
|
366 | /**
|
367 | *
|
368 | */
|
369 | getInstances: function() { return instances; },
|
370 |
|
371 |
|
372 | /**
|
373 | *
|
374 | */
|
375 | instantiateAll: function() {
|
376 | var exceptions = [];
|
377 | if (0 === instances.length) {
|
378 | var elements = q.byClass("sticky");
|
379 | for (var i = 0, len = elements.length; i < len; ++i) {
|
380 | var element = elements[i];
|
381 | try {
|
382 | new Sticky(elements[i]);
|
383 | } catch (ex) {
|
384 | exceptions.push(ex);
|
385 | }
|
386 | }
|
387 | }
|
388 | return {
|
389 | exceptions: exceptions,
|
390 | instances: instances
|
391 | };
|
392 | }
|
393 | }
|
394 | );
|
395 |
|
396 |
|
397 | /* Provides class Sticky. */
|
398 | return Sticky;
|
399 | }
|
400 | );
|