comparison core/misc/tabbingmanager.es6.js @ 0:c75dbcec494b

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