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'));
+ }
+
+}