You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
412 lines
13 KiB
412 lines
13 KiB
/** |
|
* @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 {boolean} options.released |
|
* A released tabbingContext can never be activated again. It will be |
|
* cleaned up when the TabbingManager unwinds its stack. |
|
* @param {boolean} 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. |
|
* @param {boolean} options.trapFocus |
|
* When true, focus is trapped within the tabbable elements, i.e. focus will |
|
* remain within the browser. |
|
*/ |
|
function TabbingContext(options) { |
|
$.extend( |
|
this, |
|
/** @lends Drupal~TabbingContext# */ { |
|
/** |
|
* @type {?number} |
|
*/ |
|
level: null, |
|
|
|
/** |
|
* @type {jQuery} |
|
*/ |
|
$tabbableElements: $(), |
|
|
|
/** |
|
* @type {jQuery} |
|
*/ |
|
$disabledElements: $(), |
|
|
|
/** |
|
* @type {boolean} |
|
*/ |
|
released: false, |
|
|
|
/** |
|
* @type {boolean} |
|
*/ |
|
active: false, |
|
|
|
/** |
|
* @type {boolean} |
|
*/ |
|
trapFocus: 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. |
|
* @param {object} [options={}] |
|
* Constrain options. |
|
* @param {boolean} [options.trapFocus=false] |
|
* When true, tabbing is trapped within the set of elements and can't |
|
* leave the browser. If the final element in the set is tabbed, the |
|
* first element in the set will receive focus. If the first element in |
|
* the set is shift-tabbed, the last element in the set will receive |
|
* focus. |
|
* When false, it is possible to tab out of the browser window by |
|
* tabbing the final element in the set or shift-tabbing the first |
|
* element in the set. |
|
* |
|
* @return {Drupal~TabbingContext} |
|
* The TabbingContext instance. |
|
* |
|
* @fires event:drupalTabbingConstrained |
|
*/ |
|
constrain(elements, { trapFocus = false } = {}) { |
|
// 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), |
|
trapFocus, |
|
}); |
|
|
|
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'); |
|
|
|
// Trap focus within the set. |
|
if ($set.length && tabbingContext.trapFocus) { |
|
$set.last().on('keydown.focus-trap', (event) => { |
|
if (event.key === 'Tab' && !event.shiftKey) { |
|
event.preventDefault(); |
|
$set.first().focus(); |
|
} |
|
}); |
|
$set.first().on('keydown.focus-trap', (event) => { |
|
if (event.key === 'Tab' && event.shiftKey) { |
|
event.preventDefault(); |
|
$set.last().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; |
|
|
|
tabbingContext.$tabbableElements.first().off('keydown.focus-trap'); |
|
tabbingContext.$tabbableElements.last().off('keydown.focus-trap'); |
|
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);
|
|
|