X-Git-Url: https://scripts.mit.edu/gitweb/autoinstalls/wordpress.git/blobdiff_plain/e08b42e8ad054ec67522d7ac1aaae5dc68cb3d01..b925718b4bf2dd47a8429f844d0a255ca6e35bd1:/wp-admin/js/theme.js diff --git a/wp-admin/js/theme.js b/wp-admin/js/theme.js index ac5e51d9..9cd738a1 100644 --- a/wp-admin/js/theme.js +++ b/wp-admin/js/theme.js @@ -12,10 +12,37 @@ themes = wp.themes = wp.themes || {}; themes.data = _wpThemeSettings; l10n = themes.data.l10n; +// Shortcut for isInstall check +themes.isInstall = !! themes.data.settings.isInstall; + // Setup app structure _.extend( themes, { model: {}, view: {}, routes: {}, router: {}, template: wp.template }); -themes.model = Backbone.Model.extend({}); +themes.Model = Backbone.Model.extend({ + // Adds attributes to the default data coming through the .org themes api + // Map `id` to `slug` for shared code + initialize: function() { + var description; + + // If theme is already installed, set an attribute. + if ( _.indexOf( themes.data.installedThemes, this.get( 'slug' ) ) !== -1 ) { + this.set({ installed: true }); + } + + // Set the attributes + this.set({ + // 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 @@ -28,10 +55,11 @@ themes.view.Appearance = wp.Backbone.View.extend({ page: 0, // Sets up a throttler for binding to 'scroll' - initialize: function() { + 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.bind( 'scroll', _.throttle( this.scroller, 300 ) ); @@ -51,10 +79,12 @@ themes.view.Appearance = wp.Backbone.View.extend({ // Render and append this.view.render(); - this.$el.empty().append( this.view.el ).addClass('rendered'); - this.$el.append( '
' ); + this.$el.empty().append( this.view.el ).addClass( 'rendered' ); }, + // Defines search element container + searchContainer: $( '#wpbody h1:first' ), + // Search input and view // for current theme collection search: function() { @@ -66,12 +96,15 @@ themes.view.Appearance = wp.Backbone.View.extend({ return; } - view = new themes.view.Search({ collection: self.collection }); + view = new this.SearchView({ + collection: self.collection, + parent: this + }); // Render and append after screen title view.render(); - $('#wpbody h2:first') - .append( $.parseHTML( '' ) ) + this.searchContainer + .append( $.parseHTML( '' ) ) .append( view.el ); }, @@ -95,7 +128,7 @@ themes.view.Appearance = wp.Backbone.View.extend({ // @has 'id' 'name' 'screenshot' 'author' 'authorURI' 'version' 'active' ... themes.Collection = Backbone.Collection.extend({ - model: themes.model, + model: themes.Model, // Search terms terms: '', @@ -122,31 +155,37 @@ themes.Collection = Backbone.Collection.extend({ // Useful for resetting the views when you clean the input if ( this.terms === '' ) { this.reset( themes.data.themes ); + $( 'body' ).removeClass( 'no-results' ); } - // Trigger an 'update' event - this.trigger( 'update' ); + // Trigger a 'themes:update' event + this.trigger( 'themes:update' ); }, // Performs a search within the collection // @uses RegExp search: function( term ) { - var match, results, haystack; + var match, results, haystack, name, description, author; // Start with a full collection this.reset( themes.data.themes, { silent: true } ); - // The RegExp object to match - // + // 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( ' ', ')(?=.*' ); + term = term.replace( / /g, ')(?=.*' ); match = new RegExp( '^(?=.*' + term + ').+', 'i' ); // Find results // _.filter and .test results = this.filter( function( data ) { - haystack = _.union( data.get( 'name' ), data.get( 'description' ), data.get( 'author' ), data.get( 'tags' ) ); + 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 ); @@ -155,6 +194,12 @@ themes.Collection = Backbone.Collection.extend({ return match.test( haystack ); }); + if ( results.length === 0 ) { + this.trigger( 'query:empty' ); + } else { + $( 'body' ).removeClass( 'no-results' ); + } + this.reset( results ); }, @@ -164,12 +209,151 @@ themes.Collection = Backbone.Collection.extend({ var collection = this; instance = instance || 0; - // Themes per instance are set at 15 - collection = _( collection.rest( 15 * instance ) ); - collection = _( collection.first( 15 ) ); + // Themes per instance are set at 20 + collection = _( collection.rest( 20 * instance ) ); + collection = _( collection.first( 20 ) ); return collection; - } + }, + + count: false, + + // 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 ) { + /** + * @static + * @type Array + */ + var queries = this.queries, + self = this, + 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. + if ( ! isPaginated ) { + 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. + if ( data.themes ) { + 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' ); + } + + }).fail( function() { + self.trigger( 'query:fail' ); + }); + } else { + // If it's a paginated request we need to fetch more themes... + if ( isPaginated ) { + return this.apiCall( request, isPaginated ).done( function( data ) { + // Add the new themes to the current collection + // @todo update counter + self.add( data.themes ); + self.trigger( 'query:success' ); + + // We are done loading themes for now. + self.loadingThemes = false; + + }).fail( function() { + self.trigger( 'query:fail' ); + }); + } + + if ( query.themes.length === 0 ) { + self.trigger( 'query:empty' ); + } else { + $( 'body' ).removeClass( 'no-results' ); + } + + // Only trigger an update event since we already have the themes + // on our cached object + if ( _.isNumber( query.total ) ) { + this.count = query.total; + } + + this.reset( query.themes ); + if ( ! query.total ) { + this.count = this.length; + } + + this.trigger( 'themes:update' ); + this.trigger( 'query:success', this.count ); + } + }, + + // Local cache array for API queries + queries: [], + + // Keep track of current query so we can handle pagination + currentQuery: { + page: 1, + request: {} + }, + + // Send request to api.wordpress.org/themes + apiCall: function( request, paginated ) { + return wp.ajax.send( 'query-themes', { + data: { + // Request data + request: _.extend({ + per_page: 100, + fields: { + description: true, + tested: true, + requires: true, + rating: true, + downloaded: true, + downloadLink: true, + last_updated: true, + homepage: true, + num_ratings: true + } + }, request) + }, + + beforeSend: function() { + if ( ! paginated ) { + // Spin it + $( 'body' ).addClass( 'loading-content' ).removeClass( 'no-results' ); + } + } + }); + }, + + // Static status controller for when we are loading themes. + loadingThemes: false }); // This is the view that controls each theme item @@ -187,8 +371,10 @@ themes.view.Theme = wp.Backbone.View.extend({ html: themes.template( 'theme' ), events: { - 'click': 'expand', - 'touchend': 'expand', + 'click': themes.isInstall ? 'preview': 'expand', + 'keydown': themes.isInstall ? 'preview': 'expand', + 'touchend': themes.isInstall ? 'preview': 'expand', + 'keyup': 'addFocus', 'touchmove': 'preventExpand' }, @@ -197,13 +383,21 @@ themes.view.Theme = wp.Backbone.View.extend({ render: function() { var data = this.model.toJSON(); // Render themes using the html template - this.$el.html( this.html( data ) ); + this.$el.html( this.html( data ) ).attr({ + tabindex: 0, + 'aria-describedby' : data.id + '-action ' + data.id + '-name' + }); + // Renders active theme styles this.activeTheme(); if ( this.model.get( 'displayAuthor' ) ) { this.$el.addClass( 'display-author' ); } + + if ( this.model.get( 'installed' ) ) { + this.$el.addClass( 'is-installed' ); + } }, // Adds a class to the currently active theme @@ -214,29 +408,177 @@ themes.view.Theme = wp.Backbone.View.extend({ } }, + // Add class of focus to the theme we are focused on. + addFocus: function() { + 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 ) { var self = this; + 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 ) ) { + return; + } + // Bail if the user scrolled on a touch device if ( this.touchDrag === true ) { return this.touchDrag = false; } - event = event || window.event; - // Prevent the modal from showing when the user clicks // one of the direct action buttons if ( $( event.target ).is( '.theme-actions a' ) ) { return; } + // Set focused theme to current element + themes.focusedTheme = this.$el; + this.trigger( 'theme:expand', self.model.cid ); }, preventExpand: function() { this.touchDrag = true; + }, + + preview: function( event ) { + var self = this, + current, preview; + + 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 ).hasClass( 'button-primary' ) ) { + return; + } + + // 'enter' and 'space' keys expand the details view when a theme is :focused + if ( event.type === 'keydown' && ( event.which !== 13 && event.which !== 32 ) ) { + return; + } + + // pressing enter while focused on the buttons shouldn't open the preview + if ( event.type === 'keydown' && event.which !== 13 && $( ':focus' ).hasClass( 'button' ) ) { + return; + } + + event.preventDefault(); + + event = event || window.event; + + // Set focus to current theme. + themes.focusedTheme = this.$el; + + // Construct a new Preview view. + preview = new themes.view.Preview({ + model: this.model + }); + + // Render the view and append it. + preview.render(); + this.setNavButtonsState(); + + // Hide previous/next navigation if there is only one theme + if ( this.model.collection.length === 1 ) { + preview.$el.addClass( 'no-navigation' ); + } else { + preview.$el.removeClass( 'no-navigation' ); + } + + // Append preview + $( 'div.wrap' ).append( preview.el ); + + // Listen to our preview object + // for `theme:next` and `theme:previous` events. + this.listenTo( preview, 'theme:next', function() { + + // Keep local track of current theme model. + current = self.model; + + // If we have ventured away from current model update the current model position. + if ( ! _.isUndefined( self.current ) ) { + current = self.current; + } + + // Get next theme model. + self.current = self.model.collection.at( self.model.collection.indexOf( current ) + 1 ); + + // If we have no more themes, bail. + if ( _.isUndefined( self.current ) ) { + self.options.parent.parent.trigger( 'theme:end' ); + return self.current = current; + } + + preview.model = self.current; + + // Render and append. + preview.render(); + this.setNavButtonsState(); + $( '.next-theme' ).focus(); + }) + .listenTo( preview, 'theme:previous', function() { + + // Keep track of current theme model. + current = self.model; + + // Bail early if we are at the beginning of the collection + if ( self.model.collection.indexOf( self.current ) === 0 ) { + return; + } + + // If we have ventured away from current model update the current model position. + if ( ! _.isUndefined( self.current ) ) { + current = self.current; + } + + // Get previous theme model. + self.current = self.model.collection.at( self.model.collection.indexOf( current ) - 1 ); + + // If we have no more themes, bail. + if ( _.isUndefined( self.current ) ) { + return; + } + + preview.model = self.current; + + // Render and append. + preview.render(); + this.setNavButtonsState(); + $( '.previous-theme' ).focus(); + }); + + this.listenTo( preview, 'preview:close', function() { + self.current = self.model; + }); + }, + + // Handles .disabled classes for previous/next buttons in theme installer preview + setNavButtonsState: function() { + var $themeInstaller = $( '.theme-install-overlay' ), + current = _.isUndefined( this.current ) ? this.model : this.current; + + // Disable previous at the zero position + if ( 0 === this.model.collection.indexOf( current ) ) { + $themeInstaller.find( '.previous-theme' ).addClass( 'disabled' ); + } + + // Disable next if the next model is undefined + if ( _.isUndefined( this.model.collection.at( this.model.collection.indexOf( current ) + 1 ) ) ) { + $themeInstaller.find( '.next-theme' ).addClass( 'disabled' ); + } } }); @@ -266,6 +608,8 @@ themes.view.Details = wp.Backbone.View.extend({ this.navigation(); // Checks screenshot size this.screenshotCheck( this.$el ); + // Contain "tabbing" inside the overlay + this.containFocus( this.$el ); }, // Adds a class to the currently active theme @@ -275,6 +619,34 @@ themes.view.Details = wp.Backbone.View.extend({ this.$el.toggleClass( 'active', this.model.get( 'active' ) ); }, + // Keeps :focus within the theme details elements + containFocus: function( $el ) { + var $target; + + // Move focus to the primary action + _.delay( function() { + $( '.theme-wrap a.button-primary:visible' ).focus(); + }, 500 ); + + $el.on( 'keydown.wp-themes', function( event ) { + + // Tab key + if ( event.which === 9 ) { + $target = $( event.target ); + + // Keep focus within the overlay by making the last link on theme actions + // switch focus to button.left on tabbing and vice versa + if ( $target.is( 'button.left' ) && event.shiftKey ) { + $el.find( '.theme-actions a:last-child' ).focus(); + event.preventDefault(); + } else if ( $target.is( '.theme-actions a:last-child' ) ) { + $el.find( 'button.left' ).focus(); + event.preventDefault(); + } + } + }); + }, + // Single theme overlay screen // It's shown when clicking a theme collapse: function( event ) { @@ -291,7 +663,7 @@ themes.view.Details = wp.Backbone.View.extend({ // Detect if the click is inside the overlay // and don't close it unless the target was // the div.back button - if ( $( event.target ).is( '.theme-backdrop' ) || $( event.target ).is( 'div.close' ) || event.keyCode === 27 ) { + if ( $( event.target ).is( '.theme-backdrop' ) || $( event.target ).is( '.close' ) || event.keyCode === 27 ) { // Add a temporary closing class while overlay fades out $( 'body' ).addClass( 'closing-overlay' ); @@ -299,7 +671,7 @@ themes.view.Details = wp.Backbone.View.extend({ // With a quick fade out animation this.$el.fadeOut( 130, function() { // Clicking outside the modal box closes the overlay - $( 'body' ).removeClass( 'theme-overlay-open closing-overlay' ); + $( 'body' ).removeClass( 'closing-overlay' ); // Handle event cleanup self.closeOverlay(); @@ -307,10 +679,15 @@ themes.view.Details = wp.Backbone.View.extend({ scroll = document.body.scrollTop; // Clean the url structure - themes.router.navigate( themes.router.baseUrl( '' ), { replace: true } ); + themes.router.navigate( themes.router.baseUrl( '' ) ); // Restore scroll position document.body.scrollTop = scroll; + + // Return focus to the theme div + if ( themes.focusedTheme ) { + themes.focusedTheme.focus(); + } }); } }, @@ -330,12 +707,13 @@ themes.view.Details = wp.Backbone.View.extend({ // Performs the actions to effectively close // the theme details overlay closeOverlay: function() { + $( 'body' ).removeClass( 'modal-open' ); this.remove(); this.unbind(); this.trigger( 'theme:collapse' ); }, - // Confirmation dialoge for deleting a theme + // Confirmation dialog for deleting a theme deleteTheme: function() { return confirm( themes.data.settings.confirmDelete ); }, @@ -343,11 +721,13 @@ themes.view.Details = wp.Backbone.View.extend({ nextTheme: function() { var self = this; self.trigger( 'theme:next', self.model.cid ); + return false; }, previousTheme: function() { var self = this; self.trigger( 'theme:previous', self.model.cid ); + return false; }, // Checks if the theme screenshot is the old 300px width version @@ -366,11 +746,127 @@ themes.view.Details = wp.Backbone.View.extend({ } }); +// Theme Preview view +// Set ups a modal overlay with the expanded theme data +themes.view.Preview = themes.view.Details.extend({ + + className: 'wp-full-overlay expanded', + el: '.theme-install-overlay', + + events: { + 'click .close-full-overlay': 'close', + 'click .collapse-sidebar': 'collapse', + 'click .devices button': 'previewDevice', + 'click .previous-theme': 'previousTheme', + 'click .next-theme': 'nextTheme', + 'keyup': 'keyEvent' + }, + + // The HTML template for the theme preview + html: themes.template( 'theme-preview' ), + + render: function() { + var self = this, currentPreviewDevice, + data = this.model.toJSON(); + + this.$el.removeClass( 'iframe-ready' ).html( this.html( data ) ); + + currentPreviewDevice = this.$el.data( 'current-preview-device' ); + if ( currentPreviewDevice ) { + self.tooglePreviewDeviceButtons( currentPreviewDevice ); + } + + themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.get( 'id' ) ), { replace: true } ); + + this.$el.fadeIn( 200, function() { + $( 'body' ).addClass( 'theme-installer-active full-overlay-active' ); + $( '.close-full-overlay' ).focus(); + }); + + this.$el.find( 'iframe' ).one( 'load', function() { + self.iframeLoaded(); + }); + }, + + iframeLoaded: function() { + this.$el.addClass( 'iframe-ready' ); + }, + + close: function() { + this.$el.fadeOut( 200, function() { + $( 'body' ).removeClass( 'theme-installer-active full-overlay-active' ); + + // Return focus to the theme div + if ( themes.focusedTheme ) { + themes.focusedTheme.focus(); + } + }).removeClass( 'iframe-ready' ); + + themes.router.navigate( themes.router.baseUrl( '' ) ); + this.trigger( 'preview:close' ); + this.undelegateEvents(); + this.unbind(); + return false; + }, + + collapse: function( event ) { + var $button = $( event.currentTarget ); + if ( 'true' === $button.attr( 'aria-expanded' ) ) { + $button.attr({ 'aria-expanded': 'false', 'aria-label': l10n.expandSidebar }); + } else { + $button.attr({ 'aria-expanded': 'true', 'aria-label': l10n.collapseSidebar }); + } + + this.$el.toggleClass( 'collapsed' ).toggleClass( 'expanded' ); + return false; + }, + + previewDevice: function( event ) { + var device = $( event.currentTarget ).data( 'device' ); + + this.$el + .removeClass( 'preview-desktop preview-tablet preview-mobile' ) + .addClass( 'preview-' + device ) + .data( 'current-preview-device', device ); + + this.tooglePreviewDeviceButtons( device ); + }, + + tooglePreviewDeviceButtons: function( newDevice ) { + var $devices = $( '.wp-full-overlay-footer .devices' ); + + $devices.find( 'button' ) + .removeClass( 'active' ) + .attr( 'aria-pressed', false ); + + $devices.find( 'button.preview-' + newDevice ) + .addClass( 'active' ) + .attr( 'aria-pressed', true ); + }, + + keyEvent: function( event ) { + // The escape key closes the preview + if ( event.keyCode === 27 ) { + this.undelegateEvents(); + this.close(); + } + // The right arrow key, next theme + if ( event.keyCode === 39 ) { + _.once( this.nextTheme() ); + } + + // The left arrow key, previous theme + if ( event.keyCode === 37 ) { + this.previousTheme(); + } + } +}); + // Controls the rendering of div.themes, // a wrapper that will hold all the theme elements themes.view.Themes = wp.Backbone.View.extend({ - className: 'themes', + className: 'themes wp-clearfix', $overlay: $( 'div.theme-overlay' ), // Number to keep track of scroll position @@ -378,7 +874,10 @@ themes.view.Themes = wp.Backbone.View.extend({ index: 0, // The theme count element - count: $( '.theme-count' ), + count: $( '.wrap .theme-count' ), + + // The live themes count + liveThemeCount: 0, initialize: function( options ) { var self = this; @@ -393,18 +892,39 @@ themes.view.Themes = wp.Backbone.View.extend({ self.currentTheme(); // When the collection is updated by user input... - this.listenTo( self.collection, 'update', function() { + this.listenTo( self.collection, 'themes:update', function() { self.parent.page = 0; self.currentTheme(); self.render( this ); + } ); + + // 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 ); + } else { + 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() { + if ( self.overlay ) { + self.overlay.closeOverlay(); + } + } ); + // Bind keyboard events. - $('body').on( 'keyup', function( event ) { + $( 'body' ).on( 'keyup', function( event ) { if ( ! self.overlay ) { return; } @@ -430,7 +950,7 @@ themes.view.Themes = wp.Backbone.View.extend({ // and keeping theme count in sync render: function() { // Clear the DOM, please - this.$el.html( '' ); + this.$el.empty(); // If the user doesn't have switch capabilities // or there is only one theme in the collection @@ -450,10 +970,16 @@ themes.view.Themes = wp.Backbone.View.extend({ // Generate the themes // Using page instance - this.renderThemes( this.parent.page ); + // 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.count.text( this.collection.length ); + this.liveThemeCount = this.collection.count ? this.collection.count : this.collection.length; + this.count.text( this.liveThemeCount ); + + this.announceSearchResults( this.liveThemeCount ); }, // Iterates through each instance of the collection @@ -464,19 +990,22 @@ themes.view.Themes = wp.Backbone.View.extend({ self.instance = self.collection.paginate( page ); // If we have no more themes bail - if ( self.instance.length === 0 ) { + if ( self.instance.size() === 0 ) { + // Fire a no-more-themes event. + this.parent.trigger( 'theme:end' ); return; } // Make sure the add-new stays at the end - if ( page >= 1 ) { + 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({ - model: theme + model: theme, + parent: self }); // Render the views... @@ -490,8 +1019,8 @@ themes.view.Themes = wp.Backbone.View.extend({ }); // 'Add new theme' element shown at the end of the grid - if ( themes.data.settings.canInstall ) { - this.$el.append( '

