Make WordPress Core

Changeset 50829


Ignore:
Timestamp:
05/07/2021 11:17:33 PM (5 years ago)
Author:
joedolson
Message:

Media: Remove infinite scroll from media library and modal.

Replace infinitely autoloading behavior on scroll with a user-controlled load more button. Fix a long standing accessibility issue in the media library. Infinite scroll poses a wide range of problems for accessibility, usability, and performance.

This change modifies the library to load 40 items in the initial view, with a load more button to load the next 40 items and a button to move focus from the load more region to the first of the most recently added items.

The text for communicating the jump target was broadly discussed, agreeing that the text incorporated here would most concisely and clearly convey the purpose of the button, and any further detail is learnable from use.

Props afercia, adamsilverstein, joedolson, audrasjb, francina
Fixes #50105. See #40330.

Location:
trunk/src
Files:
8 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/js/media/models/attachments.js

    r50068 r50829  
    349349        },
    350350        /**
     351         * Holds the total number of attachments.
     352         *
     353         * @since 5.7.0
     354         */
     355        totalAttachments: 0,
     356
     357        /**
     358         * Gets the total number of attachments.
     359         *
     360         * @since 5.7.0
     361         *
     362         * @return {number} The total number of attachments.
     363         */
     364        getTotalAttachments: function() {
     365                return this.mirroring ? this.mirroring.totalAttachments : 0;
     366        },
     367
     368        /**
    351369         * A custom Ajax-response parser.
    352370         *
    353371         * See trac ticket #24753
    354372         *
    355          * @param {Object|Array} resp The raw response Object/Array.
     373         * Called automatically by Backbone whenever a collection's models are returned
     374         * by the server, in fetch. The default implementation is a no-op, simply
     375         * passing through the JSON response. We override this to add attributes to
     376         * the collection items.
     377         *
     378         * Since WordPress 5.5, the response returns the attachments under `response.attachments`
     379         * and `response.totalAttachments` holds the total number of attachments found.
     380         *
     381         * @param {Object|Array} response The raw response Object/Array.
    356382         * @param {Object} xhr
    357383         * @return {Array} The array of model attributes to be added to the collection
    358384         */
    359         parse: function( resp, xhr ) {
    360                 if ( ! _.isArray( resp ) ) {
    361                         resp = [resp];
    362                 }
    363 
    364                 return _.map( resp, function( attrs ) {
     385        parse: function( response, xhr ) {
     386                if ( ! _.isArray( response.attachments ) ) {
     387                        response = [response.attachments];
     388                }
     389
     390                this.totalAttachments = parseInt( response.totalAttachments, 10 );
     391
     392                return _.map( response.attachments, function( attrs ) {
    365393                        var id, attachment, newAttributes;
    366394
  • trunk/src/js/media/models/query.js

    r50067 r50829  
    113113                options.remove = false;
    114114
    115                 return this._more = this.fetch( options ).done( function( resp ) {
    116                         if ( _.isEmpty( resp ) || -1 === this.args.posts_per_page || resp.length < this.args.posts_per_page ) {
     115                return this._more = this.fetch( options ).done( function( response ) {
     116                        // Since WordPress 5.5, the response returns the attachments under `response.attachments`.
     117                        var attachments = response.attachments;
     118
     119                        if ( _.isEmpty( attachments ) || -1 === this.args.posts_per_page || attachments.length < this.args.posts_per_page ) {
    117120                                query._hasMore = false;
    118121                        }
  • trunk/src/js/media/views/attachments.js

    r47122 r50829  
    11var View = wp.media.View,
    22        $ = jQuery,
    3         Attachments;
     3        Attachments,
     4        infiniteScrolling = wp.media.view.settings.infiniteScrolling;
    45
    56Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{
    … …  
    3637
    3738                /**
     39                 * @param infiniteScrolling  Whether to enable infinite scrolling or use
     40                 *                           the default "load more" button.
    3841                 * @param refreshSensitivity The time in milliseconds to throttle the scroll
    3942                 *                           handler.
    … …  
    5053                 */
    5154                _.defaults( this.options, {
     55                        infiniteScrolling:  infiniteScrolling || false,
    5256                        refreshSensitivity: wp.media.isTouchDevice ? 300 : 200,
    5357                        refreshThreshold:   3,
    … …  
    8589                this.controller.on( 'library:selection:add', this.attachmentFocus, this );
    8690
    87                 // Throttle the scroll handler and bind this.
    88                 this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value();
    89 
    90                 this.options.scrollElement = this.options.scrollElement || this.el;
    91                 $( this.options.scrollElement ).on( 'scroll', this.scroll );
     91                if ( this.options.infiniteScrolling ) {
     92                        // Throttle the scroll handler and bind this.
     93                        this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value();
     94
     95                        this.options.scrollElement = this.options.scrollElement || this.el;
     96                        $( this.options.scrollElement ).on( 'scroll', this.scroll );
     97                }
    9298
    9399                this.initSortable();
    … …  
    388394                } else {
    389395                        this.views.unset();
    390                         this.collection.more().done( this.scroll );
     396                        if ( this.options.infiniteScrolling ) {
     397                                this.collection.more().done( this.scroll );
     398                        }
    391399                }
    392400        },
    … …  
    401409         */
    402410        ready: function() {
    403                 this.scroll();
     411                if ( this.options.infiniteScrolling ) {
     412                        this.scroll();
     413                }
    404414        },
    405415
  • trunk/src/js/media/views/attachments/browser.js

    r49539 r50829  
    33        l10n = wp.media.view.l10n,
    44        $ = jQuery,
    5         AttachmentsBrowser;
     5        AttachmentsBrowser,
     6        infiniteScrolling = wp.media.view.settings.infiniteScrolling,
     7        __ = wp.i18n.__,
     8        sprintf = wp.i18n.sprintf;
    69
    710/**
    … …  
    6972                }
    7073
    71 
    7274                // Add a heading before the attachments list.
    7375                this.createAttachmentsHeading();
    7476
    75                 // Create the list of attachments.
    76                 this.createAttachments();
     77                // Create the attachments wrapper view.
     78                this.createAttachmentsWrapperView();
     79
     80                if ( ! infiniteScrolling ) {
     81                        this.$el.addClass( 'has-load-more' );
     82                        this.createLoadMoreView();
     83                }
    7784
    7885                // For accessibility reasons, place the normal sidebar after the attachments, see ticket #36909.
    … …  
    9299
    93100                this.collection.on( 'add remove reset', this.updateContent, this );
     101
     102                if ( ! infiniteScrolling ) {
     103                        this.collection.on( 'add remove reset', this.updateLoadMoreView, this );
     104                }
    94105
    95106                // The non-cached or cached attachments query has completed.
    … …  
    107118         */
    108119        announceSearchResults: _.debounce( function() {
    109                 var count;
     120                var count,
     121                        /* translators: Accessibility text. %d: Number of attachments found in a search. */
     122                        mediaFoundHasMoreResultsMessage = __( 'Number of media items displayed: %d. Click load more for more results.' );
     123
     124                if ( infiniteScrolling ) {
     125                        /* translators: Accessibility text. %d: Number of attachments found in a search. */
     126                        mediaFoundHasMoreResultsMessage = __( 'Number of media items displayed: %d. Scroll the page for more results.' );
     127                }
    110128
    111129                if ( this.collection.mirroring.args.s ) {
    … …  
    118136
    119137                        if ( this.collection.hasMore() ) {
    120                                 wp.a11y.speak( l10n.mediaFoundHasMoreResults.replace( '%d', count ) );
     138                                wp.a11y.speak( mediaFoundHasMoreResultsMessage.replace( '%d', count ) );
    121139                                return;
    122140                        }
    … …  
    393411
    394412                if ( this.controller.isModeActive( 'grid' ) ) {
     413                        // Usually the media library.
    395414                        noItemsView = view.attachmentsNoResults;
    396415                } else {
     416                        // Usually the media modal.
    397417                        noItemsView = view.uploader;
    398418                }
    … …  
    434454        },
    435455
     456        /**
     457         * Creates the Attachments wrapper view.
     458         *
     459         * @since 5.7.0
     460         *
     461         * @return {void}
     462         */
     463        createAttachmentsWrapperView: function() {
     464                this.attachmentsWrapper = new wp.media.View( {
     465                        className: 'attachments-wrapper'
     466                } );
     467
     468                // Create the list of attachments.
     469                this.views.add( this.attachmentsWrapper );
     470                this.createAttachments();
     471        },
     472
    436473        createAttachments: function() {
    437474                this.attachments = new wp.media.view.Attachments({
    … …  
    452489                this.controller.on( 'attachment:details:shift-tab', _.bind( this.attachments.restoreFocus, this.attachments ) );
    453490
    454                 this.views.add( this.attachments );
    455 
     491                this.views.add( '.attachments-wrapper', this.attachments );
    456492
    457493                if ( this.controller.isModeActive( 'grid' ) ) {
    … …  
    466502                        this.views.add( this.attachmentsNoResults );
    467503                }
     504        },
     505
     506        /**
     507         * Creates the load more button and attachments counter view.
     508         *
     509         * @since 5.7.0
     510         *
     511         * @return {void}
     512         */
     513        createLoadMoreView: function() {
     514                var view = this;
     515
     516                this.loadMoreWrapper = new View( {
     517                        controller: this.controller,
     518                        className: 'load-more-wrapper'
     519                } );
     520
     521                this.loadMoreCount = new View( {
     522                        controller: this.controller,
     523                        tagName: 'p',
     524                        className: 'load-more-count hidden'
     525                } );
     526
     527                this.loadMoreButton = new wp.media.view.Button( {
     528                        text: __( 'Load more' ),
     529                        className: 'load-more hidden',
     530                        style: 'primary',
     531                        size: '',
     532                        click: function() {
     533                                view.loadMoreAttachments();
     534                        }
     535                } );
     536
     537                this.loadMoreSpinner = new wp.media.view.Spinner();
     538
     539                this.loadMoreJumpToFirst = new wp.media.view.Button( {
     540                        text: __( 'Jump to first loaded item' ),
     541                        className: 'load-more-jump hidden',
     542                        size: '',
     543                        click: function() {
     544                                view.jumpToFirstAddedItem();
     545                        }
     546                } );
     547
     548                this.views.add( '.attachments-wrapper', this.loadMoreWrapper );
     549                this.views.add( '.load-more-wrapper', this.loadMoreSpinner );
     550                this.views.add( '.load-more-wrapper', this.loadMoreCount );
     551                this.views.add( '.load-more-wrapper', this.loadMoreButton );
     552                this.views.add( '.load-more-wrapper', this.loadMoreJumpToFirst );
     553        },
     554
     555        /**
     556         * Updates the Load More view. This function is debounced because the
     557         * collection updates multiple times at the add, remove, and reset events.
     558         * We need it to run only once, after all attachments are added or removed.
     559         *
     560         * @since 5.7.0
     561         *
     562         * @return {void}
     563         */
     564        updateLoadMoreView: _.debounce( function() {
     565                // Ensure the load more view elements are initially hidden at each update.
     566                this.loadMoreButton.$el.addClass( 'hidden' );
     567                this.loadMoreCount.$el.addClass( 'hidden' );
     568                this.loadMoreJumpToFirst.$el.addClass( 'hidden' ).prop( 'disabled', true );
     569
     570                if ( ! this.collection.getTotalAttachments() ) {
     571                        return;
     572                }
     573
     574                if ( this.collection.length ) {
     575                        this.loadMoreCount.$el.text(
     576                                /* translators: 1: Number of displayed attachments, 2: Number of total attachments. */
     577                                sprintf(
     578                                        __( 'Showing %1$s of %2$s media items' ),
     579                                        this.collection.length,
     580                                        this.collection.getTotalAttachments()
     581                                )
     582                        );
     583
     584                        this.loadMoreCount.$el.removeClass( 'hidden' );
     585                }
     586
     587                /*
     588                 * Notice that while the collection updates multiple times hasMore() may
     589                 * return true when it's actually not true.
     590                 */
     591                if ( this.collection.hasMore() ) {
     592                        this.loadMoreButton.$el.removeClass( 'hidden' );
     593                }
     594
     595                // Find the media item to move focus to. The jQuery `eq()` index is zero-based.
     596                this.firstAddedMediaItem = this.$el.find( '.attachment' ).eq( this.firstAddedMediaItemIndex );
     597
     598                // If there's a media item to move focus to, make the "Jump to" button available.
     599                if ( this.firstAddedMediaItem.length ) {
     600                        this.firstAddedMediaItem.addClass( 'new-media' );
     601                        this.loadMoreJumpToFirst.$el.removeClass( 'hidden' ).prop( 'disabled', false );
     602                }
     603
     604                // If there are new items added, but no more to be added, move focus to Jump button.
     605                if ( this.firstAddedMediaItem.length && ! this.collection.hasMore() ) {
     606                        this.loadMoreJumpToFirst.$el.trigger( 'focus' );
     607                }
     608        }, 10 ),
     609
     610        /**
     611         * Loads more attachments.
     612         *
     613         * @since 5.7.0
     614         *
     615         * @return {void}
     616         */
     617        loadMoreAttachments: function() {
     618                var view = this;
     619
     620                if ( ! this.collection.hasMore() ) {
     621                        return;
     622                }
     623
     624                /*
     625                 * The collection index is zero-based while the length counts the actual
     626                 * amount of items. Thus the length is equivalent to the position of the
     627                 * first added item.
     628                 */
     629                this.firstAddedMediaItemIndex = this.collection.length;
     630
     631                this.$el.addClass( 'more-loaded' );
     632                this.collection.each( function( attachment ) {
     633                        var attach_id = attachment.attributes.id;
     634                        $( '[data-id="' + attach_id + '"]' ).addClass( 'found-media' );
     635                });
     636
     637                view.loadMoreSpinner.show();
     638
     639                this.collection.more().done( function() {
     640                        // Within done(), `this` is the returned collection.
     641                        view.loadMoreSpinner.hide();
     642                } );
     643        },
     644
     645        /**
     646         * Moves focus to the first new added item.     .
     647         *
     648         * @since 5.7.0
     649         *
     650         * @return {void}
     651         */
     652        jumpToFirstAddedItem: function() {
     653                // Set focus on first added item.
     654                this.firstAddedMediaItem.focus();
    468655        },
    469656
  • trunk/src/wp-admin/css/media.css

    r50025 r50829  
    421421.media-frame.mode-grid,
    422422.media-frame.mode-grid .media-frame-content,
    423 .media-frame.mode-grid .attachments-browser .attachments,
     423.media-frame.mode-grid .attachments-browser:not(.has-load-more) .attachments,
     424.media-frame.mode-grid .attachments-browser.has-load-more .attachments-wrapper,
    424425.media-frame.mode-grid .uploader-inline-content {
    425426        position: static;
    … …  
    499500}
    500501
    501 .media-frame.mode-select .attachments-browser.fixed .attachments {
     502.media-frame.mode-select .attachments-browser.fixed:not(.has-load-more) .attachments,
     503.media-frame.mode-select .attachments-browser.has-load-more.fixed .attachments-wrapper {
    502504        position: relative;
    503505        top: 94px; /* prevent jumping up when the toolbar becomes fixed */
  • trunk/src/wp-admin/includes/ajax-actions.php

    r50556 r50829  
    29942994        $posts = array_filter( $posts );
    29952995
    2996         wp_send_json_success( $posts );
     2996        $result = array(
     2997                'attachments'      => $posts,
     2998                'totalAttachments' => $query->found_posts,
     2999        );
     3000
     3001        wp_send_json_success( $result );
    29973002}
    29983003
  • trunk/src/wp-includes/css/media-views.css

    r50784 r50829  
    11891189}
    11901190
    1191 .attachments-browser .attachments,
     1191.attachments-browser:not(.has-load-more) .attachments,
     1192.attachments-browser.has-load-more .attachments-wrapper,
    11921193.attachments-browser .uploader-inline {
    11931194        position: absolute;
    … …  
    12661267.attachments-browser .no-media {
    12671268        padding: 2em 0 0 2em;
     1269}
     1270
     1271.more-loaded .attachment:not(.found-media) {
     1272        background: #dcdcde;
     1273}
     1274
     1275.load-more-wrapper {
     1276        clear: both;
     1277        display: flex;
     1278        flex-wrap: wrap;
     1279        align-items: center;
     1280        justify-content: center;
     1281        padding: 1em 0;
     1282}
     1283
     1284.load-more-wrapper .load-more-count {
     1285        min-width: 100%;
     1286        margin: 0 0 1em;
     1287        text-align: center;
     1288}
     1289
     1290.load-more-wrapper .load-more {
     1291        margin: 0;
     1292}
     1293
     1294/* Needs high specificity. */
     1295.media-frame .load-more-wrapper .load-more + .spinner {
     1296        float: none;
     1297        margin: 0 -30px 0 10px;
     1298}
     1299
     1300/* Reset spinner margin when the button is hidden to avoid horizontal scrollbar. */
     1301.media-frame .load-more-wrapper .load-more.hidden + .spinner {
     1302        margin: 0;
     1303}
     1304
     1305/* Force a new row within the flex container. */
     1306.load-more-wrapper::after {
     1307        content: "";
     1308        min-width: 100%;
     1309        order: 1;
     1310}
     1311
     1312.load-more-wrapper .load-more-jump {
     1313        margin: 0 0 0 12px;
     1314}
     1315
     1316.attachment.new-media {
     1317        outline: 2px dotted #c3c4c7;
     1318}
     1319
     1320.load-more-wrapper {
     1321        clear: both;
     1322        display: flex;
     1323        flex-wrap: wrap;
     1324        align-items: center;
     1325        justify-content: center;
     1326        padding: 1em 0;
     1327}
     1328
     1329.load-more-wrapper .load-more-count {
     1330        min-width: 100%;
     1331        margin: 0 0 1em;
     1332        text-align: center;
     1333}
     1334
     1335.load-more-wrapper .load-more {
     1336        margin: 0;
     1337}
     1338
     1339/* Needs high specificity. */
     1340.media-frame .load-more-wrapper .load-more + .spinner {
     1341        float: none;
     1342        margin: 0 -30px 0 10px;
     1343}
     1344
     1345/* Reset spinner margin when the button is hidden to avoid horizontal scrollbar. */
     1346.media-frame .load-more-wrapper .load-more.hidden + .spinner {
     1347        margin: 0;
     1348}
     1349
     1350/* Force a new row within the flex container. */
     1351.load-more-wrapper::after {
     1352        content: "";
     1353        min-width: 100%;
     1354        order: 1;
     1355}
     1356
     1357.load-more-wrapper .load-more-jump {
     1358        margin: 0 0 0 12px;
    12681359}
    12691360
    … …  
    28192910                display: none;
    28202911        }
     2912
     2913        /* Change margin direction on load more button in responsive views. */
     2914        .load-more-wrapper .load-more-jump {
     2915                margin: 12px 0 0 0;
     2916        }
     2917
    28212918}
    28222919
    … …  
    28272924                padding-top: 44px;
    28282925        }
     2926
     2927        /* Change margin direction on load more button in responsive views. */
     2928        .load-more-wrapper .load-more-jump {
     2929                margin: 12px 0 0 0;
     2930        }
     2931
    28292932}
    28302933
  • trunk/src/wp-includes/media.php

    r50820 r50829  
    43074307        }
    43084308
     4309        /**
     4310         * Filters whether the Media Library grid has infinite scrolling. Default `false`.
     4311         *
     4312         * @since 5.7.0
     4313         *
     4314         * @param bool $value The filtered value, defaults to `false`.
     4315         */
     4316        $infinite_scrolling = apply_filters( 'media_library_infinite_scrolling', false );
     4317
    43094318        $settings = array(
    4310                 'tabs'             => $tabs,
    4311                 'tabUrl'           => add_query_arg( array( 'chromeless' => true ), admin_url( 'media-upload.php' ) ),
    4312                 'mimeTypes'        => wp_list_pluck( get_post_mime_types(), 0 ),
     4319                'tabs'              => $tabs,
     4320                'tabUrl'            => add_query_arg( array( 'chromeless' => true ), admin_url( 'media-upload.php' ) ),
     4321                'mimeTypes'         => wp_list_pluck( get_post_mime_types(), 0 ),
    43134322                /** This filter is documented in wp-admin/includes/media.php */
    4314                 'captions'         => ! apply_filters( 'disable_captions', '' ),
    4315                 'nonce'            => array(
     4323                'captions'          => ! apply_filters( 'disable_captions', '' ),
     4324                'nonce'             => array(
    43164325                        'sendToEditor' => wp_create_nonce( 'media-send-to-editor' ),
    43174326                ),
    4318                 'post'             => array(
     4327                'post'              => array(
    43194328                        'id' => 0,
    43204329                ),
    4321                 'defaultProps'     => $props,
    4322                 'attachmentCounts' => array(
     4330                'defaultProps'      => $props,
     4331                'attachmentCounts'  => array(
    43234332                        'audio' => ( $show_audio_playlist ) ? 1 : 0,
    43244333                        'video' => ( $show_video_playlist ) ? 1 : 0,
    43254334                ),
    4326                 'oEmbedProxyUrl'   => rest_url( 'oembed/1.0/proxy' ),
    4327                 'embedExts'        => $exts,
    4328                 'embedMimes'       => $ext_mimes,
    4329                 'contentWidth'     => $content_width,
    4330                 'months'           => $months,
    4331                 'mediaTrash'       => MEDIA_TRASH ? 1 : 0,
     4335                'oEmbedProxyUrl'    => rest_url( 'oembed/1.0/proxy' ),
     4336                'embedExts'         => $exts,
     4337                'embedMimes'        => $ext_mimes,
     4338                'contentWidth'      => $content_width,
     4339                'months'            => $months,
     4340                'mediaTrash'        => MEDIA_TRASH ? 1 : 0,
     4341                'infiniteScrolling' => ( $infinite_scrolling ) ? 1 : 0,
    43324342        );
    43334343
    … …  
    44134423                'searchMediaLabel'            => __( 'Search media' ),          // Backward compatibility pre-5.3.
    44144424                'searchMediaPlaceholder'      => __( 'Search media items...' ), // Placeholder (no ellipsis), backward compatibility pre-5.3.
     4425                /* translators: %d: Number of attachments found in a search. */
    44154426                'mediaFound'                  => __( 'Number of media items found: %d' ),
    4416                 'mediaFoundHasMoreResults'    => __( 'Number of media items displayed: %d. Scroll the page for more results.' ),
    44174427                'noMedia'                     => __( 'No media items found.' ),
    44184428                'noMediaTryNewSearch'         => __( 'No media items found. Try a different search.' ),
Note: See TracChangeset for help on using the changeset viewer.