From 8a79cdc1077af0d8e504f4c9e158672dd1f23bbb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 19 Oct 2016 03:19:13 +0000 Subject: [PATCH] Customize: Introduce a new experience for discovering, installing, and previewing themes within the customizer. Unify the theme-browsing and theme-customization experiences by introducing a comprehensive theme browser and installer directly accessible in the customizer. Replaces the customizer theme switcher with a full-screen panel for discovering/browsing and installing themes available on WordPress.org. Themes can now be installed and previewed directly in the customizer without entering the wp-admin context. For details, see https://make.wordpress.org/core/2016/10/03/feature-proposal-a-new-experience-for-discovering-installing-and-previewing-themes-in-the-customizer/ Fixes #37661, #34843. Props celloexpressions, folletto, westonruter, karmatosed, afercia. git-svn-id: https://develop.svn.wordpress.org/trunk@38813 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/customize-controls.css | 566 +++++++++-- src/wp-admin/css/themes.css | 2 +- src/wp-admin/customize.php | 3 +- src/wp-admin/includes/theme.php | 48 +- src/wp-admin/js/customize-controls.js | 953 ++++++++++++++---- src/wp-admin/js/updates.js | 46 +- .../class-wp-customize-manager.php | 266 ++++- .../class-wp-customize-theme-control.php | 53 +- .../class-wp-customize-themes-panel.php | 113 +++ .../class-wp-customize-themes-section.php | 140 ++- tests/phpunit/tests/customize/manager.php | 2 +- 11 files changed, 1799 insertions(+), 393 deletions(-) create mode 100644 src/wp-includes/customize/class-wp-customize-themes-panel.php diff --git a/src/wp-admin/css/customize-controls.css b/src/wp-admin/css/customize-controls.css index c225569a4d..be32bd7b3b 100644 --- a/src/wp-admin/css/customize-controls.css +++ b/src/wp-admin/css/customize-controls.css @@ -271,21 +271,17 @@ body { } #customize-theme-controls .customize-pane-child.open, -#customize-theme-controls .customize-pane-child.current-panel, -#customize-theme-controls .customize-themes-panel.customize-pane-child.current-panel { +#customize-theme-controls .customize-pane-child.current-panel { -webkit-transform: none; -ms-transform: none; transform: none; } -#customize-theme-controls .customize-themes-panel.customize-pane-child, .section-open #customize-theme-controls .customize-pane-parent, .in-sub-panel #customize-theme-controls .customize-pane-parent, .section-open #customize-info, .in-sub-panel #customize-info, -.in-sub-panel.section-open #customize-theme-controls .customize-pane-child.current-panel, -.in-themes-panel #customize-theme-controls .customize-pane-parent, -.in-themes-panel #customize-info { +.in-sub-panel.section-open #customize-theme-controls .customize-pane-child.current-panel { visibility: hidden; height: 0; overflow: hidden; @@ -296,10 +292,8 @@ body { .section-open #customize-theme-controls .customize-pane-parent.busy, .in-sub-panel #customize-theme-controls .customize-pane-parent.busy, -.in-themes-panel #customize-theme-controls .customize-pane-parent.busy, .section-open #customize-info.busy, .in-sub-panel #customize-info.busy, -.in-themes-panel #customize-info.busy, .busy.section-open.in-sub-panel #customize-theme-controls .customize-pane-child.current-panel, #customize-theme-controls .customize-pane-child.open, #customize-theme-controls .customize-pane-child.current-panel, @@ -309,13 +303,6 @@ body { overflow: auto; } -.in-themes-panel #customize-theme-controls .customize-pane-parent, -.in-themes-panel #customize-info { - -webkit-transform: translateX(100%); - -ms-transform: translateX(100%); - transform: translateX(100%); -} - #customize-theme-controls .customize-pane-child.accordion-section-content, #customize-theme-controls .customize-pane-child.accordion-sub-container { display: block; @@ -406,7 +393,7 @@ h3.customize-section-title { display: block; float: left; width: 48px; - height: 71px; + height: 70px; padding: 0 24px 0 0; margin: 0; background: #fff; @@ -420,7 +407,7 @@ h3.customize-section-title { } .customize-section-back { - height: 74px; + height: 73px; } .ios .customize-panel-back { @@ -996,15 +983,21 @@ p.customize-section-description { animation: customize-reload .75s; } -#customize-theme-controls .control-section-themes .accordion-section-title:hover, /* Not a focusable element. */ -#customize-theme-controls .control-section-themes .accordion-section-title { +#customize-theme-controls .control-panel-themes { + border-bottom: none; +} + +#customize-theme-controls .control-panel-themes > .accordion-section-title:hover, /* Not a focusable element. */ +#customize-theme-controls .control-panel-themes > .accordion-section-title { cursor: default; background: #fff; color: #555; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; border-left: none; - margin-top: 0; + border-right: none; + margin: 0 0 15px 0; + padding-right: 100px; /* Space for the button */ } #customize-theme-controls .control-section-themes .customize-themes-panel .accordion-section-title:first-child:hover, /* Not a focusable element. */ @@ -1012,29 +1005,14 @@ p.customize-section-description { border-top: 0; } -#customize-theme-controls .control-section-themes > .accordion-section-title:hover, /* Not a focusable element. */ -#customize-theme-controls .control-section-themes > .accordion-section-title { - margin: 0 0 15px; -} - -#customize-controls .customize-themes-panel .accordion-section-title { - margin: 15px -8px; -} - -#customize-controls .control-section-themes .accordion-section-title, -#customize-controls .customize-themes-panel .accordion-section-title { - padding-right: 100px; /* Space for the button */ -} - -#customize-controls .control-section-themes .accordion-section-title span.customize-action, +.control-panel-themes .accordion-section-title span.customize-action, #customize-controls .customize-section-title span.customize-action { font-size: 13px; display: block; font-weight: 400; } -#customize-controls .control-section-themes .accordion-section-title .change-theme, -#customize-controls .customize-themes-panel .accordion-section-title .customize-theme { +.control-panel-themes .accordion-section-title .change-theme { position: absolute; right: 10px; top: 50%; @@ -1042,38 +1020,363 @@ p.customize-section-description { font-weight: 400; } -#customize-controls .control-section-themes .accordion-section-title:before { +#customize-theme-controls .control-panel-themes > .accordion-section-title:after { display: none; } -#customize-controls .customize-themes-panel { - padding: 0 8px; - background: #f1f1f1; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; +.control-panel-themes .customize-themes-full-container { + position: fixed; + top: 0; + left: 0; + transition: .18s left ease-in-out; + margin: 0 0 0 300px; + padding:25px; + overflow-y: scroll; + width: calc(100% - 350px); + height: calc(100% - 50px); + background: #eee; + z-index: 20; } -#customize-controls .customize-themes-panel .accordion-section-title:first-child { - margin-top: 0; +/* Animations for opening the themes panel */ +#customize-header-actions .save, +#customize-header-actions .spinner, +#customize-header-actions .customize-controls-preview-toggle { + position: relative; + top: 0; + transition: .18s top ease-in-out; } -#customize-controls .customize-themes-panel .accordion-section-title:nth-child(2) { +#customize-footer-actions, +#customize-footer-actions .collapse-sidebar { + bottom: 0; + transition: .18s bottom ease-in-out; +} + +.in-themes-panel:not(.animating) #customize-header-actions .save, +.in-themes-panel:not(.animating) #customize-header-actions .spinner, +.in-themes-panel:not(.animating) #customize-header-actions .customize-controls-preview-toggle, +.in-themes-panel:not(.animating) #customize-preview, +.in-themes-panel:not(.animating) #customize-footer-actions { + visibility: hidden; +} + +.wp-full-overlay.in-themes-panel { + background: #eee; /* Prevents a black flash when fading in the panel */ +} + +.in-themes-panel #customize-header-actions .save, +.in-themes-panel #customize-header-actions .spinner, +.in-themes-panel #customize-header-actions .customize-controls-preview-toggle { + top: -45px; +} + +.in-themes-panel #customize-footer-actions, +.in-themes-panel #customize-footer-actions .collapse-sidebar { + bottom: -45px; +} + +/* Don't show the theme count while the panel opens, as it's in the wrong place during the animation */ +.in-themes-panel.animating .control-panel-themes .filter-themes-count { + display: none; +} + +.in-themes-panel.wp-full-overlay .wp-full-overlay-sidebar-content { + bottom: 0; +} + +/* Adds a delay before fading in to avoid it "jumping" */ +@keyframes themes-fade-in { + 0% { + opacity: 0; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.control-panel-themes .customize-themes-full-container.animate { + animation: .6s themes-fade-in 1; +} + +.in-themes-panel:not(.animating) .control-panel-themes .filter-themes-count { + animation: .6s themes-fade-in 1; +} + +.control-panel-themes .filter-themes-count { + position: fixed; + top: 0; + left: 48px; + width: 222px; + padding: 6px 15px; + margin: 0; + line-height: 32px; + text-align: right; + z-index: 10; +} + +.control-panel-themes .filter-themes-count .themes-displayed { + font-weight: 600; + color: #555d66; +} + +.control-panel-themes .filter-themes-count .see-themes, +.control-panel-themes .filter-themes-count .filter-themes { + display: none; +} + + +/* Mobile - toggle between themes and filters */ +@media screen and (max-width:600px) { + + /* Show a spinner in the filters view also, reusing the main customize spinner */ + .in-themes-panel.loading #customize-header-actions .spinner { + position: fixed; + top: 0; + left: 48px; + visibility: visible; + } + + .in-themes-panel.loading.showing-themes #customize-header-actions .spinner { + visibility: hidden; + } + + .control-panel-themes .filter-themes-count { + width: calc(100% - 93px); + } + + .control-panel-themes .filter-themes-count .themes-displayed { + display: none; + } + + .wp-full-overlay:not(.showing-themes) .control-panel-themes .filter-themes-count .see-themes { + display: block; + float: right; + } + + .wp-full-overlay.showing-themes .control-panel-themes .filter-themes-count .filter-themes { + display: block; + float: right; + } + + .in-themes-panel.showing-themes .control-panel-themes .customize-panel-back { + position: fixed; + top: 0; + left: 0; + z-index: 10; + height: 45px; + background: #eee; + } + + .in-themes-panel.showing-themes .control-panel-themes .customize-panel-back:before { + line-height: 45px; + } + + .control-panel-themes .customize-themes-full-container { + width: calc(100% - 50px); + margin: 0; + top: 46px; + height: calc(100% - 96px); + z-index: 1; + display: none; + } + + .showing-themes .control-panel-themes .customize-themes-full-container { + display: block; + } +} + +.control-panel-themes .customize-themes-notifications .notice { + margin: 0 0 25px 0; +} + +.customize-themes-full-container .customize-themes-section { + display: none !important; /* There is unknown JS that perpetually tries to show all theme sections when more items are added. */ + overflow: hidden; +} + +.customize-themes-full-container .customize-themes-section.current-section { + display: list-item !important; /* There is unknown JS that perpetually tries to show all theme sections when more items are added. */ +} + +.theme-section .customize-themes-text-before { + padding: 0 0 8px 15px; + margin: 15px 0 0 0; + line-height: 16px; + border-bottom: 1px solid #ddd; + color: #555d66; +} + +.control-panel-themes .customize-themes-section-title { + width: 100%; + background: #fff; + box-shadow: none; + outline: none; + border-top: none; + border-bottom: 1px solid #ddd; + border-left: 4px solid #fff; + border-right: none; + cursor: pointer; + padding: 10px 15px; + position: relative; + text-align: left; font-size: 14px; font-weight: 600; + color: #555d66; + text-shadow: none; +} + +.control-panel-themes .theme-section { + margin: 0; + position: relative; +} + +.control-panel-themes .customize-themes-section-title:focus, +.control-panel-themes .customize-themes-section-title:hover { + border-left-color: #0073aa; + color: #0073aa; + background: #f5f5f5; +} + +.control-panel-themes .theme-section .customize-themes-section-title.selected:after { + content: "\f147"; + font: 16px/1 dashicons; + box-sizing: border-box; + width: 20px; + height: 20px; + padding: 3px 3px 1px 1px; /* Re-align the icon to the smaller grid */ + border-radius: 100%; + position: absolute; + top: 9px; + right: 15px; + background: #0073aa; + color: #fff; +} + +.control-panel-themes .customize-themes-section-title.selected { + color: #0073aa; +} + +.control-panel-themes .customize-themes-section-title.themes-section-search_themes { + border-left: none; + padding: 5px 10px 5px 15px; + width: auto; +} + +.control-panel-themes .customize-themes-section-title.themes-section-feature_filter_themes:after, +.control-panel-themes .customize-themes-section-title.themes-section-favorites_themes:after { + content: "\f140"; + font: 20px/1 dashicons; + position: absolute; + right: 15px; + top: 8px; +} + +.control-panel-themes .customize-themes-section-title.themes-section-search_themes .wp-filter-search { + width: 100%; +} + +.control-panel-themes .customize-themes-section-title.themes-section-search_themes.selected, +.control-panel-themes .customize-themes-section-title.themes-section-search_themes:hover { + background: #fff; + cursor: default; +} + +.control-panel-themes .customize-themes-section-title.themes-section-feature_filter_themes { + margin-top: 15px; + border-top: 1px solid #ddd; +} + +.control-panel-themes .filter-details { + background: #f5f5f5; + margin: 0; + padding: 8px 15px; + border-top: none; + border-bottom: 1px solid #ddd; + display: none; +} + +.control-panel-themes .customize-themes-section-title.selected.details-open { + border-bottom-color: #f5f5f5; + border-left-color: #f5f5f5; + background: #f5f5f5; +} + +.control-panel-themes .favorites-form.filter-details label { + padding-bottom: 6px; + display: inline-block; +} + +.control-panel-themes .filter-details .filter-group { + float: none; + width: 100%; + background: transparent; + border: none; + padding: 0; + box-shadow: none; +} + +.control-panel-themes .filter-details .filter-group legend button { + padding: 18px 15px 8px 10px; + line-height: 14px; + border-bottom: 1px solid #ddd; + width: 100%; + text-align: left; +} + +.control-panel-themes .filter-details .filter-group legend { + position: relative; + top: 0; + width: 100%; +} + +.control-panel-themes .filter-details .filter-group legend button:after { + content: "\f140"; + font: 20px/1 dashicons; + position: absolute; + bottom: 6px; + right: 5px; +} + +.control-panel-themes .filter-details .filter-group legend button:hover, +.control-panel-themes .filter-details .filter-group legend button:focus { + color: #0073aa; + border-bottom-color: #0073aa; /* Color change for focus style should be acceptable because border-bottom is barely visible previously. */ + outline: none; + box-shadow: none; +} + +.control-panel-themes .filter-details .filter-group legend button.open:after { + content: "\f142"; +} + +.control-panel-themes .filter-details .filter-group .filter-group-feature { + display: none; + margin: 0; +} + +.control-panel-themes .filter-details .filter-group-feature label { + border: 1px solid #ddd; + border-top: 0; + background: #fff; + color: #555d66; + margin: 0; + padding: 12px 10px 12px 34px; + width: calc(100% - 46px); + line-height: 16px; + font-weight: 600; } -#customize-controls .customize-themes-panel > h2 { - padding: 15px 8px 0 8px; +.control-panel-themes .filter-details .filter-group-feature input { + position: absolute; + margin: 12px 10px; } -#customize-theme-controls .customize-themes-panel .accordion-section-content { - background: transparent; - display: block; -} - -.customize-control.customize-control-theme { - margin-bottom: 8px; +.control-panel-themes .filter-details .filter-group-feature label:hover { + color: #0073aa; } #customize-theme-controls .themes.accordion-section-content { @@ -1083,17 +1386,108 @@ p.customize-section-description { width: 100%; } +.loading .customize-themes-section .spinner { + display: block; + visibility: visible; + position: relative; + clear: both; + width: 20px; + height: 20px; + left: calc(50% - 10px); + float: none; + margin-top: 50px; +} + +.customize-themes-section .filter-drawer { + border-top: none; + display: block; + background: transparent; + padding-top: 5px; +} + +.customize-themes-section .clear-filters { + margin-left: 8px; + display: none; +} + +.customize-themes-section .no-themes { + display: none; +} + +.themes-section-installed_themes .theme .notice-success { + display: none; /* Hide "installed" notice on installed themes tab. */ +} + +.control-panel-themes .theme-browser .theme .theme-actions .button-primary { + margin: 0 0 0 8px; +} + +.customize-control-theme .theme { + width: 100%; + margin: 0; +} + +.customize-control.customize-control-theme { /* override most properties on .customize-control */ + box-sizing: border-box; + width: 18.4%; + margin: 0 2% 2% 0; + padding: 0; + clear: none; +} + +/* 5 columns above 2100px */ +@media screen and (min-width: 2101px) { + .customize-control.customize-control-theme:nth-child(5n) { + margin-right: 0; + } +} + +/* 4 columns up to 2100px */ +@media screen and (min-width: 1601px) and (max-width: 2100px) { + .customize-control.customize-control-theme { + width: 23.5%; + } + + .customize-control.customize-control-theme:nth-child(4n) { + margin-right: 0; + } +} + +/* 3 columns up to 1600px */ +@media screen and (min-width: 1201px) and (max-width: 1600px) { + .customize-control.customize-control-theme { + width: 32%; + } + + .customize-control.customize-control-theme:nth-child(3n) { + margin-right: 0; + } +} + +/* 2 columns up to 1200px */ +@media screen and (min-width: 851px) and (max-width: 1200px) { + .customize-control.customize-control-theme { + width: 49%; + } + + .customize-control.customize-control-theme:nth-child(even) { + margin-right: 0; + } +} + +/* 1 column up to 850 px */ +@media screen and (max-width: 850px) { + .customize-control.customize-control-theme { + width: 100%; + margin: 0 0 3% 0; + } +} + .wp-customizer .theme-browser .themes { padding-bottom: 8px; } -.wp-customizer .theme-browser .theme { - margin: 0; - width: 100%; -} - .wp-customizer .theme-browser .theme .theme-actions { - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)"; opacity: 1; } @@ -1112,15 +1506,6 @@ p.customize-section-description { width: 100%; } -.control-section-themes .accordion-section-title:after, -.customize-themes-panel .accordion-section-title:after { - display: none; -} - -.customize-themes-panel.control-panel-content { - border-top: 1px solid #ddd; -} - /* Details View */ .wp-customizer .theme-overlay { display: none; @@ -1135,31 +1520,58 @@ p.customize-section-description { z-index: 109; } +/* Avoid a z-index war by resetting elements that should be under the overlay. + This is likely required because of the way that sections and panels are positioned. */ +.wp-customizer.modal-open #customize-header-actions, +.wp-customizer.modal-open .control-panel-themes .filter-themes-count, +.wp-customizer.modal-open .control-panel-themes .customize-themes-section-title.selected:after { + z-index: -1; +} + .wp-customizer .theme-overlay .theme-backdrop { background: rgba( 238, 238, 238, 0.75 ); position: fixed; z-index: 110; } +.wp-customizer .theme-overlay .star-rating { + float: left; + margin-right: 8px; +} + +.wp-customizer .theme-rating .num-ratings { + line-height: 20px; +} + .wp-customizer .theme-overlay .theme-wrap { left: 90px; right: 90px; top: 45px; bottom: 45px; z-index: 120; - max-width: 1740px; /* To ensure that theme screenshots are not displayed larger than 880px wide. */ } .wp-customizer .theme-overlay .theme-actions { - text-align: right; /* Because there's only one action, match the pattern of media modals and right-align the action. */ + text-align: right; /* Because there're only one or two actions, match the UI pattern of media modals and right-align the action. */ + padding: 10px 15px; } -.ie8 .wp-customizer .theme-overlay .theme-header, -.ie8 .wp-customizer .theme-overlay .theme-about, -.ie8 .wp-customizer .theme-overlay .theme-actions { - position: static; +.wp-customizer .theme-overlay .theme-actions .theme-install.preview { + margin-left: 8px; } +.control-panel-themes .theme-actions .delete-theme { + left: 15px; /* these override themes.css on mobile */ + right: auto; + bottom: auto; + position: absolute; +} + +.modal-open .in-themes-panel #customize-controls .wp-full-overlay-sidebar-content { + overflow: visible; /* Prevent the top-level Customizer controls from becoming visible when elements on the right of the details modal are focused. */ +} + + /* Small Screens */ @media (max-width:850px), (max-height:472px) { .wp-customizer .theme-overlay .theme-wrap { diff --git a/src/wp-admin/css/themes.css b/src/wp-admin/css/themes.css index 92f31fdb80..885f3a0755 100644 --- a/src/wp-admin/css/themes.css +++ b/src/wp-admin/css/themes.css @@ -570,7 +570,7 @@ body.folded .theme-browser ~ .theme-overlay .theme-wrap { float: left; margin: 0 30px 0 0; width: 55%; - max-width: 880px; + max-width: 1200px; /* Recommended theme screenshot width, set here to avoid stretching */ text-align: center; } diff --git a/src/wp-admin/customize.php b/src/wp-admin/customize.php index 0a5350f54c..2f796cb4c9 100644 --- a/src/wp-admin/customize.php +++ b/src/wp-admin/customize.php @@ -109,7 +109,8 @@ $admin_title = sprintf( $wp_customize->get_document_title_template(), __( 'Loadi ?><?php echo $admin_title; ?> ', + section: section.params.id, + active: true, + theme: theme, + priority: section.loaded + 1 + }, + previewer: api.previewer + } ); + + api.control.add( customizeId, themeControl ); + newThemeControls.push( themeControl ); + section.loaded = section.loaded + 1; + }); + + if ( 1 === page ) { + // Pre-load the first 3 theme screenshots. + _.each( section.controls().slice( 0, 3 ), function ( control ) { + var img, src = control.params.theme.screenshot[0]; + if ( src ) { + img = new Image(); + img.src = src; + } + }); + if ( 'search' === section.params.action ) { + wp.a11y.speak( api.settings.l10n.themeSearchResults.replace( '%d', data.info.results ) ); + } + } else { + Array.prototype.push.apply( section.screenshotQueue, newThemeControls ); // Add new themes to the screenshot queue. } - } ); + _.delay( section.renderScreenshots, 100 ); // Wait for the controls to become visible. - overlay.addClass( 'in-themes-panel' ); - section.addClass( 'current-panel' ); - - } else if ( ! expanded && section.hasClass( 'current-panel' ) ) { - panel._animateChangeExpanded( function() { - changeBtn.attr( 'tabindex', '0' ); - customizeBtn.attr( 'tabindex', '-1' ); - - changeBtn.focus(); - section.css( 'top', '' ); - - if ( args.completeCallback ) { - args.completeCallback(); + if ( 'installed' === section.action || 100 > themes.length ) { // If we have less than the requested 100 themes, it's the end of the list. + section.fullyLoaded = true; } - } ); + } else { + if ( 0 === section.loaded ) { + section.container.find( '.no-themes' ).show(); + wp.a11y.speak( section.container.find( '.no-themes' ).text() ); + } else { + section.fullyLoaded = true; + } + } + if ( 'installed' === section.params.action ) { + section.updateCount(); + } else { + section.updateCount( data.info.results ); + } + section.container.find( '.unexpected-error' ).hide(); // Hide error notice in case it was previously shown. - overlay.removeClass( 'in-themes-panel' ); - section.removeClass( 'current-panel' ); + // This cannot run on request.always, as section.loading may turn false before the new controls load in the success case. + section.headContainer.closest( '.wp-full-overlay' ).removeClass( 'loading' ); + section.loading = false; + }); + request.fail(function( data ) { + if ( 'undefined' === typeof data ) { + section.container.find( '.unexpected-error' ).show(); + wp.a11y.speak( section.container.find( '.unexpected-error' ).text() ); + } else if ( typeof console !== 'undefined' && console.error ) { + console.error( data ); + } + + // This cannot run on request.always, as section.loading may turn false before the new controls load in the success case. + section.headContainer.closest( '.wp-full-overlay' ).removeClass( 'loading' ); + section.loading = false; + }); + }, + + /** + * Determines whether more themes should be loaded, and loads them. + * + * @since 4.7.0 + */ + loadMore: function() { + var section = this, container, bottom, threshold; + if ( ! section.fullyLoaded && ! section.loading ) { + container = section.container.closest( '.customize-themes-full-container' ); + + bottom = container.scrollTop() + container.height(); + threshold = container.prop( 'scrollHeight' ) - 3000; // Use a fixed distance to the bottom of loaded results to avoid unnecessarily loading results sooner when using a percentage of scroll distance. + + if ( bottom > threshold ) { + section.loadControls(); + } + } + }, + + /** + * Event handler for search, feature filter, and favorites input that determines if the term has changed and loads new controls as needed. + * + * @since 4.7.0 + * + * @param {wp.customize.ThemesSection} section The current theme section, passed through the debouncer. + */ + checkTerm: function( section ) { + var newTerm; + + // Find term. + if ( 'search' === section.params.action ) { + newTerm = $( '#wp-filter-search-input' ).val(); + } else if ( 'favorites' === section.params.action ) { + newTerm = $( '#wporg-username-input' ).val(); + } else if ( 'feature_filter' === section.params.action ) { + newTerm = section.term; // Set separately by filtersChecked(), as they're changed. + if ( '' === newTerm ) { + return; + } + } else { + return; + } + + if ( section.term === newTerm && 'feature_filter' !== section.params.action ) { + return; + } + + // Clear the controls in the section. + _.each( section.controls(), function( control ) { + control.container.remove(); + api.control.remove( control.id ); + }); + section.loaded = 0; + section.fullyLoaded = false; + section.screenshotQueue = null; + + if ( '' !== newTerm ) { // Empty term should not show any results. + // Run a new query, with loadControls handling paging, etc. + section.term = newTerm; + section.loadControls(); + if ( ! section.expanded() ) { + section.expand(); // Expand the section if it isn't expanded. + } + } + }, + + /** + * Check for filters checked in the feature filter list. + * + * @since 4.7.0 + */ + filtersChecked: function() { + var section = this, + items = section.container.find( '.filter-group' ).find( ':checkbox' ), + tags = []; + + if ( 'feature_filter' !== section.params.action ) { + return false; + } + + _.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 ) { + section.term = ''; + } else { + section.term = tags; } }, @@ -1270,12 +1562,15 @@ renderScreenshots: function( ) { var section = this; - // Fill queue initially. - if ( section.screenshotQueue === null ) { - section.screenshotQueue = section.controls(); + // Fill queue initially, or check for more if empty. + if ( section.screenshotQueue === null || 0 === section.screenshotQueue.length ) { + // Add controls that haven't had their screenshots rendered. + section.screenshotQueue = _.filter( section.controls(), function( control ) { + return ! control.screenshotRendered; + }); } - // Are all screenshots rendered? + // Are all screenshots rendered (for now)? if ( ! section.screenshotQueue.length ) { return; } @@ -1310,6 +1605,31 @@ } ); }, + /** + * Update the number of themes in the section. + * + * @since 4.7.0 + */ + updateCount: function ( count ) { + if ( ! count ) { + count = this.loaded; + } + + var displayed = this.container.closest( '.control-panel-content' ).find( '.themes-displayed' ), + countEl = this.container.closest( '.control-panel-content' ).find( '.theme-count' ); + + if ( 0 === count ) { + countEl.text( count ); + } else { + // Animate the count change for emphasis. + displayed.fadeOut( 180, function() { + countEl.text( count ); + displayed.fadeIn( 180 ); + } ); + wp.a11y.speak( api.settings.l10n.announceThemeCount.replace( '%d', count ) ); + } + }, + /** * Advance the modal to the next theme. * @@ -1330,13 +1650,13 @@ * @since 4.2.0 */ getNextTheme: function () { - var control, next; - control = api.control( 'theme_' + this.currentTheme ); + var section = this, control, next; + control = api.control( section.params.action + '_theme_' + this.currentTheme ); next = control.container.next( 'li.customize-control-theme' ); if ( ! next.length ) { return false; } - next = next[0].id.replace( 'customize-control-', '' ); + next = next[0].id.replace( 'customize-control-theme-' + section.params.action, section.params.action + '_theme' ); control = api.control( next ); return control.params.theme; @@ -1362,13 +1682,13 @@ * @since 4.2.0 */ getPreviousTheme: function () { - var control, previous; - control = api.control( 'theme_' + this.currentTheme ); + var section = this, control, previous; + control = api.control( section.params.action + '_theme_' + this.currentTheme ); previous = control.container.prev( 'li.customize-control-theme' ); if ( ! previous.length ) { return false; } - previous = previous[0].id.replace( 'customize-control-', '' ); + previous = previous[0].id.replace( 'customize-control-theme-' + section.params.action, section.params.action + '_theme' ); control = api.control( previous ); return control.params.theme; @@ -1388,57 +1708,6 @@ } }, - /** - * Load theme preview. - * - * @since 4.7.0 - * - * @param {string} themeId Theme ID. - * @returns {jQuery.promise} Promise. - */ - loadThemePreview: function( themeId ) { - var deferred = $.Deferred(), onceProcessingComplete, overlay, urlParser; - - urlParser = document.createElement( 'a' ); - urlParser.href = location.href; - urlParser.search = $.param( _.extend( - api.utils.parseQueryString( urlParser.search.substr( 1 ) ), - { - theme: themeId, - changeset_uuid: api.settings.changeset.uuid - } - ) ); - - overlay = $( '.wp-full-overlay' ); - overlay.addClass( 'customize-loading' ); - - onceProcessingComplete = function() { - var request; - if ( api.state( 'processing' ).get() > 0 ) { - return; - } - - api.state( 'processing' ).unbind( onceProcessingComplete ); - - request = api.requestChangesetUpdate(); - request.done( function() { - $( window ).off( 'beforeunload.customize-confirm' ); - window.location.href = urlParser.href; - } ); - request.fail( function() { - overlay.removeClass( 'customize-loading' ); - } ); - }; - - if ( 0 === api.state( 'processing' ).get() ) { - onceProcessingComplete(); - } else { - api.state( 'processing' ).bind( onceProcessingComplete ); - } - - return deferred.promise(); - }, - /** * Render & show the theme details for a given theme model. * @@ -1456,9 +1725,9 @@ $( 'body' ).addClass( 'modal-open' ); section.containFocus( section.overlay ); section.updateLimits(); + wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', theme.name ) ); link = section.overlay.find( '.inactive-theme > a' ); - link.on( 'click', function( event ) { event.preventDefault(); @@ -1468,7 +1737,7 @@ } link.addClass( 'disabled' ); - section.loadThemePreview( theme.id ).fail( function() { + api.panel( 'themes' ).loadThemePreview( theme.id ).fail( function() { link.removeClass( 'disabled' ); } ); } ); @@ -1483,7 +1752,7 @@ closeDetails: function () { $( 'body' ).removeClass( 'modal-open' ); this.overlay.fadeOut( 'fast' ); - api.control( 'theme_' + this.currentTheme ).focus(); + api.control( this.params.action + '_theme_' + this.currentTheme ).container.find( '.theme' ).focus(); }, /** @@ -1563,8 +1832,8 @@ } if ( ! panel.contentContainer.parent().is( panel.headContainer ) ) { container.append( panel.contentContainer ); - panel.renderContent(); } + panel.renderContent(); panel.deferred.embedded.resolve(); }, @@ -1753,6 +2022,301 @@ } }); + + /** + * wp.customize.ThemesPanel + * + * Custom section for themes that displays without the customize preview. + * + * @constructor + * @augments wp.customize.Panel + * @augments wp.customize.Container + */ + api.ThemesPanel = api.Panel.extend({ + installingThemes: [], + + /** + * @since 4.7.0 + */ + attachEvents: function () { + var panel = this; + + // Attach regular panel events. + api.Panel.prototype.attachEvents.apply( this ); + + // Collapse panel to customize the current theme. + panel.contentContainer.on( 'click', '.customize-theme', function() { + panel.collapse(); + }); + + // Toggle between filtering and browsing themes on mobile. + panel.contentContainer.on( 'click', '.see-themes, .filter-themes', function() { + $( '.wp-full-overlay' ).toggleClass( 'showing-themes' ); + }); + + // Install (and maybe preview) a theme. + panel.contentContainer.on( 'click', '.theme-install', function( event ) { + panel.installTheme( event ); + }); + + // Update a theme. Theme cards have the class, the details modal has the id. + panel.contentContainer.on( 'click', '.update-theme, #update-theme', function( event ) { + // #update-theme is a link. + event.preventDefault(); + event.stopPropagation(); + + panel.updateTheme( event ); + }); + + // Delete a theme. + panel.contentContainer.on( 'click', '.delete-theme', function( event ) { + panel.deleteTheme( event ); + }); + + _.bindAll( this, 'installTheme', 'updateTheme' ); + }, + + /** + * Update UI to reflect expanded state + * + * @since 4.7.0 + * + * @param {Boolean} expanded + * @param {Object} args + * @param {Boolean} args.unchanged + * @param {Function} args.completeCallback + */ + onChangeExpanded: function ( expanded, args ) { + + // Expand/collapse the panel normally. + api.Panel.prototype.onChangeExpanded.apply( this, [ expanded, args ] ); + + // Immediately call the complete callback if there were no changes + if ( args.unchanged ) { + if ( args.completeCallback ) { + args.completeCallback(); + } + return; + } + + // Note: there is a second argument 'args' passed + var panel = this, + overlay = panel.headContainer.closest( '.wp-full-overlay' ); + + if ( expanded ) { + overlay + .addClass( 'in-themes-panel' ).addClass( 'showing-themes' ) + .delay( 200 ).find( '.customize-themes-full-container' ).addClass( 'animate' ); + + // Automatically open the installed themes section. + api.section( 'installed_themes' ).expand(); + } else { + overlay + .removeClass( 'in-themes-panel' ) + .find( '.customize-themes-full-container' ).removeClass( 'animate' ); + } + }, + + /** + * Install a theme via wp.updates. + * + * @since 4.7.0 + */ + installTheme: function( event ) { + var panel = this, preview = false, slug = $( event.target ).data( 'slug' ); + + if ( -1 !== $.inArray( this.installingThemes, slug ) ) { + return; // Theme is already being installed. + } + + wp.updates.maybeRequestFilesystemCredentials( event ); + + $( document ).one( 'wp-theme-install-success', function( event, response ) { + var theme = false, customizeId, themeControl; + if ( preview ) { + + panel.loadThemePreview( slug ).fail( function() { + $( '.wp-full-overlay' ).removeClass( 'customize-loading' ); + } ); + + } else { + api.control.each( function( control ) { + if ( 'theme' === control.params.type && control.params.theme.id === response.slug ) { + theme = control.params.theme; // Used below to add theme control. + control.rerenderAsInstalled( true ); + } + }); + + // Don't add the same theme more than once. + if ( ! theme || 'undefined' !== typeof api.control( 'installed_theme_' + theme.id ) ) { + return; + } + + // Add theme control to installed section. + theme.type = 'installed'; + customizeId = 'installed_theme_' + theme.id; + themeControl = new api.controlConstructor.theme( customizeId, { + params: { + type: 'theme', + content: $( '
  • ' ).attr( 'id', 'customize-control-theme-installed_' + theme.id ).prop( 'outerHTML' ), + section: 'installed_themes', + active: true, + theme: theme, + priority: 0 // Add all newly-installed themes to the top. + }, + previewer: api.previewer + } ); + + api.control.add( customizeId, themeControl ); + api.control( customizeId ).container.trigger( 'render-screenshot' ); + + // Close the details modal if it's open to the installed theme. + api.section.each( function( section ) { + if ( 'themes' === section.params.type ) { + if ( theme.id === section.currentTheme ) { // Don't close the modal if the user has navigated elsewhere. + section.closeDetails(); + } + } + }); + } + } ); + + this.installingThemes.push( $( event.target ).data( 'slug' ) ); // Note: we don't remove elements from installingThemes, since they shouldn't be installed again. + wp.updates.installTheme( { + slug: slug + } ); + + // Also preview the theme as the event is triggered on Install & Preview. + if ( $( event.target ).hasClass( 'preview' ) ) { + preview = true; + $( '.wp-full-overlay' ).addClass( 'customize-loading' ); + } + }, + + /** + * Load theme preview. + * + * @since 4.7.0 + * + * @param {string} themeId Theme ID. + * @returns {jQuery.promise} Promise. + */ + loadThemePreview: function( themeId ) { + var deferred = $.Deferred(), onceProcessingComplete, overlay, urlParser; + + urlParser = document.createElement( 'a' ); + urlParser.href = location.href; + urlParser.search = $.param( _.extend( + api.utils.parseQueryString( urlParser.search.substr( 1 ) ), + { + theme: themeId, + changeset_uuid: api.settings.changeset.uuid + } + ) ); + + overlay = $( '.wp-full-overlay' ); + overlay.addClass( 'customize-loading' ); + + onceProcessingComplete = function() { + var request; + if ( api.state( 'processing' ).get() > 0 ) { + return; + } + + api.state( 'processing' ).unbind( onceProcessingComplete ); + + request = api.requestChangesetUpdate(); + request.done( function() { + $( window ).off( 'beforeunload.customize-confirm' ); + window.location.href = urlParser.href; + } ); + request.fail( function() { + overlay.removeClass( 'customize-loading' ); + } ); + }; + + if ( 0 === api.state( 'processing' ).get() ) { + onceProcessingComplete(); + } else { + api.state( 'processing' ).bind( onceProcessingComplete ); + } + + return deferred.promise(); + }, + + /** + * Update a theme via wp.updates. + * + * @since 4.7.0 + */ + updateTheme: function( event ) { + wp.updates.maybeRequestFilesystemCredentials( event ); + + $( document ).one( 'wp-theme-update-success', function( event, response ) { + // Rerender the control to reflect the update. + api.control.each( function( control ) { + if ( 'theme' === control.params.type && control.params.theme.id === response.slug ) { + control.params.theme.hasUpdate = false; + control.rerenderAsInstalled( true ); + } + }); + } ); + + wp.updates.updateTheme( { + slug: $( event.target ).closest( '.notice' ).data( 'slug' ) + } ); + }, + + /** + * Delete a theme via wp.updates. + * + * @since 4.7.0 + */ + deleteTheme: function( event ) { + var theme, section; + theme = $( event.target ).data( 'slug' ); + section = api.section( 'installed_themes' ); + + event.preventDefault(); + + // Confirmation dialog for deleting a theme. + if ( ! window.confirm( api.settings.l10n.confirmDeleteTheme ) ) { + return; + } + + wp.updates.maybeRequestFilesystemCredentials( event ); + + $( document ).one( 'wp-theme-delete-success', function() { + var control = api.control( 'installed_theme_' + theme ); + + // Remove theme control. + control.container.remove(); + api.control.remove( control.id ); + + // Update installed count. + section.loaded = section.loaded - 1; + section.updateCount(); + + // Rerender any other theme controls as uninstalled. + api.control.each( function( control ) { + if ( 'theme' === control.params.type && control.params.theme.id === theme ) { + control.rerenderAsInstalled( false ); + } + }); + } ); + + wp.updates.deleteTheme( { + slug: theme + } ); + + // Close modal and focus the section. + section.closeDetails(); + section.focus(); + } + + }); + + /** * A Customizer Control. * @@ -2048,7 +2612,7 @@ * @param {Boolean} active * @param {Object} args * @param {Number} args.duration - * @param {Callback} args.completeCallback + * @param {Function} args.completeCallback */ onChangeActive: function ( active, args ) { if ( args.unchanged ) { @@ -3071,31 +3635,7 @@ api.ThemeControl = api.Control.extend({ touchDrag: false, - isRendered: false, - - /** - * Defer rendering the theme control until the section is displayed. - * - * @since 4.2.0 - */ - renderContent: function () { - var control = this, - renderContentArgs = arguments; - - api.section( control.section(), function( section ) { - if ( section.expanded() ) { - api.Control.prototype.renderContent.apply( control, renderContentArgs ); - control.isRendered = true; - } else { - section.expanded.bind( function( expanded ) { - if ( expanded && ! control.isRendered ) { - api.Control.prototype.renderContent.apply( control, renderContentArgs ); - control.isRendered = true; - } - } ); - } - } ); - }, + screenshotRendered: false, /** * @since 4.2.0 @@ -3119,20 +3659,11 @@ } // Prevent the modal from showing when the user clicks the action button. - if ( $( event.target ).is( '.theme-actions .button' ) ) { - return; - } - - api.section( control.section() ).loadThemePreview( control.params.theme.id ); - }); - - control.container.on( 'click keydown', '.theme-actions .theme-details', function( event ) { - if ( api.utils.isKeydownButNotEnterEvent( event ) ) { + if ( $( event.target ).is( '.theme-actions .button, .update-theme' ) ) { return; } event.preventDefault(); // Keep this AFTER the key filter above - api.section( control.section() ).showDetails( control.params.theme ); }); @@ -3143,11 +3674,12 @@ if ( source ) { $screenshot.attr( 'src', source ); } + control.screenshotRendered = true; }); }, /** - * Show or hide the theme based on the presence of the term in the title, description, and author. + * Show or hide the theme based on the presence of the term in the title, description, tags, and author. * * @since 4.2.0 */ @@ -3163,6 +3695,23 @@ } else { control.deactivate(); } + }, + + /** + * Rerender the theme from its JS template with the installed type. + * + * @since 4.7.0 + */ + rerenderAsInstalled: function( installed ) { + var control = this, section; + if ( installed ) { + control.params.theme.type = 'installed'; + } else { + section = api.section( control.params.section ); + control.params.theme.type = section.params.action; + } + control.renderContent(); // replaces existing content + control.container.trigger( 'render-screenshot' ); } }); @@ -3844,7 +4393,9 @@ background: api.BackgroundControl, theme: api.ThemeControl }; - api.panelConstructor = {}; + api.panelConstructor = { + themes: api.ThemesPanel + }; api.sectionConstructor = { themes: api.ThemesSection }; @@ -3962,6 +4513,10 @@ // Sort the sections within each panel api.panel.each( function ( panel ) { + if ( 'themes' === panel.id ) { + return; // Don't reflow theme sections, as doing so moves them after the themes container. + } + var sections = panel.sections(), sectionHeadContainers = _.pluck( sections, 'headContainer' ); rootNodes.push( panel ); @@ -4567,6 +5122,16 @@ // Collapse the most granular expanded object. collapsedObject = expandedControls[0] || expandedSections[0] || expandedPanels[0]; if ( collapsedObject ) { + if ( 'themes' === collapsedObject.params.type ) { + // Themes panel or section. + if ( $( 'body' ).hasClass( 'modal-open' ) ) { + collapsedObject.closeDetails(); + } else { + // If we're collapsing a section, collapse the panel also. + wp.customize.panel( 'themes' ).collapse(); + } + return; + } collapsedObject.collapse(); event.preventDefault(); } diff --git a/src/wp-admin/js/updates.js b/src/wp-admin/js/updates.js index 200241b853..f321eae91c 100644 --- a/src/wp-admin/js/updates.js +++ b/src/wp-admin/js/updates.js @@ -179,7 +179,11 @@ if ( $notice.length ) { $notice.replaceWith( $adminNotice ); } else { - $( '.wrap' ).find( '> h1' ).after( $adminNotice ); + if ( 'customize' === pagenow ) { + $( '.customize-themes-notifications' ).append( $adminNotice ); + } else { + $( '.wrap' ).find( '> h1' ).after( $adminNotice ); + } } $document.trigger( 'wp-updates-notice-added' ); @@ -907,6 +911,17 @@ if ( 'themes-network' === pagenow ) { $notice = $( '[data-slug="' + args.slug + '"]' ).find( '.update-message' ).removeClass( 'notice-error' ).addClass( 'updating-message notice-warning' ).find( 'p' ); + } else if ( 'customize' === pagenow ) { + + // Update the theme details UI. + $notice = $( '#update-theme' ).closest( '.notice' ).removeClass( 'notice-large' ); + + $notice.find( 'h3' ).remove(); + + // Add the top-level UI, and update both. + $notice = $notice.add( $( '#customize-control-theme-installed_' + args.slug ).find( '.update-message' ) ); + $notice = $notice.addClass( 'updating-message' ).find( 'p' ); + } else { $notice = $( '#update-theme' ).closest( '.notice' ).removeClass( 'notice-large' ); @@ -949,6 +964,10 @@ }, $notice, newText; + if ( 'customize' === pagenow ) { + $theme = wp.customize.control( 'installed_theme_' + response.slug ).container; + } + if ( 'themes-network' === pagenow ) { $notice = $theme.find( '.update-message' ); @@ -1003,6 +1022,10 @@ return; } + if ( 'customize' === pagenow ) { + $theme = wp.customize.control( 'installed_theme_' + response.slug ).container; + } + if ( 'themes-network' === pagenow ) { $notice = $theme.find( '.update-message ' ); } else { @@ -1139,12 +1162,23 @@ return; } - if ( $document.find( 'body' ).hasClass( 'full-overlay-active' ) ) { - $button = $( '.theme-install[data-slug="' + response.slug + '"]' ); - $card = $( '.install-theme-info' ).prepend( $message ); + if ( 'customize' === pagenow ) { + if ( $document.find( 'body' ).hasClass( 'modal-open' ) ) { + $button = $( '.theme-install[data-slug="' + response.slug + '"]' ); + $card = $( '.theme-overlay .theme-info' ).prepend( $message ); + } else { + $button = $( '.theme-install[data-slug="' + response.slug + '"]' ); + $card = $button.closest( '.theme' ).addClass( 'theme-install-failed' ).append( $message ); + } + $( '.wp-full-overlay' ).removeClass( 'customize-loading' ); } else { - $card = $( '[data-slug="' + response.slug + '"]' ).removeClass( 'focus' ).addClass( 'theme-install-failed' ).append( $message ); - $button = $card.find( '.theme-install' ); + if ( $document.find( 'body' ).hasClass( 'full-overlay-active' ) ) { + $button = $( '.theme-install[data-slug="' + response.slug + '"]' ); + $card = $( '.install-theme-info' ).prepend( $message ); + } else { + $card = $( '[data-slug="' + response.slug + '"]' ).removeClass( 'focus' ).addClass( 'theme-install-failed' ).append( $message ); + $button = $card.find( '.theme-install' ); + } } $button diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index 54efcd534b..28ec792d0f 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -294,6 +294,7 @@ final class WP_Customize_Manager { require_once( ABSPATH . WPINC . '/customize/class-wp-customize-nav-menus-panel.php' ); + require_once( ABSPATH . WPINC . '/customize/class-wp-customize-themes-panel.php' ); require_once( ABSPATH . WPINC . '/customize/class-wp-customize-themes-section.php' ); require_once( ABSPATH . WPINC . '/customize/class-wp-customize-sidebar-section.php' ); require_once( ABSPATH . WPINC . '/customize/class-wp-customize-nav-menu-section.php' ); @@ -348,6 +349,7 @@ final class WP_Customize_Manager { add_action( 'wp_ajax_customize_save', array( $this, 'save' ) ); add_action( 'wp_ajax_customize_refresh_nonces', array( $this, 'refresh_nonces' ) ); + add_action( 'wp_ajax_customize-load-themes', array( $this, 'load_themes_ajax' ) ); add_action( 'customize_register', array( $this, 'register_controls' ) ); add_action( 'customize_register', array( $this, 'register_dynamic_settings' ), 11 ); // allow code to create settings first @@ -361,6 +363,12 @@ final class WP_Customize_Manager { // Export the settings to JS via the _wpCustomizeSettings variable. add_action( 'customize_controls_print_footer_scripts', array( $this, 'customize_pane_settings' ), 1000 ); + + // Add theme update notices. + if ( current_user_can( 'install_themes' ) || current_user_can( 'update_themes' ) ) { + require_once( ABSPATH . '/wp-admin/includes/update.php' ); + add_action( 'customize_controls_print_footer_scripts', 'wp_print_admin_notice_templates' ); + } } /** @@ -2584,6 +2592,9 @@ final class WP_Customize_Manager { foreach ( $this->controls as $control ) { $control->enqueue(); } + if ( ! is_multisite() && ( current_user_can( 'install_themes' ) || current_user_can( 'update_themes' ) || current_user_can( 'delete_themes' ) ) ) { + wp_enqueue_script( 'updates' ); + } } /** @@ -2798,6 +2809,7 @@ final class WP_Customize_Manager { $nonces = array( 'save' => wp_create_nonce( 'save-customize_' . $this->get_stylesheet() ), 'preview' => wp_create_nonce( 'preview-customize_' . $this->get_stylesheet() ), + 'switch-themes' => wp_create_nonce( 'switch-themes' ), ); /** @@ -2871,6 +2883,14 @@ final class WP_Customize_Manager { 'autofocus' => $this->get_autofocus(), 'documentTitleTmpl' => $this->get_document_title_template(), 'previewableDevices' => $this->get_previewable_devices(), + 'l10n' => array( + 'confirmDeleteTheme' => __( 'Are you sure you want to delete this theme?' ), + /* translators: %d is the number of theme search results, which cannot consider singular vs. plural forms */ + 'themeSearchResults' => __( '%d themes found' ), + /* translators: %d is the number of themes being displayed, which cannot consider singular vs. plural forms */ + 'announceThemeCount' => __( 'Displaying %d themes' ), + 'announceThemeDetails' => __( 'Showing details for theme: %s' ), + ), ); // Prepare Customize Section objects to pass to JavaScript. @@ -2974,8 +2994,10 @@ final class WP_Customize_Manager { /* Panel, Section, and Control Types */ $this->register_panel_type( 'WP_Customize_Panel' ); + $this->register_panel_type( 'WP_Customize_Themes_Panel' ); $this->register_section_type( 'WP_Customize_Section' ); $this->register_section_type( 'WP_Customize_Sidebar_Section' ); + $this->register_section_type( 'WP_Customize_Themes_Section' ); $this->register_control_type( 'WP_Customize_Color_Control' ); $this->register_control_type( 'WP_Customize_Media_Control' ); $this->register_control_type( 'WP_Customize_Upload_Control' ); @@ -2985,12 +3007,71 @@ final class WP_Customize_Manager { $this->register_control_type( 'WP_Customize_Site_Icon_Control' ); $this->register_control_type( 'WP_Customize_Theme_Control' ); - /* Themes */ + /* Themes (controls are loaded via ajax) */ - $this->add_section( new WP_Customize_Themes_Section( $this, 'themes', array( - 'title' => $this->theme()->display( 'Name' ), - 'capability' => 'switch_themes', - 'priority' => 0, + $this->add_panel( new WP_Customize_Themes_Panel( $this, 'themes', array( + 'title' => $this->theme()->display( 'Name' ), + 'description' => __( 'Once themes are installed, you can live-preview them on your site, customize them, and publish your new design. Browse available themes via the filters in this menu.' ), + 'capability' => 'switch_themes', + 'priority' => 0, + ) ) ); + + $this->add_section( new WP_Customize_Themes_Section( $this, 'installed_themes', array( + 'title' => __( 'Installed' ), + 'text_before' => __( 'Your local site' ), + 'action' => 'installed', + 'capability' => 'switch_themes', + 'panel' => 'themes', + 'priority' => 0, + ) ) ); + + $this->add_section( new WP_Customize_Themes_Section( $this, 'search_themes', array( + 'title' => __( 'Search themes…' ), + 'text_before' => __( 'Browse all WordPress.org themes' ), + 'action' => 'search', + 'capability' => 'install_themes', + 'panel' => 'themes', + 'priority' => 5, + ) ) ); + + $this->add_section( new WP_Customize_Themes_Section( $this, 'featured_themes', array( + 'title' => __( 'Featured' ), + 'action' => 'featured', + 'capability' => 'install_themes', + 'panel' => 'themes', + 'priority' => 10, + ) ) ); + + $this->add_section( new WP_Customize_Themes_Section( $this, 'popular_themes', array( + 'title' => __( 'Popular' ), + 'action' => 'popular', + 'capability' => 'install_themes', + 'panel' => 'themes', + 'priority' => 15, + ) ) ); + + $this->add_section( new WP_Customize_Themes_Section( $this, 'latest_themes', array( + 'title' => __( 'Latest' ), + 'action' => 'latest', + 'capability' => 'install_themes', + 'panel' => 'themes', + 'priority' => 20, + ) ) ); + + $this->add_section( new WP_Customize_Themes_Section( $this, 'feature_filter_themes', array( + 'title' => __( 'Feature Filter' ), + 'action' => 'feature_filter', + 'capability' => 'install_themes', + 'panel' => 'themes', + 'priority' => 25, + ) ) ); + + $this->add_section( new WP_Customize_Themes_Section( $this, 'favorites_themes', array( + 'title' => __( 'Favorites' ), + 'action' => 'favorites', + 'capability' => 'install_themes', + 'panel' => 'themes', + 'priority' => 30, ) ) ); // Themes Setting (unused - the theme is considerably more fundamental to the Customizer experience). @@ -2998,37 +3079,6 @@ final class WP_Customize_Manager { 'capability' => 'switch_themes', ) ) ); - require_once( ABSPATH . 'wp-admin/includes/theme.php' ); - - // Theme Controls. - - // Add a control for the active/original theme. - if ( ! $this->is_theme_active() ) { - $themes = wp_prepare_themes_for_js( array( wp_get_theme( $this->original_stylesheet ) ) ); - $active_theme = current( $themes ); - $active_theme['isActiveTheme'] = true; - $this->add_control( new WP_Customize_Theme_Control( $this, $active_theme['id'], array( - 'theme' => $active_theme, - 'section' => 'themes', - 'settings' => 'active_theme', - ) ) ); - } - - $themes = wp_prepare_themes_for_js(); - foreach ( $themes as $theme ) { - if ( $theme['active'] || $theme['id'] === $this->original_stylesheet ) { - continue; - } - - $theme_id = 'theme_' . $theme['id']; - $theme['isActiveTheme'] = false; - $this->add_control( new WP_Customize_Theme_Control( $this, $theme_id, array( - 'theme' => $theme, - 'section' => 'themes', - 'settings' => 'active_theme', - ) ) ); - } - /* Site Identity */ $this->add_section( 'title_tagline', array( @@ -3355,6 +3405,150 @@ final class WP_Customize_Manager { $this->add_dynamic_settings( $setting_ids ); } + /** + * Load themes into the theme browsing/installation UI. + * + * @since 4.7.0 + * @access public + */ + public function load_themes_ajax() { + check_ajax_referer( 'switch-themes', 'switch-themes-nonce' ); + + if ( ! current_user_can( 'switch_themes' ) ) { + wp_die( -1 ); + } + + if ( empty( $_POST['theme_action'] ) ) { + wp_send_json_error( 'missing_theme_action' ); + } + + if ( 'search' === $_POST['theme_action'] && ! array_key_exists( 'search', $_POST ) ) { + wp_send_json_error( 'empty_search' ); + } elseif ( 'favorites' === $_POST['theme_action'] && ! array_key_exists( 'user', $_POST ) ) { + wp_send_json_error( 'empty_user' ); + } elseif ( 'feature_filter' === $_POST['theme_action'] && ! array_key_exists( 'tags', $_POST ) ) { + wp_send_json_error( 'no_features' ); + } + + require_once( ABSPATH . 'wp-admin/includes/theme.php' ); + if ( 'installed' === $_POST['theme_action'] ) { + $themes = array( 'themes' => wp_prepare_themes_for_js() ); + foreach ( $themes['themes'] as &$theme ) { + $theme['type'] = 'installed'; + // Set active based on customized theme. + if ( $_POST['customized_theme'] === $theme['id'] ) { + $theme['active'] = true; + } else { + $theme['active'] = false; + } + } + } else { + if ( ! current_user_can( 'install_themes' ) ) { + wp_die( -1 ); + } + + // Arguments for all queries. + $args = array( + 'per_page' => 100, + 'page' => absint( $_POST['page'] ), + 'fields' => array( + 'slug' => true, + 'screenshot' => true, + 'description' => true, + 'requires' => true, + 'rating' => true, + 'downloaded' => true, + 'downloadLink' => true, + 'last_updated' => true, + 'homepage' => true, + 'num_ratings' => true, + 'tags' => true, + ), + ); + + // Specialized handling for each query. + switch ( $_POST['theme_action'] ) { + case 'search': + $args['search'] = wp_unslash( $_POST['search'] ); + break; + case 'favorites': + $args['user'] = wp_unslash( $_POST['user'] ); + case 'featured': + case 'popular': + $args['browse'] = wp_unslash( $_POST['theme_action'] ); + break; + case 'latest': + $args['browse'] = 'new'; + break; + case 'feature_filter': + $args['tag'] = wp_unslash( $_POST['tags'] ); + break; + } + + // Load themes from the .org API. + $themes = themes_api( 'query_themes', $args ); + if ( is_wp_error( $themes ) ) { + wp_send_json_error(); + } + + // This list matches the allowed tags in wp-admin/includes/theme-install.php. + $themes_allowedtags = array('a' => array('href' => array(), 'title' => array(), 'target' => array()), + 'abbr' => array('title' => array()), 'acronym' => array('title' => array()), + 'code' => array(), 'pre' => array(), 'em' => array(), 'strong' => array(), + 'div' => array(), 'p' => array(), 'ul' => array(), 'ol' => array(), 'li' => array(), + 'h1' => array(), 'h2' => array(), 'h3' => array(), 'h4' => array(), 'h5' => array(), 'h6' => array(), + 'img' => array('src' => array(), 'class' => array(), 'alt' => array()) + ); + + // Prepare a list of installed themes to check against before the loop. + $installed_themes = array(); + $wp_themes = wp_get_themes(); + foreach ( $wp_themes as $theme ) { + $installed_themes[] = $theme->get_stylesheet(); + } + $update_php = network_admin_url( 'update.php?action=install-theme' ); + foreach ( $themes->themes as &$theme ) { + $theme->install_url = add_query_arg( array( + 'theme' => $theme->slug, + '_wpnonce' => wp_create_nonce( 'install-theme_' . $theme->slug ), + ), $update_php ); + + $theme->name = wp_kses( $theme->name, $themes_allowedtags ); + $theme->author = wp_kses( $theme->author, $themes_allowedtags ); + $theme->version = wp_kses( $theme->version, $themes_allowedtags ); + $theme->description = wp_kses( $theme->description, $themes_allowedtags ); + $theme->tags = implode( ', ', $theme->tags ); + $theme->stars = wp_star_rating( array( 'rating' => $theme->rating, 'type' => 'percent', 'number' => $theme->num_ratings, 'echo' => false ) ); + $theme->num_ratings = number_format_i18n( $theme->num_ratings ); + $theme->preview_url = set_url_scheme( $theme->preview_url ); + + // Handle themes that are already installed as installed themes. + if ( in_array( $theme->slug, $installed_themes, true ) ) { + $theme->type = 'installed'; + } else { + $theme->type = $_POST['theme_action']; + } + + // Set active based on customized theme. + if ( $_POST['customized_theme'] === $theme->slug ) { + $theme->active = true; + } else { + $theme->active = false; + } + + // Map available theme properties to installed theme properties. + $theme->id = $theme->slug; + $theme->screenshot = array( $theme->screenshot_url ); + $theme->authorAndUri = $theme->author; + unset( $theme->slug ); + unset( $theme->screenshot_url ); + unset( $theme->author ); + } // End foreach(). + } // End if(). + wp_send_json_success( $themes ); + } + + /** * Callback for validating the header_textcolor value. * diff --git a/src/wp-includes/customize/class-wp-customize-theme-control.php b/src/wp-includes/customize/class-wp-customize-theme-control.php index fdd8f131da..ac2e821010 100644 --- a/src/wp-includes/customize/class-wp-customize-theme-control.php +++ b/src/wp-includes/customize/class-wp-customize-theme-control.php @@ -62,18 +62,22 @@ class WP_Customize_Theme_Control extends WP_Customize_Control { * @access public */ public function content_template() { - $current_url = set_url_scheme( 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] ); - $active_url = esc_url( remove_query_arg( 'customize_theme', $current_url ) ); - $preview_url = esc_url( add_query_arg( 'customize_theme', '__THEME__', $current_url ) ); // Token because esc_url() strips curly braces. - $preview_url = str_replace( '__THEME__', '{{ data.theme.id }}', $preview_url ); + /* translators: %s: theme name */ + $details_label = sprintf( __( 'Details for theme: %s' ), '{{ data.theme.name }}' ); + /* translators: %s: theme name */ + $customize_label = sprintf( __( 'Customize theme: %s' ), '{{ data.theme.name }}' ); + /* translators: %s: theme name */ + $preview_label = sprintf( __( 'Live preview theme: %s' ), '{{ data.theme.name }}' ); + /* translators: %s: theme name */ + $install_label = sprintf( __( 'Install and preview theme: %s' ), '{{ data.theme.name }}' ); ?> - <# if ( data.theme.isActiveTheme ) { #> -
    + <# if ( data.theme.active ) { #> +
    <# } else { #> -
    +
    <# } #> - <# if ( data.theme.screenshot[0] ) { #> + <# if ( data.theme.screenshot && data.theme.screenshot[0] ) { #>
    @@ -81,25 +85,34 @@ class WP_Customize_Theme_Control extends WP_Customize_Control {
    <# } #> - <# if ( data.theme.isActiveTheme ) { #> - - <# } else { #> - + + + + <# if ( 'installed' === data.theme.type && data.theme.hasUpdate ) { #> +

    ' . __( 'Update now' ) . '' ); ?>

    <# } #> -
    - - <# if ( data.theme.isActiveTheme ) { #> -

    + <# if ( data.theme.active ) { #> +

    Active: %s' ), '{{{ data.theme.name }}}' ); + printf( __( 'Current: %s' ), '{{ data.theme.name }}' ); ?>

    - <# } else { #> -

    {{{ data.theme.name }}}

    - + +
    +

    + <# } else if ( 'installed' === data.theme.type ) { #> +

    {{ data.theme.name }}

    +
    +
    +

    + <# } else { #> +

    {{ data.theme.name }}

    +
    +
    <# } #>
    diff --git a/src/wp-includes/customize/class-wp-customize-themes-panel.php b/src/wp-includes/customize/class-wp-customize-themes-panel.php new file mode 100644 index 0000000000..cc2c5069a6 --- /dev/null +++ b/src/wp-includes/customize/class-wp-customize-themes-panel.php @@ -0,0 +1,113 @@ + +
  • +

    + manager->is_theme_active() ) { + echo '' . __( 'Active theme' ) . ' {{ data.title }}'; + } else { + echo '' . __( 'Previewing theme' ) . ' {{ data.title }}'; + } + ?> + + + + +

    +
      +
    • + +
    • + 0' ); + ?> + + +
    • +
    • + +
      + ' . __( 'Themes' ) . '' ); // Separate strings for consistency with other panels. + ?> + + <# if ( data.description ) { #> + + <# } #> + +
      + + <# if ( data.description ) { #> +
      + {{{ data.description }}} +
      + <# } #> + +
    • +
    • +
    • +
        +
      • +
      +
    • + action; + $exported['text_before'] = $this->text_before; + + return $exported; + } + + /** + * Render a themes section as a JS template. + * + * The template is only rendered by PHP once, so all actions are prepared at once on the server side. + * + * @since 4.7.0 * @access protected */ - protected function render() { - $classes = 'accordion-section control-section control-section-' . $this->type; + protected function render_template() { ?> -
    • -

      - manager->is_theme_active() ) { - echo '' . __( 'Active theme' ) . ' ' . $this->title; +
    • + <# if ( '' !== data.text_before ) { #> +

      {{ data.text_before }}

      + <# } #> + <# if ( 'search' === data.action ) { #> +
      + + + +
      + <# } else { #> + <# if ( 'favorites' === data.action || 'feature_filter' === data.action ) { + var attr = ' aria-expanded="false"'; } else { - echo '' . __( 'Previewing theme' ) . ' ' . $this->title; - } - ?> - - controls ) > 0 ) : ?> - - -
    • -
      -

      - - - controls ) + 1 /* Active theme */; ?> -

      -

      + var attr = ''; + } #> + + <# } #> + + <# if ( 'installed' === data.action ) { #> +

      + +

      + <# } #> + + <# if ( 'favorites' === data.action ) { #> +
      +

      +

      + + + +

      +
      + <# } else if ( 'feature_filter' === data.action ) { #> +
      manager->is_theme_active() ) { - echo '' . __( 'Active theme' ) . ' ' . $this->title; - } else { - echo '' . __( 'Previewing theme' ) . ' ' . $this->title; + $feature_list = get_theme_feature_list(); + foreach ( $feature_list as $feature_name => $features ) { + echo '
      '; + $feature_name = esc_html( $feature_name ); + echo ''; + echo '
      '; + foreach ( $features as $feature => $feature_name ) { + $feature = esc_attr( $feature ); + echo ' '; + echo '
      '; + } + echo '
      '; + echo '
      '; } ?> - -

      - +
      + <# } #> +
      - -
      - controls ) > 4 ) : ?> -

      -
      -
        + +
        +

        +

    • diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index 42f3e10c36..634baf6198 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -1486,7 +1486,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $data = json_decode( $json, true ); $this->assertNotEmpty( $data ); - $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices', 'changeset', 'timeouts' ), array_keys( $data ) ); + $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices', 'changeset', 'timeouts', 'l10n' ), array_keys( $data ) ); $this->assertEquals( $autofocus, $data['autofocus'] ); $this->assertArrayHasKey( 'save', $data['nonce'] ); $this->assertArrayHasKey( 'preview', $data['nonce'] );