--- /dev/null
+/**
+ * @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.<Drupal~TabbingContext>}
+ */
+ this.stack = [];
+ }
+
+ /**
+ * 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);
+ }
+ }
+ },
+ });
+
+ /**
+ * 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 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));