Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions resources/js/accessible_toggle.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/
(function (bootstrap, Drupal) {

const staticOffcanvasRoles = new WeakMap();

/**
* Attaches the accessible toggle behavior to Bootstrap components.
*
Expand All @@ -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));
}
);
});
Comment on lines +25 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new WeakMap stores the current role from the live DOM, which can already be dialog if this behavior attaches after Bootstrap has opened the offcanvas. In that case we restore the wrong role on close. Please cache the intended static role from an author controlled source instead, for example a data-* attribute set in Twig, and restore from that value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed, indeed. thanks

}
};

Expand Down
29 changes: 29 additions & 0 deletions templates/patterns/offcanvas/pattern-offcanvas--preview.html.twig
Original file line number Diff line number Diff line change
@@ -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 %}

<div class="mt-4">
{{ 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
}) }}
</div>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -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 %}

<div class="mt-4">
{{ 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
}) }}
</div>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
11 changes: 10 additions & 1 deletion templates/patterns/offcanvas/pattern-offcanvas.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 %}
Expand Down
26 changes: 24 additions & 2 deletions tests/src/FunctionalJavascript/AccessibleToggleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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');
Expand All @@ -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);
}
Expand Down
21 changes: 21 additions & 0 deletions tests/src/Kernel/fixtures/markup_rendering_patterns/offcanvas.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down