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