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