diff --git a/src/js/_enqueues/wp/customize/controls.js b/src/js/_enqueues/wp/customize/controls.js index a5846d45f687c..00123f9141c30 100644 --- a/src/js/_enqueues/wp/customize/controls.js +++ b/src/js/_enqueues/wp/customize/controls.js @@ -1701,6 +1701,7 @@ filtersHeight: 0, headerContainer: null, updateCountDebounced: null, + announceThemeDebounced: null, /** * wp.customize.ThemesSection @@ -1724,6 +1725,13 @@ section.$body = $( document.body ); api.Section.prototype.initialize.call( section, id, options ); section.updateCountDebounced = _.debounce( section.updateCount, 500 ); + section.announceThemeDebounced = _.debounce( function( name ) { + if ( ! name ) { + return; + } + + wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', name ) ); + }, 500 ); }, /** @@ -1777,13 +1785,20 @@ return; } + // Require the alt key for arrow events. + if ( 27 !== event.keyCode && ! event.altKey ) { + return; + } + // Pressing the right arrow key fires a theme:next event. if ( 39 === event.keyCode ) { + event.preventDefault(); // Prevent browser from triggering history shortcuts. section.nextTheme(); } // Pressing the left arrow key fires a theme:previous event. if ( 37 === event.keyCode ) { + event.preventDefault(); // Prevent browser from triggering history shortcuts. section.previousTheme(); } @@ -2602,7 +2617,8 @@ section.$body.addClass( 'modal-open' ); section.containFocus( section.overlay ); section.updateLimits(); - wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', theme.name ) ); + + section.announceThemeDebounced( theme.name ); if ( callback ) { callback(); } @@ -2620,6 +2636,8 @@ section.$body.removeClass( 'modal-open' ); section.overlay.fadeOut( 'fast' ); api.control( section.params.action + '_theme_' + section.currentTheme ).container.find( '.theme' ).focus(); + // Cancel any pending navigation announcement. + section.announceThemeDebounced.cancel(); }, /** diff --git a/src/js/_enqueues/wp/theme.js b/src/js/_enqueues/wp/theme.js index 56107ee475057..a9964de581372 100644 --- a/src/js/_enqueues/wp/theme.js +++ b/src/js/_enqueues/wp/theme.js @@ -16,6 +16,30 @@ themes = wp.themes = wp.themes || {}; themes.data = _wpThemeSettings; l10n = themes.data.l10n; +/** + * Announces to screen readers the theme shown after previous/next navigation. + * + * @since 7.1.0 + * + * @param {Object} model The theme model. + * @return {void} + */ +themes.announceThemeDebounced = _.debounce( function( model ) { + var name; + + if ( ! model ) { + return; + } + + name = model.get( 'name' ) || model.get( 'id' ); + + if ( ! name ) { + return; + } + + wp.a11y.speak( l10n.themeViewed.replace( '%s', name ) ); +}, 500 ); + // Shortcut for isInstall check. themes.isInstall = !! themes.data.settings.isInstall; @@ -549,6 +573,7 @@ themes.view.Theme = wp.Backbone.View.extend({ preview.render(); this.setNavButtonsState(); $( '.next-theme' ).trigger( 'focus' ); + themes.announceThemeDebounced( self.current ); }) .listenTo( preview, 'theme:previous', function() { @@ -579,6 +604,7 @@ themes.view.Theme = wp.Backbone.View.extend({ preview.render(); this.setNavButtonsState(); $( '.previous-theme' ).trigger( 'focus' ); + themes.announceThemeDebounced( self.current ); }); this.listenTo( preview, 'preview:close', function() { @@ -769,6 +795,9 @@ themes.view.Details = wp.Backbone.View.extend({ } }); } + + // Cancel any pending navigation announcement. + themes.announceThemeDebounced.cancel(); }, // Handles .disabled classes for next/previous buttons. @@ -909,7 +938,7 @@ themes.view.Preview = themes.view.Details.extend({ 'click .devices button': 'previewDevice', 'click .previous-theme': 'previousTheme', 'click .next-theme': 'nextTheme', - 'keyup': 'keyEvent', + 'keydown': 'keyEvent', 'click .theme-install': 'installTheme' }, @@ -967,6 +996,9 @@ themes.view.Preview = themes.view.Details.extend({ this.trigger( 'preview:close' ); this.undelegateEvents(); this.unbind(); + + // Cancel any pending navigation announcement. + themes.announceThemeDebounced.cancel(); return false; }, @@ -1012,18 +1044,20 @@ themes.view.Preview = themes.view.Details.extend({ this.close(); } - // Return if Ctrl + Shift or Shift key pressed - if ( event.shiftKey || ( event.ctrlKey && event.shiftKey ) ) { + // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation. + if ( ! event.altKey ) { return; } // The right arrow key, next theme. if ( event.keyCode === 39 ) { - _.once( this.nextTheme() ); + event.preventDefault(); + this.nextTheme(); } // The left arrow key, previous theme. if ( event.keyCode === 37 ) { + event.preventDefault(); this.previousTheme(); } }, @@ -1111,7 +1145,7 @@ themes.view.Themes = wp.Backbone.View.extend({ } ); // Bind keyboard events. - $( 'body' ).on( 'keyup', function( event ) { + $( 'body' ).on( 'keydown.wp-themes', function( event ) { if ( ! self.overlay ) { return; } @@ -1121,25 +1155,27 @@ themes.view.Themes = wp.Backbone.View.extend({ return; } - // Return if Ctrl + Shift or Shift key pressed - if ( event.shiftKey || ( event.ctrlKey && event.shiftKey ) ) { + // Pressing the escape key fires a theme:collapse event. + if ( event.keyCode === 27 ) { + self.overlay.collapse( event ); + } + + // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation. + if ( ! event.altKey ) { return; } - // Pressing the right arrow key fires a theme:next event. + // Pressing Alt + right arrow key fires a theme:next event. if ( event.keyCode === 39 ) { + event.preventDefault(); self.overlay.nextTheme(); } - // Pressing the left arrow key fires a theme:previous event. + // Pressing Alt + left arrow key fires a theme:previous event. if ( event.keyCode === 37 ) { + event.preventDefault(); self.overlay.previousTheme(); } - - // Pressing the escape key fires a theme:collapse event. - if ( event.keyCode === 27 ) { - self.overlay.collapse( event ); - } }); }, @@ -1322,7 +1358,7 @@ themes.view.Themes = wp.Backbone.View.extend({ // Trigger a route update for the current model. self.theme.trigger( 'theme:expand', nextModel.cid ); - + themes.announceThemeDebounced( nextModel ); } }, @@ -1349,7 +1385,7 @@ themes.view.Themes = wp.Backbone.View.extend({ // Trigger a route update for the current model. self.theme.trigger( 'theme:expand', previousModel.cid ); - + themes.announceThemeDebounced( previousModel ); } }, diff --git a/src/js/media/views/frame/edit-attachments.js b/src/js/media/views/frame/edit-attachments.js index 250f1b5214665..f6bb4b8afa2cc 100644 --- a/src/js/media/views/frame/edit-attachments.js +++ b/src/js/media/views/frame/edit-attachments.js @@ -1,5 +1,6 @@ var Frame = wp.media.view.Frame, MediaFrame = wp.media.view.MediaFrame, + l10n = wp.media.view.l10n, $ = jQuery, EditAttachments; @@ -33,6 +34,30 @@ EditAttachments = MediaFrame.extend(/** @lends wp.media.view.MediaFrame.EditAtta 'click .right': 'nextMediaItem' }, + /** + * Announces to screen readers the attachment shown after previous/next navigation. + * + * @since 7.1.0 + * + * @param {Object} model The attachment model. + * @return {void} + */ + announceMediaItemDebounced: _.debounce( function( model ) { + var title; + + if ( ! model ) { + return; + } + + title = model.get( 'title' ) || model.get( 'filename' ) || model.get( 'id' ); + + if ( ! title ) { + return; + } + + wp.a11y.speak( l10n.mediaItemViewed.replace( '%s', title ) ); + }, 500 ), + initialize: function() { Frame.prototype.initialize.apply( this, arguments ); @@ -96,6 +121,8 @@ EditAttachments = MediaFrame.extend(/** @lends wp.media.view.MediaFrame.EditAtta // Move focus back to the original item in the grid if possible. $( 'li.attachment[data-id="' + this.model.get( 'id' ) +'"]' ).trigger( 'focus' ); this.resetRoute(); + // Cancel any pending navigation announcement. + this.announceMediaItemDebounced.cancel(); }, this ) ); // Set this frame as the modal's content. @@ -202,26 +229,34 @@ EditAttachments = MediaFrame.extend(/** @lends wp.media.view.MediaFrame.EditAtta * Click handler to switch to the previous media item. */ previousMediaItem: function() { + var model; + if ( ! this.hasPrevious() ) { return; } - this.trigger( 'refresh', this.library.at( this.getCurrentIndex() - 1 ) ); + model = this.library.at( this.getCurrentIndex() - 1 ); + this.trigger( 'refresh', model ); // Move focus to the Previous button. When there are no more items, to the Next button. this.focusNavButton( this.hasPrevious() ? '.left' : '.right' ); + this.announceMediaItemDebounced( model ); }, /** * Click handler to switch to the next media item. */ nextMediaItem: function() { + var model; + if ( ! this.hasNext() ) { return; } - this.trigger( 'refresh', this.library.at( this.getCurrentIndex() + 1 ) ); + model = this.library.at( this.getCurrentIndex() + 1 ); + this.trigger( 'refresh', model ); // Move focus to the Next button. When there are no more items, to the Previous button. this.focusNavButton( this.hasNext() ? '.right' : '.left' ); + this.announceMediaItemDebounced( model ); }, /** @@ -247,25 +282,28 @@ EditAttachments = MediaFrame.extend(/** @lends wp.media.view.MediaFrame.EditAtta return ( this.getCurrentIndex() - 1 ) > -1; }, /** - * Respond to the keyboard events: right arrow, left arrow, except when - * focus is in a textarea or input field. + * Respond to the keyboard events: Alt + right arrow, Alt + left arrow, + * except when focus is in a form field. Requires the Alt modifier key to + * avoid interfering with screen reader navigation. */ keyEvent: function( event ) { - if ( ( 'INPUT' === event.target.nodeName || 'TEXTAREA' === event.target.nodeName ) && ! event.target.disabled ) { + if ( ( 'INPUT' === event.target.nodeName || 'TEXTAREA' === event.target.nodeName || 'SELECT' === event.target.nodeName ) && ! event.target.disabled ) { return; } - // Return if Ctrl + Shift or Shift key pressed - if ( event.shiftKey || ( event.ctrlKey && event.shiftKey ) ) { + // Arrow key navigation requires Alt key to avoid interfering with screen reader navigation. + if ( ! event.altKey ) { return; } - // The right arrow key. + // Alt + right arrow key. if ( 39 === event.keyCode ) { + event.preventDefault(); this.nextMediaItem(); } - // The left arrow key. + // Alt + left arrow key. if ( 37 === event.keyCode ) { + event.preventDefault(); this.previousMediaItem(); } }, diff --git a/src/wp-admin/theme-install.php b/src/wp-admin/theme-install.php index fc24334abff85..8e6fc5d1eea2c 100644 --- a/src/wp-admin/theme-install.php +++ b/src/wp-admin/theme-install.php @@ -67,6 +67,8 @@ /* translators: %d: Number of themes. */ 'themesFound' => __( 'Number of Themes found: %d' ), 'noThemesFound' => __( 'No themes found. Try a different search.' ), + /* translators: %s: Theme name. */ + 'themeViewed' => __( 'Theme details: %s' ), 'collapseSidebar' => __( 'Collapse Sidebar' ), 'expandSidebar' => __( 'Expand Sidebar' ), /* translators: Hidden accessibility text. */ diff --git a/src/wp-admin/themes.php b/src/wp-admin/themes.php index a9f24765ce742..ac2cd4a9824cb 100644 --- a/src/wp-admin/themes.php +++ b/src/wp-admin/themes.php @@ -131,9 +131,10 @@ if ( current_user_can( 'switch_themes' ) ) { $help_overview = '
' . __( 'This screen is used for managing your installed themes. Aside from the default theme(s) included with your WordPress installation, themes are designed and developed by third parties.' ) . '
' . '' . __( 'From this screen you can:' ) . '
' . - 'alt/option plus the left or right arrow keys on your keyboard, to navigate between themes quickly.' ) . '' . __( 'The active theme is displayed highlighted as the first theme.' ) . '
' . '' . __( 'The search for installed themes will search for terms in their name, description, author, or tag.' ) . ' ' . __( 'The search results will be updated as you type.' ) . '
'; @@ -236,6 +237,8 @@ /* translators: %d: Number of themes. */ 'themesFound' => __( 'Number of Themes found: %d' ), 'noThemesFound' => __( 'No themes found. Try a different search.' ), + /* translators: %s: Theme name. */ + 'themeViewed' => __( 'Theme details: %s' ), ), ) ); diff --git a/src/wp-admin/upload.php b/src/wp-admin/upload.php index 1f42a287e4957..7cf0f6fe10108 100644 --- a/src/wp-admin/upload.php +++ b/src/wp-admin/upload.php @@ -190,7 +190,7 @@ function () { 'title' => __( 'Attachment Details' ), 'content' => '' . __( 'Clicking an item will display an Attachment Details dialog, which allows you to preview media and make quick edits. Any changes you make to the attachment details will be automatically saved.' ) . '
' . - '' . __( 'Use the arrow buttons at the top of the dialog, or the left and right arrow keys on your keyboard, to navigate between media items quickly.' ) . '
' . + '' . __( 'Use the buttons at the top of the dialog, or alt/option plus the left or right arrow keys on your keyboard, to navigate between media items quickly.' ) . '
' . __( 'You can also delete individual items and access the extended edit screen from the details dialog.' ) . '
', ) ); diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index c2198acf20f66..e298b04efcf90 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -4959,7 +4959,7 @@ public function customize_pane_settings() { /* translators: %d: Number of themes being displayed, which cannot currently consider singular vs. plural forms. */ 'announceThemeCount' => __( 'Displaying %d themes' ), /* translators: %s: Theme name. */ - 'announceThemeDetails' => __( 'Showing details for theme: %s' ), + 'announceThemeDetails' => __( 'Theme details: %s' ), ), ); diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 456684d08e221..1bbe0bcc0ffe9 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5189,6 +5189,8 @@ function wp_enqueue_media( $args = array() ) { 'mediaFound' => __( 'Number of media items found: %d' ), 'noMedia' => __( 'No media items found.' ), 'noMediaTryNewSearch' => __( 'No media items found. Try a different search.' ), + /* translators: %s: Media item title or file name. */ + 'mediaItemViewed' => __( 'Viewing media item: %s' ), // Library Details. 'attachmentDetails' => __( 'Attachment details' ), diff --git a/tests/qunit/wp-admin/js/theme.js b/tests/qunit/wp-admin/js/theme.js index c17a5d59d41f9..d82b26e1521db 100644 --- a/tests/qunit/wp-admin/js/theme.js +++ b/tests/qunit/wp-admin/js/theme.js @@ -12,16 +12,18 @@ nextTheme: function() { nextCalled++; }, previousTheme: function() { prevCalled++; }, keyEvent: function( event ) { - if ( event.shiftKey || event.ctrlKey || event.altKey || event.metaKey ) { + if ( event.shiftKey || event.ctrlKey || event.metaKey ) { return; } // Right arrow - if ( event.keyCode === 39 ) { + if ( event.altKey && event.keyCode === 39 ) { + event.preventDefault(); this.nextTheme(); } // Left arrow - else if ( event.keyCode === 37 ) { + else if ( event.altKey && event.keyCode === 37 ) { + event.preventDefault(); this.previousTheme(); } } @@ -34,28 +36,70 @@ themePreview = createThemePreview(); }); - QUnit.test( 'Arrow keys without modifiers', function( assert ) { + QUnit.test( 'Arrow keys with Alt modifier', function( assert ) { // Right arrow themePreview.keyEvent( $.Event( 'keydown', { keyCode: 39, + altKey: true, shiftKey: false, ctrlKey: false }) ); - assert.equal( nextCalled, 1, 'Right arrow triggers nextTheme' ); + assert.equal( nextCalled, 1, 'Alt + Right arrow triggers nextTheme' ); // Left arrow themePreview.keyEvent( $.Event( 'keydown', { keyCode: 37, + altKey: true, shiftKey: false, ctrlKey: false }) ); - assert.equal( prevCalled, 1, 'Left arrow triggers previousTheme' ); + assert.equal( prevCalled, 1, 'Alt + Left arrow triggers previousTheme' ); } ); + QUnit.test( 'Arrow keys without Alt do nothing', function( assert ) { + // Right arrow without Alt - should NOT call nextTheme + themePreview.keyEvent( $.Event( 'keydown', { + keyCode: 39, + altKey: false, + shiftKey: false, + ctrlKey: false + }) ); + assert.equal( nextCalled, 0, 'Right arrow without Alt does nothing' ); + + // Left arrow without Alt - should NOT call previousTheme + themePreview.keyEvent( $.Event( 'keydown', { + keyCode: 37, + altKey: false, + shiftKey: false, + ctrlKey: false + }) ); + assert.equal( prevCalled, 0, 'Left arrow without Alt does nothing' ); + } ); + + QUnit.test( 'PreventDefault is called for arrow keys with Alt', function( assert ) { + // This test would need to check if preventDefault was called + var event = $.Event( 'keydown', { + keyCode: 39, + altKey: true, + shiftKey: false, + ctrlKey: false + }); + + // Mock the preventDefault method to track if it's called + var preventDefaultCalled = false; + event.preventDefault = function() { + preventDefaultCalled = true; + }; + + themePreview.keyEvent( event ); + assert.ok( preventDefaultCalled, 'preventDefault is called for arrow keys with Alt' ); + }); + QUnit.test( 'Shift+Arrow keys do nothing', function( assert ) { // Shift + Right themePreview.keyEvent( $.Event( 'keydown', { keyCode: 39, + altKey: false, shiftKey: true, ctrlKey: false }) ); @@ -64,6 +108,7 @@ // Shift + Left themePreview.keyEvent( $.Event( 'keydown', { keyCode: 37, + altKey: false, shiftKey: true, ctrlKey: false }) ); @@ -74,6 +119,7 @@ // Ctrl + Right themePreview.keyEvent( $.Event( 'keydown', { keyCode: 39, + altKey: false, ctrlKey: true, shiftKey: false }) ); @@ -82,6 +128,7 @@ // Ctrl + Left themePreview.keyEvent( $.Event( 'keydown', { keyCode: 37, + altKey: false, ctrlKey: true, shiftKey: false }) );