aboutsummaryrefslogtreecommitdiff
path: root/assets/js/plugins/gumshoe.js
blob: 713b6eb303020902eb126ddc2b58c582a0c1ff21 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
/*!
 * gumshoejs v5.1.1
 * A simple, framework-agnostic scrollspy script.
 * (c) 2019 Chris Ferdinandi
 * MIT License
 * http://github.com/cferdinandi/gumshoe
 */

(function (root, factory) {
	if ( typeof define === 'function' && define.amd ) {
		define([], (function () {
			return factory(root);
		}));
	} else if ( typeof exports === 'object' ) {
		module.exports = factory(root);
	} else {
		root.Gumshoe = factory(root);
	}
})(typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this, (function (window) {

	'use strict';

	//
	// Defaults
	//

	var defaults = {

		// Active classes
		navClass: 'active',
		contentClass: 'active',

		// Nested navigation
		nested: false,
		nestedClass: 'active',

		// Offset & reflow
		offset: 0,
		reflow: false,

		// Event support
		events: true

	};


	//
	// Methods
	//

	/**
	 * Merge two or more objects together.
	 * @param   {Object}   objects  The objects to merge together
	 * @returns {Object}            Merged values of defaults and options
	 */
	var extend = function () {
		var merged = {};
		Array.prototype.forEach.call(arguments, (function (obj) {
			for (var key in obj) {
				if (!obj.hasOwnProperty(key)) return;
				merged[key] = obj[key];
			}
		}));
		return merged;
	};

	/**
	 * Emit a custom event
	 * @param  {String} type   The event type
	 * @param  {Node}   elem   The element to attach the event to
	 * @param  {Object} detail Any details to pass along with the event
	 */
	var emitEvent = function (type, elem, detail) {

		// Make sure events are enabled
		if (!detail.settings.events) return;

		// Create a new event
		var event = new CustomEvent(type, {
			bubbles: true,
			cancelable: true,
			detail: detail
		});

		// Dispatch the event
		elem.dispatchEvent(event);

	};

	/**
	 * Get an element's distance from the top of the Document.
	 * @param  {Node} elem The element
	 * @return {Number}    Distance from the top in pixels
	 */
	var getOffsetTop = function (elem) {
		var location = 0;
		if (elem.offsetParent) {
			while (elem) {
				location += elem.offsetTop;
				elem = elem.offsetParent;
			}
		}
		return location >= 0 ? location : 0;
	};

	/**
	 * Sort content from first to last in the DOM
	 * @param  {Array} contents The content areas
	 */
	var sortContents = function (contents) {
		if(contents) {
			contents.sort((function (item1, item2) {
				var offset1 = getOffsetTop(item1.content);
				var offset2 = getOffsetTop(item2.content);
				if (offset1 < offset2) return -1;
				return 1;
			}));
		}
	};

	/**
	 * Get the offset to use for calculating position
	 * @param  {Object} settings The settings for this instantiation
	 * @return {Float}           The number of pixels to offset the calculations
	 */
	var getOffset = function (settings) {

		// if the offset is a function run it
		if (typeof settings.offset === 'function') {
			return parseFloat(settings.offset());
		}

		// Otherwise, return it as-is
		return parseFloat(settings.offset);

	};

	/**
	 * Get the document element's height
	 * @private
	 * @returns {Number}
	 */
	var getDocumentHeight = function () {
		return Math.max(
			document.body.scrollHeight, document.documentElement.scrollHeight,
			document.body.offsetHeight, document.documentElement.offsetHeight,
			document.body.clientHeight, document.documentElement.clientHeight
		);
	};

	/**
	 * Determine if an element is in view
	 * @param  {Node}    elem     The element
	 * @param  {Object}  settings The settings for this instantiation
	 * @param  {Boolean} bottom   If true, check if element is above bottom of viewport instead
	 * @return {Boolean}          Returns true if element is in the viewport
	 */
	var isInView = function (elem, settings, bottom) {
		var bounds = elem.getBoundingClientRect();
		var offset = getOffset(settings);
		if (bottom) {
			return parseInt(bounds.bottom, 10) < (window.innerHeight || document.documentElement.clientHeight);
		}
		return parseInt(bounds.top, 10) <= offset;
	};

	/**
	 * Check if at the bottom of the viewport
	 * @return {Boolean} If true, page is at the bottom of the viewport
	 */
	var isAtBottom = function () {
		if (window.innerHeight + window.pageYOffset >= getDocumentHeight()) return true;
		return false;
	};

	/**
	 * Check if the last item should be used (even if not at the top of the page)
	 * @param  {Object} item     The last item
	 * @param  {Object} settings The settings for this instantiation
	 * @return {Boolean}         If true, use the last item
	 */
	var useLastItem = function (item, settings) {
		if (isAtBottom() && isInView(item.content, settings, true)) return true;
		return false;
	};

	/**
	 * Get the active content
	 * @param  {Array}  contents The content areas
	 * @param  {Object} settings The settings for this instantiation
	 * @return {Object}          The content area and matching navigation link
	 */
	var getActive = function (contents, settings) {
		var last = contents[contents.length-1];
		if (useLastItem(last, settings)) return last;
		for (var i = contents.length - 1; i >= 0; i--) {
			if (isInView(contents[i].content, settings)) return contents[i];
		}
	};

	/**
	 * Deactivate parent navs in a nested navigation
	 * @param  {Node}   nav      The starting navigation element
	 * @param  {Object} settings The settings for this instantiation
	 */
	var deactivateNested = function (nav, settings) {

		// If nesting isn't activated, bail
		if (!settings.nested) return;

		// Get the parent navigation
		var li = nav.parentNode.closest('li');
		if (!li) return;

		// Remove the active class
		li.classList.remove(settings.nestedClass);

		// Apply recursively to any parent navigation elements
		deactivateNested(li, settings);

	};

	/**
	 * Deactivate a nav and content area
	 * @param  {Object} items    The nav item and content to deactivate
	 * @param  {Object} settings The settings for this instantiation
	 */
	var deactivate = function (items, settings) {

		// Make sure their are items to deactivate
		if (!items) return;

		// Get the parent list item
		var li = items.nav.closest('li');
		if (!li) return;

		// Remove the active class from the nav and content
		li.classList.remove(settings.navClass);
		items.content.classList.remove(settings.contentClass);

		// Deactivate any parent navs in a nested navigation
		deactivateNested(li, settings);

		// Emit a custom event
		emitEvent('gumshoeDeactivate', li, {
			link: items.nav,
			content: items.content,
			settings: settings
		});

	};


	/**
	 * Activate parent navs in a nested navigation
	 * @param  {Node}   nav      The starting navigation element
	 * @param  {Object} settings The settings for this instantiation
	 */
	var activateNested = function (nav, settings) {

		// If nesting isn't activated, bail
		if (!settings.nested) return;

		// Get the parent navigation
		var li = nav.parentNode.closest('li');
		if (!li) return;

		// Add the active class
		li.classList.add(settings.nestedClass);

		// Apply recursively to any parent navigation elements
		activateNested(li, settings);

	};

	/**
	 * Activate a nav and content area
	 * @param  {Object} items    The nav item and content to activate
	 * @param  {Object} settings The settings for this instantiation
	 */
	var activate = function (items, settings) {

		// Make sure their are items to activate
		if (!items) return;

		// Get the parent list item
		var li = items.nav.closest('li');
		if (!li) return;

		// Add the active class to the nav and content
		li.classList.add(settings.navClass);
		items.content.classList.add(settings.contentClass);

		// Activate any parent navs in a nested navigation
		activateNested(li, settings);

		// Emit a custom event
		emitEvent('gumshoeActivate', li, {
			link: items.nav,
			content: items.content,
			settings: settings
		});

	};

	/**
	 * Create the Constructor object
	 * @param {String} selector The selector to use for navigation items
	 * @param {Object} options  User options and settings
	 */
	var Constructor = function (selector, options) {

		//
		// Variables
		//

		var publicAPIs = {};
		var navItems, contents, current, timeout, settings;


		//
		// Methods
		//

		/**
		 * Set variables from DOM elements
		 */
		publicAPIs.setup = function () {

			// Get all nav items
			navItems = document.querySelectorAll(selector);

			// Create contents array
			contents = [];

			// Loop through each item, get it's matching content, and push to the array
			Array.prototype.forEach.call(navItems, (function (item) {

				// Get the content for the nav item
				var content = document.getElementById(decodeURIComponent(item.hash.substr(1)));
				if (!content) return;

				// Push to the contents array
				contents.push({
					nav: item,
					content: content
				});

			}));

			// Sort contents by the order they appear in the DOM
			sortContents(contents);

		};

		/**
		 * Detect which content is currently active
		 */
		publicAPIs.detect = function () {

			// Get the active content
			var active = getActive(contents, settings);

			// if there's no active content, deactivate and bail
			if (!active) {
				if (current) {
					deactivate(current, settings);
					current = null;
				}
				return;
			}

			// If the active content is the one currently active, do nothing
			if (current && active.content === current.content) return;

			// Deactivate the current content and activate the new content
			deactivate(current, settings);
			activate(active, settings);

			// Update the currently active content
			current = active;

		};

		/**
		 * Detect the active content on scroll
		 * Debounced for performance
		 */
		var scrollHandler = function (event) {

			// If there's a timer, cancel it
			if (timeout) {
				window.cancelAnimationFrame(timeout);
			}

			// Setup debounce callback
			timeout = window.requestAnimationFrame(publicAPIs.detect);

		};

		/**
		 * Update content sorting on resize
		 * Debounced for performance
		 */
		var resizeHandler = function (event) {

			// If there's a timer, cancel it
			if (timeout) {
				window.cancelAnimationFrame(timeout);
			}

			// Setup debounce callback
			timeout = window.requestAnimationFrame((function () {
				sortContents(contents);
				publicAPIs.detect();
			}));

		};

		/**
		 * Destroy the current instantiation
		 */
		publicAPIs.destroy = function () {

			// Undo DOM changes
			if (current) {
				deactivate(current, settings);
			}

			// Remove event listeners
			window.removeEventListener('scroll', scrollHandler, false);
			if (settings.reflow) {
				window.removeEventListener('resize', resizeHandler, false);
			}

			// Reset variables
			contents = null;
			navItems = null;
			current = null;
			timeout = null;
			settings = null;

		};

		/**
		 * Initialize the current instantiation
		 */
		var init = function () {

			// Merge user options into defaults
			settings = extend(defaults, options || {});

			// Setup variables based on the current DOM
			publicAPIs.setup();

			// Find the currently active content
			publicAPIs.detect();

			// Setup event listeners
			window.addEventListener('scroll', scrollHandler, false);
			if (settings.reflow) {
				window.addEventListener('resize', resizeHandler, false);
			}

		};


		//
		// Initialize and return the public APIs
		//

		init();
		return publicAPIs;

	};


	//
	// Return the Constructor
	//

	return Constructor;

}));