Skip to content

Commit

Permalink
Accessibility: Media: Use the ARIA tabs pattern for the media modal m…
Browse files Browse the repository at this point in the history
…enus.

The ARIA tabs pattern improves interaction for keyboard and assistive technologies users.
It gives the menu items proper roles, and `aria-selected` allows users of assistive technologies to know which tab is currently selected.

Props audrasjb, afercia, joedolson, karmatosed, melchoyce.
See #47149.


git-svn-id: https://develop.svn.wordpress.org/trunk@46363 602fd350-edb4-49c9-b593-d223f7449a82
  • Loading branch information
afercia committed Sep 30, 2019
1 parent da73b95 commit 6013fa1
Show file tree
Hide file tree
Showing 8 changed files with 448 additions and 135 deletions.
200 changes: 195 additions & 5 deletions src/js/media/views/focus-manager.js
Original file line number Original file line Diff line number Diff line change
@@ -1,3 +1,5 @@
var $ = jQuery;

/** /**
* wp.media.view.FocusManager * wp.media.view.FocusManager
* *
Expand All @@ -11,7 +13,40 @@
var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.prototype */{ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.prototype */{


events: { 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 );
}
}, },


/** /**
Expand Down Expand Up @@ -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 * Hides from assistive technologies all the body children.
* provided element and other elements that should not be hidden. *
* 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 * 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"` * in Safari 11.1 and support is spotty in other browsers. Also, `aria-modal="true"`
Expand Down Expand Up @@ -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. * previously hidden and stored in this.ariaHiddenElements.
* *
* @since 5.2.3 * @since 5.2.3
Expand Down Expand Up @@ -165,7 +204,158 @@ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.pr
* *
* @since 5.2.3 * @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; module.exports = FocusManager;
7 changes: 5 additions & 2 deletions src/js/media/views/frame/post.js
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -257,8 +257,11 @@ Post = Select.extend(/** @lends wp.media.view.MediaFrame.Post.prototype */{
mainMenu: function( view ) { mainMenu: function( view ) {
view.set({ view.set({
'library-separator': new wp.media.View({ 'library-separator': new wp.media.View({
className: 'separator', className: 'separator',
priority: 100 priority: 100,
attributes: {
role: 'presentation'
}
}) })
}); });
}, },
Expand Down
100 changes: 91 additions & 9 deletions src/js/media/views/media-frame.js
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{
regions: ['menu','title','content','toolbar','router'], regions: ['menu','title','content','toolbar','router'],


events: { events: {
'click div.media-frame-title h1': 'toggleMenu' 'click .media-frame-menu-toggle': 'toggleMenu'
}, },


/** /**
Expand Down Expand Up @@ -75,13 +75,78 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{
this.on( 'title:create:default', this.createTitle, this ); this.on( 'title:create:default', this.createTitle, this );
this.title.mode('default'); this.title.mode('default');


this.on( 'title:render', function( view ) {
view.$el.append( '<span class="dashicons dashicons-arrow-down"></span>' );
});

// Bind default menu. // Bind default menu.
this.on( 'menu:create:default', this.createMenu, this ); 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 * @returns {wp.media.view.MediaFrame} Returns itself to allow chaining
*/ */
Expand Down Expand Up @@ -111,12 +176,22 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{
*/ */
createMenu: function( menu ) { createMenu: function( menu ) {
menu.view = new wp.media.view.Menu({ menu.view = new wp.media.view.Menu({
controller: this controller: this,

attributes: {
role: 'tablist',
'aria-orientation': 'vertical'
}
}); });

this.menuView = menu.view;
}, },


toggleMenu: function() { toggleMenu: function( event ) {
this.$el.find( '.media-menu' ).toggleClass( 'visible' ); var menu = this.$el.find( '.media-menu' );

menu.toggleClass( 'visible' );
$( event.target ).attr( 'aria-expanded', menu.hasClass( 'visible' ) );
}, },


/** /**
Expand All @@ -134,8 +209,15 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{
*/ */
createRouter: function( router ) { createRouter: function( router ) {
router.view = new wp.media.view.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 * @param {Object} options
Expand Down
Loading

0 comments on commit 6013fa1

Please sign in to comment.