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