diff --git a/core/misc/collapse.es6.js b/core/misc/collapse.es6.js index 4683e84..374af0c 100644 --- a/core/misc/collapse.es6.js +++ b/core/misc/collapse.es6.js @@ -137,6 +137,28 @@ }, }; + /** + * Open parent details elements of a targeted page fragment. + * + * Opens all (nested) details element on a hash change or fragment link click + * when the target is a child element, in order to make sure the targeted + * element is visible. Aria attributes on the summary + * are set by triggering the click event listener in details-aria.js. + * + * @param {jQuery.Event} e + * The event triggered. + * @param {jQuery} $target + * The targeted node as a jQuery object. + */ + const handleFragmentLinkClickOrHashChange = (e, $target) => { + $target.parents('details').not('[open]').find('> summary').trigger('click'); + }; + + /** + * Binds a listener to handle fragment link clicks and URL hash changes. + */ + $('body').on('formFragmentLinkClickOrHashChange.details', handleFragmentLinkClickOrHashChange); + // Expose constructor in the public space. Drupal.CollapsibleDetails = CollapsibleDetails; }(jQuery, Modernizr, Drupal)); diff --git a/core/misc/collapse.js b/core/misc/collapse.js index 67b8606..2f157ba 100644 --- a/core/misc/collapse.js +++ b/core/misc/collapse.js @@ -77,5 +77,11 @@ } }; + var handleFragmentLinkClickOrHashChange = function handleFragmentLinkClickOrHashChange(e, $target) { + $target.parents('details').not('[open]').find('> summary').trigger('click'); + }; + + $('body').on('formFragmentLinkClickOrHashChange.details', handleFragmentLinkClickOrHashChange); + Drupal.CollapsibleDetails = CollapsibleDetails; })(jQuery, Modernizr, Drupal); \ No newline at end of file diff --git a/core/misc/form.es6.js b/core/misc/form.es6.js index 581eca4..96fc990 100644 --- a/core/misc/form.es6.js +++ b/core/misc/form.es6.js @@ -12,6 +12,16 @@ * @event formUpdated */ +/** + * Triggers when a click on a page fragment link or hash change is detected. + * + * The event triggers when the fragment in the URL changes (a hash change) and + * when a link containing a fragment identifier is clicked. In case the hash + * changes due to a click this event will only be triggered once. + * + * @event formFragmentLinkClickOrHashChange + */ + (function ($, Drupal, debounce) { /** * Retrieves the summary for the first element. @@ -245,4 +255,45 @@ }); }, }; + + /** + * Sends a fragment interaction event on a hash change or fragment link click. + * + * @param {jQuery.Event} e + * The event triggered. + * + * @fires event:formFragmentLinkClickOrHashChange + */ + const handleFragmentLinkClickOrHashChange = (e) => { + let $target; + + if (e.type === 'click') { + $target = e.currentTarget.location ? $(e.currentTarget.location.hash) : $(e.currentTarget.hash); + } + else { + $target = $(`#${location.hash.substr(1)}`); + } + + $('body').trigger('formFragmentLinkClickOrHashChange', [$target]); + + /** + * Clicking a fragment link or a hash change should focus the target + * element, but event timing issues in multiple browsers require a timeout. + */ + setTimeout(() => { + $target.focus(); + }, 300, $target); + }; + + // Binds a listener to handle URL fragment changes. + $(window).on('hashchange.form-fragment', debounce(handleFragmentLinkClickOrHashChange, 300, true)); + + /** + * Binds a listener to handle clicks on fragment links and absolute URL links + * containing a fragment, this is needed next to the hash change listener + * because clicking such links doesn't trigger a hash change when the fragment + * is already in the URL. + */ + $(document).on('click.form-fragment', 'a[href*="#"]', debounce(handleFragmentLinkClickOrHashChange, 300, true)); + }(jQuery, Drupal, Drupal.debounce)); diff --git a/core/misc/form.js b/core/misc/form.js index e55d574..b5689db 100644 --- a/core/misc/form.js +++ b/core/misc/form.js @@ -124,4 +124,24 @@ }); } }; + + var handleFragmentLinkClickOrHashChange = function handleFragmentLinkClickOrHashChange(e) { + var $target = void 0; + + if (e.type === 'click') { + $target = e.currentTarget.location ? $(e.currentTarget.location.hash) : $(e.currentTarget.hash); + } else { + $target = $('#' + location.hash.substr(1)); + } + + $('body').trigger('formFragmentLinkClickOrHashChange', [$target]); + + setTimeout(function () { + $target.focus(); + }, 300, $target); + }; + + $(window).on('hashchange.form-fragment', debounce(handleFragmentLinkClickOrHashChange, 300, true)); + + $(document).on('click.form-fragment', 'a[href*="#"]', debounce(handleFragmentLinkClickOrHashChange, 300, true)); })(jQuery, Drupal, Drupal.debounce); \ No newline at end of file diff --git a/core/misc/vertical-tabs.es6.js b/core/misc/vertical-tabs.es6.js index 9c351d7..74272d0 100644 --- a/core/misc/vertical-tabs.es6.js +++ b/core/misc/vertical-tabs.es6.js @@ -14,6 +14,23 @@ (function ($, Drupal, drupalSettings) { /** + * Show the parent vertical tab pane of a targeted page fragment. + * + * In order to make sure a targeted element inside a vertical tab pane is + * visible on a hash change or fragment link click, show all parent panes. + * + * @param {jQuery.Event} e + * The event triggered. + * @param {jQuery} $target + * The targeted node as a jQuery object. + */ + const handleFragmentLinkClickOrHashChange = (e, $target) => { + $target.parents('.vertical-tabs__pane').each((index, pane) => { + $(pane).data('verticalTab').focus(); + }); + }; + + /** * This script transforms a set of details into a stack of vertical tabs. * * Each tab may have a summary which can be updated by another @@ -36,6 +53,11 @@ return; } + /** + * Binds a listener to handle fragment link clicks and URL hash changes. + */ + $('body').once('vertical-tabs-fragments').on('formFragmentLinkClickOrHashChange.verticalTabs', handleFragmentLinkClickOrHashChange); + $(context).find('[data-vertical-tabs-panes]').once('vertical-tabs').each(function () { const $this = $(this).addClass('vertical-tabs__panes'); const focusID = $this.find(':hidden.vertical-tabs__active-tab').val(); diff --git a/core/misc/vertical-tabs.js b/core/misc/vertical-tabs.js index b5ea23b..ea89e85 100644 --- a/core/misc/vertical-tabs.js +++ b/core/misc/vertical-tabs.js @@ -6,6 +6,12 @@ **/ (function ($, Drupal, drupalSettings) { + var handleFragmentLinkClickOrHashChange = function handleFragmentLinkClickOrHashChange(e, $target) { + $target.parents('.vertical-tabs__pane').each(function (index, pane) { + $(pane).data('verticalTab').focus(); + }); + }; + Drupal.behaviors.verticalTabs = { attach: function attach(context) { var width = drupalSettings.widthBreakpoint || 640; @@ -15,6 +21,8 @@ return; } + $('body').once('vertical-tabs-fragments').on('formFragmentLinkClickOrHashChange.verticalTabs', handleFragmentLinkClickOrHashChange); + $(context).find('[data-vertical-tabs-panes]').once('vertical-tabs').each(function () { var $this = $(this).addClass('vertical-tabs__panes'); var focusID = $this.find(':hidden.vertical-tabs__active-tab').val(); diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/Form/FormGroupingElementsTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/Form/FormGroupingElementsTest.php new file mode 100644 index 0000000..92b3a73 --- /dev/null +++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/Form/FormGroupingElementsTest.php @@ -0,0 +1,133 @@ +drupalCreateUser(); + $this->drupalLogin($account); + } + + /** + * Tests that vertical tab children become visible. + * + * Makes sure that a child element of a vertical tab that is not visible, + * becomes visible when the tab is clicked, a fragment link to the child is + * clicked or when the URI fragment pointing to that child changes. + */ + public function testVerticalTabChildVisibility() { + $session = $this->getSession(); + $web_assert = $this->assertSession(); + + // Request the group vertical tabs testing page with a fragment identifier + // to the second element. + $this->drupalGet('form-test/group-vertical-tabs', ['fragment' => 'edit-element-2']); + + $page = $session->getPage(); + + $tab_link_1 = $page->find('css', '.vertical-tabs__menu-item > a'); + + $child_1_selector = '#edit-element'; + $child_1 = $page->find('css', $child_1_selector); + + $child_2_selector = '#edit-element-2'; + $child_2 = $page->find('css', $child_2_selector); + + // Assert that the child in the second vertical tab becomes visible. + // It should be visible after initial load due to the fragment in the URI. + $this->assertTrue($child_2->isVisible(), 'Child 2 is visible due to a URI fragment'); + + // Click on a fragment link pointing to an invisible child inside an + // inactive vertical tab. + $session->executeScript("jQuery('').insertAfter('h1')[0].click()"); + + // Assert that the child in the first vertical tab becomes visible. + $web_assert->waitForElementVisible('css', $child_1_selector, 50); + + // Trigger a URI fragment change (hashchange) to show the second vertical + // tab again. + $session->executeScript("location.replace('$child_2_selector')"); + + // Assert that the child in the second vertical tab becomes visible again. + $web_assert->waitForElementVisible('css', $child_2_selector, 50); + + $tab_link_1->click(); + + // Assert that the child in the first vertical tab is visible again after + // a click on the first tab. + $this->assertTrue($child_1->isVisible(), 'Child 1 is visible after clicking the parent tab'); + } + + /** + * Tests that details element children become visible. + * + * Makes sure that a child element of a details element that is not visible, + * becomes visible when a fragment link to the child is clicked or when the + * URI fragment pointing to that child changes. + */ + public function testDetailsChildVisibility() { + $session = $this->getSession(); + $web_assert = $this->assertSession(); + + // Store reusable JavaScript code to remove the current URI fragment and + // close all details. + $reset_js = "location.replace('#'); jQuery('details').removeAttr('open')"; + + // Request the group details testing page. + $this->drupalGet('form-test/group-details'); + + $page = $session->getPage(); + + $session->executeScript($reset_js); + + $child_selector = '#edit-element'; + $child = $page->find('css', $child_selector); + + // Assert that the child is not visible. + $this->assertFalse($child->isVisible(), 'Child is not visible'); + + // Trigger a URI fragment change (hashchange) to open all parent details + // elements of the child. + $session->executeScript("location.replace('$child_selector')"); + + // Assert that the child becomes visible again after a hash change. + $web_assert->waitForElementVisible('css', $child_selector, 50); + + $session->executeScript($reset_js); + + // Click on a fragment link pointing to an invisible child inside a closed + // details element. + $session->executeScript("jQuery('').insertAfter('h1')[0].click()"); + + // Assert that the child is visible again after a fragment link click. + $web_assert->waitForElementVisible('css', $child_selector, 50); + + // Find the summary belonging to the closest details element. + $summary = $page->find('css', '#edit-meta > summary'); + + // Assert that both aria-expanded and aria-pressed are true. + $this->assertTrue($summary->getAttribute('aria-expanded')); + $this->assertTrue($summary->getAttribute('aria-pressed')); + } + +}