Customize: Add global notifications area.

* Displays an error notification in the global area when a save attempt is rejected due to invalid settings. An error notification is also displayed when saving fails due to a network error or server error.
* Introduces `wp.customize.Notifications` subclass of `wp.customize.Values` to contain instances of `wp.customize.Notification` and manage their rendering into a container.
* Exposes the global notification area as `wp.customize.notifications` collection instance.
* Updates the `notifications` object on `Control` to use `Notifications` rather than `Values` and to re-use the rendering logic from the former. The old `Control#renderNotifications` method is deprecated.
* Allows notifications to be dismissed by instantiating them with a `dismissible` property.
* Allows `wp.customize.Notification` to be extended with custom templates and `render` functions.
* Triggers a `removed` event on `wp.customize.Values` instances _after_ a value has been removed from the collection.

Props delawski, westonruter, karmatosed, celloexpressions, Fab1en, melchoyce, Kelderic, afercia, adamsilverstein.
See #34893, #39896.
Fixes #35210, #31582, #37727, #37269.

Built from https://develop.svn.wordpress.org/trunk@41374


git-svn-id: http://core.svn.wordpress.org/trunk@41207 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
Weston Ruter 2017-09-12 07:03:46 +00:00
parent 560d705b00
commit d8f445bf0f
14 changed files with 491 additions and 62 deletions

View File

