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