diff --git a/resources/js/accessible_toggle.js b/resources/js/accessible_toggle.js index c116858f8..1f72d19ec 100644 --- a/resources/js/accessible_toggle.js +++ b/resources/js/accessible_toggle.js @@ -4,6 +4,8 @@ */ (function (bootstrap, Drupal) { + const staticOffcanvasRoles = new WeakMap(); + /** * Attaches the accessible toggle behavior to Bootstrap components. * @@ -19,6 +21,26 @@ { selector: '[data-bs-toggle="offcanvas"]', type: 'offcanvas' } // Additional components like collapse can be added here in the future. ]); + + document + .querySelectorAll('[class*="offcanvas-"][data-offcanvas-static-role]') + .forEach(function initializeStaticOffcanvasRole(offcanvas) { + if (staticOffcanvasRoles.has(offcanvas)) { + return; + } + + staticOffcanvasRoles.set( + offcanvas, + offcanvas.getAttribute('data-offcanvas-static-role') + ); + // Bootstrap removes its dialog role when the offcanvas closes. + offcanvas.addEventListener( + 'hidden.bs.offcanvas', + function restoreStaticOffcanvasRole() { + offcanvas.setAttribute('role', staticOffcanvasRoles.get(offcanvas)); + } + ); + }); } }; diff --git a/templates/patterns/offcanvas/pattern-offcanvas--preview.html.twig b/templates/patterns/offcanvas/pattern-offcanvas--preview.html.twig new file mode 100644 index 000000000..e5314ae6c --- /dev/null +++ b/templates/patterns/offcanvas/pattern-offcanvas--preview.html.twig @@ -0,0 +1,29 @@ +{# +/** + * @file + * Offcanvas pattern preview. + */ +#} +{% extends 'pattern-offcanvas.html.twig' %} +{% block toggle_button %} +{% if toggle is not empty %} + {% set _toggle_attributes = create_attribute(toggle.attributes|default({})) + .addClass('d-lg-none') + .setAttribute('data-bs-toggle', 'offcanvas') + .setAttribute('data-bs-target', '#' ~ offcanvas_id) + .setAttribute('aria-controls', offcanvas_id) + %} + {% set _toggle = toggle|merge({ + attributes: _toggle_attributes + }) %} + {{ pattern('button', _toggle) }} +{% endif %} + +
+ {{ pattern('alert', { + 'variant': 'info', + 'message': 'The toggle button is only available on mobile (below the "lg" breakpoint). At "lg" and above, the offcanvas is displayed as a static, always-visible panel.', + 'dismissible': false + }) }} +
+{% endblock %} diff --git a/templates/patterns/offcanvas/pattern-offcanvas--variant-search--preview.html.twig b/templates/patterns/offcanvas/pattern-offcanvas--variant-search--preview.html.twig new file mode 100644 index 000000000..eaaf07d57 --- /dev/null +++ b/templates/patterns/offcanvas/pattern-offcanvas--variant-search--preview.html.twig @@ -0,0 +1,29 @@ +{# +/** + * @file + * Offcanvas pattern search variant preview. + */ +#} +{% extends 'pattern-offcanvas--variant-search.html.twig' %} +{% block toggle_button %} +{% if toggle is not empty %} + {% set _toggle_attributes = create_attribute(toggle.attributes|default({})) + .addClass('d-lg-none') + .setAttribute('data-bs-toggle', 'offcanvas') + .setAttribute('data-bs-target', '#' ~ offcanvas_id) + .setAttribute('aria-controls', offcanvas_id) + %} + {% set _toggle = toggle|merge({ + attributes: _toggle_attributes + }) %} + {{ pattern('button', _toggle) }} +{% endif %} + +
+ {{ pattern('alert', { + 'variant': 'info', + 'message': 'The toggle button is only available on mobile (below the "lg" breakpoint). At "lg" and above, the offcanvas is displayed as a static, always-visible panel.', + 'dismissible': false + }) }} +
+{% endblock %} diff --git a/templates/patterns/offcanvas/pattern-offcanvas--variant-search.html.twig b/templates/patterns/offcanvas/pattern-offcanvas--variant-search.html.twig index 7acb164d2..c05b71bd8 100644 --- a/templates/patterns/offcanvas/pattern-offcanvas--variant-search.html.twig +++ b/templates/patterns/offcanvas/pattern-offcanvas--variant-search.html.twig @@ -18,7 +18,7 @@ 'extra_classes_body': 'p-lg-0 d-block', 'extra_classes_close': "d-lg-none", 'extra_classes_header': "p-lg-0 d-lg-block", - 'attributes': attributes.addClass('bcl-offcanvas'), + 'attributes': _offcanvas_attributes.addClass('bcl-offcanvas'), 'responsiveness': 'lg' } only %} {% endblock %} diff --git a/templates/patterns/offcanvas/pattern-offcanvas.html.twig b/templates/patterns/offcanvas/pattern-offcanvas.html.twig index 93d7f2ab5..4d8ddf9a6 100644 --- a/templates/patterns/offcanvas/pattern-offcanvas.html.twig +++ b/templates/patterns/offcanvas/pattern-offcanvas.html.twig @@ -4,6 +4,15 @@ * Offcanvas pattern. */ #} +{% set _offcanvas_attributes = attributes %} +{# Bootstrap replaces this static role with dialog while the panel is open. #} +{% if not _offcanvas_attributes.hasAttribute('role') and title is not empty %} + {% set _offcanvas_attributes = _offcanvas_attributes.setAttribute('role', 'region') %} +{% endif %} +{% if _offcanvas_attributes.hasAttribute('role') %} + {% set _offcanvas_attributes = _offcanvas_attributes.setAttribute('data-offcanvas-static-role', _offcanvas_attributes['role']) %} +{% endif %} + {% block offcanvas %} {% include '@oe-bcl/offcanvas' with { 'title': title, @@ -13,7 +22,7 @@ 'with_body_scroll': body_scroll, 'with_backdrop': backdrop, 'close_aria_label': 'Close'|t, - 'attributes': attributes, + 'attributes': _offcanvas_attributes, 'responsiveness': 'lg' } only %} {% endblock %} diff --git a/tests/src/FunctionalJavascript/AccessibleToggleTest.php b/tests/src/FunctionalJavascript/AccessibleToggleTest.php index 41730d34e..0010f335a 100644 --- a/tests/src/FunctionalJavascript/AccessibleToggleTest.php +++ b/tests/src/FunctionalJavascript/AccessibleToggleTest.php @@ -33,6 +33,9 @@ class AccessibleToggleTest extends WebDriverTestBase { */ public function testAccessibleToggleAttributes(): void { $this->drupalLogin($this->drupalCreateUser([], NULL, TRUE)); + // The offcanvas toggle is only visible below Bootstrap's "lg" breakpoint + // in the pattern preview, so resize the window to a mobile width. + $this->getSession()->resizeWindow(600, 800); $this->drupalGet('/admin/appearance/ui/patterns'); $assert = $this->assertSession(); @@ -41,9 +44,27 @@ public function testAccessibleToggleAttributes(): void { $modalTrigger = $assert->waitForElementVisible('css', "{$modalSelector}[aria-haspopup=\"dialog\"]"); $offcanvasTrigger = $assert->waitForElementVisible('css', "{$offcanvasSelector}[aria-haspopup=\"dialog\"]"); + $offcanvasTarget = $offcanvasTrigger->getAttribute('data-bs-target'); + $this->assertNotEmpty($offcanvasTarget); $this->assertAccessibleAttributes($modalTrigger); $this->assertAccessibleAttributes($offcanvasTrigger, expanded: FALSE); + $assert->elementExists('css', "{$offcanvasTarget}[role=\"region\"][data-offcanvas-static-role=\"region\"]"); + + // Ensure late behavior attachment does not cache Bootstrap's dialog role. + $this->getSession()->executeScript(<<<'JS' + (function () { + var offcanvas = document.createElement('div'); + offcanvas.id = 'late-offcanvas'; + offcanvas.classList.add('offcanvas-lg'); + offcanvas.setAttribute('role', 'dialog'); + offcanvas.setAttribute('data-offcanvas-static-role', 'complementary'); + document.body.appendChild(offcanvas); + Drupal.behaviors.accessibleToggle.attach(document, drupalSettings); + offcanvas.dispatchEvent(new Event('hidden.bs.offcanvas')); + }()); + JS); + $assert->elementExists('css', '#late-offcanvas[role="complementary"]'); $this->clickWhenInViewport($modalSelector); $assert->waitForElementVisible('css', '.modal.show'); @@ -58,13 +79,14 @@ public function testAccessibleToggleAttributes(): void { $this->assertAccessibleAttributes($offcanvasTrigger, expanded: FALSE); $this->clickWhenInViewport($offcanvasSelector); - $assert->waitForElementVisible('css', '.offcanvas.show'); + $assert->waitForElementVisible('css', "{$offcanvasTarget}.show[role=\"dialog\"][aria-modal=\"true\"]"); $offcanvasTrigger = $assert->waitForElement('css', "{$offcanvasSelector}[aria-expanded=\"true\"]"); $this->assertAccessibleAttributes($modalTrigger, expanded: FALSE); $this->assertAccessibleAttributes($offcanvasTrigger, expanded: TRUE); - $this->clickWhenInViewport('.offcanvas-backdrop'); + $this->clickWhenInViewport("{$offcanvasTarget}.show .btn-close"); $offcanvasTrigger = $assert->waitForElement('css', "{$offcanvasSelector}[aria-expanded=\"false\"]"); + $assert->waitForElement('css', "{$offcanvasTarget}[role=\"region\"]:not([aria-modal])"); $this->assertAccessibleAttributes($modalTrigger, expanded: FALSE); $this->assertAccessibleAttributes($offcanvasTrigger, expanded: FALSE); } diff --git a/tests/src/Kernel/fixtures/markup_rendering_patterns/offcanvas.yml b/tests/src/Kernel/fixtures/markup_rendering_patterns/offcanvas.yml index fac938b2f..58915fcab 100644 --- a/tests/src/Kernel/fixtures/markup_rendering_patterns/offcanvas.yml +++ b/tests/src/Kernel/fixtures/markup_rendering_patterns/offcanvas.yml @@ -19,6 +19,7 @@ offcanvas_top_with_backdrop_and_toggle: '.btn-close[data-bs-target="#bcl-offcanvas"][aria-label="Close"]': 1 'div[data-drupal-selector="offcanvas"]': 1 'div#bcl-offcanvas': 1 + 'div#bcl-offcanvas[role="region"][data-offcanvas-static-role="region"][aria-labelledby="bcl-offcanvas-title"]': 1 'div#bcl-offcanvas .btn-close[data-bs-target="#bcl-offcanvas"]': 1 'div.offcanvas-lg.offcanvas-top': 1 equals: @@ -40,6 +41,7 @@ offcanvas_start_with_body_scroll: count: 'div[data-drupal-selector="offcanvas"]': 1 'div#bcl-offcanvas': 1 + 'div#bcl-offcanvas[role="region"][data-offcanvas-static-role="region"][aria-labelledby="bcl-offcanvas-title"]': 1 'div.offcanvas-lg.offcanvas-start': 1 'button[data-bs-target="#bcl-offcanvas"]': 1 '.btn-primary[data-bs-toggle="offcanvas"]': 0 @@ -48,6 +50,21 @@ offcanvas_start_with_body_scroll: equals: '.offcanvas-title': 'Offcanvas title' 'div.offcanvas-body': 'This is the offcanvas body content.' +offcanvas_without_title: + render: + '#type': pattern + '#id': offcanvas + '#fields': + settings: + placement: start + body: 'This is the offcanvas body content.' + assertions: + count: + 'div#bcl-offcanvas[role]': 0 + 'div#bcl-offcanvas[data-offcanvas-static-role]': 0 + 'div#bcl-offcanvas[aria-labelledby]': 0 + equals: + 'div.offcanvas-body': 'This is the offcanvas body content.' offcanvas_with_toggle_with_icon: render: '#type': pattern @@ -69,6 +86,7 @@ offcanvas_with_toggle_with_icon: '.btn-close[data-bs-target="#bcl-offcanvas"][aria-label="Close"]': 1 'div[data-drupal-selector="offcanvas"]': 1 'div#bcl-offcanvas': 1 + 'div#bcl-offcanvas[role="region"][data-offcanvas-static-role="region"][aria-labelledby="bcl-offcanvas-title"]': 1 'div.offcanvas-lg.offcanvas-top': 1 'div#bcl-offcanvas .btn-close[data-bs-target="#bcl-offcanvas"][aria-label="Close"]': 1 'svg.bi': 1 @@ -92,9 +110,11 @@ offcanvas_with_id_from_attributes: icon: 'filter' attributes: id: 'custom-id' + role: 'complementary' assertions: count: 'div#custom-id': 1 + 'div#custom-id[role="complementary"][data-offcanvas-static-role="complementary"][aria-labelledby="custom-id-title"]': 1 'div.offcanvas-lg.offcanvas-bottom': 1 'svg.bi': 1 'button[data-bs-target="#custom-id"]': 2 @@ -128,6 +148,7 @@ offcanvas_search_variant: '.btn-close[data-bs-target="#bcl-offcanvas"][aria-label="Close"]': 1 'div[data-drupal-selector="offcanvas"]': 1 'div#bcl-offcanvas': 1 + 'div#bcl-offcanvas[role="region"][data-offcanvas-static-role="region"][aria-labelledby="bcl-offcanvas-title"]': 1 'div#bcl-offcanvas .btn-close[data-bs-target="#bcl-offcanvas"][aria-label="Close"]': 1 'div.offcanvas-lg.offcanvas-top': 1 'svg.bi': 1