Skip to content

Commit 6013fa1

Browse files
committed
Accessibility: Media: Use the ARIA tabs pattern for the media modal menus.
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
1 parent da73b95 commit 6013fa1

8 files changed

Lines changed: 448 additions & 135 deletions

File tree

src/js/media/views/focus-manager.js

Lines changed: 195 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
var $ = jQuery;
2+
13
/**
24
* wp.media.view.FocusManager
35
*
@@ -11,7 +13,40 @@
1113
var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.prototype */{
1214

1315
events: {
14-
'keydown': 'constrainTabbing'
16+
'keydown': 'focusManagementMode'
17+
},
18+
19+
/**
20+
* Initializes the Focus Manager.
21+
*
22+
* @param {object} options The Focus Manager options.
23+
*
24+
* @since 5.3.0
25+
*
26+
* @return {void}
27+
*/
28+
initialize: function( options ) {
29+
this.mode = options.mode || 'constrainTabbing';
30+
this.tabsAutomaticActivation = options.tabsAutomaticActivation || false;
31+
},
32+
33+
/**
34+
* Determines which focus management mode to use.
35+
*
36+
* @since 5.3.0
37+
*
38+
* @param {object} event jQuery event object.
39+
*
40+
* @returns {void}
41+
*/
42+
focusManagementMode: function( event ) {
43+
if ( this.mode === 'constrainTabbing' ) {
44+
this.constrainTabbing( event );
45+
}
46+
47+
if ( this.mode === 'tabsNavigation' ) {
48+
this.tabsNavigation( event );
49+
}
1550
},
1651

1752
/**
@@ -67,8 +102,10 @@ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.pr
67102
},
68103

69104
/**
70-
* Hides from assistive technologies all the body children except the
71-
* provided element and other elements that should not be hidden.
105+
* Hides from assistive technologies all the body children.
106+
*
107+
* Sets an `aria-hidden="true"` attribute on all the body children except
108+
* the provided element and other elements that should not be hidden.
72109
*
73110
* The reason why we use `aria-hidden` is that `aria-modal="true"` is buggy
74111
* 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
111148
},
112149

113150
/**
114-
* Makes visible again to assistive technologies all body children
151+
* Unhides from assistive technologies all the body children.
152+
*
153+
* Makes visible again to assistive technologies all the body children
115154
* previously hidden and stored in this.ariaHiddenElements.
116155
*
117156
* @since 5.2.3
@@ -165,7 +204,158 @@ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.pr
165204
*
166205
* @since 5.2.3
167206
*/
168-
ariaHiddenElements: []
207+
ariaHiddenElements: [],
208+
209+
/**
210+
* Holds the jQuery collection of ARIA tabs.
211+
*
212+
* @since 5.3.0
213+
*/
214+
tabs: $(),
215+
216+
/**
217+
* Sets up tabs in an ARIA tabbed interface.
218+
*
219+
* @since 5.3.0
220+
*
221+
* @param {object} event jQuery event object.
222+
*
223+
* @returns {void}
224+
*/
225+
setupAriaTabs: function() {
226+
this.tabs = this.$( '[role="tab"]' );
227+
228+
// Set up initial attributes.
229+
this.tabs.attr( {
230+
'aria-selected': 'false',
231+
tabIndex: '-1'
232+
} );
233+
234+
// Set up attributes on the initially active tab.
235+
this.tabs.filter( '.active' )
236+
.removeAttr( 'tabindex' )
237+
.attr( 'aria-selected', 'true' );
238+
},
239+
240+
/**
241+
* Enables arrows navigation within the ARIA tabbed interface.
242+
*
243+
* @since 5.3.0
244+
*
245+
* @param {object} event jQuery event object.
246+
*
247+
* @returns {void}
248+
*/
249+
tabsNavigation: function( event ) {
250+
var orientation = 'horizontal',
251+
keys = [ 32, 35, 36, 37, 38, 39, 40 ];
252+
253+
// Return if not Spacebar, End, Home, or Arrow keys.
254+
if ( keys.indexOf( event.which ) === -1 ) {
255+
return;
256+
}
257+
258+
// Determine navigation direction.
259+
if ( this.$el.attr( 'aria-orientation' ) === 'vertical' ) {
260+
orientation = 'vertical';
261+
}
262+
263+
// Make Up and Down arrow keys do nothing with horizontal tabs.
264+
if ( orientation === 'horizontal' && [ 38, 40 ].indexOf( event.which ) !== -1 ) {
265+
return;
266+
}
267+
268+
// Make Left and Right arrow keys do nothing with vertical tabs.
269+
if ( orientation === 'vertical' && [ 37, 39 ].indexOf( event.which ) !== -1 ) {
270+
return;
271+
}
272+
273+
this.switchTabs( event, this.tabs );
274+
},
275+
276+
/**
277+
* Switches tabs in the ARIA tabbed interface.
278+
*
279+
* @since 5.3.0
280+
*
281+
* @param {object} event jQuery event object.
282+
*
283+
* @returns {void}
284+
*/
285+
switchTabs: function( event ) {
286+
var key = event.which,
287+
index = this.tabs.index( $( event.target ) ),
288+
newIndex;
289+
290+
switch ( key ) {
291+
// Space bar: Activate current targeted tab.
292+
case 32: {
293+
this.activateTab( this.tabs[ index ] );
294+
break;
295+
}
296+
// End key: Activate last tab.
297+
case 35: {
298+
event.preventDefault();
299+
this.activateTab( this.tabs[ this.tabs.length - 1 ] );
300+
break;
301+
}
302+
// Home key: Activate first tab.
303+
case 36: {
304+
event.preventDefault();
305+
this.activateTab( this.tabs[ 0 ] );
306+
break;
307+
}
308+
// Left and up keys: Activate previous tab.
309+
case 37:
310+
case 38: {
311+
event.preventDefault();
312+
newIndex = ( index - 1 ) < 0 ? this.tabs.length - 1 : index - 1;
313+
this.activateTab( this.tabs[ newIndex ] );
314+
break;
315+
}
316+
// Right and down keys: Activate next tab.
317+
case 39:
318+
case 40: {
319+
event.preventDefault();
320+
newIndex = ( index + 1 ) === this.tabs.length ? 0 : index + 1;
321+
this.activateTab( this.tabs[ newIndex ] );
322+
break;
323+
}
324+
}
325+
},
326+
327+
/**
328+
* Sets a single tab to be focusable and semantically selected.
329+
*
330+
* @since 5.3.0
331+
*
332+
* @param {object} tab The tab DOM element.
333+
*
334+
* @returns {void}
335+
*/
336+
activateTab: function( tab ) {
337+
if ( ! tab ) {
338+
return;
339+
}
340+
341+
// The tab is a DOM element: no need for jQuery methods.
342+
tab.focus();
343+
344+
// Handle automatic activation.
345+
if ( this.tabsAutomaticActivation ) {
346+
tab.removeAttribute( 'tabindex' );
347+
tab.setAttribute( 'aria-selected', 'true' );
348+
tab.click();
349+
350+
return;
351+
}
352+
353+
// Handle manual activation.
354+
$( tab ).on( 'click', function() {
355+
tab.removeAttribute( 'tabindex' );
356+
tab.setAttribute( 'aria-selected', 'true' );
357+
} );
358+
}
169359
});
170360

