Security update for Core, with self-updated composer
[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    * 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));