tabbingmanager.es6.js 12 KB

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