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