Sunday, June 23, 2013

Backbone Router-Friendly jQuery UI Tabs Widget

jquery-ui-tabs-widgetTabs are a basic page layout tool that help organize and separate content. The jQuery UI library provides a fairly feature-rich tabs solution complete with AJAX content loading. I had originally thought I could just drop that widget on my page, setup the links for each tab, and use a Backbone router to manage capturing the tab changes and loading my content into the appropriate tab. However, I quickly discovered that the tabs widget prevented the click event from bubbling up to the point where the browser location would change such that the router would see the change. Upon further review, I realized that I really didn't need a whole lot of elaborate functionality. While the jQUery UI widgets are great, they are designed to provide functionality that may not make sense in a Backbone-based application. In the case of tabs, you are most interested in the layout and not much else. Depending on the use case, you'll probably tie the tabs to routes and then use the Backbone library to handle loading and generating all the content for each tab. Now, you could just listen for the "activate" event from the tab widget and change rewrite the location yourself:


var myTabsView = Backbone.View.extend({

render: function () {

this.$el.html(...);
this.$el.tabs();

return this;
},

events: {
'tabsactivate' : function ( e, ui ) {

var hash = ui.newTab.find('a[href^=#]').attr('href');

if ( location.hash.length == 0 )
location.href += hash;
else if ( location.hash != hash )
location.href = location.href.replace( /#.*$/, hash );

}
}

});


But, somehow, that just seems to defeat the purpose. You're essentially building a router to trigger your router. I thought I'd at least explore what would be necessary to build a basic tabs layout that would provide the same behavior without any intervention from external code. Given that, I decided to build a tabs widget using jQuery UI as a foundation so it would have a similar look-and-feel, leverage the existing framework, and provide flexibility for using it in several contexts.

My markup will look very close the required markup for the jQuery UI tabs widget. I did not make the link between the tab LI element and its panel DIV element based on the HREF/ID attributes. Instead, I assumed the order (and number) would match:



<div id="tab-layout">
<ul>
<li><a href="#overview">Overview</a></li>
<li><a href="#usage">Usage</a></li>
<li><a href="#example">Example</a></li>
<li><a href="#lorum">Lorum Ipsum</a></li>
</ul>
<div></div>
<div></div>
<div></div>
<div></div>
</div>



Each of those LI/A elements need to look like tabs. I was trying to keep things as simple as possible so I thought I could use a combination of button widgets and a few well place styles to reproduce the look and functionality with as little code as possible. After all, the button widget can style an anchor tag to look almost exactly like a tab. However, again, there's a lot of extra functionality happening in the button widget which wanted to work against me. A button will not keep the active (pushed) state after its clicked. I tried to defeat it, but the widget will attach a single-execute click handler on the document node to remove that state. I just decided that the only reason I wanted the widget was for the styles so just wrote code to replicate the results:



// Generate jQuery UI Button markup on an anchor
// without using a button widget
$el.find('a')
.addClass( 'ui-button ui-widget ui-state-default ui-button-text-only ui-corner-top' )
.each(function () {
var $button = $( this );
$( '<span>' )
.addClass( 'ui-button-text' )
.text( $button.text() )
.appendTo( $button.empty() )
})



The next consideration was to expose a way to both programatically change and retreive the current tab and panel node. This allows the widget to decide what consistitutes the container for a tab panel and nicely encapsulate that part of the design away from the rest of the code. Additionally, in cases where you may not use a router to detect tab changes, I decided to publish both the beforeActivate and activate events just like the existing jQuery UI tabs widget. With those considerations in mind, I organized the widget to cache the tabs and panels DOM nodes, build a consistent object that could be used to represent a tab in both the events and get/set method, and add classes to style the tabs. Here's some of the highlights:




/**
* Listen for clicks, trigger events, and use active to change the tab
*
*/
events: {
'click li' : function ( event ) {

// Get the index amongst the LIs siblings
var idx = $( event.currentTarget ).index(),

// Make normalized objects for the tab we're leaving
// and the tab we're changing to. Don't need to know
// the index of the current tab, the function will figure it
// out.
oTab = this._getTabInfo(),
nTab = this._getTabInfo( idx ),

eventData = {
oldTab: oTab.tab,
oldPanel: oTab.panel,
newTab: nTab.tab,
newPanel: nTab.panel
};

// Provide a way to cancel it
if ( oTab.tab.index != nTab.tab.index &&
this._trigger( 'beforeActivate', event, eventData ) !== false ) {

// Use the setting to change the tab
this.active( idx );
this._trigger( 'activate', event, nTab );
} else {

event.preventDefault();
}

}
},

/**
* Get/Set the current tab. Accepts the index or string match the hash (less #)
*
*/
active: function ( tab ) {

var idx = 0;

if ( arguments.length > 0 ) {

// Resolve the argument type and find the tab
if ( typeof(tab) == 'string' && tab.length > 0 ) {
idx = this.tabs.index( this.tabs.find( '[href=#'+tab+']' ).closest( 'li' ) );
} else if ( typeof(tab) == 'number' && tab >= 0 ) {
idx = tab;
}

this.panels.hide().eq(idx).show();

} else {

return this._getTabInfo();

}
},

/**
* Assemble tab info object from the provided index. No argument means
* to get the current active tab.
*
*/
_getTabInfo: function ( idx ) {

var idx = arguments.length > 0 ? idx : this.tabs.find( 'a.ui-state-active' ).closest( 'li' ).index(),
tab = this.tabs.eq( idx ).find( 'a' );

return {
tab: { index: idx, hash: tab.attr( 'href' ).slice(1), label: tab.text() },
panel: this.panels.eq( idx )
}
}



Now its time to use the widget to build a simple test Backbone app. All this does is create a router for the changing tabs and set each tab with some static content.



$(function () {

var Router = Backbone.Router.extend({

routes: {

"overview" : "showAsText",
"usage" : "showAsCode",
"example" : "showAsCode",
"lorum" : "showAsText"

},

showAsText: function () {
var selected = $('#tab-layout').simpletabs('active');

selected.panel.html($('#tab-'+selected.tab.hash).html());
},

showAsCode: function () {
var selected = $('#tab-layout').simpletabs('active');

selected.panel.html('<pre><code class="prettyprint">'+htmlEncode($('#tab-'+selected.tab.hash).html())+'</code></pre>');

PR.prettyPrint();
}

});


$('#tab-layout').simpletabs();

var router = new Router();
Backbone.history.start();

});



I have a demo setup on my sandbox which has additional usage information. If you're interested in using the widget or want to use it as a starting point, you'll need both the Javascript and CSS files. The widget has no dependencies on Backbone. It only requires jQuery and jQuery UI to work.

Several features are missing that are in the jQuery UI widget which might make sense have available. I did not implement the ARIA attributes to enable screen readers, the keyboard navigation, or the ability to enable/disable individual tabs. Depending on your needs, these features may be desirable. My initial goal was to try to determine what seemed like a minimal setup to provide the tabs functionality.


Now that I have this widget built, I can use it in several different ways that work well in my Backbone apps. It maintains the visual style to match other jQuery UI widgets I use on my pages and keeps the familiar jQuery call interface for creating and managing instances. By keeping it light-weight, it reduces the likelihood that the widget will behave in a way that doesn't work with the functionality I want to build. It seems that the jQuery UI widgets can be a great platform for building application. Sometimes, however, you may need to either tweak an existing component or build something similar to enable them to integrate better with Backbone.