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