171361
module.exports = FocusManager;

src/js/media/views/frame/post.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,11 @@ Post = Select.extend(/** @lends wp.media.view.MediaFrame.Post.prototype */{
257257
mainMenu: function( view ) {
258258
view.set({
259259
'library-separator': new wp.media.View({
260-
className: 'separator',
261-
priority: 100
260+
className: 'separator',
261+
priority: 100,
262+
attributes: {
263+
role: 'presentation'
264+
}
262265
})
263266
});
264267
},

src/js/media/views/media-frame.js

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{
2323
regions: ['menu','title','content','toolbar','router'],
2424

2525
events: {
26-
'click div.media-frame-title h1': 'toggleMenu'
26+
'click .media-frame-menu-toggle': 'toggleMenu'
2727
},
2828

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

78-
this.on( 'title:render', function( view ) {
79-
view.$el.append( '<span class="dashicons dashicons-arrow-down"></span>' );
80-
});
81-
8278
// Bind default menu.
8379
this.on( 'menu:create:default', this.createMenu, this );
80+
81+
// Set the menu ARIA tab panel attributes when the modal opens.
82+
this.on( 'open', this.setMenuTabPanelAriaAttributes, this );
83+
// Set the router ARIA tab panel attributes when the modal opens.
84+
this.on( 'open', this.setRouterTabPanelAriaAttributes, this );
85+
86+
// Update the menu ARIA tab panel attributes when the content updates.
87+
this.on( 'content:render', this.setMenuTabPanelAriaAttributes, this );
88+
// Update the router ARIA tab panel attributes when the content updates.
89+
this.on( 'content:render', this.setRouterTabPanelAriaAttributes, this );
90+
},
91+
92+
/**
93+
* Sets the attributes to be used on the menu ARIA tab panel.
94+
*
95+
* @since 5.3.0
96+
*
97+
* @returns {void}
98+
*/
99+
setMenuTabPanelAriaAttributes: function() {
100+
var stateId = this.state().get( 'id' ),
101+
tabPanelEl = this.$el.find( '.media-frame-tab-panel' ),
102+
ariaLabelledby;
103+
104+
tabPanelEl.removeAttr( 'role aria-labelledby tabindex' );
105+
106+
if ( this.menuView && this.menuView.isVisible ) {
107+
ariaLabelledby = 'menu-item-' + stateId;
108+
109+
// Set the tab panel attributes only if the tabs are visible.
110+
tabPanelEl
111+
.attr( {
112+
role: 'tabpanel',
113+
'aria-labelledby': ariaLabelledby,
114+
tabIndex: '0'
115+
} );
116+
}
117+
},
118+
119+
/**
120+
* Sets the attributes to be used on the router ARIA tab panel.
121+
*
122+
* @since 5.3.0
123+
*
124+
* @returns {void}
125+
*/
126+
setRouterTabPanelAriaAttributes: function() {
127+
var tabPanelEl = this.$el.find( '.media-frame-content' ),
128+
ariaLabelledby;
129+
130+
tabPanelEl.removeAttr( 'role aria-labelledby tabindex' );
131+
132+
// On the Embed view the router menu is hidden.
133+
if ( 'embed' === this.content._mode ) {
134+
return;
135+
}
136+
137+
// Set the tab panel attributes only if the tabs are visible.
138+
if ( this.routerView && this.routerView.isVisible && this.content._mode ) {
139+
ariaLabelledby = 'menu-item-' + this.content._mode;
140+
141+
tabPanelEl
142+
.attr( {
143+
role: 'tabpanel',
144+
'aria-labelledby': ariaLabelledby,
145+
tabIndex: '0'
146+
} );
147+
}
84148
},
149+
85150
/**
86151
* @returns {wp.media.view.MediaFrame} Returns itself to allow chaining
87152
*/
@@ -111,12 +176,22 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{
111176
*/
112177
createMenu: function( menu ) {
113178
menu.view = new wp.media.view.Menu({
114-
controller: this
179+
controller: this,
180+
181+
attributes: {
182+
role: 'tablist',
183+
'aria-orientation': 'vertical'
184+
}
115185
});
186+
187+
this.menuView = menu.view;
116188
},
117189

118-
toggleMenu: function() {
119-
this.$el.find( '.media-menu' ).toggleClass( 'visible' );
190+
toggleMenu: function( event ) {
191+
var menu = this.$el.find( '.media-menu' );
192+
193+
menu.toggleClass( 'visible' );
194+
$( event.target ).attr( 'aria-expanded', menu.hasClass( 'visible' ) );
120195
},
121196

122197
/**
@@ -134,8 +209,15 @@ MediaFrame = Frame.extend(/** @lends wp.media.view.MediaFrame.prototype */{
134209
*/
135210
createRouter: function( router ) {
136211
router.view = new wp.media.view.Router({
137-
controller: this
212+
controller: this,
213+
214+
attributes: {
215+
role: 'tablist',
216+
'aria-orientation': 'horizontal'
217+
}
138218
});
219+
220+
this.routerView = router.view;
139221
},
140222
/**
141223
* @param {Object} options

0 commit comments

Comments
 (0)