diff --git a/src/js/media/views/focus-manager.js b/src/js/media/views/focus-manager.js index 4ac1b08fa8..193864a4df 100644 --- a/src/js/media/views/focus-manager.js +++ b/src/js/media/views/focus-manager.js @@ -1,3 +1,5 @@ +var $ = jQuery; + /** * wp.media.view.FocusManager * @@ -11,7 +13,40 @@ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.prototype */{ events: { - 'keydown': 'constrainTabbing' + 'keydown': 'focusManagementMode' + }, + + /** + * Initializes the Focus Manager. + * + * @param {object} options The Focus Manager options. + * + * @since 5.3.0 + * + * @return {void} + */ + initialize: function( options ) { + this.mode = options.mode || 'constrainTabbing'; + this.tabsAutomaticActivation = options.tabsAutomaticActivation || false; + }, + + /** + * Determines which focus management mode to use. + * + * @since 5.3.0 + * + * @param {object} event jQuery event object. + * + * @returns {void} + */ + focusManagementMode: function( event ) { + if ( this.mode === 'constrainTabbing' ) { + this.constrainTabbing( event ); + } + + if ( this.mode === 'tabsNavigation' ) { + this.tabsNavigation( event ); + } }, /** @@ -67,8 +102,10 @@ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.pr }, /** - * Hides from assistive technologies all the body children except the - * provided element and other elements that should not be hidden. + * Hides from assistive technologies all the body children. + * + * Sets an `aria-hidden="true"` attribute on all the body children except + * the provided element and other elements that should not be hidden. * * The reason why we use `aria-hidden` is that `aria-modal="true"` is buggy * in Safari 11.1 and support is spotty in other browsers. Also, `aria-modal="true"` @@ -111,7 +148,9 @@ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.pr }, /** - * Makes visible again to assistive technologies all body children + * Unhides from assistive technologies all the body children. + * + * Makes visible again to assistive technologies all the body children * previously hidden and stored in this.ariaHiddenElements. * * @since 5.2.3 @@ -165,7 +204,158 @@ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.pr * * @since 5.2.3 */ - ariaHiddenElements: [] + ariaHiddenElements: [], + + /** + * Holds the jQuery collection of ARIA tabs. + * + * @since 5.3.0 + */ + tabs: $(), + + /** + * Sets up tabs in an ARIA tabbed interface. + * + * @since 5.3.0 + * + * @param {object} event jQuery event object. + * + * @returns {void} + */ + setupAriaTabs: function() { + this.tabs = this.$( '[role="tab"]' ); + + // Set up initial attributes. + this.tabs.attr( { + 'aria-selected': 'false', + tabIndex: '-1' + } ); + + // Set up attributes on the initially active tab. + this.tabs.filter( '.active' ) + .removeAttr( 'tabindex' ) + .attr( 'aria-selected', 'true' ); + }, + + /** + * Enables arrows navigation within the ARIA tabbed interface. + * + * @since 5.3.0 + * + * @param {object} event jQuery event object. + * + * @returns {void} + */ + tabsNavigation: function( event ) { + var orientation = 'horizontal', + keys = [ 32, 35, 36, 37, 38, 39, 40 ]; + + // Return if not Spacebar, End, Home, or Arrow keys. + if ( keys.indexOf( event.which ) === -1 ) { + return; + } + + // Determine navigation direction. + if ( this.$el.attr( 'aria-orientation' ) === 'vertical' ) { + orientation = 'vertical'; + } + + // Make Up and Down arrow keys do nothing with horizontal tabs. + if ( orientation === 'horizontal' && [ 38, 40 ].indexOf( event.which ) !== -1 ) { + return; + } + + // Make Left and Right arrow keys do nothing with vertical tabs. + if ( orientation === 'vertical' && [ 37, 39 ].indexOf( event.which ) !== -1 ) { + return; + } + + this.switchTabs( event, this.tabs ); + }, + + /** + * Switches tabs in the ARIA tabbed interface. + * + * @since 5.3.0 + * + * @param {object} event jQuery event object. + * + * @returns {void} + */ + switchTabs: function( event ) { + var key = event.which, + index = this.tabs.index( $( event.target ) ), + newIndex; + + switch ( key ) { + // Space bar: Activate current targeted tab. + case 32: { + this.activateTab( this.tabs[ index ] ); + break; + } + // End key: Activate last tab. + case 35: { + event.preventDefault(); + this.activateTab( this.tabs[ this.tabs.length - 1 ] ); + break; + } + // Home key: Activate first tab. + case 36: { + event.preventDefault(); + this.activateTab( this.tabs[ 0 ] ); + break; + } + // Left and up keys: Activate previous tab. + case 37: + case 38: { + event.preventDefault(); + newIndex = ( index - 1 ) < 0 ? this.tabs.length - 1 : index - 1; + this.activateTab( this.tabs[ newIndex ] ); + break; + } + // Right and down keys: Activate next tab. + case 39: + case 40: { + event.preventDefault(); + newIndex = ( index + 1 ) === this.tabs.length ? 0 : index + 1; + this.activateTab( this.tabs[ newIndex ] ); + break; + } + } + }, + + /** + * Sets a single tab to be focusable and semantically selected. + * + * @since 5.3.0 + * + * @param {object} tab The tab DOM element. + * + * @returns {void} + */ + activateTab: function( tab ) { + if ( ! tab ) { + return; + } + + // The tab is a DOM element: no need for jQuery methods. + tab.focus(); + + // Handle automatic activation. + if ( this.tabsAutomaticActivation ) { + tab.removeAttribute( 'tabindex' ); + tab.setAttribute( 'aria-selected', 'true' ); + tab.click(); + + return; + } + + // Handle manual activation. + $( tab ).on( 'click', function() { + tab.removeAttribute( 'tabindex' ); + tab.setAttribute( 'aria-selected', 'true' ); + } ); + } }); module.exports = FocusManager; diff --git a/src/js/media/views/frame/post.js b/src/js/media/views/frame/post.js index 3d78aea40f..dbf550d0ec 100644 --- a/src/js/media/views/frame/post.js +++ b/src/js/media/views/frame/post.js @@ -257,8 +257,11 @@ Post = Select.extend(/** @lends wp.media.view.MediaFrame.Post.prototype */{ mainMenu: function( view ) { view.set({ 'library-separator': new wp.media.View({ - className: 'separator', - priority: 100 + className: 'separator', + priority: 100, + attributes: { + role: 'presentation' + } }) }); }, diff --git a/src/js/media/views/media-frame.js b/src/js/media/views/media-frame.js index 6d7e720639..b51177cfca 100644 --- a/src/js/media/views/media-frame.js +++ b/src/js/media/views/media-frame.js @@ -23,7 +23,7 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{ regions: ['menu','title','content','toolbar','router'], events: { - 'click div.media-frame-title h1': 'toggleMenu' + 'click .media-frame-menu-toggle': 'toggleMenu' }, /** @@ -75,13 +75,78 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{ this.on( 'title:create:default', this.createTitle, this ); this.title.mode('default'); - this.on( 'title:render', function( view ) { - view.$el.append( '' ); - }); - // Bind default menu. this.on( 'menu:create:default', this.createMenu, this ); + + // Set the menu ARIA tab panel attributes when the modal opens. + this.on( 'open', this.setMenuTabPanelAriaAttributes, this ); + // Set the router ARIA tab panel attributes when the modal opens. + this.on( 'open', this.setRouterTabPanelAriaAttributes, this ); + + // Update the menu ARIA tab panel attributes when the content updates. + this.on( 'content:render', this.setMenuTabPanelAriaAttributes, this ); + // Update the router ARIA tab panel attributes when the content updates. + this.on( 'content:render', this.setRouterTabPanelAriaAttributes, this ); }, + + /** + * Sets the attributes to be used on the menu ARIA tab panel. + * + * @since 5.3.0 + * + * @returns {void} + */ + setMenuTabPanelAriaAttributes: function() { + var stateId = this.state().get( 'id' ), + tabPanelEl = this.$el.find( '.media-frame-tab-panel' ), + ariaLabelledby; + + tabPanelEl.removeAttr( 'role aria-labelledby tabindex' ); + + if ( this.menuView && this.menuView.isVisible ) { + ariaLabelledby = 'menu-item-' + stateId; + + // Set the tab panel attributes only if the tabs are visible. + tabPanelEl + .attr( { + role: 'tabpanel', + 'aria-labelledby': ariaLabelledby, + tabIndex: '0' + } ); + } + }, + + /** + * Sets the attributes to be used on the router ARIA tab panel. + * + * @since 5.3.0 + * + * @returns {void} + */ + setRouterTabPanelAriaAttributes: function() { + var tabPanelEl = this.$el.find( '.media-frame-content' ), + ariaLabelledby; + + tabPanelEl.removeAttr( 'role aria-labelledby tabindex' ); + + // On the Embed view the router menu is hidden. + if ( 'embed' === this.content._mode ) { + return; + } + + // Set the tab panel attributes only if the tabs are visible. + if ( this.routerView && this.routerView.isVisible && this.content._mode ) { + ariaLabelledby = 'menu-item-' + this.content._mode; + + tabPanelEl + .attr( { + role: 'tabpanel', + 'aria-labelledby': ariaLabelledby, + tabIndex: '0' + } ); + } + }, + /** * @returns {wp.media.view.MediaFrame} Returns itself to allow chaining */ @@ -111,12 +176,22 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{ */ createMenu: function( menu ) { menu.view = new wp.media.view.Menu({ - controller: this + controller: this, + + attributes: { + role: 'tablist', + 'aria-orientation': 'vertical' + } }); + + this.menuView = menu.view; }, - toggleMenu: function() { - this.$el.find( '.media-menu' ).toggleClass( 'visible' ); + toggleMenu: function( event ) { + var menu = this.$el.find( '.media-menu' ); + + menu.toggleClass( 'visible' ); + $( event.target ).attr( 'aria-expanded', menu.hasClass( 'visible' ) ); }, /** @@ -134,8 +209,15 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{ */ createRouter: function( router ) { router.view = new wp.media.view.Router({ - controller: this + controller: this, + + attributes: { + role: 'tablist', + 'aria-orientation': 'horizontal' + } }); + + this.routerView = router.view; }, /** * @param {Object} options diff --git a/src/js/media/views/menu-item.js b/src/js/media/views/menu-item.js index 7f13f9cb48..459c0f6019 100644 --- a/src/js/media/views/menu-item.js +++ b/src/js/media/views/menu-item.js @@ -11,25 +11,23 @@ var MenuItem; * @augments Backbone.View */ MenuItem = wp.media.View.extend(/** @lends wp.media.view.MenuItem.prototype */{ - tagName: 'a', + tagName: 'button', className: 'media-menu-item', attributes: { - href: '#' + type: 'button', + role: 'tab' }, events: { 'click': '_click' }, - /** - * @param {Object} event - */ - _click: function( event ) { - var clickOverride = this.options.click; - if ( event ) { - event.preventDefault(); - } + /** + * Allows to override the click event. + */ + _click: function() { + var clickOverride = this.options.click; if ( clickOverride ) { clickOverride.call( this ); @@ -43,14 +41,17 @@ MenuItem = wp.media.View.extend(/** @lends wp.media.view.MenuItem.prototype */{ if ( state ) { this.controller.setState( state ); + // Toggle the menu visibility in the responsive view. this.views.parent.$el.removeClass( 'visible' ); // TODO: or hide on any click, see below } }, + /** * @returns {wp.media.view.MenuItem} returns itself to allow chaining */ render: function() { - var options = this.options; + var options = this.options, + menuProperty = options.state || options.contentMode; if ( options.text ) { this.$el.text( options.text ); @@ -58,6 +59,9 @@ MenuItem = wp.media.View.extend(/** @lends wp.media.view.MenuItem.prototype */{ this.$el.html( options.html ); } + // Set the menu item ID based on the frame state associated to the menu item. + this.$el.attr( 'id', 'menu-item-' + menuProperty ); + return this; } }); diff --git a/src/js/media/views/menu.js b/src/js/media/views/menu.js index 899b99d654..4ea75c7148 100644 --- a/src/js/media/views/menu.js +++ b/src/js/media/views/menu.js @@ -20,15 +20,30 @@ Menu = PriorityList.extend(/** @lends wp.media.view.Menu.prototype */{ ItemView: MenuItem, region: 'menu', - /* TODO: alternatively hide on any click anywhere - events: { - 'click': 'click' + attributes: { + role: 'tablist', + 'aria-orientation': 'horizontal' }, - click: function() { - this.$el.removeClass( 'visible' ); + initialize: function() { + this._views = {}; + + this.set( _.extend( {}, this._views, this.options.views ), { silent: true }); + delete this.options.views; + + if ( ! this.options.silent ) { + this.render(); + } + + // Initialize the Focus Manager. + this.focusManager = new wp.media.view.FocusManager( { + el: this.el, + mode: 'tabsNavigation' + } ); + + // The menu is always rendered and can be visible or hidden on some frames. + this.isVisible = true; }, - */ /** * @param {Object} options @@ -47,6 +62,9 @@ Menu = PriorityList.extend(/** @lends wp.media.view.Menu.prototype */{ */ PriorityList.prototype.ready.apply( this, arguments ); this.visibility(); + + // Set up aria tabs initial attributes. + this.focusManager.setupAriaTabs(); }, set: function() { @@ -72,6 +90,9 @@ Menu = PriorityList.extend(/** @lends wp.media.view.Menu.prototype */{ hide = ! views || views.length < 2; if ( this === view ) { + // Flag this menu as hidden or visible. + this.isVisible = ! hide; + // Set or remove a CSS class to hide the menu. this.controller.$el.toggleClass( 'hide-' + region, hide ); } }, @@ -87,6 +108,9 @@ Menu = PriorityList.extend(/** @lends wp.media.view.Menu.prototype */{ this.deselect(); view.$el.addClass('active'); + + // Set up again the aria tabs initial attributes after the menu updates. + this.focusManager.setupAriaTabs(); }, deselect: function() { diff --git a/src/js/media/views/router.js b/src/js/media/views/router.js index 85a1eee77d..32369781c0 100644 --- a/src/js/media/views/router.js +++ b/src/js/media/views/router.js @@ -20,6 +20,11 @@ Router = Menu.extend(/** @lends wp.media.view.Router.prototype */{ ItemView: wp.media.view.RouterItem, region: 'router', + attributes: { + role: 'tablist', + 'aria-orientation': 'horizontal' + }, + initialize: function() { this.controller.on( 'content:render', this.update, this ); // Call 'initialize' directly on the parent class. diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 5fc17d279a..c020b110e1 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -580,23 +580,29 @@ user-select: none; } -.media-menu > a { +.media-menu .media-menu-item { display: block; + box-sizing: border-box; + width: 100%; position: relative; - padding: 8px 20px; + border: 0; margin: 0; - line-height: 1.28571428; + padding: 8px 20px; font-size: 14px; + line-height: 1.28571428; + background: transparent; color: #0073aa; + text-align: left; text-decoration: none; + cursor: pointer; } -.media-menu > a:hover { - color: #0073aa; +.media-menu .media-menu-item:hover { background: rgba(0, 0, 0, 0.04); } -.media-menu > a:active { +.media-menu .media-menu-item:active { + color: #0073aa; outline: none; } @@ -606,6 +612,15 @@ font-weight: 600; } +.media-menu .media-menu-item:focus { + box-shadow: + 0 0 0 1px #5b9dd9, + 0 0 2px 1px rgba(30, 140, 190, 0.8); + color: #124964; + /* Only visible in Windows High Contrast mode */ + outline: 1px solid transparent; +} + .media-menu .separator { height: 0; margin: 12px 20px; @@ -621,42 +636,48 @@ padding: 0 6px; margin: 0; clear: both; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; } -.media-router a { - transition: none; -} - -.media-router > a { +.media-router .media-menu-item { position: relative; float: left; - padding: 8px 10px 9px; + border: 0; margin: 0; + padding: 8px 10px 9px; height: 18px; line-height: 1.28571428; font-size: 14px; text-decoration: none; + background: transparent; + cursor: pointer; + transition: none; } -.media-router > a:last-child { +.media-router .media-menu-item:last-child { border-right: 0; } -.media-router > a:active { - outline: none; +.media-router .media-menu-item:hover, +.media-router .media-menu-item:active { + color: #0073aa; } .media-router .active, .media-router .active:hover { - color: #32373c; + color: #23282d; +} + +.media-router .media-menu-item:focus { + box-shadow: + 0 0 0 1px #5b9dd9, + 0 0 2px 1px rgba(30, 140, 190, 0.8); + color: #124964; + /* Only visible in Windows High Contrast mode */ + outline: 1px solid transparent; } .media-router .active, -.media-router > a.active:last-child { +.media-router .media-menu-item.active:last-child { margin: -1px -1px 0; background: #fff; border: 1px solid #ddd; @@ -752,15 +773,6 @@ display: none; } -.media-frame.hide-router .media-frame-title { - border-bottom: 1px solid #ddd; - box-shadow: 0 4px 4px -4px rgba(0, 0, 0, 0.1); -} - -.media-frame-title .dashicons { - display: none; -} - .media-frame-title h1 { padding: 0 16px; font-size: 22px; @@ -768,6 +780,10 @@ margin: 0; } +.wp-core-ui .button.media-frame-menu-toggle { + display: none; +} + .media-frame-title .suggested-dimensions { font-size: 14px; float: right; @@ -2251,27 +2267,60 @@ * Responsive layout */ @media only screen and (max-width: 900px) { + .media-modal .media-frame-title { + height: 40px; + } + + .media-modal .media-frame-title h1 { + line-height: 2.22222222; + font-size: 18px; + } + + .media-modal-close { + width: 42px; + height: 42px; + } /* Drop-down menu */ - .media-frame:not(.hide-menu) .media-frame-title, + .media-frame .media-frame-title { + position: static; + padding: 0 44px; + text-align: center; + } + .media-frame:not(.hide-menu) .media-frame-router, .media-frame:not(.hide-menu) .media-frame-content, .media-frame:not(.hide-menu) .media-frame-toolbar { left: 0; } + .media-frame:not(.hide-menu) .media-frame-router { + /* 40 title + (40 - 6) menu toggle button + 6 spacing */ + top: 80px; + } + + .media-frame:not(.hide-menu) .media-frame-content { + /* 80 + room for the tabs */ + top: 114px; + } + + .media-frame.hide-router .media-frame-content { + top: 80px; + } + .media-frame:not(.hide-menu) .media-frame-menu { position: static; width: 0; } .media-frame:not(.hide-menu) .media-menu { + display: none; width: auto; max-width: 80%; overflow: auto; z-index: 2000; - top: 50px; - left: -300px; + top: 75px; + left: 0; right: auto; bottom: auto; padding: 5px 0; @@ -2279,7 +2328,7 @@ } .media-frame:not(.hide-menu) .media-menu.visible { - left: 0; + display: block; } .media-frame:not(.hide-menu) .media-menu > a { @@ -2287,29 +2336,32 @@ font-size: 16px; } - .media-frame:not(.hide-menu) .media-menu > a.active { - display: none; - } - .media-frame:not(.hide-menu) .media-menu .separator { margin: 5px 10px; } - .media-frame:not(.hide-menu) .media-frame-title { - left: 0; + .wp-core-ui .media-frame:not(.hide-menu) .button.media-frame-menu-toggle { + display: inline-flex; + align-items: center; + vertical-align: top; + min-height: 40px; + margin: -6px 6px 0; + padding: 0 2px 0 12px; + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + background: transparent; } - .media-frame:not(.hide-menu) .media-frame-title .dashicons { - display: inline-block; - line-height: 2.5; + .wp-core-ui .button.media-frame-menu-toggle:hover, + .wp-core-ui .button.media-frame-menu-toggle:active { + background: transparent; + transform: none; } - .media-frame:not(.hide-menu) .media-frame-title h1 { - color: #0073aa; - line-height: 3; - font-size: 18px; - float: left; - cursor: pointer; + .wp-core-ui .button.media-frame-menu-toggle:focus { + /* Only visible in Windows High Contrast mode */ + outline: 1px solid transparent; } /* End drop-down menu */ @@ -2561,31 +2613,6 @@ } } -/* Landscape specific header override */ -@media screen and (max-height: 400px) { - .media-menu, - .media-frame:not(.hide-menu) .media-menu { - top: 44px; - } - - .media-frame-router { - top: 44px; - } - - .media-frame-content { - top: 78px; - } - - .attachments-browser .attachments { - top: 40px; - } - - /* Prevent unnecessary scrolling on title input */ - .embed-link-settings { - overflow: visible; - } -} - @media only screen and (min-width: 901px) and (max-height: 400px) { .media-menu, .media-frame:not(.hide-menu) .media-menu { @@ -2595,38 +2622,10 @@ } @media only screen and (max-width: 480px) { - .media-modal-close { - top: -5px; - } - - .media-modal .media-frame-title { - height: 40px; - } - .wp-core-ui.wp-customizer .media-button { margin-top: 13px; } - .media-modal .media-frame-title h1, - .media-frame:not(.hide-menu) .media-frame-title h1 { - font-size: 18px; - line-height: 2.22222222; - } - - .media-frame:not(.hide-menu) .media-frame-title .dashicons { - line-height: 2; - } - - .media-frame-router, - .media-frame:not(.hide-menu) .media-menu { - top: 40px; - padding-top: 0; - } - - .media-frame-content { - top: 74px; - } - .media-frame.hide-router .media-frame-content { top: 40px; } diff --git a/src/wp-includes/media-template.php b/src/wp-includes/media-template.php index 64eba598a0..d71cca9003 100644 --- a/src/wp-includes/media-template.php +++ b/src/wp-includes/media-template.php @@ -178,9 +178,15 @@ function wp_print_media_templates() {