WordPress.org

Make WordPress Core

Changeset 50829


Ignore:
Timestamp:
05/07/2021 11:17:33 PM (5 months 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.