vertical-tabs.es6.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. /**
  2. * @file
  3. * Defines vertical tabs functionality.
  4. *
  5. * This file replaces core/misc/vertical-tabs.js to fix some bugs in the
  6. * original implementation, as well as makes minor changes to enable Claro
  7. * designs:
  8. * 1. Replaces hard-coded markup and adds 'js-' prefixed CSS classes for the
  9. * JavaScript functionality (https://www.drupal.org/node/3081489).
  10. * - The original Drupal.behavior and Drupal.verticalTab object hard-code
  11. * markup of the tab list and (the outermost) wrapper of the vertical tabs
  12. * component.
  13. * - The original Drupal.verticalTab object is built on the same (unprefixed)
  14. * CSS classes that should be used only for theming the component:
  15. * - .vertical-tabs__pane - replaced by .js-vertical-tabs-pane;
  16. * - .vertical-tabs__menu-item - replaced by .js-vertical-tabs-menu-item;
  17. * - .vertical-tab--hidden - replaced by .js-vertical-tab-hidden.
  18. * 2. Fixes accessibility bugs (https://www.drupal.org/node/3081500):
  19. * - The original Drupal.verticalTab object doesn't take care of the right
  20. * aria attributes. Every details summary element is described with
  21. * aria-expanded="false" and aria-pressed="false".
  22. * - The original Drupal.verticalTab object uses a non-unique CSS id
  23. * '#active-vertical-tab' for the marker of the active menu tab. This leads
  24. * to broken behavior on filter format and editor configuration form where
  25. * multiple vertical tabs may appear
  26. * (/admin/config/content/formats/manage/basic_html).
  27. * - Auto-focus bug: if the vertical tab is activated by pressing enter on
  28. * the vertical tab menu link, the original Drupal.verticalTab object tries
  29. * to focus the first visible :input element in a vertical tab content. The
  30. * implementation doesn't work in all scenarios. For example, on the
  31. * 'Filter format and editor' form
  32. * (/admin/config/content/formats/manage/basic_html), if the user presses
  33. * the enter key on the last vertical tabs element's menu link ('Filter
  34. * settings'), the focused element will be the first vertical tabs
  35. * ('CKEditor plugin settings') active input, and not the expected one.
  36. * 3. Consistency between browsers (https://www.drupal.org/node/3081508):
  37. * We have to display the setting summary on the 'accordion look' as well.
  38. * Using the original file, these are displayed only on browsers without
  39. * HTML5 details support, where core's built-in core/misc/collapse.js HTML5
  40. * details polyfill is in action.
  41. * 4. Help fulfill our custom needs (https://www.drupal.org/node/3081519):
  42. * The original behavior applies its features only when the actual screen
  43. * width is bigger than 640 pixels (or the value of the
  44. * drupalSettings.widthBreakpoint). But we want to switch between the
  45. * 'accordion look' and 'tab look' dynamically, right after the browser
  46. * viewport was resized, and not only on page load.
  47. * This would be possible even by defining drupalSettings.widthBreakpoint
  48. * with '0' value. But since the name of this configuration does not suggest
  49. * that it is (and will be) used only by vertical tabs, it is much cleaner
  50. * to remove the unneeded condition from the functionality.
  51. */
  52. /**
  53. * Triggers when form values inside a vertical tab changes.
  54. *
  55. * This is used to update the summary in vertical tabs in order to know what
  56. * are the important fields' values.
  57. *
  58. * @event summaryUpdated
  59. */
  60. (($, Drupal) => {
  61. /**
  62. * Show the parent vertical tab pane of a targeted page fragment.
  63. *
  64. * In order to make sure a targeted element inside a vertical tab pane is
  65. * visible on a hash change or fragment link click, show all parent panes.
  66. *
  67. * @param {jQuery.Event} e
  68. * The event triggered.
  69. * @param {jQuery} $target
  70. * The targeted node as a jQuery object.
  71. */
  72. const handleFragmentLinkClickOrHashChange = (e, $target) => {
  73. $target.parents('.js-vertical-tabs-pane').each((index, pane) => {
  74. $(pane)
  75. .data('verticalTab')
  76. .focus();
  77. });
  78. };
  79. /**
  80. * This script transforms a set of details into a stack of vertical tabs.
  81. *
  82. * Each tab may have a summary which can be updated by another
  83. * script. For that to work, each details element has an associated
  84. * 'verticalTabCallback' (with jQuery.data() attached to the details),
  85. * which is called every time the user performs an update to a form
  86. * element inside the tab pane.
  87. *
  88. * @type {Drupal~behavior}
  89. *
  90. * @prop {Drupal~behaviorAttach} attach
  91. * Attaches behaviors for vertical tabs.
  92. */
  93. Drupal.behaviors.claroVerticalTabs = {
  94. attach(context) {
  95. /**
  96. * Binds a listener to handle fragment link clicks and URL hash changes.
  97. */
  98. $('body')
  99. .once('vertical-tabs-fragments')
  100. .on(
  101. 'formFragmentLinkClickOrHashChange.verticalTabs',
  102. handleFragmentLinkClickOrHashChange,
  103. );
  104. $(context)
  105. .find('[data-vertical-tabs-panes]')
  106. .once('vertical-tabs')
  107. .each(function initializeVerticalTabs() {
  108. const $this = $(this).addClass('vertical-tabs__items--processed');
  109. const focusID = $this.find(':hidden.vertical-tabs__active-tab').val();
  110. let tabFocus;
  111. // Check if there are some details that can be converted to
  112. // vertical-tabs.
  113. const $details = $this.find('> details');
  114. if ($details.length === 0) {
  115. return;
  116. }
  117. // Create the tab column.
  118. const tabList = $(Drupal.theme.verticalTabListWrapper());
  119. $this
  120. .wrap(
  121. $(Drupal.theme.verticalTabsWrapper()).addClass(
  122. 'js-vertical-tabs',
  123. ),
  124. )
  125. .before(tabList);
  126. // Transform each details into a tab.
  127. $details.each(function initializeVerticalTabItems() {
  128. const $that = $(this);
  129. /* eslint-disable new-cap */
  130. const verticalTab = new Drupal.verticalTab({
  131. title: $that.find('> summary').text(),
  132. details: $that,
  133. });
  134. /* eslint-enable new-cap */
  135. tabList.append(verticalTab.item);
  136. $that
  137. // prop() can't be used on browsers not supporting details
  138. // element, the style won't apply to them if prop() is used.
  139. .removeAttr('open')
  140. .addClass('js-vertical-tabs-pane')
  141. .data('verticalTab', verticalTab);
  142. if (this.id === focusID) {
  143. tabFocus = $that;
  144. }
  145. });
  146. if (!tabFocus) {
  147. // If the current URL has a fragment and one of the tabs contains an
  148. // element that matches the URL fragment, activate that tab.
  149. const $locationHash = $this.find(window.location.hash);
  150. if (window.location.hash && $locationHash.length) {
  151. tabFocus = $locationHash.is('.js-vertical-tabs-pane')
  152. ? $locationHash
  153. : $locationHash.closest('.js-vertical-tabs-pane');
  154. } else {
  155. tabFocus = $this.find('> .js-vertical-tabs-pane').eq(0);
  156. }
  157. }
  158. if (tabFocus.length) {
  159. tabFocus.data('verticalTab').focus(false);
  160. }
  161. });
  162. },
  163. };
  164. /**
  165. * The vertical tab object represents a single tab within a tab group.
  166. *
  167. * @constructor
  168. *
  169. * @param {object} settings
  170. * Settings object.
  171. * @param {string} settings.title
  172. * The name of the tab.
  173. * @param {jQuery} settings.details
  174. * The jQuery object of the details element that is the tab pane.
  175. *
  176. * @fires event:summaryUpdated
  177. *
  178. * @listens event:summaryUpdated
  179. */
  180. Drupal.verticalTab = function verticalTab(settings) {
  181. const self = this;
  182. $.extend(this, settings, Drupal.theme('verticalTab', settings));
  183. this.item.addClass('js-vertical-tabs-menu-item');
  184. this.link.attr('href', `#${settings.details.attr('id')}`);
  185. this.detailsSummaryDescription = $(
  186. Drupal.theme.verticalTabDetailsDescription(),
  187. ).appendTo(this.details.find('> summary'));
  188. this.link.on('click', event => {
  189. event.preventDefault();
  190. self.focus();
  191. });
  192. this.details.on('toggle', event => {
  193. // We will control this by summary clicks.
  194. event.preventDefault();
  195. });
  196. // Open the tab for every browser, with or without details support.
  197. this.details
  198. .find('> summary')
  199. .on('click', event => {
  200. event.preventDefault();
  201. self.details.attr('open', true);
  202. if (self.details.hasClass('collapse-processed')) {
  203. setTimeout(() => {
  204. self.focus();
  205. }, 10);
  206. } else {
  207. self.focus();
  208. }
  209. })
  210. .on('keydown', event => {
  211. if (event.keyCode === 13) {
  212. // Set focus on the first input field of the current visible details/tab
  213. // pane.
  214. setTimeout(() => {
  215. self.details
  216. .find(':input:visible:enabled')
  217. .eq(0)
  218. .trigger('focus');
  219. }, 10);
  220. }
  221. });
  222. // Keyboard events added:
  223. // Pressing the Enter key will open the tab pane.
  224. this.link.on('keydown', event => {
  225. if (event.keyCode === 13) {
  226. event.preventDefault();
  227. self.focus();
  228. // Set focus on the first input field of the current visible details/tab
  229. // pane.
  230. self.details
  231. .find(':input:visible:enabled')
  232. .eq(0)
  233. .trigger('focus');
  234. }
  235. });
  236. this.details
  237. .on('summaryUpdated', () => {
  238. self.updateSummary();
  239. })
  240. .trigger('summaryUpdated');
  241. };
  242. Drupal.verticalTab.prototype = {
  243. /**
  244. * Displays the tab's content pane.
  245. *
  246. * @param {bool} triggerFocus
  247. * Whether focus should be triggered for the summary element.
  248. */
  249. focus(triggerFocus = true) {
  250. this.details
  251. .siblings('.js-vertical-tabs-pane')
  252. .each(function closeOtherTabs() {
  253. const tab = $(this).data('verticalTab');
  254. if (tab.details.attr('open')) {
  255. tab.details
  256. .removeAttr('open')
  257. .find('> summary')
  258. .attr({
  259. 'aria-expanded': 'false',
  260. 'aria-pressed': 'false',
  261. });
  262. tab.item.removeClass('is-selected');
  263. }
  264. })
  265. .end()
  266. .siblings(':hidden.vertical-tabs__active-tab')
  267. .val(this.details.attr('id'));
  268. this.details
  269. .attr('open', true)
  270. .find('> summary')
  271. .attr({
  272. 'aria-expanded': 'true',
  273. 'aria-pressed': 'true',
  274. })
  275. .closest('.js-vertical-tabs')
  276. .find('.js-vertical-tab-active')
  277. .remove();
  278. if (triggerFocus) {
  279. const $summary = this.details.find('> summary');
  280. if ($summary.is(':visible')) {
  281. $summary.trigger('focus');
  282. }
  283. }
  284. this.item.addClass('is-selected');
  285. // Mark the active tab for screen readers.
  286. this.title.after(
  287. $(Drupal.theme.verticalTabActiveTabIndicator()).addClass(
  288. 'js-vertical-tab-active',
  289. ),
  290. );
  291. },
  292. /**
  293. * Updates the tab's summary.
  294. */
  295. updateSummary() {
  296. const summary = this.details.drupalGetSummary();
  297. this.detailsSummaryDescription.html(summary);
  298. this.summary.html(summary);
  299. },
  300. /**
  301. * Shows a vertical tab pane.
  302. *
  303. * @return {Drupal.verticalTab}
  304. * The verticalTab instance.
  305. */
  306. tabShow() {
  307. // Display the tab.
  308. this.item.removeClass('vertical-tabs__menu-item--hidden').show();
  309. // Show the vertical tabs.
  310. this.item.closest('.js-form-type-vertical-tabs').show();
  311. // Display the details element.
  312. this.details
  313. .removeClass('vertical-tab--hidden js-vertical-tab-hidden')
  314. .show();
  315. // Update first and last CSS classes for details.
  316. this.details
  317. .parent()
  318. .children('.js-vertical-tabs-pane')
  319. .removeClass('vertical-tabs__item--first vertical-tabs__item--last')
  320. .filter(':visible')
  321. .eq(0)
  322. .addClass('vertical-tabs__item--first');
  323. this.details
  324. .parent()
  325. .children('.js-vertical-tabs-pane')
  326. .filter(':visible')
  327. .eq(-1)
  328. .addClass('vertical-tabs__item--last');
  329. // Make tab active, but without triggering focus.
  330. this.focus(false);
  331. return this;
  332. },
  333. /**
  334. * Hides a vertical tab pane.
  335. *
  336. * @return {Drupal.verticalTab}
  337. * The verticalTab instance.
  338. */
  339. tabHide() {
  340. // Hide this tab.
  341. this.item.addClass('vertical-tabs__menu-item--hidden').hide();
  342. // Hide the details element.
  343. this.details
  344. .addClass('vertical-tab--hidden js-vertical-tab-hidden')
  345. .hide();
  346. // Update first and last CSS classes for details.
  347. this.details
  348. .parent()
  349. .children('.js-vertical-tabs-pane')
  350. .removeClass('vertical-tabs__item--first vertical-tabs__item--last')
  351. .filter(':visible')
  352. .eq(0)
  353. .addClass('vertical-tabs__item--first');
  354. this.details
  355. .parent()
  356. .children('.js-vertical-tabs-pane')
  357. .filter(':visible')
  358. .eq(-1)
  359. .addClass('vertical-tabs__item--last');
  360. // Focus the first visible tab (if there is one).
  361. const $firstTab = this.details
  362. .siblings('.js-vertical-tabs-pane:not(.js-vertical-tab-hidden)')
  363. .eq(0);
  364. if ($firstTab.length) {
  365. $firstTab.data('verticalTab').focus(false);
  366. }
  367. // Hide the vertical tabs (if no tabs remain).
  368. else {
  369. this.item.closest('.js-form-type-vertical-tabs').hide();
  370. }
  371. return this;
  372. },
  373. };
  374. /**
  375. * Theme function for a vertical tab.
  376. *
  377. * @param {object} settings
  378. * An object with the following keys:
  379. * @param {string} settings.title
  380. * The name of the tab.
  381. *
  382. * @return {object}
  383. * This function has to return an object with at least these keys:
  384. * - item: The root tab jQuery element
  385. * - link: The anchor tag that acts as the clickable area of the tab
  386. * (jQuery version)
  387. * - summary: The jQuery element that contains the tab summary
  388. */
  389. Drupal.theme.verticalTab = settings => {
  390. const tab = {};
  391. tab.item = $(
  392. '<li class="vertical-tabs__menu-item" tabindex="-1"></li>',
  393. ).append(
  394. (tab.link = $('<a href="#" class="vertical-tabs__menu-link"></a>').append(
  395. $('<span class="vertical-tabs__menu-link-content"></span>')
  396. .append(
  397. (tab.title = $(
  398. '<strong class="vertical-tabs__menu-link-title"></strong>',
  399. ).text(settings.title)),
  400. )
  401. .append(
  402. (tab.summary = $(
  403. '<span class="vertical-tabs__menu-link-summary"></span>',
  404. )),
  405. ),
  406. )),
  407. );
  408. return tab;
  409. };
  410. /**
  411. * Wrapper of the menu and the panes.
  412. *
  413. * @return {string}
  414. * A string representing the DOM fragment.
  415. */
  416. Drupal.theme.verticalTabsWrapper = () =>
  417. '<div class="vertical-tabs clearfix"></div>';
  418. /**
  419. * The wrapper of the vertical tab menu items.
  420. *
  421. * @return {string}
  422. * A string representing the DOM fragment.
  423. */
  424. Drupal.theme.verticalTabListWrapper = () =>
  425. '<ul class="vertical-tabs__menu"></ul>';
  426. /**
  427. * The wrapper of the details summary message added to the summary element.
  428. *
  429. * @return {string}
  430. * A string representing the DOM fragment.
  431. */
  432. Drupal.theme.verticalTabDetailsDescription = () =>
  433. '<span class="vertical-tabs__details-summary-summary"></span>';
  434. /**
  435. * Themes the active vertical tab menu item message.
  436. *
  437. * @return {string}
  438. * A string representing the DOM fragment.
  439. */
  440. Drupal.theme.verticalTabActiveTabIndicator = () =>
  441. `<span class="visually-hidden">${Drupal.t('(active tab)')}</span>`;
  442. })(jQuery, Drupal);