' + l10n.addNew + '

' ); + if ( ! themes.isInstall && themes.data.settings.canInstall ) { + this.$el.append( '

' + l10n.addNew + '

' ); } this.parent.page++; @@ -525,11 +1054,11 @@ themes.view.Themes = wp.Backbone.View.extend({ this.model = self.collection.get( id ); // Trigger a route update for the current model - themes.router.navigate( themes.router.baseUrl( '?theme=' + this.model.id ), { replace: true } ); + themes.router.navigate( themes.router.baseUrl( themes.router.themePath + this.model.id ) ); // Sets this.view to 'detail' this.setView( 'detail' ); - $( 'body' ).addClass( 'theme-overlay-open' ); + $( 'body' ).addClass( 'modal-open' ); // Set up the theme details view this.overlay = new themes.view.Details({ @@ -602,6 +1131,15 @@ themes.view.Themes = wp.Backbone.View.extend({ self.theme.trigger( 'theme:expand', previousModel.cid ); } + }, + + // Dispatch audible search results feedback message + announceSearchResults: function( count ) { + if ( 0 === count ) { + wp.a11y.speak( l10n.noThemesFound ); + } else { + wp.a11y.speak( l10n.themesFound.replace( '%d', count ) ); + } } }); @@ -609,50 +1147,111 @@ themes.view.Themes = wp.Backbone.View.extend({ themes.view.Search = wp.Backbone.View.extend({ tagName: 'input', - className: 'theme-search', + className: 'wp-filter-search', + id: 'wp-filter-search-input', + searching: false, attributes: { placeholder: l10n.searchPlaceholder, - type: 'search' + type: 'search', + 'aria-describedby': 'live-search-desc' }, events: { - 'input': 'search', - 'keyup': 'search', - 'change': 'search', - 'search': 'search' + 'input': 'search', + 'keyup': 'search', + 'blur': 'pushState' + }, + + initialize: function( options ) { + + this.parent = options.parent; + + this.listenTo( this.parent, 'theme:close', function() { + this.searching = false; + } ); + }, - // Runs a search on the theme collection. search: function( event ) { // Clear on escape. if ( event.type === 'keyup' && event.which === 27 ) { event.target.value = ''; } + /** + * Since doSearch is debounced, it will only run when user input comes to a rest + */ + this.doSearch( event ); + }, + + // Runs a search on the theme collection. + doSearch: _.debounce( function( event ) { + var options = {}; + this.collection.doSearch( event.target.value ); + // if search is initiated and key is not return + if ( this.searching && event.which !== 13 ) { + options.replace = true; + } else { + this.searching = true; + } + // Update the URL hash if ( event.target.value ) { - themes.router.navigate( themes.router.baseUrl( '?search=' + event.target.value ), { replace: true } ); + themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + event.target.value ), options ); } else { - themes.router.navigate( themes.router.baseUrl( '' ), { replace: true } ); + themes.router.navigate( themes.router.baseUrl( '' ) ); } + }, 500 ), + + pushState: function( event ) { + var url = themes.router.baseUrl( '' ); + + if ( event.target.value ) { + url = themes.router.baseUrl( themes.router.searchPath + event.target.value ); + } + + this.searching = false; + themes.router.navigate( url ); + } }); // Sets up the routes events for relevant url queries // Listens to [theme] and [search] params -themes.routes = Backbone.Router.extend({ - - initialize: function() { - this.routes = _.object([ - ]); +themes.Router = Backbone.Router.extend({ + + routes: { + 'themes.php?theme=:slug': 'theme', + 'themes.php?search=:query': 'search', + 'themes.php?s=:query': 'search', + 'themes.php': 'themes', + '': 'themes' }, baseUrl: function( url ) { - return themes.data.settings.root + url; + return 'themes.php' + url; + }, + + themePath: '?theme=', + searchPath: '?search=', + + search: function( query ) { + $( '.wp-filter-search' ).val( query ); + }, + + themes: function() { + $( '.wp-filter-search' ).val( '' ); + }, + + navigate: function() { + if ( Backbone.history._hasPushState ) { + Backbone.Router.prototype.navigate.apply( this, arguments ); + } } + }); // Execute and setup the application @@ -671,42 +1270,516 @@ themes.Run = { }, render: function() { + // Render results this.view.render(); this.routes(); - // Set the initial theme - if ( 'undefined' !== typeof themes.data.settings.theme && '' !== themes.data.settings.theme ){ - this.view.view.theme.trigger( 'theme:expand', this.view.collection.findWhere( { id: themes.data.settings.theme } ) ); + Backbone.history.start({ + root: themes.data.settings.adminUrl, + pushState: true, + hashChange: false + }); + }, + + routes: function() { + var self = this; + // Bind to our global thx object + // so that the object is available to sub-views + themes.router = new themes.Router(); + + // Handles theme details route event + themes.router.on( 'route:theme', function( slug ) { + self.view.view.expand( slug ); + }); + + themes.router.on( 'route:themes', function() { + self.themes.doSearch( '' ); + self.view.trigger( 'theme:close' ); + }); + + // Handles search route event + themes.router.on( 'route:search', function() { + $( '.wp-filter-search' ).trigger( 'keyup' ); + }); + + this.extraRoutes(); + }, + + extraRoutes: function() { + return false; + } +}; + +// Extend the main Search view +themes.view.InstallerSearch = themes.view.Search.extend({ + + events: { + 'input': 'search', + 'keyup': 'search' + }, + + // Handles Ajax request for searching through themes in public repo + search: function( event ) { + + // Tabbing or reverse tabbing into the search input shouldn't trigger a search + if ( event.type === 'keyup' && ( event.which === 9 || event.which === 16 ) ) { + return; + } + + this.collection = this.options.parent.view.collection; + + // Clear on escape. + if ( event.type === 'keyup' && event.which === 27 ) { + event.target.value = ''; + } + + this.doSearch( event.target.value ); + }, + + doSearch: _.debounce( function( value ) { + var request = {}; + + request.search = value; + + // Intercept an [author] search. + // + // If input value starts with `author:` send a request + // for `author` instead of a regular `search` + if ( value.substring( 0, 7 ) === 'author:' ) { + request.search = ''; + request.author = value.slice( 7 ); + } + + // Intercept a [tag] search. + // + // If input value starts with `tag:` send a request + // for `tag` instead of a regular `search` + if ( value.substring( 0, 4 ) === 'tag:' ) { + request.search = ''; + request.tag = [ value.slice( 4 ) ]; + } + + $( '.filter-links li > a.current' ).removeClass( 'current' ); + $( 'body' ).removeClass( 'show-filters filters-applied show-favorites-form' ); + + // Get the themes by sending Ajax POST request to api.wordpress.org/themes + // or searching the local cache + this.collection.query( request ); + + // Set route + themes.router.navigate( themes.router.baseUrl( themes.router.searchPath + value ), { replace: true } ); + }, 500 ) +}); + +themes.view.Installer = themes.view.Appearance.extend({ + + el: '#wpbody-content .wrap', + + // Register events for sorting and filters in theme-navigation + events: { + 'click .filter-links li > a': 'onSort', + 'click .theme-filter': 'onFilter', + 'click .drawer-toggle': 'moreFilters', + 'click .filter-drawer .apply-filters': 'applyFilters', + 'click .filter-group [type="checkbox"]': 'addFilter', + 'click .filter-drawer .clear-filters': 'clearFilters', + 'click .filtered-by': 'backToFilters', + 'click .favorites-form-submit' : 'saveUsername', + 'keyup #wporg-username-input': 'saveUsername' + }, + + // Initial render method + render: function() { + var self = this; + + this.search(); + this.uploader(); + + this.collection = new themes.Collection(); + + // Bump `collection.currentQuery.page` and request more themes if we hit the end of the page. + this.listenTo( this, 'theme:end', function() { + + // Make sure we are not already loading + if ( self.collection.loadingThemes ) { + return; + } + + // Set loadingThemes to true and bump page instance of currentQuery. + self.collection.loadingThemes = true; + self.collection.currentQuery.page++; + + // Use currentQuery.page to build the themes request. + _.extend( self.collection.currentQuery.request, { page: self.collection.currentQuery.page } ); + self.collection.query( self.collection.currentQuery.request ); + }); + + this.listenTo( this.collection, 'query:success', function() { + $( 'body' ).removeClass( 'loading-content' ); + $( '.theme-browser' ).find( 'div.error' ).remove(); + }); + + this.listenTo( this.collection, 'query:fail', function() { + $( 'body' ).removeClass( 'loading-content' ); + $( '.theme-browser' ).find( 'div.error' ).remove(); + $( '.theme-browser' ).find( 'div.themes' ).before( '

