* @output wp-admin/js/theme.js
/* global _wpThemeSettings, confirm, tb_position */
window.wp = window.wp || {};
// Set up our namespace...
themes = wp.themes = wp.themes || {};
// Store the theme data and settings for organized and quick access.
// themes.data.settings, themes.data.themes, themes.data.l10n.
themes.data = _wpThemeSettings;
// Shortcut for isInstall check.
themes.isInstall = !! themes.data.settings.isInstall;
_.extend( themes, { model: {}, view: {}, routes: {}, router: {}, template: wp.template });
themes.Model = Backbone.Model.extend({
// Adds attributes to the default data coming through the .org themes api.
// Map `id` to `slug` for shared code.
if ( this.get( 'slug' ) ) {
// If the theme is already installed, set an attribute.
if ( _.indexOf( themes.data.installedThemes, this.get( 'slug' ) ) !== -1 ) {
this.set({ installed: true });
// If the theme is active, set an attribute.
if ( themes.data.activeTheme === this.get( 'slug' ) ) {
this.set({ active: true });
// `slug` is for installation, `id` is for existing.
id: this.get( 'slug' ) || this.get( 'id' )
// Map `section.description` to `description`
// as the API sometimes returns it differently.
if ( this.has( 'sections' ) ) {
description = this.get( 'sections' ).description;
this.set({ description: description });
// Main view controller for themes.php.
// Unifies and renders all available views.
themes.view.Appearance = wp.Backbone.View.extend({
el: '#wpbody-content .wrap .theme-browser',
// Sets up a throttler for binding to 'scroll'.
initialize: function( options ) {
// Scroller checks how far the scroll position is.
_.bindAll( this, 'scroller' );
this.SearchView = options.SearchView ? options.SearchView : themes.view.Search;
// Bind to the scroll event and throttle
// the results from this.scroller.
this.window.on( 'scroll', _.throttle( this.scroller, 300 ) );
// Setup the main theme view
// with the current theme collection.
this.view = new themes.view.Themes({
collection: this.collection,
this.$el.removeClass( 'search-loading' );
this.$el.empty().append( this.view.el ).addClass( 'rendered' );
// Defines search element container.
searchContainer: $( '.search-form' ),
// for current theme collection.
// Don't render the search if there is only one theme.
if ( themes.data.themes.length === 1 ) {
view = new this.SearchView({
collection: self.collection,
// Render and append after screen title.
.append( $.parseHTML( '<label class="screen-reader-text" for="wp-filter-search-input">' + l10n.search + '</label>' ) )
.on( 'submit', function( event ) {
// Checks when the user gets close to the bottom
// of the mage and triggers a theme:scroll event.
bottom = this.window.scrollTop() + self.window.height();
threshold = self.$el.offset().top + self.$el.outerHeight( false ) - self.window.height();
threshold = Math.round( threshold * 0.9 );
if ( bottom > threshold ) {
this.trigger( 'theme:scroll' );
// Set up the Collection for our theme data.
// @has 'id' 'name' 'screenshot' 'author' 'authorURI' 'version' 'active' ...
themes.Collection = Backbone.Collection.extend({
// Controls searching on the current theme collection
// and triggers an update event.
doSearch: function( value ) {
// Don't do anything if we've already done this search.
// Useful because the Search handler fires multiple times per keystroke.
if ( this.terms === value ) {
// Updates terms with the value passed.
// If we have terms, run a search...
if ( this.terms.length > 0 ) {
this.search( this.terms );
// If search is blank, show all themes.
// Useful for resetting the views when you clean the input.
if ( this.terms === '' ) {
this.reset( themes.data.themes );
$( 'body' ).removeClass( 'no-results' );
// Trigger a 'themes:update' event.
this.trigger( 'themes:update' );
* Performs a search within the collection.
search: function( term ) {
var match, results, haystack, name, description, author;
// Start with a full collection.
this.reset( themes.data.themes, { silent: true } );
// Escape the term string for RegExp meta characters.
term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
// Consider spaces as word delimiters and match the whole string
// so matching terms can be combined.
term = term.replace( / /g, ')(?=.*' );
match = new RegExp( '^(?=.*' + term + ').+', 'i' );
// _.filter() and .test().
results = this.filter( function( data ) {
name = data.get( 'name' ).replace( /(<([^>]+)>)/ig, '' );
description = data.get( 'description' ).replace( /(<([^>]+)>)/ig, '' );
author = data.get( 'author' ).replace( /(<([^>]+)>)/ig, '' );
haystack = _.union( [ name, data.get( 'id' ), description, author, data.get( 'tags' ) ] );
if ( match.test( data.get( 'author' ) ) && term.length > 2 ) {
data.set( 'displayAuthor', true );
return match.test( haystack );
if ( results.length === 0 ) {
this.trigger( 'query:empty' );
$( 'body' ).removeClass( 'no-results' );
// Paginates the collection with a helper method
// that slices the collection.
paginate: function( instance ) {
instance = instance || 0;
// Themes per instance are set at 20.
collection = _( collection.rest( 20 * instance ) );
collection = _( collection.first( 20 ) );
* Handles requests for more themes and caches results.
* When we are missing a cache object we fire an apiCall()
* which triggers events of `query:success` or `query:fail`.
query: function( request ) {
var queries = this.queries,
query, isPaginated, count;
// Store current query request args
// for later use with the event `theme:end`.
this.currentQuery.request = request;
// Search the query cache for matches.
query = _.find( queries, function( query ) {
return _.isEqual( query.request, request );
// If the request matches the stored currentQuery.request
// it means we have a paginated request.
isPaginated = _.has( request, 'page' );
// Reset the internal api page counter for non-paginated queries.
this.currentQuery.page = 1;
// Otherwise, send a new API call and add it to the cache.
if ( ! query && ! isPaginated ) {
query = this.apiCall( request ).done( function( data ) {
// Update the collection with the queried data.
self.reset( data.themes );
count = data.info.results;
// Store the results and the query request.
queries.push( { themes: data.themes, request: request, total: count } );
// Trigger a collection refresh event
// and a `query:success` event with a `count` argument.
self.trigger( 'themes:update' );
self.trigger( 'query:success', count );
if ( data.themes && data.themes.length === 0 ) {
self.trigger( 'query:empty' );
self.trigger( 'query:fail' );
// If it's a paginated request we need to fetch more themes...
return this.apiCall( request, isPaginated ).done( function( data ) {
// Add the new themes to the current collection.
self.trigger( 'query:success' );
// We are done loading themes for now.
self.loadingThemes = false;
self.trigger( 'query:fail' );
if ( query.themes.length === 0 ) {
self.trigger( 'query:empty' );
$( 'body' ).removeClass( 'no-results' );
// Only trigger an update event since we already have the themes
if ( _.isNumber( query.total ) ) {
this.count = query.total;
this.reset( query.themes );
this.count = this.length;
this.trigger( 'themes:update' );
this.trigger( 'query:success', this.count );
// Local cache array for API queries.
// Keep track of current query so we can handle pagination.
// Send request to api.wordpress.org/themes.
apiCall: function( request, paginated ) {
return wp.ajax.send( 'query-themes', {
$( 'body' ).addClass( 'loading-content' ).removeClass( 'no-results' );
// Static status controller for when we are loading themes.
// This is the view that controls each theme item
// that will be displayed on the screen.
themes.view.Theme = wp.Backbone.View.extend({
// Wrap theme data on a div.theme element.
// Reflects which theme view we have.
// 'grid' (default) or 'detail'.
// The HTML template for each element to be rendered.
html: themes.template( 'theme' ),
'click': themes.isInstall ? 'preview': 'expand',
'keydown': themes.isInstall ? 'preview': 'expand',
'touchend': themes.isInstall ? 'preview': 'expand',
'touchmove': 'preventExpand',
'click .theme-install': 'installTheme',
'click .update-message': 'updateTheme'
this.model.on( 'change', this.render, this );
var data = this.model.toJSON();
// Render themes using the html template.
this.$el.html( this.html( data ) ).attr({
'aria-describedby' : data.id + '-action ' + data.id + '-name',
// Renders active theme styles.
if ( this.model.get( 'displayAuthor' ) ) {
this.$el.addClass( 'display-author' );
// Adds a class to the currently active theme
// and to the overlay in detailed view mode.
activeTheme: function() {
if ( this.model.get( 'active' ) ) {
this.$el.addClass( 'active' );
// Add class of focus to the theme we are focused on.
var $themeToFocus = ( $( ':focus' ).hasClass( 'theme' ) ) ? $( ':focus' ) : $(':focus').parents('.theme');
$('.theme.focus').removeClass('focus');
$themeToFocus.addClass('focus');
// Single theme overlay screen.
// It's shown when clicking a theme.
expand: function( event ) {
event = event || window.event;
// 'Enter' and 'Space' keys expand the details view when a theme is :focused.
if ( event.type === 'keydown' && ( event.which !== 13 && event.which !== 32 ) ) {
// Bail if the user scrolled on a touch device.
if ( this.touchDrag === true ) {
return this.touchDrag = false;
// Prevent the modal from showing when the user clicks
// one of the direct action buttons.
if ( $( event.target ).is( '.theme-actions a' ) ) {
// Prevent the modal from showing when the user clicks one of the direct action buttons.
if ( $( event.target ).is( '.theme-actions a, .update-message, .button-link, .notice-dismiss' ) ) {
// Set focused theme to current element.
themes.focusedTheme = this.$el;
this.trigger( 'theme:expand', self.model.cid );
preventExpand: function() {
preview: function( event ) {
event = event || window.event;
// Bail if the user scrolled on a touch device.
if ( this.touchDrag === true ) {
return this.touchDrag = false;
// Allow direct link path to installing a theme.
if ( $( event.target ).not( '.install-theme-preview' ).parents( '.theme-actions' ).length ) {
// 'Enter' and 'Space' keys expand the details view when a theme is :focused.
if ( event.type === 'keydown' && ( event.which !== 13 && event.which !== 32 ) ) {
// Pressing Enter while focused on the buttons shouldn't open the preview.
if ( event.type === 'keydown' && event.which !== 13 && $( ':focus' ).hasClass( 'button' ) ) {