@ -766,7 +766,6 @@ p.customize-section-description {
#customize-controls .customize-control-notifications-container { /* Scoped to #customize-controls for specificity over notification styles in common.css. */
margin: 4px 0 8px 0;
padding: 0;
display: none;
cursor: default;
}
@ -798,6 +797,33 @@ p.customize-section-description {
outline: 2px solid #dc3232;
}
#customize-controls #customize-notifications-area {
position: absolute;
top: 46px;
width: 100%;
max-height: 210px;
overflow-x: hidden;
overflow-y: auto;
border-bottom: 1px solid #ddd;
display: block;
padding: 0;
margin: 0;
}
#customize-controls #customize-notifications-area > ul,
#customize-controls #customize-notifications-area .notice {
margin: 0;
}
#customize-controls #customize-notifications-area .notice {
padding: 9px 14px;
}
#customize-controls #customize-notifications-area .notice.is-dismissible {
padding-left: 38px;
}
#customize-controls #customize-notifications-area .notice + .notice {
margin-top: 1px;
}
/* Style for custom settings */
/**

File diff suppressed because one or more lines are too long

View File

@ -766,7 +766,6 @@ p.customize-section-description {
#customize-controls .customize-control-notifications-container { /* Scoped to #customize-controls for specificity over notification styles in common.css. */
margin: 4px 0 8px 0;
padding: 0;
display: none;
cursor: default;
}
@ -798,6 +797,33 @@ p.customize-section-description {
outline: 2px solid #dc3232;
}
#customize-controls #customize-notifications-area {
position: absolute;
top: 46px;
width: 100%;
max-height: 210px;
overflow-x: hidden;
overflow-y: auto;
border-bottom: 1px solid #ddd;
display: block;
padding: 0;
margin: 0;
}
#customize-controls #customize-notifications-area > ul,
#customize-controls #customize-notifications-area .notice {
margin: 0;
}
#customize-controls #customize-notifications-area .notice {
padding: 9px 14px;
}
#customize-controls #customize-notifications-area .notice.is-dismissible {
padding-right: 38px;
}
#customize-controls #customize-notifications-area .notice + .notice {
margin-top: 1px;
}
/* Style for custom settings */
/**

File diff suppressed because one or more lines are too long

View File

@ -151,24 +151,27 @@ do_action( 'customize_controls_print_scripts' );
</div>
<div id="widgets-right" class="wp-clearfix"><!-- For Widget Customizer, many widgets try to look for instances under div#widgets-right, so we have to add that ID to a container div in the Customizer for compat -->
<div class="wp-full-overlay-sidebar-content" tabindex="-1">
<div id="customize-info" class="accordion-section customize-info">
<div class="accordion-section-title">
<span class="preview-notice"><?php
echo sprintf( __( 'You are customizing %s' ), '<strong class="panel-title site-title">' . get_bloginfo( 'name', 'display' ) . '</strong>' );
?></span>
<button type="button" class="customize-help-toggle dashicons dashicons-editor-help" aria-expanded="false"><span class="screen-reader-text"><?php _e( 'Help' ); ?></span></button>
<div id="customize-notifications-area" class="customize-control-notifications-container">
<ul></ul>
</div>
<div class="wp-full-overlay-sidebar-content" tabindex="-1">
<div id="customize-info" class="accordion-section customize-info">
<div class="accordion-section-title">
<span class="preview-notice"><?php
echo sprintf( __( 'You are customizing %s' ), '<strong class="panel-title site-title">' . get_bloginfo( 'name', 'display' ) . '</strong>' );
?></span>
<button type="button" class="customize-help-toggle dashicons dashicons-editor-help" aria-expanded="false"><span class="screen-reader-text"><?php _e( 'Help' ); ?></span></button>
</div>
<div class="customize-panel-description"><?php
_e( 'The Customizer allows you to preview changes to your site before publishing them. You can navigate to different pages on your site within the preview. Edit shortcuts are shown for some editable elements.' );
?></div>
</div>
<div class="customize-panel-description"><?php
_e( 'The Customizer allows you to preview changes to your site before publishing them. You can navigate to different pages on your site within the preview. Edit shortcuts are shown for some editable elements.' );
?></div>
</div>
<div id="customize-theme-controls">
<ul class="customize-pane-parent"><?php // Panels and sections are managed here via JavaScript ?></ul>
<div id="customize-theme-controls">
<ul class="customize-pane-parent"><?php // Panels and sections are managed here via JavaScript ?></ul>
</div>
</div>
</div>
</div>
<div id="customize-footer-actions" class="wp-full-overlay-footer">
<button type="button" class="collapse-sidebar button" aria-expanded="true" aria-label="<?php echo esc_attr( _x( 'Hide Controls', 'label for hide controls button without length constraints' ) ); ?>">

View File

@ -1,7 +1,213 @@
/* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer */
/* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer, console */
(function( exports, $ ){
var Container, focus, normalizedTransitionendEventName, api = wp.customize;
/**
* A collection of observable notifications.
*
* @since 4.9.0
* @class
* @augments wp.customize.Values
*/
api.Notifications = api.Values.extend({
/**
* Whether the alternative style should be used.
*
* @since 4.9.0
* @type {boolean}
*/
alt: false,
/**
* The default constructor for items of the collection.
*
* @since 4.9.0
* @type {object}
*/
defaultConstructor: api.Notification,
/**
* Initialize notifications area.
*
* @since 4.9.0
* @constructor
* @param {object} options - Options.
* @param {jQuery} [options.container] - Container element for notifications. This can be injected later.
* @param {boolean} [options.alt] - Whether alternative style should be used when rendering notifications.
* @returns {void}
* @this {wp.customize.Notifications}
*/
initialize: function( options ) {
var collection = this;
api.Values.prototype.initialize.call( collection, options );
// Keep track of the order in which the notifications were added for sorting purposes.
collection._addedIncrement = 0;
collection._addedOrder = {};
// Trigger change event when notification is added or removed.
collection.bind( 'add', function( notification ) {
collection.trigger( 'change', notification );
});
collection.bind( 'removed', function( notification ) {
collection.trigger( 'change', notification );
});
},
/**
* Get the number of notifications added.
*
* @since 4.9.0
* @return {number} Count of notifications.
*/
count: function() {
return _.size( this._value );
},
/**
* Add notification to the collection.
*
* @since 4.9.0
* @param {string} code - Notification code.
* @param {object} params - Notification params.
* @return {api.Notification} Added instance (or existing instance if it was already added).
*/
add: function( code, params ) {
var collection = this;
if ( ! collection.has( code ) ) {
collection._addedIncrement += 1;
collection._addedOrder[ code ] = collection._addedIncrement;
}
return api.Values.prototype.add.call( this, code, params );
},
/**
* Add notification to the collection.
*
* @since 4.9.0
* @param {string} code - Notification code to remove.
* @return {api.Notification} Added instance (or existing instance if it was already added).
*/
remove: function( code ) {
var collection = this;
delete collection._addedOrder[ code ];
return api.Values.prototype.remove.call( this, code );
},
/**
* Get list of notifications.
*
* Notifications may be sorted by type followed by added time.
*
* @since 4.9.0
* @param {object} args - Args.
* @param {boolean} [args.sort=false] - Whether to return the notifications sorted.
* @return {Array.<wp.customize.Notification>} Notifications.
* @this {wp.customize.Notifications}
*/
get: function( args ) {
var collection = this, notifications, errorTypePriorities, params;
notifications = _.values( collection._value );
params = _.extend(
{ sort: false },
args
);
if ( params.sort ) {
errorTypePriorities = { error: 4, warning: 3, success: 2, info: 1 };
notifications.sort( function( a, b ) {
var aPriority = 0, bPriority = 0;
if ( ! _.isUndefined( errorTypePriorities[ a.type ] ) ) {
aPriority = errorTypePriorities[ a.type ];
}
if ( ! _.isUndefined( errorTypePriorities[ b.type ] ) ) {
bPriority = errorTypePriorities[ b.type ];
}
if ( aPriority !== bPriority ) {
return bPriority - aPriority; // Show errors first.
}
return collection._addedOrder[ b.code ] - collection._addedOrder[ a.code ]; // Show newer notifications higher.
});
}
return notifications;
},
/**
* Render notifications area.
*
* @since 4.9.0
* @returns {void}
* @this {wp.customize.Notifications}
*/
render: function() {
var collection = this,
notifications,
renderedNotificationContainers,
prevRenderedCodes,
nextRenderedCodes,
addedCodes,
removedCodes,
listElement;
// Short-circuit if there are no container to render into.
if ( ! collection.container || ! collection.container.length ) {
return;
}
listElement = collection.container.children( 'ul' ).first();
if ( ! listElement.length ) {
listElement = $( '<ul></ul>' );
collection.container.append( listElement );
}
notifications = collection.get( { sort: true } );
renderedNotificationContainers = {};
listElement.find( '> [data-code]' ).each( function() {
renderedNotificationContainers[ $( this ).data( 'code' ) ] = $( this );
});
collection.container.toggle( 0 !== notifications.length );
nextRenderedCodes = _.pluck( notifications, 'code' );
prevRenderedCodes = _.keys( renderedNotificationContainers );
// Short-circuit if there are no notifications added.
if ( _.isEqual( nextRenderedCodes, prevRenderedCodes ) ) {
return;
}
addedCodes = _.difference( nextRenderedCodes, prevRenderedCodes );
removedCodes = _.difference( prevRenderedCodes, nextRenderedCodes );
// Remove notifications that have been removed.
_.each( renderedNotificationContainers, function( renderedContainer, code ) {
if ( -1 !== _.indexOf( removedCodes, code ) ) {
renderedContainer.remove(); // @todo Consider slideUp as enhancement.
}
});
// Add all notifications in the sorted order.
_.each( notifications, function( notification ) {
var notificationContainer = renderedNotificationContainers[ notification.code ];
if ( notificationContainer ) {
listElement.append( notificationContainer );
} else {
notificationContainer = $( notification.render() );
listElement.append( notificationContainer ); // @todo Consider slideDown() as enhancement.
if ( wp.a11y ) {
wp.a11y.speak( notification.message, 'assertive' );
}
}
});
collection.trigger( 'rendered' );
}
});
/**
* A Customizer Setting.
*
@ -1883,7 +2089,9 @@
control.priority = new api.Value();
control.active = new api.Value();
control.activeArgumentsQueue = [];
control.notifications = new api.Values({ defaultConstructor: api.Notification });
control.notifications = new api.Notifications({
alt: control.altNotice
});
control.elements = [];
@ -1973,21 +2181,17 @@
// After the control is embedded on the page, invoke the "ready" method.
control.deferred.embedded.done( function () {
/*
* Note that this debounced/deferred rendering is needed for two reasons:
* 1) The 'remove' event is triggered just _before_ the notification is actually removed.
* 2) Improve performance when adding/removing multiple notifications at a time.
*/
var debouncedRenderNotifications = _.debounce( function renderNotifications() {
control.renderNotifications();
var renderNotifications = function() {
control.notifications.render();
};
control.notifications.container = control.getNotificationsContainerElement();
control.notifications.bind( 'rendered', function() {
var notifications = control.notifications.get();
control.container.toggleClass( 'has-notifications', 0 !== notifications.length );
control.container.toggleClass( 'has-error', 0 !== _.where( notifications, { type: 'error' } ).length );
} );
control.notifications.bind( 'add', function( notification ) {
wp.a11y.speak( notification.message, 'assertive' );
debouncedRenderNotifications();
} );
control.notifications.bind( 'remove', debouncedRenderNotifications );
control.renderNotifications();
renderNotifications();
control.notifications.bind( 'change', _.debounce( renderNotifications ) );
control.ready();
});
},
@ -2091,11 +2295,17 @@
* Control subclasses may override this method to do their own handling
* of rendering notifications.
*
* @deprecated in favor of `control.notifications.render()`
* @since 4.6.0
* @this {wp.customize.Control}
*/
renderNotifications: function() {
var control = this, container, notifications, hasError = false;
if ( 'undefined' !== typeof console && console.warn ) {
console.warn( '[DEPRECATED] wp.customize.Control.prototype.renderNotifications() is deprecated in favor of instantating a wp.customize.Notifications and calling its render() method.' );
}
container = control.getNotificationsContainerElement();
if ( ! container || ! container.length ) {
return;
@ -3427,6 +3637,9 @@
api.section = new api.Values({ defaultConstructor: api.Section });
api.panel = new api.Values({ defaultConstructor: api.Panel });
// Create the collection for global Notifications.
api.notifications = new api.Notifications();
/**
* An object that fetches a preview in the background of the document, which
* allows for seamless replacement of an existing preview.
@ -4501,6 +4714,13 @@
api.unbind( 'change', captureSettingModifiedDuringSave );
} );
// Remove notifications that were added due to save failures.
api.notifications.each( function( notification ) {
if ( notification.saveFailure ) {
api.notifications.remove( notification.code );
}
});
request.fail( function ( response ) {
if ( '0' === response ) {
@ -4518,6 +4738,22 @@
previewer.save();
previewer.preview.iframe.show();
} );
} else if ( response.code ) {
api.notifications.add( response.code, new api.Notification( response.code, {
message: response.message,
type: 'error',
dismissible: true,
fromServer: true,
saveFailure: true
} ) );
} else {
api.notifications.add( 'unknown_error', new api.Notification( 'unknown_error', {
message: api.l10n.serverSaveError,
type: 'error',
dismissible: true,
fromServer: true,
saveFailure: true
} ) );
}
if ( response.setting_validities ) {
@ -4688,6 +4924,29 @@
values.bind( 'remove', debouncedReflowPaneContents );
} );
// Set up global notifications area.
api.bind( 'ready', function setUpGlobalNotificationsArea() {
var sidebar, containerHeight, containerInitialTop;
api.notifications.container = $( '#customize-notifications-area' );
api.notifications.bind( 'change', _.debounce( function() {
api.notifications.render();
} ) );
sidebar = $( '.wp-full-overlay-sidebar-content' );
api.notifications.bind( 'rendered', function updateSidebarTop() {
sidebar.css( 'top', '' );
if ( 0 !== api.notifications.count() ) {
containerHeight = api.notifications.container.outerHeight() + 1;
containerInitialTop = parseInt( sidebar.css( 'top' ), 10 );
sidebar.css( 'top', containerInitialTop + containerHeight + 'px' );
}
api.notifications.trigger( 'sidebarTopUpdated' );
});
api.notifications.render();
});
// Save and activated states
(function() {
var state = new api.Values(),
@ -4971,12 +5230,32 @@
}
var scrollTop = parentContainer.scrollTop(),
isScrollingUp = ( lastScrollTop ) ? scrollTop <= lastScrollTop : true;
scrollDirection;
if ( ! lastScrollTop ) {
scrollDirection = 1;
} else {
if ( scrollTop === lastScrollTop ) {
scrollDirection = 0;
} else if ( scrollTop > lastScrollTop ) {
scrollDirection = 1;
} else {
scrollDirection = -1;
}
}
lastScrollTop = scrollTop;
positionStickyHeader( activeHeader, scrollTop, isScrollingUp );
if ( 0 !== scrollDirection ) {
positionStickyHeader( activeHeader, scrollTop, scrollDirection );
}
}, 8 ) );
// Update header position on sidebar layout change.
api.notifications.bind( 'sidebarTopUpdated', function() {
if ( activeHeader && activeHeader.element.hasClass( 'is-sticky' ) ) {
activeHeader.element.css( 'top', parentContainer.css( 'top' ) );
}
});
// Release header element if it is sticky.
releaseStickyHeader = function( headerElement ) {
if ( ! headerElement.hasClass( 'is-sticky' ) ) {
@ -4990,13 +5269,15 @@
// Reset position of the sticky header.
resetStickyHeader = function( headerElement, headerParent ) {
headerElement
.removeClass( 'maybe-sticky is-in-view' )
.css( {
width: '',
top: ''
} );
headerParent.css( 'padding-top', '' );
if ( headerElement.hasClass( 'is-in-view' ) ) {
headerElement
.removeClass( 'maybe-sticky is-in-view' )
.css( {
width: '',
top: ''
} );
headerParent.css( 'padding-top', '' );
}
};
/**
@ -5023,19 +5304,20 @@
* @since 4.7.0
* @access private
*
* @param {object} header Header.
* @param {number} scrollTop Scroll top.
* @param {boolean} isScrollingUp Is scrolling up?
* @param {object} header - Header.
* @param {number} scrollTop - Scroll top.
* @param {number} scrollDirection - Scroll direction, negative number being up and positive being down.
* @returns {void}
*/
positionStickyHeader = function( header, scrollTop, isScrollingUp ) {
positionStickyHeader = function( header, scrollTop, scrollDirection ) {
var headerElement = header.element,
headerParent = header.parent,
headerHeight = header.height,
headerTop = parseInt( headerElement.css( 'top' ), 10 ),
maybeSticky = headerElement.hasClass( 'maybe-sticky' ),
isSticky = headerElement.hasClass( 'is-sticky' ),
isInView = headerElement.hasClass( 'is-in-view' );
isInView = headerElement.hasClass( 'is-in-view' ),
isScrollingUp = ( -1 === scrollDirection );
// When scrolling down, gradually hide sticky header.
if ( ! isScrollingUp ) {
@ -5078,7 +5360,7 @@
headerElement
.addClass( 'is-sticky' )
.css( {
top: '',
top: parentContainer.css( 'top' ),
width: headerParent.outerWidth() + 'px'
} );
}

File diff suppressed because one or more lines are too long

View File

@ -550,6 +550,10 @@
}
control.widgetContentEmbedded = true;
// Update the notification container element now that the widget content has been embedded.
control.notifications.container = control.getNotificationsContainerElement();
control.notifications.render();
widgetContent = $( control.params.widget_content );
control.container.find( '.widget-content:first' ).append( widgetContent );

File diff suppressed because one or more lines are too long

View File

@ -348,7 +348,7 @@ final class WP_Customize_Manager {
add_action( 'customize_controls_init', array( $this, 'prepare_controls' ) );
add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_control_scripts' ) );
// Render Panel, Section, and Control templates.
// Render Common, Panel, Section, and Control templates.
add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_panel_templates' ), 1 );
add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_section_templates' ), 1 );
add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_control_templates' ), 1 );
@ -2355,7 +2355,8 @@ final class WP_Customize_Manager {
if ( $update_transactionally && $invalid_setting_count > 0 ) {
$response = array(
'setting_validities' => $setting_validities,
'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ),
/* translators: placeholder is number of invalid settings */
'message' => sprintf( _n( 'Unable to save due to %s invalid setting.', 'Unable to save due to %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ),
);
return new WP_Error( 'transaction_fail', '', $response );
}
@ -3183,6 +3184,19 @@ final class WP_Customize_Manager {
) );
$control->print_template();
}
?>
<script type="text/html" id="tmpl-customize-notification">
<li class="notice notice-{{ data.type || 'info' }} {{ data.alt ? 'notice-alt' : '' }} {{ data.dismissible ? 'is-dismissible' : '' }}" data-code="{{ data.code }}" data-type="{{ data.type }}">
{{{ data.message || data.code }}}
<# if ( data.dismissible ) { #>
<button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php _e( 'Dismiss' ); ?></span></button>
<# } #>
</li>
</script>
<?php
/* The following template is obsolete in core but retained for plugins. */
?>
<script type="text/html" id="tmpl-customize-control-notifications">
<ul>

View File

@ -433,18 +433,26 @@ window.wp = window.wp || {};
* @param {string} id The ID of the item to remove.
*/
remove: function( id ) {
var value;
var value = this.value( id );
if ( this.has( id ) ) {
value = this.value( id );
if ( value ) {
// Trigger event right before the element is removed from the collection.
this.trigger( 'remove', value );
if ( value.extended( api.Value ) )
if ( value.extended( api.Value ) ) {
value.unbind( this._change );
}
delete value.parent;
}
delete this._value[ id ];
delete this._deferreds[ id ];
// Trigger removed event after the item has been eliminated from the collection.
if ( value ) {
this.trigger( 'removed', value );
}
},
/**
@ -790,6 +798,39 @@ window.wp = window.wp || {};
* @param {*} [params.data=null] - Any additional data.
*/
api.Notification = api.Class.extend(/** @lends wp.customize.Notification.prototype */{
/**
* Template function for rendering the notification.
*
* This will be populated with template option or else it will be populated with template from the ID.
*
* @since 4.9.0
* @var {Function}
*/
template: null,
/**
* ID for the template to render the notification.
*
* @since 4.9.0
* @var {string}
*/
templateId: 'customize-notification',
/**
* Initialize notification.
*
* @since 4.9.0
*
* @param {string} code - Notification code.
* @param {object} params - Notification parameters.
* @param {string} params.message - Message.
* @param {string} [params.type=error] - Type.
* @param {string} [params.setting] - Related setting ID.
* @param {Function} [params.template] - Function for rendering template. If not provided, this will come from templateId.
* @param {string} [params.templateId] - ID for template to render the notification.
* @param {boolean} [params.dismissible] - Whether the notification can be dismissed.
*/
initialize: function( code, params ) {
var _params;
this.code = code;
@ -799,12 +840,44 @@ window.wp = window.wp || {};
type: 'error',
fromServer: false,
data: null,
setting: null
setting: null,
template: null,
dismissible: false
},
params
);
delete _params.code;
_.extend( this, _params );
},
/**
* Render the notification.
*
* @since 4.9.0
*
* @returns {jQuery} Notification container element.
*/
render: function() {
var notification = this, container, data;
if ( ! notification.template ) {
notification.template = wp.template( notification.templateId );
}
data = _.extend( {}, notification, {
alt: notification.parent && notification.parent.alt
} );
container = $( notification.template( data ) );
if ( notification.dismissible ) {
container.find( '.notice-dismiss' ).on( 'click', function() {
if ( notification.parent ) {
notification.parent.remove( notification.code );
} else {
container.remove();
}
});
}
return container;
}
});

File diff suppressed because one or more lines are too long

View File

@ -546,6 +546,7 @@ function wp_default_scripts( &$scripts ) {
'collapseSidebar' => _x( 'Hide Controls', 'label for hide controls button without length constraints' ),
'expandSidebar' => _x( 'Show Controls', 'label for hide controls button without length constraints' ),
'untitledBlogName' => __( '(Untitled)' ),
'serverSaveError' => __( 'Failed connecting to the server. Please try saving again.' ),
// Used for overriding the file types allowed in plupload.
'allowedFiles' => __( 'Allowed Files' ),
) );

View File

@ -4,7 +4,7 @@
*
* @global string $wp_version
*/
$wp_version = '4.9-alpha-41373';
$wp_version = '4.9-alpha-41374';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.