tabbingmanager.es6.js 11 KB

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