/**
* @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, { tabbable, isTabbable }) {
/**
* 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.<Drupal~TabbingContext>}
*/
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|Selector|Element|ElementArray|object|selection} elements
* The set of elements to which tabbing should be constrained. Can also
* be any jQuery-compatible argument.
*
* @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.
let tabbableElements = [];
$(elements).each((index, rootElement) => {
tabbableElements = [...tabbableElements, ...tabbable(rootElement)];
if (isTabbable(rootElement)) {
tabbableElements = [...tabbableElements, rootElement];
}
});
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: $(tabbableElements),
});
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(document.body))
// 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, window.tabbable);