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.