codesignd Patterns

//=require flexismooth/flexismooth.js

(function(app) {
	'use strict';
	
	// Cache for successful requests
	var cache = {};
	var hasOwnProperty = cache.hasOwnProperty;
	
	/**
	 * Navigates to another page using AJAX
	 *
	 * @param  {Element/string} target Anchor element or URL string to navigate to
	 * @param  {string}         mode   Either 'normal' or 'popstate', defaults to 'normal'
	 * @return {boolean}               Whether the event will be handled by this function
	 *                                 May be used to prevent default browser behavior
	 */
	app.go = function(target, mode) {
		if(mode === undefined) mode = 'normal';
		
		// Refuse to do anything if the History API is not supported
		if(!history.pushState) return false;
		
		// Validate params
		if(typeof target !== 'string' && target.nodeType !== 1) {
			throw new TypeError('Invalid parameter target.');
		}
		if(mode !== 'normal' && mode !== 'popstate') {
			throw new TypeError('Invalid parameter mode.');
		}
		
		// Convert to anchor element if param is a string
		if(typeof target === 'string') {
			var link = document.createElement('a');
			link.href = target;
			target = link;
		}
		
		// Only do something if:
		//  - The user clicked on a link
		//  - The link is internal
		//  - The link doesn't contain a file extension
		var targetOrigin, windowOrigin;
		if(target.origin) {
			targetOrigin = target.origin;
			windowOrigin = window.location.origin;
		} else {
			targetOrigin = target.protocol + '//' + target.hostname;
			windowOrigin = window.location.protocol + '//' + window.location.hostname;
		}
		if(target.nodeName !== 'A' ||
		   targetOrigin !== windowOrigin ||
		   target.pathname.indexOf('.') !== -1) return false;
		
		// Just scroll to the top/element if the link points to the current page
		// In this case, we also don't want to reload, so we return true
		if(mode === 'normal' && target.pathname === window.location.pathname) {
			var element;
			if(target.hash) {
				if(target.hash !== '#top') {
					history.pushState({}, document.title, target.href);
				}
				element = document.getElementById(target.hash.substr(1));
			}
			
			Flexismooth(element || 0, 500);
			return true;
		}
		
		// Success, we will navigate using AJAX
		// Hide the content using CSS and trigger event
		document.body.classList.add('ajax-transition');
		app.emit('ajax.before', target);
		
		// Scroll to the top of the page and ignore interruption
		var scrollPromise = Flexismooth(0, 500).catch(function() {});
		
		// Check if the resource is in cache
		var dataPromise;
		if(hasOwnProperty.call(cache, target.href)) {
			console.info('Loading new page ' + target.href + ' (cache)');
			
			dataPromise = Promise.resolve(cache[target.href]);
		} else {
			console.info('Loading new page ' + target.href + ' (ajax)');
			
			var options = {
				credentials: 'same-origin',
				headers: {'Accept': 'application/json'}
			};
			dataPromise = fetch(target.href, options)
			              	.then(app.validateHttpStatus)
			              	.then(app.parseJsonResponse)
			              	.then(function(data) {
			              		// Cache data if the page explicitly agrees
			              		if(data.cachable === true) {
			              			cache[data.url] = data;
			              		}
			              		
			              		return data;
			              	});
		}
		
		// Prefetch external resources
		dataPromise = dataPromise.then(function(data) {
			var container = document.createElement('div');
			container.innerHTML = data.main;
			
			return data;
		});
		
		// Wait until both data fetching and scrolling are done
		// Wait at least 600 ms for the page transition fading even if scrolling is interrupted
		Promise.all([dataPromise, scrollPromise, app.timerPromise(600)])
			.then(function(values) {
				// Get the result of the data promise
				var data = values[0];
				
				// Set browser metadata
				if(mode === 'normal') history.pushState({}, data.title, data.url + target.hash);
				document.title = data.title;
				
				// Update content blocks
				document.querySelector('.main').innerHTML = data.main;
				
				// Trigger event
				app.emit('ajax.after', target, data);
				
				// Display the content again
				document.body.classList.remove('ajax-transition');
				
				// If a hash was given, scroll to that element
				if(target.hash) {
					var element = document.getElementById(target.hash.substr(1));
					if(element) Flexismooth(element, 500);
				}
			})
			.catch(function(err) {
				console.error(err);
				
				// Fall back to normal browser behavior
				window.location.href = target.href;
			});
		
		return true;
	};
	
	// Listen on click events on body (events from links will bubble up)
	document.body.addEventListener('click', function(e) {
		// Only continue if no modifier was used (open in new tab etc.)
		if(e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1)) return;
		
		// Find closest link parent
		var link = e.target.closest('a');
		
		// Only continue if the link was found
		if(!link) return;
		
		// Prevent the browser from navigating if app.go() wants to be responsible for the event
		if(app.go(link)) e.preventDefault();
	});
	
	// Replace the state for the normal page load
	history.replaceState({}, document.title, window.location.href);
	
	// Listen on the popstate event on window
	window.addEventListener('popstate', function(e) {
		if(e.state !== null) app.go(window.location.href, 'popstate');
	});
})(window.app = window.app || {});