Chris@0
|
1 /**
|
Chris@0
|
2 * @file
|
Chris@0
|
3 * Manages page tabbing modifications made by modules.
|
Chris@0
|
4 */
|
Chris@0
|
5
|
Chris@0
|
6 /**
|
Chris@0
|
7 * Allow modules to respond to the constrain event.
|
Chris@0
|
8 *
|
Chris@0
|
9 * @event drupalTabbingConstrained
|
Chris@0
|
10 */
|
Chris@0
|
11
|
Chris@0
|
12 /**
|
Chris@0
|
13 * Allow modules to respond to the tabbingContext release event.
|
Chris@0
|
14 *
|
Chris@0
|
15 * @event drupalTabbingContextReleased
|
Chris@0
|
16 */
|
Chris@0
|
17
|
Chris@0
|
18 /**
|
Chris@0
|
19 * Allow modules to respond to the constrain event.
|
Chris@0
|
20 *
|
Chris@0
|
21 * @event drupalTabbingContextActivated
|
Chris@0
|
22 */
|
Chris@0
|
23
|
Chris@0
|
24 /**
|
Chris@0
|
25 * Allow modules to respond to the constrain event.
|
Chris@0
|
26 *
|
Chris@0
|
27 * @event drupalTabbingContextDeactivated
|
Chris@0
|
28 */
|
Chris@0
|
29
|
Chris@17
|
30 (function($, Drupal) {
|
Chris@0
|
31 /**
|
Chris@0
|
32 * Provides an API for managing page tabbing order modifications.
|
Chris@0
|
33 *
|
Chris@0
|
34 * @constructor Drupal~TabbingManager
|
Chris@0
|
35 */
|
Chris@0
|
36 function TabbingManager() {
|
Chris@0
|
37 /**
|
Chris@0
|
38 * Tabbing sets are stored as a stack. The active set is at the top of the
|
Chris@0
|
39 * stack. We use a JavaScript array as if it were a stack; we consider the
|
Chris@0
|
40 * first element to be the bottom and the last element to be the top. This
|
Chris@0
|
41 * allows us to use JavaScript's built-in Array.push() and Array.pop()
|
Chris@0
|
42 * methods.
|
Chris@0
|
43 *
|
Chris@0
|
44 * @type {Array.<Drupal~TabbingContext>}
|
Chris@0
|
45 */
|
Chris@0
|
46 this.stack = [];
|
Chris@0
|
47 }
|
Chris@0
|
48
|
Chris@0
|
49 /**
|
Chris@0
|
50 * Stores a set of tabbable elements.
|
Chris@0
|
51 *
|
Chris@0
|
52 * This constraint can be removed with the release() method.
|
Chris@0
|
53 *
|
Chris@0
|
54 * @constructor Drupal~TabbingContext
|
Chris@0
|
55 *
|
Chris@0
|
56 * @param {object} options
|
Chris@0
|
57 * A set of initiating values
|
Chris@0
|
58 * @param {number} options.level
|
Chris@0
|
59 * The level in the TabbingManager's stack of this tabbingContext.
|
Chris@0
|
60 * @param {jQuery} options.$tabbableElements
|
Chris@0
|
61 * The DOM elements that should be reachable via the tab key when this
|
Chris@0
|
62 * tabbingContext is active.
|
Chris@0
|
63 * @param {jQuery} options.$disabledElements
|
Chris@0
|
64 * The DOM elements that should not be reachable via the tab key when this
|
Chris@0
|
65 * tabbingContext is active.
|
Chris@0
|
66 * @param {bool} options.released
|
Chris@0
|
67 * A released tabbingContext can never be activated again. It will be
|
Chris@0
|
68 * cleaned up when the TabbingManager unwinds its stack.
|
Chris@0
|
69 * @param {bool} options.active
|
Chris@0
|
70 * When true, the tabbable elements of this tabbingContext will be reachable
|
Chris@0
|
71 * via the tab key and the disabled elements will not. Only one
|
Chris@0
|
72 * tabbingContext can be active at a time.
|
Chris@0
|
73 */
|
Chris@0
|
74 function TabbingContext(options) {
|
Chris@17
|
75 $.extend(
|
Chris@17
|
76 this,
|
Chris@17
|
77 /** @lends Drupal~TabbingContext# */ {
|
Chris@17
|
78 /**
|
Chris@17
|
79 * @type {?number}
|
Chris@17
|
80 */
|
Chris@17
|
81 level: null,
|
Chris@17
|
82
|
Chris@17
|
83 /**
|
Chris@17
|
84 * @type {jQuery}
|
Chris@17
|
85 */
|
Chris@17
|
86 $tabbableElements: $(),
|
Chris@17
|
87
|
Chris@17
|
88 /**
|
Chris@17
|
89 * @type {jQuery}
|
Chris@17
|
90 */
|
Chris@17
|
91 $disabledElements: $(),
|
Chris@17
|
92
|
Chris@17
|
93 /**
|
Chris@17
|
94 * @type {bool}
|
Chris@17
|
95 */
|
Chris@17
|
96 released: false,
|
Chris@17
|
97
|
Chris@17
|
98 /**
|
Chris@17
|
99 * @type {bool}
|
Chris@17
|
100 */
|
Chris@17
|
101 active: false,
|
Chris@17
|
102 },
|
Chris@17
|
103 options,
|
Chris@17
|
104 );
|
Chris@17
|
105 }
|
Chris@17
|
106
|
Chris@17
|
107 /**
|
Chris@17
|
108 * Add public methods to the TabbingManager class.
|
Chris@17
|
109 */
|
Chris@17
|
110 $.extend(
|
Chris@17
|
111 TabbingManager.prototype,
|
Chris@17
|
112 /** @lends Drupal~TabbingManager# */ {
|
Chris@17
|
113 /**
|
Chris@17
|
114 * Constrain tabbing to the specified set of elements only.
|
Chris@17
|
115 *
|
Chris@17
|
116 * Makes elements outside of the specified set of elements unreachable via
|
Chris@17
|
117 * the tab key.
|
Chris@17
|
118 *
|
Chris@17
|
119 * @param {jQuery} elements
|
Chris@17
|
120 * The set of elements to which tabbing should be constrained. Can also
|
Chris@17
|
121 * be a jQuery-compatible selector string.
|
Chris@17
|
122 *
|
Chris@17
|
123 * @return {Drupal~TabbingContext}
|
Chris@17
|
124 * The TabbingContext instance.
|
Chris@17
|
125 *
|
Chris@17
|
126 * @fires event:drupalTabbingConstrained
|
Chris@17
|
127 */
|
Chris@17
|
128 constrain(elements) {
|
Chris@17
|
129 // Deactivate all tabbingContexts to prepare for the new constraint. A
|
Chris@17
|
130 // tabbingContext instance will only be reactivated if the stack is
|
Chris@17
|
131 // unwound to it in the _unwindStack() method.
|
Chris@17
|
132 const il = this.stack.length;
|
Chris@17
|
133 for (let i = 0; i < il; i++) {
|
Chris@17
|
134 this.stack[i].deactivate();
|
Chris@17
|
135 }
|
Chris@17
|
136
|
Chris@17
|
137 // The "active tabbing set" are the elements tabbing should be constrained
|
Chris@17
|
138 // to.
|
Chris@17
|
139 const $elements = $(elements)
|
Chris@17
|
140 .find(':tabbable')
|
Chris@17
|
141 .addBack(':tabbable');
|
Chris@17
|
142
|
Chris@17
|
143 const tabbingContext = new TabbingContext({
|
Chris@17
|
144 // The level is the current height of the stack before this new
|
Chris@17
|
145 // tabbingContext is pushed on top of the stack.
|
Chris@17
|
146 level: this.stack.length,
|
Chris@17
|
147 $tabbableElements: $elements,
|
Chris@17
|
148 });
|
Chris@17
|
149
|
Chris@17
|
150 this.stack.push(tabbingContext);
|
Chris@17
|
151
|
Chris@17
|
152 // Activates the tabbingContext; this will manipulate the DOM to constrain
|
Chris@17
|
153 // tabbing.
|
Chris@17
|
154 tabbingContext.activate();
|
Chris@17
|
155
|
Chris@17
|
156 // Allow modules to respond to the constrain event.
|
Chris@17
|
157 $(document).trigger('drupalTabbingConstrained', tabbingContext);
|
Chris@17
|
158
|
Chris@17
|
159 return tabbingContext;
|
Chris@17
|
160 },
|
Chris@0
|
161
|
Chris@0
|
162 /**
|
Chris@17
|
163 * Restores a former tabbingContext when an active one is released.
|
Chris@17
|
164 *
|
Chris@17
|
165 * The TabbingManager stack of tabbingContext instances will be unwound
|
Chris@17
|
166 * from the top-most released tabbingContext down to the first non-released
|
Chris@17
|
167 * tabbingContext instance. This non-released instance is then activated.
|
Chris@0
|
168 */
|
Chris@17
|
169 release() {
|
Chris@17
|
170 // Unwind as far as possible: find the topmost non-released
|
Chris@17
|
171 // tabbingContext.
|
Chris@17
|
172 let toActivate = this.stack.length - 1;
|
Chris@17
|
173 while (toActivate >= 0 && this.stack[toActivate].released) {
|
Chris@17
|
174 toActivate--;
|
Chris@17
|
175 }
|
Chris@17
|
176
|
Chris@17
|
177 // Delete all tabbingContexts after the to be activated one. They have
|
Chris@17
|
178 // already been deactivated, so their effect on the DOM has been reversed.
|
Chris@17
|
179 this.stack.splice(toActivate + 1);
|
Chris@17
|
180
|
Chris@17
|
181 // Get topmost tabbingContext, if one exists, and activate it.
|
Chris@17
|
182 if (toActivate >= 0) {
|
Chris@17
|
183 this.stack[toActivate].activate();
|
Chris@17
|
184 }
|
Chris@17
|
185 },
|
Chris@0
|
186
|
Chris@0
|
187 /**
|
Chris@17
|
188 * Makes all elements outside of the tabbingContext's set untabbable.
|
Chris@17
|
189 *
|
Chris@17
|
190 * Elements made untabbable have their original tabindex and autofocus
|
Chris@17
|
191 * values stored so that they might be restored later when this
|
Chris@17
|
192 * tabbingContext is deactivated.
|
Chris@17
|
193 *
|
Chris@17
|
194 * @param {Drupal~TabbingContext} tabbingContext
|
Chris@17
|
195 * The TabbingContext instance that has been activated.
|
Chris@0
|
196 */
|
Chris@17
|
197 activate(tabbingContext) {
|
Chris@17
|
198 const $set = tabbingContext.$tabbableElements;
|
Chris@17
|
199 const level = tabbingContext.level;
|
Chris@17
|
200 // Determine which elements are reachable via tabbing by default.
|
Chris@17
|
201 const $disabledSet = $(':tabbable')
|
Chris@17
|
202 // Exclude elements of the active tabbing set.
|
Chris@17
|
203 .not($set);
|
Chris@17
|
204 // Set the disabled set on the tabbingContext.
|
Chris@17
|
205 tabbingContext.$disabledElements = $disabledSet;
|
Chris@17
|
206 // Record the tabindex for each element, so we can restore it later.
|
Chris@17
|
207 const il = $disabledSet.length;
|
Chris@17
|
208 for (let i = 0; i < il; i++) {
|
Chris@17
|
209 this.recordTabindex($disabledSet.eq(i), level);
|
Chris@17
|
210 }
|
Chris@17
|
211 // Make all tabbable elements outside of the active tabbing set
|
Chris@17
|
212 // unreachable.
|
Chris@17
|
213 $disabledSet.prop('tabindex', -1).prop('autofocus', false);
|
Chris@17
|
214
|
Chris@17
|
215 // Set focus on an element in the tabbingContext's set of tabbable
|
Chris@17
|
216 // elements. First, check if there is an element with an autofocus
|
Chris@17
|
217 // attribute. Select the last one from the DOM order.
|
Chris@17
|
218 let $hasFocus = $set.filter('[autofocus]').eq(-1);
|
Chris@17
|
219 // If no element in the tabbable set has an autofocus attribute, select
|
Chris@17
|
220 // the first element in the set.
|
Chris@17
|
221 if ($hasFocus.length === 0) {
|
Chris@17
|
222 $hasFocus = $set.eq(0);
|
Chris@17
|
223 }
|
Chris@17
|
224 $hasFocus.trigger('focus');
|
Chris@17
|
225 },
|
Chris@0
|
226
|
Chris@0
|
227 /**
|
Chris@17
|
228 * Restores that tabbable state of a tabbingContext's disabled elements.
|
Chris@17
|
229 *
|
Chris@17
|
230 * Elements that were made untabbable have their original tabindex and
|
Chris@17
|
231 * autofocus values restored.
|
Chris@17
|
232 *
|
Chris@17
|
233 * @param {Drupal~TabbingContext} tabbingContext
|
Chris@17
|
234 * The TabbingContext instance that has been deactivated.
|
Chris@0
|
235 */
|
Chris@17
|
236 deactivate(tabbingContext) {
|
Chris@17
|
237 const $set = tabbingContext.$disabledElements;
|
Chris@17
|
238 const level = tabbingContext.level;
|
Chris@17
|
239 const il = $set.length;
|
Chris@17
|
240 for (let i = 0; i < il; i++) {
|
Chris@17
|
241 this.restoreTabindex($set.eq(i), level);
|
Chris@17
|
242 }
|
Chris@17
|
243 },
|
Chris@0
|
244
|
Chris@0
|
245 /**
|
Chris@17
|
246 * Records the tabindex and autofocus values of an untabbable element.
|
Chris@17
|
247 *
|
Chris@17
|
248 * @param {jQuery} $el
|
Chris@17
|
249 * The set of elements that have been disabled.
|
Chris@17
|
250 * @param {number} level
|
Chris@17
|
251 * The stack level for which the tabindex attribute should be recorded.
|
Chris@0
|
252 */
|
Chris@17
|
253 recordTabindex($el, level) {
|
Chris@17
|
254 const tabInfo = $el.data('drupalOriginalTabIndices') || {};
|
Chris@17
|
255 tabInfo[level] = {
|
Chris@17
|
256 tabindex: $el[0].getAttribute('tabindex'),
|
Chris@17
|
257 autofocus: $el[0].hasAttribute('autofocus'),
|
Chris@17
|
258 };
|
Chris@17
|
259 $el.data('drupalOriginalTabIndices', tabInfo);
|
Chris@17
|
260 },
|
Chris@0
|
261
|
Chris@0
|
262 /**
|
Chris@17
|
263 * Restores the tabindex and autofocus values of a reactivated element.
|
Chris@17
|
264 *
|
Chris@17
|
265 * @param {jQuery} $el
|
Chris@17
|
266 * The element that is being reactivated.
|
Chris@17
|
267 * @param {number} level
|
Chris@17
|
268 * The stack level for which the tabindex attribute should be restored.
|
Chris@0
|
269 */
|
Chris@17
|
270 restoreTabindex($el, level) {
|
Chris@17
|
271 const tabInfo = $el.data('drupalOriginalTabIndices');
|
Chris@17
|
272 if (tabInfo && tabInfo[level]) {
|
Chris@17
|
273 const data = tabInfo[level];
|
Chris@17
|
274 if (data.tabindex) {
|
Chris@17
|
275 $el[0].setAttribute('tabindex', data.tabindex);
|
Chris@17
|
276 }
|
Chris@17
|
277 // If the element did not have a tabindex at this stack level then
|
Chris@17
|
278 // remove it.
|
Chris@17
|
279 else {
|
Chris@17
|
280 $el[0].removeAttribute('tabindex');
|
Chris@17
|
281 }
|
Chris@17
|
282 if (data.autofocus) {
|
Chris@17
|
283 $el[0].setAttribute('autofocus', 'autofocus');
|
Chris@17
|
284 }
|
Chris@17
|
285
|
Chris@17
|
286 // Clean up $.data.
|
Chris@17
|
287 if (level === 0) {
|
Chris@17
|
288 // Remove all data.
|
Chris@17
|
289 $el.removeData('drupalOriginalTabIndices');
|
Chris@17
|
290 } else {
|
Chris@17
|
291 // Remove the data for this stack level and higher.
|
Chris@17
|
292 let levelToDelete = level;
|
Chris@17
|
293 while (tabInfo.hasOwnProperty(levelToDelete)) {
|
Chris@17
|
294 delete tabInfo[levelToDelete];
|
Chris@17
|
295 levelToDelete++;
|
Chris@17
|
296 }
|
Chris@17
|
297 $el.data('drupalOriginalTabIndices', tabInfo);
|
Chris@17
|
298 }
|
Chris@17
|
299 }
|
Chris@17
|
300 },
|
Chris@17
|
301 },
|
Chris@17
|
302 );
|
Chris@0
|
303
|
Chris@0
|
304 /**
|
Chris@0
|
305 * Add public methods to the TabbingContext class.
|
Chris@0
|
306 */
|
Chris@17
|
307 $.extend(
|
Chris@17
|
308 TabbingContext.prototype,
|
Chris@17
|
309 /** @lends Drupal~TabbingContext# */ {
|
Chris@17
|
310 /**
|
Chris@17
|
311 * Releases this TabbingContext.
|
Chris@17
|
312 *
|
Chris@17
|
313 * Once a TabbingContext object is released, it can never be activated
|
Chris@17
|
314 * again.
|
Chris@17
|
315 *
|
Chris@17
|
316 * @fires event:drupalTabbingContextReleased
|
Chris@17
|
317 */
|
Chris@17
|
318 release() {
|
Chris@17
|
319 if (!this.released) {
|
Chris@17
|
320 this.deactivate();
|
Chris@17
|
321 this.released = true;
|
Chris@17
|
322 Drupal.tabbingManager.release(this);
|
Chris@17
|
323 // Allow modules to respond to the tabbingContext release event.
|
Chris@17
|
324 $(document).trigger('drupalTabbingContextReleased', this);
|
Chris@17
|
325 }
|
Chris@17
|
326 },
|
Chris@0
|
327
|
Chris@17
|
328 /**
|
Chris@17
|
329 * Activates this TabbingContext.
|
Chris@17
|
330 *
|
Chris@17
|
331 * @fires event:drupalTabbingContextActivated
|
Chris@17
|
332 */
|
Chris@17
|
333 activate() {
|
Chris@17
|
334 // A released TabbingContext object can never be activated again.
|
Chris@17
|
335 if (!this.active && !this.released) {
|
Chris@17
|
336 this.active = true;
|
Chris@17
|
337 Drupal.tabbingManager.activate(this);
|
Chris@17
|
338 // Allow modules to respond to the constrain event.
|
Chris@17
|
339 $(document).trigger('drupalTabbingContextActivated', this);
|
Chris@17
|
340 }
|
Chris@17
|
341 },
|
Chris@17
|
342
|
Chris@17
|
343 /**
|
Chris@17
|
344 * Deactivates this TabbingContext.
|
Chris@17
|
345 *
|
Chris@17
|
346 * @fires event:drupalTabbingContextDeactivated
|
Chris@17
|
347 */
|
Chris@17
|
348 deactivate() {
|
Chris@17
|
349 if (this.active) {
|
Chris@17
|
350 this.active = false;
|
Chris@17
|
351 Drupal.tabbingManager.deactivate(this);
|
Chris@17
|
352 // Allow modules to respond to the constrain event.
|
Chris@17
|
353 $(document).trigger('drupalTabbingContextDeactivated', this);
|
Chris@17
|
354 }
|
Chris@17
|
355 },
|
Chris@0
|
356 },
|
Chris@17
|
357 );
|
Chris@0
|
358
|
Chris@0
|
359 // Mark this behavior as processed on the first pass and return if it is
|
Chris@0
|
360 // already processed.
|
Chris@0
|
361 if (Drupal.tabbingManager) {
|
Chris@0
|
362 return;
|
Chris@0
|
363 }
|
Chris@0
|
364
|
Chris@0
|
365 /**
|
Chris@0
|
366 * @type {Drupal~TabbingManager}
|
Chris@0
|
367 */
|
Chris@0
|
368 Drupal.tabbingManager = new TabbingManager();
|
Chris@17
|
369 })(jQuery, Drupal);
|