$devices.find( 'button.preview-' + newDevice )
.attr( 'aria-pressed', true );
keyEvent: function( event ) {
// The escape key closes the preview.
if ( event.keyCode === 27 ) {
// The right arrow key, next theme.
if ( event.keyCode === 39 ) {
_.once( this.nextTheme() );
// The left arrow key, previous theme.
if ( event.keyCode === 37 ) {
installTheme: function( event ) {
$target = $( event.target );
if ( $target.hasClass( 'disabled' ) ) {
wp.updates.maybeRequestFilesystemCredentials( event );
$( document ).on( 'wp-theme-install-success', function() {
_this.model.set( { 'installed': true } );
wp.updates.installTheme( {
slug: $target.data( 'slug' )
// Controls the rendering of div.themes,
// a wrapper that will hold all the theme elements.
themes.view.Themes = wp.Backbone.View.extend({
className: 'themes wp-clearfix',
$overlay: $( 'div.theme-overlay' ),
// Number to keep track of scroll position
// while in theme-overlay mode.
// The theme count element.
count: $( '.wrap .theme-count' ),
// The live themes count.
initialize: function( options ) {
this.parent = options.parent;
// Set current view to [grid].
// Move the active theme to the beginning of the collection.
// When the collection is updated by user input...
this.listenTo( self.collection, 'themes:update', function() {
// Update theme count to full result set when available.
this.listenTo( self.collection, 'query:success', function( count ) {
if ( _.isNumber( count ) ) {
self.count.text( count );
self.announceSearchResults( count );
self.count.text( self.collection.length );
self.announceSearchResults( self.collection.length );
this.listenTo( self.collection, 'query:empty', function() {
$( 'body' ).addClass( 'no-results' );
this.listenTo( this.parent, 'theme:scroll', function() {
self.renderThemes( self.parent.page );
this.listenTo( this.parent, 'theme:close', function() {
self.overlay.closeOverlay();
$( 'body' ).on( 'keyup', function( event ) {
// Bail if the filesystem credentials dialog is shown.
if ( $( '#request-filesystem-credentials-dialog' ).is( ':visible' ) ) {
// Pressing the right arrow key fires a theme:next event.
if ( event.keyCode === 39 ) {
self.overlay.nextTheme();
// Pressing the left arrow key fires a theme:previous event.
if ( event.keyCode === 37 ) {
self.overlay.previousTheme();
// Pressing the escape key fires a theme:collapse event.
if ( event.keyCode === 27 ) {
self.overlay.collapse( event );
// Manages rendering of theme pages
// and keeping theme count in sync.
// Clear the DOM, please.
// If the user doesn't have switch capabilities or there is only one theme
// in the collection, render the detailed view of the active theme.
if ( themes.data.themes.length === 1 ) {
this.singleTheme = new themes.view.Details({
model: this.collection.models[0]
// Render and apply a 'single-theme' class to our container.
this.singleTheme.render();
this.$el.addClass( 'single-theme' );
this.$el.append( this.singleTheme.el );
// Generate the themes using page instance
// while checking the collection has items.
if ( this.options.collection.size() > 0 ) {
this.renderThemes( this.parent.page );
// Display a live theme count for the collection.
this.liveThemeCount = this.collection.count ? this.collection.count : this.collection.length;
this.count.text( this.liveThemeCount );
* In the theme installer the themes count is already announced
* because `announceSearchResults` is called on `query:success`.
if ( ! themes.isInstall ) {
this.announceSearchResults( this.liveThemeCount );
// Iterates through each instance of the collection
// and renders each theme module.
renderThemes: function( page ) {
self.instance = self.collection.paginate( page );
// If we have no more themes, bail.
if ( self.instance.size() === 0 ) {
// Fire a no-more-themes event.
this.parent.trigger( 'theme:end' );
// Make sure the add-new stays at the end.
if ( ! themes.isInstall && page >= 1 ) {
$( '.add-new-theme' ).remove();
// Loop through the themes and setup each theme view.
self.instance.each( function( theme ) {
self.theme = new themes.view.Theme({
// ...and append them to div.themes.
self.$el.append( self.theme.el );
// Binds to theme:expand to show the modal box
// with the theme details.
self.listenTo( self.theme, 'theme:expand', self.expand, self );
// 'Add new theme' element shown at the end of the grid.
if ( ! themes.isInstall && themes.data.settings.canInstall ) {
this.$el.append( '<div class="theme add-new-theme"><a href="' + themes.data.settings.installURI + '"><div class="theme-screenshot"><span></span></div><h2 class="theme-name">' + l10n.addNew + '</h2></a></div>' );
// Grabs current theme and puts it at the beginning of the collection.
currentTheme: function() {
current = self.collection.findWhere({ active: true });
// Move the active theme to the beginning of the collection.
self.collection.remove( current );
self.collection.add( current, { at:0 } );
setView: function( view ) {
// Renders the overlay with the ThemeDetails view.
// Uses the current model data.
var self = this, $card, $modal;
// Set the current theme model.
this.model = self.collection.get( id );
// Trigger a route update for the current model.
themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.id ) );
// Sets this.view to 'detail'.
this.setView( 'detail' );
$( 'body' ).addClass( 'modal-open' );
// Set up the theme details view.
this.overlay = new themes.view.Details({
if ( this.model.get( 'hasUpdate' ) ) {
$card = $( '[data-slug="' + this.model.id + '"]' );
$modal = $( this.overlay.el );
if ( $card.find( '.updating-message' ).length ) {
$modal.find( '.notice-warning h3' ).remove();
$modal.find( '.notice-warning' )
.removeClass( 'notice-large' )
.addClass( 'updating-message' )
.find( 'p' ).text( wp.updates.l10n.updating );
} else if ( $card.find( '.notice-error' ).length ) {
$modal.find( '.notice-warning' ).remove();
this.$overlay.html( this.overlay.el );
// Bind to theme:next and theme:previous triggered by the arrow keys.
// Keep track of the current model so we can infer an index position.
this.listenTo( this.overlay, 'theme:next', function() {
// Renders the next theme on the overlay.
self.next( [ self.model.cid ] );
.listenTo( this.overlay, 'theme:previous', function() {
// Renders the previous theme on the overlay.
self.previous( [ self.model.cid ] );
* This method renders the next theme on the overlay modal
* based on the current position in the collection.
// Get the current theme.
model = self.collection.get( args[0] );
// Find the next model within the collection.
nextModel = self.collection.at( self.collection.indexOf( model ) + 1 );
// Sanity check which also serves as a boundary test.
if ( nextModel !== undefined ) {
// We have a new theme...
this.overlay.closeOverlay();
// Trigger a route update for the current model.
self.theme.trigger( 'theme:expand', nextModel.cid );
* This method renders the previous theme on the overlay modal
* based on the current position in the collection.
previous: function( args ) {
// Get the current theme.
model = self.collection.get( args[0] );
// Find the previous model within the collection.
previousModel = self.collection.at( self.collection.indexOf( model ) - 1 );
if ( previousModel !== undefined ) {
// We have a new theme...
this.overlay.closeOverlay();
// Trigger a route update for the current model.
self.theme.trigger( 'theme:expand', previousModel.cid );
// Dispatch audible search results feedback message.
announceSearchResults: function( count ) {
wp.a11y.speak( l10n.noThemesFound );
wp.a11y.speak( l10n.themesFound.replace( '%d', count ) );
// Search input view controller.
themes.view.Search = wp.Backbone.View.extend({
className: 'wp-filter-search',
id: 'wp-filter-search-input',
placeholder: l10n.searchPlaceholder,
'aria-describedby': 'live-search-desc'
initialize: function( options ) {
this.parent = options.parent;
this.listenTo( this.parent, 'theme:close', function() {
search: function( event ) {
if ( event.type === 'keyup' && event.which === 27 ) {
// Since doSearch is debounced, it will only run when user input comes to a rest.
// Runs a search on the theme collection.
doSearch: function( event ) {
this.collection.doSearch( event.target.value.replace( /\+/g, ' ' ) );
// if search is initiated and key is not return.
if ( this.searching && event.which !== 13 ) {
if ( event.target.value ) {
themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + event.target.value ), options );
themes.router.navigate( themes.router.baseUrl( '' ) );
pushState: function( event ) {
var url = themes.router.baseUrl( '' );
if ( event.target.value ) {
url = themes.router.baseUrl( themes.router.searchPath + encodeURIComponent( event.target.value ) );
themes.router.navigate( url );
* @param {string} url - URL to navigate to.
* @param {Object} state - State.
function navigateRouter( url, state ) {
if ( Backbone.history._hasPushState ) {
Backbone.Router.prototype.navigate.call( router, url, state );
// Sets up the routes events for relevant url queries.
// Listens to [theme] and [search] params.
themes.Router = Backbone.Router.extend({
'themes.php?theme=:slug': 'theme',
'themes.php?search=:query': 'search',
'themes.php?s=:query': 'search',
baseUrl: function( url ) {
return 'themes.php' + url;
search: function( query ) {
$( '.wp-filter-search' ).val( query.replace( /\+/g, ' ' ) );
$( '.wp-filter-search' ).val( '' );
// Execute and setup the application.
// Initializes the blog's theme library view.
// Create a new collection with data.
this.themes = new themes.Collection( themes.data.themes );
this.view = new themes.view.Appearance({
// Start debouncing user searches after Backbone.history.start().
this.view.SearchView.doSearch = _.debounce( this.view.SearchView.doSearch, 500 );
if ( Backbone.History.started ) {