Missing focus trap and ARIA attributes in Bulma Modal
0 reputation · 30 May 2022, 07:17 UTC
0 reputation · 30 May 2022, 07:17 UTC
Bulma provides the CSS styling and the is-active class for the modal component, but the framework explicitly excludes JavaScript implementation. This design choice leaves the developer responsible for all behavioral logic required to meet WAI-ARIA Dialog Pattern standards.
While the HTML structure for the modal background and content is documented, the provided markup lacks essential accessibility attributes such as role="dialog", aria-modal="true", and aria-labelledby. Furthermore, there is no built-in mechanism to manage focus when the modal is triggered or dismissed.
Without a reference implementation for focus management, the following behaviors remain unresolved in a standard Bulma v0.9.x setup:
What is the recommended pattern for implementing a focus trap that integrates with Bulma's is-active toggle without introducing heavy third-party dependencies? How should ARIA attributes be dynamically managed to ensure screen reader compatibility during the state transition?
34775 reputation · 30 May 2022, 07:56 UTC
Bulma is CSS-only. The is-active class controls visibility only. A minimal, dependency-free pattern is: on open, add ARIA attributes and aria-hidden to the rest of the page, move focus into the dialog and trap Tab/Shift+Tab inside it; on close, remove the attributes, restore focus to the trigger, and re-enable background interaction. ESC removes is-active and runs the same close routine.
.modal.is-active styling only. No JavaScript, focus management, or ARIA is shipped.role="dialog", aria-modal="true" on the dialog container and aria-labelledby pointing to the dialog title when open.With only CSS toggling, focus is never moved, the background remains reachable by Tab, and screen readers are not told the page is modal. These are developer responsibilities in Bulma.
<div class="modal" id="myModal" role="dialog" aria-modal="true" aria-labelledby="myModalTitle" aria-hidden="true">
<div class="modal-background"></div>
<div class="modal-content">
<h2 id="myModalTitle">Title</h2>
<button class="modal-close" aria-label="close"></button>
</div>
</div>
is-active, update ARIA, hide background, move focus.function openModal(modal, trigger) {
lastTrigger = trigger;
modal.classList.add('is-active');
modal.removeAttribute('aria-hidden');
document.getElementById('app').setAttribute('aria-hidden','true');
const first = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
first?.focus();
}
is-active, restore ARIA, return focus.function closeModal(modal) {
modal.classList.remove('is-active');
modal.setAttribute('aria-hidden','true');
document.getElementById('app').removeAttribute('aria-hidden');
lastTrigger?.focus();
}
modal.addEventListener('keydown', e => {
if (e.key !== 'Tab') return;
const focusables = [...modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')];
if (!focusables.length) return;
const first = focusables[0], last = focusables.at(-1);
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
is-active when modal is open.document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('is-active')) closeModal(modal);
});
is-active applied, role="dialog" and aria-modal="true" are present and background has aria-hidden="true".Uncertainty note: If your modal markup is injected dynamically after page load, the selectors and event binding should be delegated to the container that persists. Confirm whether you need to support multiple simultaneous modals; that changes the aria-hidden handling and focus stack.
Use comments to ask for clarification. Post a solution as an answer.
6,270 reputation · 30 May 2022, 18:26 UTC
When implementing the manual focus trap described, a critical detail is managing the inert attribute or aria-hidden on the main content wrapper (e.g., <main> or <div id="app">). Simply adding aria-modal="true" tells screen readers the content is modal, but it does not physically stop the browser's Tab key from reaching elements in the background DOM.
To ensure a robust trap in Bulma v0.9.x without heavy libraries, consider this logic for the Tab key interceptor:
a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])) inside the .modal-content.Shift + Tab is pressed on the first element, programmatically move focus to the last. Conversely, if Tab is pressed on the last element, wrap back to the first.aria-hidden="true" to the page's primary container when .is-active is present to prevent screen readers from navigating out of the dialog.