From f1bc9ac82b18c15586b33889276544e0e74d7712 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Fri, 7 Aug 2026 20:35:25 +0900 Subject: [PATCH] Media: Add a settings dialog to the Media Library toolbar. The "Infinite Scrolling" personal option could only be reached from the user profile screen, far from the grid it affects. This adds a toggle next to the search field in the attachments browser toolbar that opens a modal dialog with that option, in both the grid and the media modal. The dialog is a native `dialog` element opened with `showModal()`, so the focus trap, the Escape handling, the backdrop and the focus restore on close come from the platform rather than from a hand-rolled implementation. Keyboard events are kept from reaching the media modal, whose own Escape handler and focus manager would otherwise fight the dialog. Toggling the checkbox saves the preference over Ajax and applies it in place: `wp.media.view.Attachments` can now bind and unbind its scroll handler after creation, and `wp.media.view.AttachmentsBrowser` swaps the Load more view in and out, so no page reload is needed. Both views now read the setting at creation time instead of capturing it when the module loads. The toggle is not rendered when a `media_library_infinite_scrolling` filter callback overrides the user preference, since the control would have no effect. Co-Authored-By: Claude --- Gruntfile.js | 1 + src/js/_enqueues/wp/media/views.js | 1 + src/js/media/views/attachments.js | 38 ++++- src/js/media/views/attachments/browser.js | 49 +++++- src/js/media/views/library-settings.js | 172 ++++++++++++++++++++++ src/wp-admin/admin-ajax.php | 1 + src/wp-admin/css/media.css | 4 +- src/wp-admin/includes/ajax-actions.php | 24 +++ src/wp-includes/css/media-views.css | 102 +++++++++++++ src/wp-includes/media-template.php | 44 ++++++ src/wp-includes/media.php | 13 +- 11 files changed, 434 insertions(+), 15 deletions(-) create mode 100644 src/js/media/views/library-settings.js diff --git a/Gruntfile.js b/Gruntfile.js index 61f18481e23a8..1b05ec073ce4f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1403,6 +1403,7 @@ module.exports = function(grunt) { 'src/wp-includes/js/media/views/iframe.js' : 'src/js/media/views/iframe.js', 'src/wp-includes/js/media/views/image-details.js' : 'src/js/media/views/image-details.js', 'src/wp-includes/js/media/views/label.js' : 'src/js/media/views/label.js', + 'src/wp-includes/js/media/views/library-settings.js' : 'src/js/media/views/library-settings.js', 'src/wp-includes/js/media/views/media-details.js' : 'src/js/media/views/media-details.js', 'src/wp-includes/js/media/views/media-frame.js' : 'src/js/media/views/media-frame.js', 'src/wp-includes/js/media/views/menu-item.js' : 'src/js/media/views/menu-item.js', diff --git a/src/js/_enqueues/wp/media/views.js b/src/js/_enqueues/wp/media/views.js index d87046fcf0e2b..0cd31f73aca8f 100644 --- a/src/js/_enqueues/wp/media/views.js +++ b/src/js/_enqueues/wp/media/views.js @@ -128,6 +128,7 @@ media.view.AttachmentFilters = require( '../../../media/views/attachment-filters media.view.DateFilter = require( '../../../media/views/attachment-filters/date.js' ); media.view.AttachmentFilters.Uploaded = require( '../../../media/views/attachment-filters/uploaded.js' ); media.view.AttachmentFilters.All = require( '../../../media/views/attachment-filters/all.js' ); +media.view.LibrarySettings = require( '../../../media/views/library-settings.js' ); media.view.AttachmentsBrowser = require( '../../../media/views/attachments/browser.js' ); media.view.Selection = require( '../../../media/views/selection.js' ); media.view.Attachment.Selection = require( '../../../media/views/attachment/selection.js' ); diff --git a/src/js/media/views/attachments.js b/src/js/media/views/attachments.js index b2e91624cb159..6e6f9750e00dc 100644 --- a/src/js/media/views/attachments.js +++ b/src/js/media/views/attachments.js @@ -1,7 +1,6 @@ var View = wp.media.View, $ = jQuery, - Attachments, - infiniteScrolling = wp.media.view.settings.infiniteScrolling; + Attachments; Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ tagName: 'ul', @@ -54,7 +53,7 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ * calculating the total number of columns. */ _.defaults( this.options, { - infiniteScrolling: infiniteScrolling || false, + infiniteScrolling: !! wp.media.view.settings.infiniteScrolling, refreshSensitivity: wp.media.isTouchDevice ? 300 : 200, refreshThreshold: 3, AttachmentView: wp.media.view.Attachment, @@ -90,11 +89,12 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ this.controller.on( 'library:selection:add', this.attachmentFocus, this ); - if ( this.options.infiniteScrolling ) { - // Throttle the scroll handler and bind this. - this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value(); + // Throttle the scroll handler and bind this. Done even when infinite + // scrolling is off, since it can be turned on at any time. + this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value(); + this.options.scrollElement = this.options.scrollElement || this.el; - this.options.scrollElement = this.options.scrollElement || this.el; + if ( this.options.infiniteScrolling ) { $( this.options.scrollElement ).on( 'scroll', this.scroll ); } @@ -114,6 +114,28 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ } }, + /** + * Turns infinite scrolling on or off after the view has been created. + * + * @since 7.2.0 + * + * @param {boolean} enabled Whether to load more attachments on scroll. + * + * @return {void} + */ + setInfiniteScrolling: function( enabled ) { + this.options.infiniteScrolling = enabled; + + $( this.options.scrollElement ).off( 'scroll', this.scroll ); + + if ( enabled ) { + $( this.options.scrollElement ).on( 'scroll', this.scroll ); + + // The list may already be scrolled past the point where more are loaded. + this.scroll(); + } + }, + /** * Listens to the resizeEvent on the window. * @@ -231,6 +253,8 @@ Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{ */ dispose: function() { this.collection.props.off( null, null, this ); + $( this.options.scrollElement ).off( 'scroll', this.scroll ); + if ( this.options.resize ) { this.$window.off( this.resizeEvent ); } diff --git a/src/js/media/views/attachments/browser.js b/src/js/media/views/attachments/browser.js index 82b7359eb832a..6845ebd58cc3e 100644 --- a/src/js/media/views/attachments/browser.js +++ b/src/js/media/views/attachments/browser.js @@ -3,7 +3,8 @@ var View = wp.media.View, l10n = wp.media.view.l10n, $ = jQuery, AttachmentsBrowser, - infiniteScrolling = wp.media.view.settings.infiniteScrolling, + settings = wp.media.view.settings, + librarySettings = wp.media.view.settings.librarySettings, __ = wp.i18n.__, sprintf = wp.i18n.sprintf; @@ -34,6 +35,8 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro className: 'attachments-browser', initialize: function() { + var infiniteScrolling = !! settings.infiniteScrolling; + _.defaults( this.options, { filters: false, search: true, @@ -45,6 +48,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.controller.on( 'toggle:upload:attachment', this.toggleUploader, this ); this.controller.on( 'edit:selection', this.editSelection ); + this.controller.on( 'library:infinite-scrolling', this.setInfiniteScrolling, this ); // In the Media Library, the sidebar is used to display errors before the attachments grid. if ( this.options.sidebar && 'errors' === this.options.sidebar ) { @@ -111,6 +115,39 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro this.collection.on( 'attachments:received', this.announceSearchResults, this ); }, + /** + * Switches between infinite scrolling and the Load more button in place. + * + * The Load more view is created on demand and then kept, hidden by the + * `has-load-more` class, so that switching back and forth is cheap. + * + * @since 7.2.0 + * + * @param {boolean} enabled Whether to load more attachments on scroll. + * + * @return {void} + */ + setInfiniteScrolling: function( enabled ) { + if ( enabled === Boolean( this.attachments.options.infiniteScrolling ) ) { + return; + } + + this.attachments.setInfiniteScrolling( enabled ); + this.$el.toggleClass( 'has-load-more', ! enabled ); + + if ( enabled ) { + this.collection.off( 'add remove reset', this.updateLoadMoreView, this ); + return; + } + + if ( ! this.loadMoreWrapper ) { + this.createLoadMoreView(); + } + + this.collection.on( 'add remove reset', this.updateLoadMoreView, this ); + this.updateLoadMoreView(); + }, + /** * Updates the `wp.a11y.speak()` ARIA live region with a message to communicate * the number of search results to screen reader users. This function is @@ -125,7 +162,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro /* translators: Accessibility text. %d: Number of attachments found in a search. */ mediaFoundHasMoreResultsMessage = __( 'Number of media items displayed: %d. Click load more for more results.' ); - if ( infiniteScrolling ) { + if ( this.attachments.options.infiniteScrolling ) { /* translators: Accessibility text. %d: Number of attachments found in a search. */ mediaFoundHasMoreResultsMessage = __( 'Number of media items displayed: %d. Scroll the page for more results.' ); } @@ -393,6 +430,14 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro model: this.collection.props, priority: 60 }).render() ); + + // Pointless when a `media_library_infinite_scrolling` filter callback overrides the user preference. + if ( librarySettings && ! librarySettings.isFiltered ) { + this.toolbar.set( 'librarySettings', new wp.media.view.LibrarySettings({ + controller: this.controller, + priority: 70 + }).render() ); + } } if ( this.options.dragInfo ) { diff --git a/src/js/media/views/library-settings.js b/src/js/media/views/library-settings.js new file mode 100644 index 0000000000000..1f0dd4c6ebe22 --- /dev/null +++ b/src/js/media/views/library-settings.js @@ -0,0 +1,172 @@ +var View = wp.media.View, + settings = wp.media.view.settings, + $ = jQuery, + __ = wp.i18n.__, + LibrarySettings; + +/** + * wp.media.view.LibrarySettings + * + * A toolbar control opening a modal dialog with the personal options for the + * Media Library. Each toggle is saved over Ajax, so there is no submit button. + * + * @since 7.2.0 + * + * @memberOf wp.media.view + * + * @class + * @augments wp.media.View + * @augments wp.Backbone.View + * @augments Backbone.View + */ +LibrarySettings = View.extend(/** @lends wp.media.view.LibrarySettings.prototype */{ + tagName: 'button', + className: 'button button-compact media-library-settings__toggle', + template: wp.template( 'media-library-settings-toggle' ), + + attributes: { + type: 'button', + 'aria-haspopup': 'dialog' + }, + + events: { + 'click': 'open' + }, + + /** + * Identifies the most recent request, so a slow response cannot overwrite the + * outcome of a later toggle. + * + * @type {number} + */ + requestId: 0, + + initialize: function() { + // Several media frames can be attached at once, so IDs are per instance. + this.uid = _.uniqueId( 'media-library-settings-' ); + }, + + prepare: function() { + return { + infiniteScrolling: !! settings.librarySettings.infiniteScrolling, + titleId: this.uid + '-title', + infiniteScrollingId: this.uid + '-infinite-scrolling' + }; + }, + + /** + * Removes the dialog along with the view. + * + * @return {wp.media.view.LibrarySettings} Returns itself to allow chaining. + */ + dispose: function() { + if ( this.dialog ) { + $( this.dialog ).remove(); + } + + return View.prototype.dispose.apply( this, arguments ); + }, + + /** + * Inserts the dialog next to the toggle. + * + * Done on the first open, when the toggle is known to be in the document. A + * closed dialog is `display: none`, so it does not take part in the layout. + * + * @return {void} + */ + createDialog: function() { + var $dialog = $( wp.template( 'media-library-settings-dialog' )( this.prepare() ) ); + + this.$el.after( $dialog ); + + this.dialog = $dialog[0]; + this.status = $dialog.find( '.media-library-settings__status' )[0]; + + $dialog.on( 'change', '.media-library-settings__checkbox', _.bind( this.updateInfiniteScrolling, this ) ); + + /* + * In the media modal, `wp.media.view.Modal` closes on Escape and + * `wp.media.view.FocusManager` constrains Tab. Neither must run while the + * dialog is open: it handles both itself, and the rest of the modal is inert. + */ + $dialog.on( 'keydown', function( event ) { + event.stopPropagation(); + } ); + }, + + /** + * Opens the dialog. + * + * `showModal()` moves it to the top layer, out of the toolbar's clipping, and + * brings the focus trap, Escape handling and focus restore a modal needs. + * + * @return {void} + */ + open: function() { + if ( ! this.dialog ) { + this.createDialog(); + } + + this.setStatus( '' ); + this.dialog.showModal(); + }, + + /** + * Saves the "Infinite scrolling" personal option for the current user. + * + * @param {Event} event The change event of the checkbox. + * @return {void} + */ + updateInfiniteScrolling: function( event ) { + var view = this, + checkbox = event.target, + enabled = checkbox.checked, + requestId = ++this.requestId; + + this.setStatus( __( 'Saving…' ) ); + + wp.ajax.post( 'set-media-library-settings', { + _ajax_nonce: settings.librarySettings.nonce, + infinite_scrolling: enabled ? 'true' : 'false' + } ).done( function() { + if ( requestId !== view.requestId ) { + return; + } + + settings.librarySettings.infiniteScrolling = enabled ? 1 : 0; + settings.infiniteScrolling = enabled ? 1 : 0; + + // Applied to the browser this toggle belongs to, without a reload. + view.controller.trigger( 'library:infinite-scrolling', enabled ); + + view.setStatus( enabled ? + __( 'Infinite scrolling is on.' ) : + __( 'Infinite scrolling is off.' ) + ); + } ).fail( function( response ) { + if ( requestId !== view.requestId ) { + return; + } + + // Put the checkbox back in sync with the stored value. + checkbox.checked = ! enabled; + + view.setStatus( ( response && response.message ) || __( 'The setting could not be saved.' ) ); + } ); + }, + + /** + * Updates the message below the controls. + * + * It sits in a `role="status"` region, so it is shown and announced at once. + * + * @param {string} message The message to display. An empty string clears it. + * @return {void} + */ + setStatus: function( message ) { + this.status.textContent = message; + } +}); + +module.exports = LibrarySettings; diff --git a/src/wp-admin/admin-ajax.php b/src/wp-admin/admin-ajax.php index 3ad60f95766e3..73190a24f92fd 100644 --- a/src/wp-admin/admin-ajax.php +++ b/src/wp-admin/admin-ajax.php @@ -80,6 +80,7 @@ 'closed-postboxes', 'hidden-columns', 'update-welcome-panel', + 'set-media-library-settings', 'menu-get-metabox', 'wp-link-ajax', 'menu-locations-save', diff --git a/src/wp-admin/css/media.css b/src/wp-admin/css/media.css index 3d7b0c9455c83..21190af953ebf 100644 --- a/src/wp-admin/css/media.css +++ b/src/wp-admin/css/media.css @@ -571,7 +571,9 @@ border color while dragging a file over the uploader drop area */ min-height: 80px; } -.media-frame.mode-grid .media-toolbar label:not(.media-search-input-label) { +/* Hides the filter labels. Child selectors only, so labels inside toolbar views keep their styling. */ +.media-frame.mode-grid .media-toolbar-primary > label:not(.media-search-input-label), +.media-frame.mode-grid .media-toolbar-secondary > label:not(.media-search-input-label) { border: 0; clip-path: inset(50%); height: 1px; diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index c51751940a976..1c3ce07b5aea1 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -1891,6 +1891,30 @@ function wp_ajax_update_welcome_panel() { wp_die( 1 ); } +/** + * Handles saving the Media Library settings for the current user via AJAX. + * + * @since 7.2.0 + */ +function wp_ajax_set_media_library_settings() { + check_ajax_referer( 'media-library-settings' ); + + if ( ! current_user_can( 'upload_files' ) ) { + wp_send_json_error( array( 'message' => __( 'Sorry, you are not allowed to edit this setting.' ) ), 403 ); + } + + if ( ! isset( $_POST['infinite_scrolling'] ) ) { + wp_send_json_error( array( 'message' => __( 'The setting could not be saved.' ) ), 400 ); + } + + $infinite_scrolling = 'true' === wp_unslash( $_POST['infinite_scrolling'] ) ? 'true' : 'false'; + + // Plain user meta, without the site prefix update_user_option() would add, to match the personal option on the profile screen. + update_user_meta( get_current_user_id(), 'infinite_scrolling', $infinite_scrolling ); + + wp_send_json_success( array( 'infiniteScrolling' => 'true' === $infinite_scrolling ) ); +} + /** * Handles for retrieving menu meta boxes via AJAX. * diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 6748a50f00c57..053507f06eed4 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -984,6 +984,103 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { display: block; } +/** + * Media Library settings + */ +/* Square icon-only button, on top of the height `button-compact` gives it. */ +.wp-core-ui .button.media-library-settings__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + padding: 0; +} + +.wp-core-ui .button.media-library-settings__toggle .dashicons { + line-height: 1; +} + +/* The grid toolbar is a flex container with a column gap of its own. */ +.wp-core-ui .media-toolbar-primary > .media-library-settings__toggle { + margin-left: 8px; +} + +.wp-core-ui .mode-grid .media-toolbar-primary > .media-library-settings__toggle { + margin-left: 0; +} + +/* Matches the 40px min-height buttons.css gives buttons at this width. */ +@media screen and (max-width: 782px) { + .wp-core-ui .button.media-library-settings__toggle { + min-width: 40px; + width: 40px; + } +} + +.media-library-settings__dialog { + width: 320px; + max-width: calc(100vw - 32px); + padding: 0; + border: 1px solid #c3c4c7; + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + background: #fff; + color: #3c434a; + font-size: 13px; +} + +.media-library-settings__dialog::backdrop { + background: rgba(0, 0, 0, 0.5); +} + +.media-library-settings__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 8px 8px 16px; + border-bottom: 1px solid #dcdcde; +} + +.media-library-settings__header h2 { + margin: 0; + font-size: 14px; + line-height: 1.4; +} + +.media-library-settings__body { + padding: 16px; +} + +.media-library-settings__field { + margin: 0; +} + +/* The padding aligns the text with the label, past the checkbox. */ +.media-library-settings__body .description { + margin: 4px 0 0; + padding-left: 1.25rem; + color: #646970; +} + +.media-library-settings__status { + margin: 12px 0 0; + min-height: 2.4em; + color: #3c434a; +} + +.wp-core-ui .media-library-settings__close.button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + border: none; +} + +.wp-core-ui .media-library-settings__close.button .dashicons { + line-height: 1; +} + /** * Attachments */ @@ -1363,6 +1460,11 @@ select#media-attachment-filters ~ select#media-attachment-date-filters { background: #dcdcde; } +/* Kept in the DOM when infinite scrolling is turned back on, so it can be reused. */ +.attachments-browser:not(.has-load-more) .load-more-wrapper { + display: none; +} + .load-more-wrapper { clear: both; display: flex; diff --git a/src/wp-includes/media-template.php b/src/wp-includes/media-template.php index 460cf3b3020e5..2d4f66047b99d 100644 --- a/src/wp-includes/media-template.php +++ b/src/wp-includes/media-template.php @@ -344,6 +344,50 @@ function wp_print_media_templates() { + + + + + +