Wednesday, August 12, 2009

jQuery Theme Manager Plugin

Inspired by this question on Stack Overflow, I decided to make a jQuery plug-in to handle switching themes on a web site that I'm working on. I based the plugin on the code referenced in the question which is by James Padolsey. One of the requirements for the plugin is that it should work with a variety of different elements. I'm using it with a select, but I believe it would work with anchors and hidden inputs as it only depends on the name of the style sheet being located either in an href attribute or the value of the element. For select elements it relies on the value.

The plugin handles two different types of event triggers. If you are using a select, it will trigger on the change event. If you are using something else, it will trigger on the click event. To handle the case outlined in the question, I'd set up the style sheets choices using hidden inputs. I'd then add a click handler for each of these and have an interval timer that rotates among the hidden inputs, triggering the click event on the input corresponding with the current style. I haven't actually tried it, but I'm pretty sure it will work. :-)

The plugin assumes that you can switch styles by simply changing the url of a single stylesheet. If your theme isn't contained in a single stylesheet, you'll have to adapt the code to get it to work for you. The plugin is reasonably configurable. If you supply a loadingImg (full or relative url), it will use the Fade effect, otherwise it will use the Slide effect. I prefer the slide effect as it seems to work better when the stylesheet loads quickly.

Options and Defaults

loadingImg: null
No image will be shown. Supply an absolute or relative url (to the page) if you want a loading image. If this is non-null a Fade effect will be used for the overlay, otherwise a Slide effect is used.
bgColor: 'black'
This can be a hex value '#000' or a name, as shown
overlayID: 'reThemeOverlay'
The name of the overlay DIV, don't reuse an existing one
baseUrl: '/content/styles'
The CSS files supplied in the selector will be relative to this
styleSheet: 'theme'
The id of the stylesheet that will be replaced.
zIndex: 32767
The relative position in the z plane for the overlay. This needs to be large enough so that it covers all of the rest of the elements on the page.
speed: 'slow'
You can use any value available to the fadeIn/Out or slideIn/Out methods
delay: 0
This is the number of milliseconds you want plugin to wait after it thinks the stylesheet has been loaded before it begins the reveal. When combined with the speed parameter, setting this can improve the animation effect.

The plugin uses a simple heuristic to determine when the new stylesheet has been loaded. After the overlay has been applied, it first sets the href property of the stylesheet with the new url (note: if the name you supply doesn't end in .css, the extension will be supplied). Then it loads the stylesheet using an AJAX get request, with the reveal code executed by the callback on the get. Once the get completes -- presumably the other request has completed as well and the styles have been updated -- it then triggers the reveal after delaying for the specified amount of time.

Example Usage


<% var theme = ViewData["theme"] as string;
if (theme.IsNothing())
{
theme = "smoothness/jquery-ui-theme";
} %>

<script type="text/javascript">
$(function() {
$('#themeSelector')
.find('option')
.each( function() {
var $this = $(this);
if ($this.val() == '<%= theme %>') {
$this.attr('selected','selected');
}
});

$('#themeSelector').retheme({
baseUrl: '<%= Url.Content( "~/Content/styles/themes" ) %>',
wait: 1000
}).change( function() {
$.post( '<%= Url.Action( "SetTheme", "Participant" ) %>', { theme: $(this).val() }, function(data) {
if (!data.Status) {
alert('Failed to update preferences');
}
}, 'json');
});

$('#themeUI').show();
});
</script>

<div id="themeUI" style="position: absolute; right: 0px; margin-right: 5px; top: 0px; margin-top: 5px; display: none;">
<label for="themeSelector">Theme:</label>
<select id="themeSelector">
<option value="cupertino/jquery-ui-theme">Cupertino</option>
<option value="overcast/jquery-ui-theme">Overcast</option>
<option value="peppergrinder/jquery-ui-theme">Pepper Grinder</option>
<option value="smoothness/jquery-ui-theme">Smoothness (default)</option>
<option value="southstreet/jquery-ui-theme">South Street</option>
<option value="ui-darkness/jquery-ui-theme">UI Darkness</option>
<option value="vader/jquery-ui-theme">Vader</option>
</select>
</div>

Code



;(function($) {

$.fn.extend({
retheme: function(options,callback) {
options = $.extend( {}, $.Retheme.defaults, options );

if (options.loadingImg) {
var preLoad = new Image();
preLoad.src = options.loadingImg;
}

this.each(function() {
new $.Retheme(this,options,callback);
});
return this;
}
});

$.Retheme = function( ctl, options, callback ) {
if (options.styleSheet.match(/^\w/)) {
options.styleSheet = '#' + options.styleSheet;
}
$(ctl).filter('select').change( function() {
loadTheme( themeSelector(this), options );
if (callback) callback.apply(this);
return false;
});
$(ctl).filter(':not(select)').click( function() {
loadTheme( themeSelector(this), options );
if (callback) callback.apply(this);
return false;
});
};

function themeSelector(ctl) {
var $this = $(ctl);
var theme = $this.attr('href');
if (!theme) {
theme = $this.val();
}
return theme;
}

function loadTheme( theme, options ) {
var themeHref = options.baseUrl + '/' + theme;
if (!themeHref.match(/\.css$/)) {
themeHref += '.css';
}

var styleSheet = $(options.styleSheet);
var counter = options.maxTries;
var bg = options.bgColor;
if (options.loadingImg) {
bg += ' url(' + options.loadingImg + ') no-repeat center';
}
var speed = options.speed;
var delay = options.delay;

var $body = $('body');
var overlay = $('<div id="' + options.overlayID + '"></div>').appendTo($body);
$body.css( { height: '100%' } );
overlay.css({
display: 'none',
position: 'absolute',
top:0,
left: 0,
width: '100%',
height: '100%',
zIndex: options.zIndex,
background: bg
})
.stop( true );

if (options.loadingImg) {
overlay.fadeIn( speed, function() {
styleSheet.attr( 'href', themeHref );
$.get( themeHref, function() { // basically load it twice, but that will make sure it's been applied before we reveal
setTimeout( function() {
overlay.fadeOut( speed, function() {
$(this).remove();
});
}, delay );
});
});
}
else {
overlay.slideDown( speed, function() {
styleSheet.attr( 'href', themeHref );
$.get( themeHref, function() { // basically load it twice, but that will make sure it's been applied before we reveal
setTimeout( function() {
overlay.slideUp( speed, function() {
$(this).remove();
});
}, delay );
});
});
}
};

$.Retheme.defaults = {
loadingImg: null,
bgColor: 'black',
overlayID: 'reThemeOverlay',
baseUrl: '/content/styles',
styleSheet: 'theme',
zIndex: 32767,
speed: 'slow',
delay: 0
};

})(jQuery);

Demo


A simple demo that uses both a select element and buttons to choose themes can be found at http://myweb.uiowa.edu/timv/retheme-demo.

Fixed



  • Added a callback parameter so you can execute your own code after the theme has been loaded. Note that
    the callback will be invoked prior to the reveal animation.

  • The plugin now prevents the default action associated with the trigger element. If you want the default action to be taken, you'll need to reapply it via the callback mechanism.


To Do



  • I'd like to refactor the reveal code so that I don't have to repeat it for the different effects. I think I'd have to figure out the different animation parameters for each effect and set them up, but I'm too lazy to do that for now.

  • Fix the hand-coded style of the select control and apply it as a class. This was just for a proof-of-concept, but it should be refactored.