var $ = jQuery; /** * wp.media.view.FocusManager * * @memberOf wp.media.view * * @class * @augments wp.media.View * @augments wp.Backbone.View * @augments Backbone.View */ var FocusManager = wp.media.View.extend(/** @lends wp.media.view.FocusManager.prototype */{ events: { '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. * * @return {void} */ focusManagementMode: function( event ) { if ( this.mode === 'constrainTabbing' ) { this.constrainTabbing( event ); } if ( this.mode === 'tabsNavigation' ) { this.tabsNavigation( event ); } }, /** * Gets all the tabbable elements. * * @since 5.3.0 * * @return {object} A jQuery collection of tabbable elements. */ getTabbables: function() { // Skip the file input added by Plupload. return this.$( ':tabbable' ).not( '.moxie-shim input[type="file"]' ); }, /** * Moves focus to the modal dialog. * * @since 3.5.0 * * @return {void} */ focus: function() { this.$( '.media-modal' ).focus(); }, /** * Constrains navigation with the Tab key within the media view element. * * @since 4.0.0 * * @param {Object} event A keydown jQuery event. * * @return {void} */ constrainTabbing: function( event ) { var tabbables; // Look for the tab key. if ( 9 !== event.keyCode ) { return; } tabbables = this.getTabbables(); // Keep tab focus within media modal while it's open. if ( tabbables.last()[0] === event.target && ! event.shiftKey ) { tabbables.first().focus(); return false; } else if ( tabbables.first()[0] === event.target && event.shiftKey ) { tabbables.last().focus(); return false; } }, /** * 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"` * prevents the `wp.a11y.speak()` ARIA live regions to work as they're outside * of the modal dialog and get hidden from assistive technologies. * * @since 5.2.3 * * @param {object} visibleElement The jQuery object representing the element that should not be hidden. * * @return {void} */ setAriaHiddenOnBodyChildren: function( visibleElement ) { var bodyChildren, self = this; if ( this.isBodyAriaHidden ) { return; } // Get all the body children. bodyChildren = document.body.children; // Loop through the body children and hide the ones that should be hidden. _.each( bodyChildren, function( element ) { // Don't hide the modal element. if ( element === visibleElement[0] ) { return; } // Determine the body children to hide. if ( self.elementShouldBeHidden( element ) ) { element.setAttribute( 'aria-hidden', 'true' ); // Store the hidden elements. self.ariaHiddenElements.push( element ); } } ); this.isBodyAriaHidden = true; }, /** * 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 * * @return {void} */ removeAriaHiddenFromBodyChildren: function() { _.each( this.ariaHiddenElements, function( element ) { element.removeAttribute( 'aria-hidden' ); } ); this.ariaHiddenElements = []; this.isBodyAriaHidden = false; }, /** * Determines if the passed element should not be hidden from assistive technologies. * * @since 5.2.3 * * @param {object} element The DOM element that should be checked. * * @return {boolean} Whether the element should not be hidden from assistive technologies. */ elementShouldBeHidden: function( element ) { var role = element.getAttribute( 'role' ), liveRegionsRoles = [ 'alert', 'status', 'log', 'marquee', 'timer' ]; /* * Don't hide scripts, elements that already have `aria-hidden`, and * ARIA live regions. */ return ! ( element.tagName === 'SCRIPT' || element.hasAttribute( 'aria-hidden' ) || element.hasAttribute( 'aria-live' ) || liveRegionsRoles.indexOf( role ) !== -1 ); }, /** * Whether the body children are hidden from assistive technologies. * * @since 5.2.3 */ isBodyAriaHidden: false, /** * Stores an array of DOM elements that should be hidden from assistive * technologies, for example when the media modal dialog opens. * * @since 5.2.3 */ 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. * * @return {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. * * @return {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. * * @return {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. * * @return {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;