3 * Manages page tabbing modifications made by modules.
7 * Allow modules to respond to the constrain event.
9 * @event drupalTabbingConstrained
13 * Allow modules to respond to the tabbingContext release event.
15 * @event drupalTabbingContextReleased
19 * Allow modules to respond to the constrain event.
21 * @event drupalTabbingContextActivated
25 * Allow modules to respond to the constrain event.
27 * @event drupalTabbingContextDeactivated
30 (function ($, Drupal) {
35 * Provides an API for managing page tabbing order modifications.
37 * @constructor Drupal~TabbingManager
39 function TabbingManager() {
42 * Tabbing sets are stored as a stack. The active set is at the top of the
43 * stack. We use a JavaScript array as if it were a stack; we consider the
44 * first element to be the bottom and the last element to be the top. This
45 * allows us to use JavaScript's built-in Array.push() and Array.pop()
48 * @type {Array.<Drupal~TabbingContext>}
54 * Add public methods to the TabbingManager class.
56 $.extend(TabbingManager.prototype, /** @lends Drupal~TabbingManager# */{
59 * Constrain tabbing to the specified set of elements only.
61 * Makes elements outside of the specified set of elements unreachable via
64 * @param {jQuery} elements
65 * The set of elements to which tabbing should be constrained. Can also
66 * be a jQuery-compatible selector string.
68 * @return {Drupal~TabbingContext}
69 * The TabbingContext instance.
71 * @fires event:drupalTabbingConstrained
73 constrain: function (elements) {
74 // Deactivate all tabbingContexts to prepare for the new constraint. A
75 // tabbingContext instance will only be reactivated if the stack is
76 // unwound to it in the _unwindStack() method.
77 var il = this.stack.length;
78 for (var i = 0; i < il; i++) {
79 this.stack[i].deactivate();
82 // The "active tabbing set" are the elements tabbing should be constrained
84 var $elements = $(elements).find(':tabbable').addBack(':tabbable');
86 var tabbingContext = new TabbingContext({
87 // The level is the current height of the stack before this new
88 // tabbingContext is pushed on top of the stack.
89 level: this.stack.length,
90 $tabbableElements: $elements
93 this.stack.push(tabbingContext);
95 // Activates the tabbingContext; this will manipulate the DOM to constrain
97 tabbingContext.activate();
99 // Allow modules to respond to the constrain event.
100 $(document).trigger('drupalTabbingConstrained', tabbingContext);
102 return tabbingContext;
106 * Restores a former tabbingContext when an active one is released.
108 * The TabbingManager stack of tabbingContext instances will be unwound
109 * from the top-most released tabbingContext down to the first non-released
110 * tabbingContext instance. This non-released instance is then activated.
112 release: function () {
113 // Unwind as far as possible: find the topmost non-released
115 var toActivate = this.stack.length - 1;
116 while (toActivate >= 0 && this.stack[toActivate].released) {
120 // Delete all tabbingContexts after the to be activated one. They have
121 // already been deactivated, so their effect on the DOM has been reversed.
122 this.stack.splice(toActivate + 1);
124 // Get topmost tabbingContext, if one exists, and activate it.
125 if (toActivate >= 0) {
126 this.stack[toActivate].activate();
131 * Makes all elements outside of the tabbingContext's set untabbable.
133 * Elements made untabbable have their original tabindex and autofocus
134 * values stored so that they might be restored later when this
135 * tabbingContext is deactivated.
137 * @param {Drupal~TabbingContext} tabbingContext
138 * The TabbingContext instance that has been activated.
140 activate: function (tabbingContext) {
141 var $set = tabbingContext.$tabbableElements;
142 var level = tabbingContext.level;
143 // Determine which elements are reachable via tabbing by default.
144 var $disabledSet = $(':tabbable')
145 // Exclude elements of the active tabbing set.
147 // Set the disabled set on the tabbingContext.
148 tabbingContext.$disabledElements = $disabledSet;
149 // Record the tabindex for each element, so we can restore it later.
150 var il = $disabledSet.length;
151 for (var i = 0; i < il; i++) {
152 this.recordTabindex($disabledSet.eq(i), level);
154 // Make all tabbable elements outside of the active tabbing set
157 .prop('tabindex', -1)
158 .prop('autofocus', false);
160 // Set focus on an element in the tabbingContext's set of tabbable
161 // elements. First, check if there is an element with an autofocus
162 // attribute. Select the last one from the DOM order.
163 var $hasFocus = $set.filter('[autofocus]').eq(-1);
164 // If no element in the tabbable set has an autofocus attribute, select
165 // the first element in the set.
166 if ($hasFocus.length === 0) {
167 $hasFocus = $set.eq(0);
169 $hasFocus.trigger('focus');
173 * Restores that tabbable state of a tabbingContext's disabled elements.
175 * Elements that were made untabbable have their original tabindex and
176 * autofocus values restored.
178 * @param {Drupal~TabbingContext} tabbingContext
179 * The TabbingContext instance that has been deactivated.
181 deactivate: function (tabbingContext) {
182 var $set = tabbingContext.$disabledElements;
183 var level = tabbingContext.level;
184 var il = $set.length;
185 for (var i = 0; i < il; i++) {
186 this.restoreTabindex($set.eq(i), level);
191 * Records the tabindex and autofocus values of an untabbable element.
193 * @param {jQuery} $el
194 * The set of elements that have been disabled.
195 * @param {number} level
196 * The stack level for which the tabindex attribute should be recorded.
198 recordTabindex: function ($el, level) {
199 var tabInfo = $el.data('drupalOriginalTabIndices') || {};
201 tabindex: $el[0].getAttribute('tabindex'),
202 autofocus: $el[0].hasAttribute('autofocus')
204 $el.data('drupalOriginalTabIndices', tabInfo);
208 * Restores the tabindex and autofocus values of a reactivated element.
210 * @param {jQuery} $el
211 * The element that is being reactivated.
212 * @param {number} level
213 * The stack level for which the tabindex attribute should be restored.
215 restoreTabindex: function ($el, level) {
216 var tabInfo = $el.data('drupalOriginalTabIndices');
217 if (tabInfo && tabInfo[level]) {
218 var data = tabInfo[level];
220 $el[0].setAttribute('tabindex', data.tabindex);
222 // If the element did not have a tabindex at this stack level then
225 $el[0].removeAttribute('tabindex');
227 if (data.autofocus) {
228 $el[0].setAttribute('autofocus', 'autofocus');
234 $el.removeData('drupalOriginalTabIndices');
237 // Remove the data for this stack level and higher.
238 var levelToDelete = level;
239 while (tabInfo.hasOwnProperty(levelToDelete)) {
240 delete tabInfo[levelToDelete];
243 $el.data('drupalOriginalTabIndices', tabInfo);
250 * Stores a set of tabbable elements.
252 * This constraint can be removed with the release() method.
254 * @constructor Drupal~TabbingContext
256 * @param {object} options
257 * A set of initiating values
258 * @param {number} options.level
259 * The level in the TabbingManager's stack of this tabbingContext.
260 * @param {jQuery} options.$tabbableElements
261 * The DOM elements that should be reachable via the tab key when this
262 * tabbingContext is active.
263 * @param {jQuery} options.$disabledElements
264 * The DOM elements that should not be reachable via the tab key when this
265 * tabbingContext is active.
266 * @param {bool} options.released
267 * A released tabbingContext can never be activated again. It will be
268 * cleaned up when the TabbingManager unwinds its stack.
269 * @param {bool} options.active
270 * When true, the tabbable elements of this tabbingContext will be reachable
271 * via the tab key and the disabled elements will not. Only one
272 * tabbingContext can be active at a time.
274 function TabbingContext(options) {
276 $.extend(this, /** @lends Drupal~TabbingContext# */{
286 $tabbableElements: $(),
291 $disabledElements: $(),
306 * Add public methods to the TabbingContext class.
308 $.extend(TabbingContext.prototype, /** @lends Drupal~TabbingContext# */{
311 * Releases this TabbingContext.
313 * Once a TabbingContext object is released, it can never be activated
316 * @fires event:drupalTabbingContextReleased
318 release: function () {
319 if (!this.released) {
321 this.released = true;
322 Drupal.tabbingManager.release(this);
323 // Allow modules to respond to the tabbingContext release event.
324 $(document).trigger('drupalTabbingContextReleased', this);
329 * Activates this TabbingContext.
331 * @fires event:drupalTabbingContextActivated
333 activate: function () {
334 // A released TabbingContext object can never be activated again.
335 if (!this.active && !this.released) {
337 Drupal.tabbingManager.activate(this);
338 // Allow modules to respond to the constrain event.
339 $(document).trigger('drupalTabbingContextActivated', this);
344 * Deactivates this TabbingContext.
346 * @fires event:drupalTabbingContextDeactivated
348 deactivate: function () {
351 Drupal.tabbingManager.deactivate(this);
352 // Allow modules to respond to the constrain event.
353 $(document).trigger('drupalTabbingContextDeactivated', this);
358 // Mark this behavior as processed on the first pass and return if it is
359 // already processed.
360 if (Drupal.tabbingManager) {
365 * @type {Drupal~TabbingManager}
367 Drupal.tabbingManager = new TabbingManager();