/** * @file * Manages page tabbing modifications made by modules. */ /** * Allow modules to respond to the constrain event. * * @event drupalTabbingConstrained */ /** * Allow modules to respond to the tabbingContext release event. * * @event drupalTabbingContextReleased */ /** * Allow modules to respond to the constrain event. * * @event drupalTabbingContextActivated */ /** * Allow modules to respond to the constrain event. * * @event drupalTabbingContextDeactivated */ (function($, Drupal) { /** * Provides an API for managing page tabbing order modifications. * * @constructor Drupal~TabbingManager */ function TabbingManager() { /** * Tabbing sets are stored as a stack. The active set is at the top of the * stack. We use a JavaScript array as if it were a stack; we consider the * first element to be the bottom and the last element to be the top. This * allows us to use JavaScript's built-in Array.push() and Array.pop() * methods. * * @type {Array.} */ this.stack = []; } /** * Stores a set of tabbable elements. * * This constraint can be removed with the release() method. * * @constructor Drupal~TabbingContext * * @param {object} options * A set of initiating values * @param {number} options.level * The level in the TabbingManager's stack of this tabbingContext. * @param {jQuery} options.$tabbableElements * The DOM elements that should be reachable via the tab key when this * tabbingContext is active. * @param {jQuery} options.$disabledElements * The DOM elements that should not be reachable via the tab key when this * tabbingContext is active. * @param {bool} options.released * A released tabbingContext can never be activated again. It will be * cleaned up when the TabbingManager unwinds its stack. * @param {bool} options.active * When true, the tabbable elements of this tabbingContext will be reachable * via the tab key and the disabled elements will not. Only one * tabbingContext can be active at a time. */ function TabbingContext(options) { $.extend( this, /** @lends Drupal~TabbingContext# */ { /** * @type {?number} */ level: null, /** * @type {jQuery} */ $tabbableElements: $(), /** * @type {jQuery} */ $disabledElements: $(), /** * @type {bool} */ released: false, /** * @type {bool} */ active: false, }, options, ); } /** * Add public methods to the TabbingManager class. */ $.extend( TabbingManager.prototype, /** @lends Drupal~TabbingManager# */ { /** * Constrain tabbing to the specified set of elements only. * * Makes elements outside of the specified set of elements unreachable via * the tab key. * * @param {jQuery} elements * The set of elements to which tabbing should be constrained. Can also * be a jQuery-compatible selector string. * * @return {Drupal~TabbingContext} * The TabbingContext instance. * * @fires event:drupalTabbingConstrained */ constrain(elements) { // Deactivate all tabbingContexts to prepare for the new constraint. A // tabbingContext instance will only be reactivated if the stack is // unwound to it in the _unwindStack() method. const il = this.stack.length; for (let i = 0; i < il; i++) { this.stack[i].deactivate(); } // The "active tabbing set" are the elements tabbing should be constrained // to. const $elements = $(elements) .find(':tabbable') .addBack(':tabbable'); const tabbingContext = new TabbingContext({ // The level is the current height of the stack before this new // tabbingContext is pushed on top of the stack. level: this.stack.length, $tabbableElements: $elements, }); this.stack.push(tabbingContext); // Activates the tabbingContext; this will manipulate the DOM to constrain // tabbing. tabbingContext.activate(); // Allow modules to respond to the constrain event. $(document).trigger('drupalTabbingConstrained', tabbingContext); return tabbingContext; }, /** * Restores a former tabbingContext when an active one is released. * * The TabbingManager stack of tabbingContext instances will be unwound * from the top-most released tabbingContext down to the first non-released * tabbingContext instance. This non-released instance is then activated. */ release() { // Unwind as far as possible: find the topmost non-released // tabbingContext. let toActivate = this.stack.length - 1; while (toActivate >= 0 && this.stack[toActivate].released) { toActivate--; } // Delete all tabbingContexts after the to be activated one. They have // already been deactivated, so their effect on the DOM has been reversed. this.stack.splice(toActivate + 1); // Get topmost tabbingContext, if one exists, and activate it. if (toActivate >= 0) { this.stack[toActivate].activate(); } }, /** * Makes all elements outside of the tabbingContext's set untabbable. * * Elements made untabbable have their original tabindex and autofocus * values stored so that they might be restored later when this * tabbingContext is deactivated. * * @param {Drupal~TabbingContext} tabbingContext * The TabbingContext instance that has been activated. */ activate(tabbingContext) { const $set = tabbingContext.$tabbableElements; const level = tabbingContext.level; // Determine which elements are reachable via tabbing by default. const $disabledSet = $(':tabbable') // Exclude elements of the active tabbing set. .not($set); // Set the disabled set on the tabbingContext. tabbingContext.$disabledElements = $disabledSet; // Record the tabindex for each element, so we can restore it later. const il = $disabledSet.length; for (let i = 0; i < il; i++) { this.recordTabindex($disabledSet.eq(i), level); } // Make all tabbable elements outside of the active tabbing set // unreachable. $disabledSet.prop('tabindex', -1).prop('autofocus', false); // Set focus on an element in the tabbingContext's set of tabbable // elements. First, check if there is an element with an autofocus // attribute. Select the last one from the DOM order. let $hasFocus = $set.filter('[autofocus]').eq(-1); // If no element in the tabbable set has an autofocus attribute, select // the first element in the set. if ($hasFocus.length === 0) { $hasFocus = $set.eq(0); } $hasFocus.trigger('focus'); }, /** * Restores that tabbable state of a tabbingContext's disabled elements. * * Elements that were made untabbable have their original tabindex and * autofocus values restored. * * @param {Drupal~TabbingContext} tabbingContext * The TabbingContext instance that has been deactivated. */ deactivate(tabbingContext) { const $set = tabbingContext.$disabledElements; const level = tabbingContext.level; const il = $set.length; for (let i = 0; i < il; i++) { this.restoreTabindex($set.eq(i), level); } }, /** * Records the tabindex and autofocus values of an untabbable element. * * @param {jQuery} $el * The set of elements that have been disabled. * @param {number} level * The stack level for which the tabindex attribute should be recorded. */ recordTabindex($el, level) { const tabInfo = $el.data('drupalOriginalTabIndices') || {}; tabInfo[level] = { tabindex: $el[0].getAttribute('tabindex'), autofocus: $el[0].hasAttribute('autofocus'), }; $el.data('drupalOriginalTabIndices', tabInfo); }, /** * Restores the tabindex and autofocus values of a reactivated element. * * @param {jQuery} $el * The element that is being reactivated. * @param {number} level * The stack level for which the tabindex attribute should be restored. */ restoreTabindex($el, level) { const tabInfo = $el.data('drupalOriginalTabIndices'); if (tabInfo && tabInfo[level]) { const data = tabInfo[level]; if (data.tabindex) { $el[0].setAttribute('tabindex', data.tabindex); } // If the element did not have a tabindex at this stack level then // remove it. else { $el[0].removeAttribute('tabindex'); } if (data.autofocus) { $el[0].setAttribute('autofocus', 'autofocus'); } // Clean up $.data. if (level === 0) { // Remove all data. $el.removeData('drupalOriginalTabIndices'); } else { // Remove the data for this stack level and higher. let levelToDelete = level; while (tabInfo.hasOwnProperty(levelToDelete)) { delete tabInfo[levelToDelete]; levelToDelete++; } $el.data('drupalOriginalTabIndices', tabInfo); } } }, }, ); /** * Add public methods to the TabbingContext class. */ $.extend( TabbingContext.prototype, /** @lends Drupal~TabbingContext# */ { /** * Releases this TabbingContext. * * Once a TabbingContext object is released, it can never be activated * again. * * @fires event:drupalTabbingContextReleased */ release() { if (!this.released) { this.deactivate(); this.released = true; Drupal.tabbingManager.release(this); // Allow modules to respond to the tabbingContext release event. $(document).trigger('drupalTabbingContextReleased', this); } }, /** * Activates this TabbingContext. * * @fires event:drupalTabbingContextActivated */ activate() { // A released TabbingContext object can never be activated again. if (!this.active && !this.released) { this.active = true; Drupal.tabbingManager.activate(this); // Allow modules to respond to the constrain event. $(document).trigger('drupalTabbingContextActivated', this); } }, /** * Deactivates this TabbingContext. * * @fires event:drupalTabbingContextDeactivated */ deactivate() { if (this.active) { this.active = false; Drupal.tabbingManager.deactivate(this); // Allow modules to respond to the constrain event. $(document).trigger('drupalTabbingContextDeactivated', this); } }, }, ); // Mark this behavior as processed on the first pass and return if it is // already processed. if (Drupal.tabbingManager) { return; } /** * @type {Drupal~TabbingManager} */ Drupal.tabbingManager = new TabbingManager(); })(jQuery, Drupal);