Make WordPress Core

source: trunk/src/js/_enqueues/wp/widgets/custom-html.js @ 51701

Last change on this file since 51701 was 51701, checked in by desrosj, 3 years ago

Widgets: Show title and media select fields in Accessibility Mode.

This updates the Custom HTML and Media widgets to display the correct fields when adding or editing a widget when using accessibility mode through the Classic Widgets experience.

Follow up to [49973].

Props mark-k, sabernhardt, alexstine, circlecube, audrasjb.
Fixes #53641.

  • Property svn:eol-style set to native
File size: 15.4 KB
Line 
1/**
2 * @output wp-admin/js/widgets/custom-html-widgets.js
3 */
4
5/* global wp */
6/* eslint consistent-this: [ "error", "control" ] */
7/* eslint no-magic-numbers: ["error", { "ignore": [0,1,-1] }] */
8
9/**
10 * @namespace wp.customHtmlWidget
11 * @memberOf wp
12 */
13wp.customHtmlWidgets = ( function( $ ) {
14        'use strict';
15
16        var component = {
17                idBases: [ 'custom_html' ],
18                codeEditorSettings: {},
19                l10n: {
20                        errorNotice: {
21                                singular: '',
22                                plural: ''
23                        }
24                }
25        };
26
27        component.CustomHtmlWidgetControl = Backbone.View.extend(/** @lends wp.customHtmlWidgets.CustomHtmlWidgetControl.prototype */{
28
29                /**
30                 * View events.
31                 *
32                 * @type {Object}
33                 */
34                events: {},
35
36                /**
37                 * Text widget control.
38                 *
39                 * @constructs wp.customHtmlWidgets.CustomHtmlWidgetControl
40                 * @augments Backbone.View
41                 * @abstract
42                 *
43                 * @param {Object} options - Options.
44                 * @param {jQuery} options.el - Control field container element.
45                 * @param {jQuery} options.syncContainer - Container element where fields are synced for the server.
46                 *
47                 * @return {void}
48                 */
49                initialize: function initialize( options ) {
50                        var control = this;
51
52                        if ( ! options.el ) {
53                                throw new Error( 'Missing options.el' );
54                        }
55                        if ( ! options.syncContainer ) {
56                                throw new Error( 'Missing options.syncContainer' );
57                        }
58
59                        Backbone.View.prototype.initialize.call( control, options );
60                        control.syncContainer = options.syncContainer;
61                        control.widgetIdBase = control.syncContainer.parent().find( '.id_base' ).val();
62                        control.widgetNumber = control.syncContainer.parent().find( '.widget_number' ).val();
63                        control.customizeSettingId = 'widget_' + control.widgetIdBase + '[' + String( control.widgetNumber ) + ']';
64
65                        control.$el.addClass( 'custom-html-widget-fields' );
66                        control.$el.html( wp.template( 'widget-custom-html-control-fields' )( { codeEditorDisabled: component.codeEditorSettings.disabled } ) );
67
68                        control.errorNoticeContainer = control.$el.find( '.code-editor-error-container' );
69                        control.currentErrorAnnotations = [];
70                        control.saveButton = control.syncContainer.add( control.syncContainer.parent().find( '.widget-control-actions' ) ).find( '.widget-control-save, #savewidget' );
71                        control.saveButton.addClass( 'custom-html-widget-save-button' ); // To facilitate style targeting.
72
73                        control.fields = {
74                                title: control.$el.find( '.title' ),
75                                content: control.$el.find( '.content' )
76                        };
77
78                        // Sync input fields to hidden sync fields which actually get sent to the server.
79                        _.each( control.fields, function( fieldInput, fieldName ) {
80                                fieldInput.on( 'input change', function updateSyncField() {
81                                        var syncInput = control.syncContainer.find( '.sync-input.' + fieldName );
82                                        if ( syncInput.val() !== fieldInput.val() ) {
83                                                syncInput.val( fieldInput.val() );
84                                                syncInput.trigger( 'change' );
85                                        }
86                                });
87
88                                // Note that syncInput cannot be re-used because it will be destroyed with each widget-updated event.
89                                fieldInput.val( control.syncContainer.find( '.sync-input.' + fieldName ).val() );
90                        });
91                },
92
93                /**
94                 * Update input fields from the sync fields.
95                 *
96                 * This function is called at the widget-updated and widget-synced events.
97                 * A field will only be updated if it is not currently focused, to avoid
98                 * overwriting content that the user is entering.
99                 *
100                 * @return {void}
101                 */
102                updateFields: function updateFields() {
103                        var control = this, syncInput;
104
105                        if ( ! control.fields.title.is( document.activeElement ) ) {
106                                syncInput = control.syncContainer.find( '.sync-input.title' );
107                                control.fields.title.val( syncInput.val() );
108                        }
109
110                        /*
111                         * Prevent updating content when the editor is focused or if there are current error annotations,
112                         * to prevent the editor's contents from getting sanitized as soon as a user removes focus from
113                         * the editor. This is particularly important for users who cannot unfiltered_html.
114                         */
115                        control.contentUpdateBypassed = control.fields.content.is( document.activeElement ) || control.editor && control.editor.codemirror.state.focused || 0 !== control.currentErrorAnnotations.length;
116                        if ( ! control.contentUpdateBypassed ) {
117                                syncInput = control.syncContainer.find( '.sync-input.content' );
118                                control.fields.content.val( syncInput.val() );
119                        }
120                },
121
122                /**
123                 * Show linting error notice.
124                 *
125                 * @param {Array} errorAnnotations - Error annotations.
126                 * @return {void}
127                 */
128                updateErrorNotice: function( errorAnnotations ) {
129                        var control = this, errorNotice, message = '', customizeSetting;
130
131                        if ( 1 === errorAnnotations.length ) {
132                                message = component.l10n.errorNotice.singular.replace( '%d', '1' );
133                        } else if ( errorAnnotations.length > 1 ) {
134                                message = component.l10n.errorNotice.plural.replace( '%d', String( errorAnnotations.length ) );
135                        }
136
137                        if ( control.fields.content[0].setCustomValidity ) {
138                                control.fields.content[0].setCustomValidity( message );
139                        }
140
141                        if ( wp.customize && wp.customize.has( control.customizeSettingId ) ) {
142                                customizeSetting = wp.customize( control.customizeSettingId );
143                                customizeSetting.notifications.remove( 'htmlhint_error' );
144                                if ( 0 !== errorAnnotations.length ) {
145                                        customizeSetting.notifications.add( 'htmlhint_error', new wp.customize.Notification( 'htmlhint_error', {
146                                                message: message,
147                                                type: 'error'
148                                        } ) );
149                                }
150                        } else if ( 0 !== errorAnnotations.length ) {
151                                errorNotice = $( '<div class="inline notice notice-error notice-alt"></div>' );
152                                errorNotice.append( $( '<p></p>', {
153                                        text: message
154                                } ) );
155                                control.errorNoticeContainer.empty();
156                                control.errorNoticeContainer.append( errorNotice );
157                                control.errorNoticeContainer.slideDown( 'fast' );
158                                wp.a11y.speak( message );
159                        } else {
160                                control.errorNoticeContainer.slideUp( 'fast' );
161                        }
162                },
163
164                /**
165                 * Initialize editor.
166                 *
167                 * @return {void}
168                 */
169                initializeEditor: function initializeEditor() {
170                        var control = this, settings;
171
172                        if ( component.codeEditorSettings.disabled ) {
173                                return;
174                        }
175
176                        settings = _.extend( {}, component.codeEditorSettings, {
177
178                                /**
179                                 * Handle tabbing to the field before the editor.
180                                 *
181                                 * @ignore
182                                 *
183                                 * @return {void}
184                                 */
185                                onTabPrevious: function onTabPrevious() {
186                                        control.fields.title.focus();
187                                },
188
189                                /**
190                                 * Handle tabbing to the field after the editor.
191                                 *
192                                 * @ignore
193                                 *
194                                 * @return {void}
195                                 */
196                                onTabNext: function onTabNext() {
197                                        var tabbables = control.syncContainer.add( control.syncContainer.parent().find( '.widget-position, .widget-control-actions' ) ).find( ':tabbable' );
198                                        tabbables.first().focus();
199                                },
200
201                                /**
202                                 * Disable save button and store linting errors for use in updateFields.
203                                 *
204                                 * @ignore
205                                 *
206                                 * @param {Array} errorAnnotations - Error notifications.
207                                 * @return {void}
208                                 */
209                                onChangeLintingErrors: function onChangeLintingErrors( errorAnnotations ) {
210                                        control.currentErrorAnnotations = errorAnnotations;
211                                },
212
213                                /**
214                                 * Update error notice.
215                                 *
216                                 * @ignore
217                                 *
218                                 * @param {Array} errorAnnotations - Error annotations.
219                                 * @return {void}
220                                 */
221                                onUpdateErrorNotice: function onUpdateErrorNotice( errorAnnotations ) {
222                                        control.saveButton.toggleClass( 'validation-blocked disabled', errorAnnotations.length > 0 );
223                                        control.updateErrorNotice( errorAnnotations );
224                                }
225                        });
226
227                        control.editor = wp.codeEditor.initialize( control.fields.content, settings );
228
229                        // Improve the editor accessibility.
230                        $( control.editor.codemirror.display.lineDiv )
231                                .attr({
232                                        role: 'textbox',
233                                        'aria-multiline': 'true',
234                                        'aria-labelledby': control.fields.content[0].id + '-label',
235                                        'aria-describedby': 'editor-keyboard-trap-help-1 editor-keyboard-trap-help-2 editor-keyboard-trap-help-3 editor-keyboard-trap-help-4'
236                                });
237
238                        // Focus the editor when clicking on its label.
239                        $( '#' + control.fields.content[0].id + '-label' ).on( 'click', function() {
240                                control.editor.codemirror.focus();
241                        });
242
243                        control.fields.content.on( 'change', function() {
244                                if ( this.value !== control.editor.codemirror.getValue() ) {
245                                        control.editor.codemirror.setValue( this.value );
246                                }
247                        });
248                        control.editor.codemirror.on( 'change', function() {
249                                var value = control.editor.codemirror.getValue();
250                                if ( value !== control.fields.content.val() ) {
251                                        control.fields.content.val( value ).trigger( 'change' );
252                                }
253                        });
254
255                        // Make sure the editor gets updated if the content was updated on the server (sanitization) but not updated in the editor since it was focused.
256                        control.editor.codemirror.on( 'blur', function() {
257      ��                         if ( control.contentUpdateBypassed ) {
258                                        control.syncContainer.find( '.sync-input.content' ).trigger( 'change' );
259                                }
260                        });
261
262                        // Prevent hitting Esc from collapsing the widget control.
263                        if ( wp.customize ) {
264                                control.editor.codemirror.on( 'keydown', function onKeydown( codemirror, event ) {
265                                        var escKeyCode = 27;
266                                        if ( escKeyCode === event.keyCode ) {
267                                                event.stopPropagation();
268                                        }
269                                });
270                        }
271                }
272        });
273
274        /**
275         * Mapping of widget ID to instances of CustomHtmlWidgetControl subclasses.
276         *
277         * @alias wp.customHtmlWidgets.widgetControls
278         *
279         * @type {Object.<string, wp.textWidgets.CustomHtmlWidgetControl>}
280         */
281        component.widgetControls = {};
282
283        /**
284         * Handle widget being added or initialized for the first time at the widget-added event.
285         *
286         * @alias wp.customHtmlWidgets.handleWidgetAdded
287         *
288         * @param {jQuery.Event} event - Event.
289         * @param {jQuery}       widgetContainer - Widget container element.
290         *
291         * @return {void}
292         */
293        component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) {
294                var widgetForm, idBase, widgetControl, widgetId, animatedCheckDelay = 50, renderWhenAnimationDone, fieldContainer, syncContainer;
295                widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen.
296
297                idBase = widgetForm.find( '> .id_base' ).val();
298                if ( -1 === component.idBases.indexOf( idBase ) ) {
299                        return;
300                }
301
302                // Prevent initializing already-added widgets.
303                widgetId = widgetForm.find( '.widget-id' ).val();
304                if ( component.widgetControls[ widgetId ] ) {
305                        return;
306                }
307
308                /*
309                 * Create a container element for the widget control fields.
310                 * This is inserted into the DOM immediately before the the .widget-content
311                 * element because the contents of this element are essentially "managed"
312                 * by PHP, where each widget update cause the entire element to be emptied
313                 * and replaced with the rendered output of WP_Widget::form() which is
314                 * sent back in Ajax request made to save/update the widget instance.
315                 * To prevent a "flash of replaced DOM elements and re-initialized JS
316                 * components", the JS template is rendered outside of the normal form
317                 * container.
318                 */
319                fieldContainer = $( '<div></div>' );
320                syncContainer = widgetContainer.find( '.widget-content:first' );
321                syncContainer.before( fieldContainer );
322
323                widgetControl = new component.CustomHtmlWidgetControl({
324                        el: fieldContainer,
325                        syncContainer: syncContainer
326                });
327
328                component.widgetControls[ widgetId ] = widgetControl;
329
330                /*
331                 * Render the widget once the widget parent's container finishes animating,
332                 * as the widget-added event fires with a slideDown of the container.
333                 * This ensures that the textarea is visible and the editor can be initialized.
334                 */
335                renderWhenAnimationDone = function() {
336                        if ( ! ( wp.customize ? widgetContainer.parent().hasClass( 'expanded' ) : widgetContainer.hasClass( 'open' ) ) ) { // Core merge: The wp.customize condition can be eliminated with this change being in core: https://github.com/xwp/wordpress-develop/pull/247/commits/5322387d
337                                setTimeout( renderWhenAnimationDone, animatedCheckDelay );
338                        } else {
339                                widgetControl.initializeEditor();
340                        }
341                };
342                renderWhenAnimationDone();
343        };
344
345        /**
346         * Setup widget in accessibility mode.
347         *
348         * @alias wp.customHtmlWidgets.setupAccessibleMode
349         *
350         * @return {void}
351         */
352        component.setupAccessibleMode = function setupAccessibleMode() {
353                var widgetForm, idBase, widgetControl, fieldContainer, syncContainer;
354                widgetForm = $( '.editwidget > form' );
355                if ( 0 === widgetForm.length ) {
356                        return;
357                }
358
359                idBase = widgetForm.find( '.id_base' ).val();
360                if ( -1 === component.idBases.indexOf( idBase ) ) {
361                        return;
362                }
363
364                fieldContainer = $( '<div></div>' );
365                syncContainer = widgetForm.find( '> .widget-inside' );
366                syncContainer.before( fieldContainer );
367
368                widgetControl = new component.CustomHtmlWidgetControl({
369                        el: fieldContainer,
370                        syncContainer: syncContainer
371                });
372
373                widgetControl.initializeEditor();
374        };
375
376        /**
377         * Sync widget instance data sanitized from server back onto widget model.
378         *
379         * This gets called via the 'widget-updated' event when saving a widget from
380         * the widgets admin screen and also via the 'widget-synced' event when making
381         * a change to a widget in the customizer.
382         *
383         * @alias wp.customHtmlWidgets.handleWidgetUpdated
384         *
385         * @param {jQuery.Event} event - Event.
386         * @param {jQuery}       widgetContainer - Widget container element.
387         * @return {void}
388         */
389        component.handleWidgetUpdated = function handleWidgetUpdated( event, widgetContainer ) {
390                var widgetForm, widgetId, widgetControl, idBase;
391                widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );
392
393                idBase = widgetForm.find( '> .id_base' ).val();
394                if ( -1 === component.idBases.indexOf( idBase ) ) {
395                        return;
396                }
397
398                widgetId = widgetForm.find( '> .widget-id' ).val();
399                widgetControl = component.widgetControls[ widgetId ];
400                if ( ! widgetControl ) {
401                        return;
402                }
403
404                widgetControl.updateFields();
405        };
406
407        /**
408         * Initialize functionality.
409         *
410         * This function exists to prevent the JS file from having to boot itself.
411         * When WordPress enqueues this script, it should have an inline script
412         * attached which calls wp.textWidgets.init().
413         *
414         * @alias wp.customHtmlWidgets.init
415         *
416         * @param {Object} settings - Options for code editor, exported from PHP.
417         *
418         * @return {void}
419         */
420        component.init = function init( settings ) {
421                var $document = $( document );
422                _.extend( component.codeEditorSettings, settings );
423
424                $document.on( 'widget-added', component.handleWidgetAdded );
425                $document.on( 'widget-synced widget-updated', component.handleWidgetUpdated );
426
427                /*
428                 * Manually trigger widget-added events for media widgets on the admin
429                 * screen once they are expanded. The widget-added event is not triggered
430                 * for each pre-existing widget on the widgets admin screen like it is
431                 * on the customizer. Likewise, the customizer only triggers widget-added
432                 * when the widget is expanded to just-in-time construct the widget form
433                 * when it is actually going to be displayed. So the following implements
434                 * the same for the widgets admin screen, to invoke the widget-added
435                 * handler when a pre-existing media widget is expanded.
436                 */
437                $( function initializeExistingWidgetContainers() {
438                        var widgetContainers;
439                        if ( 'widgets' !== window.pagenow ) {
440                                return;
441                        }
442                        widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' );
443                        widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() {
444                                var widgetContainer = $( this );
445                                component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer );
446                        });
447
448                        // Accessibility mode.
449                        if ( document.readyState === 'complete' ) {
450                                // Page is fully loaded.
451                                component.setupAccessibleMode();
452                        } else {
453                                // Page is still loading.
454                                $( window ).on( 'load', function() {
455                                        component.setupAccessibleMode();
456                                });
457                        }
458                });
459        };
460
461        return component;
462})( jQuery );
Note: See TracBrowser for help on using the repository browser.