' + l10n.error + '

' ); + }); + + if ( this.view ) { + this.view.remove(); + } + + // Set ups the view and passes the section argument + this.view = new themes.view.Themes({ + collection: this.collection, + parent: this + }); + + // Reset pagination every time the install view handler is run + this.page = 0; + + // Render and append + this.$el.find( '.themes' ).remove(); + this.view.render(); + this.$el.find( '.theme-browser' ).append( this.view.el ).addClass( 'rendered' ); + }, + + // Handles all the rendering of the public theme directory + browse: function( section ) { + // Create a new collection with the proper theme data + // for each section + this.collection.query( { browse: section } ); + }, + + // Sorting navigation + onSort: function( event ) { + var $el = $( event.target ), + sort = $el.data( 'sort' ); + + event.preventDefault(); + + $( 'body' ).removeClass( 'filters-applied show-filters' ); + + // Bail if this is already active + if ( $el.hasClass( this.activeClass ) ) { + return; + } + + this.sort( sort ); + + // Trigger a router.naviagte update + themes.router.navigate( themes.router.baseUrl( themes.router.browsePath + sort ) ); + }, + + sort: function( sort ) { + this.clearSearch(); + + $( '.filter-links li > a, .theme-filter' ).removeClass( this.activeClass ); + $( '[data-sort="' + sort + '"]' ).addClass( this.activeClass ); + + if ( 'favorites' === sort ) { + $ ( 'body' ).addClass( 'show-favorites-form' ); + } else { + $ ( 'body' ).removeClass( 'show-favorites-form' ); + } + + this.browse( sort ); + }, + + // Filters and Tags + onFilter: function( event ) { + var request, + $el = $( event.target ), + filter = $el.data( 'filter' ); + + // Bail if this is already active + if ( $el.hasClass( this.activeClass ) ) { + return; + } + + $( '.filter-links li > a, .theme-section' ).removeClass( this.activeClass ); + $el.addClass( this.activeClass ); + + if ( ! filter ) { + return; + } + + // Construct the filter request + // using the default values + filter = _.union( [ filter, this.filtersChecked() ] ); + request = { tag: [ filter ] }; + + // Get the themes by sending Ajax POST request to api.wordpress.org/themes + // or searching the local cache + this.collection.query( request ); + }, + + // Clicking on a checkbox to add another filter to the request + addFilter: function() { + this.filtersChecked(); + }, + + // Applying filters triggers a tag request + applyFilters: function( event ) { + var name, + tags = this.filtersChecked(), + request = { tag: tags }, + filteringBy = $( '.filtered-by .tags' ); + + if ( event ) { + event.preventDefault(); + } + + $( 'body' ).addClass( 'filters-applied' ); + $( '.filter-links li > a.current' ).removeClass( 'current' ); + filteringBy.empty(); + + _.each( tags, function( tag ) { + name = $( 'label[for="filter-id-' + tag + '"]' ).text(); + filteringBy.append( '' + name + '' ); + }); + + // Get the themes by sending Ajax POST request to api.wordpress.org/themes + // or searching the local cache + this.collection.query( request ); + }, + + // Save the user's WordPress.org username and get his favorite themes. + saveUsername: function ( event ) { + var username = $( '#wporg-username-input' ).val(), + nonce = $( '#wporg-username-nonce' ).val(), + request = { browse: 'favorites', user: username }, + that = this; + + if ( event ) { + event.preventDefault(); + } + + // save username on enter + if ( event.type === 'keyup' && event.which !== 13 ) { + return; + } + + return wp.ajax.send( 'save-wporg-username', { + data: { + _wpnonce: nonce, + username: username + }, + success: function () { + // Get the themes by sending Ajax POST request to api.wordpress.org/themes + // or searching the local cache + that.collection.query( request ); + } + } ); + }, + + // Get the checked filters + // @return {array} of tags or false + filtersChecked: function() { + var items = $( '.filter-group' ).find( ':checkbox' ), + tags = []; + + _.each( items.filter( ':checked' ), function( item ) { + tags.push( $( item ).prop( 'value' ) ); + }); + + // When no filters are checked, restore initial state and return + if ( tags.length === 0 ) { + $( '.filter-drawer .apply-filters' ).find( 'span' ).text( '' ); + $( '.filter-drawer .clear-filters' ).hide(); + $( 'body' ).removeClass( 'filters-applied' ); + return false; } - // Set the initial search - if ( 'undefined' !== typeof themes.data.settings.search && '' !== themes.data.settings.search ){ - $( '.theme-search' ).val( themes.data.settings.search ); - this.themes.doSearch( themes.data.settings.search ); + $( '.filter-drawer .apply-filters' ).find( 'span' ).text( tags.length ); + $( '.filter-drawer .clear-filters' ).css( 'display', 'inline-block' ); + + return tags; + }, + + activeClass: 'current', + + // Overwrite search container class to append search + // in new location + searchContainer: $( '.wp-filter .search-form' ), + + uploader: function() { + $( 'a.upload' ).on( 'click', function( event ) { + event.preventDefault(); + $( 'body' ).addClass( 'show-upload-theme' ); + themes.router.navigate( themes.router.baseUrl( '?upload' ), { replace: true } ); + }); + $( 'a.browse-themes' ).on( 'click', function( event ) { + event.preventDefault(); + $( 'body' ).removeClass( 'show-upload-theme' ); + themes.router.navigate( themes.router.baseUrl( '' ), { replace: true } ); + }); + }, + + // Toggle the full filters navigation + moreFilters: function( event ) { + event.preventDefault(); + + if ( $( 'body' ).hasClass( 'filters-applied' ) ) { + return this.backToFilters(); + } + + // If the filters section is opened and filters are checked + // run the relevant query collapsing to filtered-by state + if ( $( 'body' ).hasClass( 'show-filters' ) && this.filtersChecked() ) { + return this.addFilter(); + } + + this.clearSearch(); + + themes.router.navigate( themes.router.baseUrl( '' ) ); + $( 'body' ).toggleClass( 'show-filters' ); + }, + + // Clears all the checked filters + // @uses filtersChecked() + clearFilters: function( event ) { + var items = $( '.filter-group' ).find( ':checkbox' ), + self = this; + + event.preventDefault(); + + _.each( items.filter( ':checked' ), function( item ) { + $( item ).prop( 'checked', false ); + return self.filtersChecked(); + }); + }, + + backToFilters: function( event ) { + if ( event ) { + event.preventDefault(); } - // Start the router if browser supports History API - if ( window.history && window.history.pushState ) { - // Calls the routes functionality - Backbone.history.start({ pushState: true, silent: true }); + $( 'body' ).removeClass( 'filters-applied' ); + }, + + clearSearch: function() { + $( '#wp-filter-search-input').val( '' ); + } +}); + +themes.InstallerRouter = Backbone.Router.extend({ + routes: { + 'theme-install.php?theme=:slug': 'preview', + 'theme-install.php?browse=:sort': 'sort', + 'theme-install.php?upload': 'upload', + 'theme-install.php?search=:query': 'search', + 'theme-install.php': 'sort' + }, + + baseUrl: function( url ) { + return 'theme-install.php' + url; + }, + + themePath: '?theme=', + browsePath: '?browse=', + searchPath: '?search=', + + search: function( query ) { + $( '.wp-filter-search' ).val( query ); + }, + + navigate: function() { + if ( Backbone.history._hasPushState ) { + Backbone.Router.prototype.navigate.apply( this, arguments ); } + } +}); + + +themes.RunInstaller = { + + init: function() { + // Set up the view + // Passes the default 'section' as an option + this.view = new themes.view.Installer({ + section: 'featured', + SearchView: themes.view.InstallerSearch + }); + + // Render results + this.render(); + + }, + + render: function() { + + // Render results + this.view.render(); + this.routes(); + + Backbone.history.start({ + root: themes.data.settings.adminUrl, + pushState: true, + hashChange: false + }); }, routes: function() { - // Bind to our global thx object - // so that the object is available to sub-views - themes.router = new themes.routes(); + var self = this, + request = {}; + + // Bind to our global `wp.themes` object + // so that the router is available to sub-views + themes.router = new themes.InstallerRouter(); + + // Handles `theme` route event + // Queries the API for the passed theme slug + themes.router.on( 'route:preview', function( slug ) { + request.theme = slug; + self.view.collection.query( request ); + self.view.collection.once( 'update', function() { + self.view.view.theme.preview(); + }); + }); + + // Handles sorting / browsing routes + // Also handles the root URL triggering a sort request + // for `featured`, the default view + themes.router.on( 'route:sort', function( sort ) { + if ( ! sort ) { + sort = 'featured'; + } + self.view.sort( sort ); + self.view.trigger( 'theme:close' ); + }); + + // Support the `upload` route by going straight to upload section + themes.router.on( 'route:upload', function() { + $( 'a.upload' ).trigger( 'click' ); + }); + + // The `search` route event. The router populates the input field. + themes.router.on( 'route:search', function() { + $( '.wp-filter-search' ).focus().trigger( 'keyup' ); + }); + + this.extraRoutes(); + }, + + extraRoutes: function() { + return false; } }; // Ready... -jQuery( document ).ready( - - // Bring on the themes - _.bind( themes.Run.init, themes.Run ) +$( document ).ready(function() { + if ( themes.isInstall ) { + themes.RunInstaller.init(); + } else { + themes.Run.init(); + } -); + $( '.broken-themes .delete-theme' ).on( 'click', function() { + return confirm( _wpThemeSettings.settings.confirmDelete ); + }); +}); })( jQuery ); @@ -720,8 +1793,8 @@ jQuery(document).ready( function($) { W = ( 1040 < width ) ? 1040 : width, adminbar_height = 0; - if ( $('body.admin-bar').length ) { - adminbar_height = parseInt( jQuery('#wpadminbar').css('height'), 10 ); + if ( $('#wpadminbar').length ) { + adminbar_height = parseInt( $('#wpadminbar').css('height'), 10 ); } if ( tbWindow.size() ) {