Drupal 7 ajax page navigation with history

Many clients these days ask for ajax enabled sites for smooth navigation between pages (here's an example http://debbievinick.com). To achieve such behaviour and maintain backwards compatibility with non-javascript browsing (of most interest in that category are search spiders, e.g. Googlebot) the fundamental problem is that for each page you must serve two versions: 1 full page for when you land on the url directly, and 1 ajax version which just returns the content to be updated.

...or do you?

The speed benefits from having ajax navigation don't really come from serving fewer bytes to load page, but from not having to re-render the entire page, and re-parse all your JavaScript and CSS. A simple solution when using a CMS is just to fetch the entire page you want to update the content for, and then extract the section you need.

The following implementation is based on a simple Drupal installation with the Zen theme: http://drupal.org/project/zen

Consider we have 3 pages (/home, /contact, /about) with this basic structure:

<html>
	<head><title><title />...</head>
	<body>
		<div id="header">...</div>
		<div id="mainwrapper">
			<div id="main">
				<div id="content"></div>
				<div id="navigation"></div>
			</div>
		</div>
		<div id="footer">...</div>
	</body>
</html>

The only content changes from page to page is div#main and title

Using jQuery's ajax load function we can specific a part of the HTML tree from the result we want to use by adding a selector:

$('#main-wrapper').load('/about #main');

If run from /home this would fetch the contents of /about, extract the HTML from div#main and overwrite the current contents of div#wrapper with it. This is the bulk of the functionality we require but there are still a few improvements.

Firstly the title of the page is outside div#main and is not updated. This can fixed fairly simply:

var loadPage = function(path) {
	$('#main-wrapper').load(path + ' #main', function(response) {
		var title = response.match(/<title>([^<]+)<\/title>/)[1];
		try {
			$('title').html(title);
		} catch(e) {
			var titleText = $('<div/>').html(title).text();
			document.title = titleText;
		}
	});
};

Setting the title is a little complex because of they way different browsers expect plain text or HTML. You can use whichever technique you prefer.

The other major issue is page state. Click the link to navigate to /about from /home, and then reloading the page would take you back to /home. This is where the history.js library comes in. If you are using Drupal you can drop in the library using this module: http://drupal.org/project/history_js (NB history.js is incompatible with the Overlay module, so you'll have to disable it)

Now we need to do two things: set up the ajax navigation on the main menu links, and pass the state change events through to our loadPage function:

var initMenu = function() {
	$('#main-menu a').each(function() {
		var path = ($(this).attr('pathname').substr(0, 1) != '/') ?
			'/' + $(this).attr('pathname') :
			$(this).attr('pathname');
		$(this).click(function() {
			History.pushState(null, null, path);
			return false;
		});
	});
};
$(document).ready(function() {
	initMenu();
	History.Adapter.bind(window, 'statechange', function() {
		var State = History.getState();
		loadPage(State.url);
	});
});

In older browsers history.js will use hash links to store the page state. If you were to reload a page at /home#./about we can add a bit of code to redirect you to /about :

if(location.hash.substr(0, 3) == '#./') {
	location.href = location.hash.substr(3);
}

We can further optimise by storing loaded page data and titles in memory, so if a page is navigated back to we don't even need to perform an ajax request:

var pageCache = {};
 
var loadPage = function(path) {
	if(pageCache[path]) {
		$('#main-wrapper').html(pageCache[path].body);
		writePage(pageCache[path].title, path);
	} else {
		$('#main-wrapper').load(path + ' #main', function(response) {
			var title = response.match(/<title>([^<]+)<\/title>/)[1];
			pageCache[path] = {
				body: $(this).html(),
				title: title
			}
			writePage(title, path);
		});
	}
};
 
var writePage = function(title, path) {
	initMenu();
	try {
		$('title').html(title);
	} catch(e) {
		var titleText = $('<div/>').html(title).text();
		document.title = titleText;
	}
};

In the final script we add Google Analytics tracking (if you have it) and an option to fade content in and out:

(function($) {
 
	var History = window.History;
	if ( !History.enabled ) {
		return false;
	}
 
	if(location.hash.substr(0, 3) == '#./') {
		location.href = location.hash.substr(3);
	}
 
	var _wrapper = '#main-wrapper';
	var _contents = '#main';
	var _menu = '#main-menu a';
	// set _fade to a selector you want to fade-in/out
	// on page change, e.g. '#content .content'
	var _fade = null;
 
	var initMenu = function() {
		$(_menu).each(function() {
			var path = ($(this).attr('pathname').substr(0, 1) != '/') ?
				'/' + $(this).attr('pathname') :
				$(this).attr('pathname');
			$(this).click(function() {
				History.pushState(null, null, path);
				return false;
			});
		});
	};
 
	var pageCache = {};
 
	var loadPage = function(path) {
		if(_fade) $(_fade).fadeOut('fast');
		if(pageCache[path]) {
			$(_wrapper).html(pageCache[path].body);
			writePage(pageCache[path].title, path);
		} else {
			$(_wrapper).load(path + ' ' + _contents, function(response) {
				var title = response.match(/<title>([^<]+)<\/title>/)[1];
				pageCache[path] = {
					body: $(this).html(),
					title: title
				};
				writePage(title, path);
			});
		}
	};
 
	var writePage = function(title, path) {
		if(_fade) $(_fade).hide().fadeIn('fast');
		initMenu();
		try {
			$('title').html(title);
		} catch(e) {
			var titleText = $('<div/>').html(title).text();
			document.title = titleText;
		}
		if(typeof _gaq != 'undefined') _gaq.push(['_trackPageview', path]);
	};
 
	$(document).ready(function() {
		initMenu();
		History.Adapter.bind(window, 'statechange', function() {
			var State = History.getState();
			loadPage(State.url);
		});
		pageCache[History.getState().url] = {
			body: $(_wrapper).html(),
			title: $('title').html()
		};
	});
 
})(jQuery);

Download here: https://github.com/edg2s/drupal-ajax-navigation

Comments

Any ideas to implement this to views infinite scroll as opposed to menu items?

Thanks a lot for this nice peace of code well documented. I just had to change line 16 of nav.js : 'wrapper': '#main-wrapper', by 'wrapper': '#main', to make to loading works.

Thanks for sharing. Nicolas

You shouldn't need to edit the code file directly, you can pass in a configurations object to init(). See the usage notes on GitHub.