PageSourceSearch

https://www.jynarque.com/core/misc/tabbingmanager.js

js jynarque.com collected 2026-10-08 00:24:11 UTC 13,704 bytes, 412 lines download raw bytes

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.