@magic-spells/dialog-panel
Version:
A lightweight, customizable Dialog Panel web component for creating accessible and responsive modal dialogs.
512 lines (440 loc) • 14.2 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.DialogPanel = {}));
})(this, (function (exports) { 'use strict';
/**
* Retrieves all focusable elements within a given container.
*
* @param {HTMLElement} container - The container element to search for focusable elements.
* @returns {HTMLElement[]} An array of focusable elements found within the container.
*/
const getFocusableElements = (container) => {
const focusableSelectors =
'summary, a[href], button:not(:disabled), [tabindex]:not([tabindex^="-"]):not(focus-trap-start):not(focus-trap-end), [draggable], area, input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), object, iframe';
return Array.from(container.querySelectorAll(focusableSelectors));
};
class FocusTrap extends HTMLElement {
/** @type {boolean} Indicates whether the styles have been injected into the DOM. */
static styleInjected = false;
constructor() {
super();
this.trapStart = null;
this.trapEnd = null;
// Inject styles only once, when the first FocusTrap instance is created.
if (!FocusTrap.styleInjected) {
this.injectStyles();
FocusTrap.styleInjected = true;
}
}
/**
* Injects necessary styles for the focus trap into the document's head.
* This ensures that focus-trap-start and focus-trap-end elements are hidden.
*/
injectStyles() {
const style = document.createElement('style');
style.textContent = `
focus-trap-start,
focus-trap-end {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
clip: rect(0, 0, 0, 0);
overflow: hidden;
white-space: nowrap;
}
`;
document.head.appendChild(style);
}
/**
* Called when the element is connected to the DOM.
* Sets up the focus trap and adds the keydown event listener.
*/
connectedCallback() {
this.setupTrap();
this.addEventListener('keydown', this.handleKeyDown);
}
/**
* Called when the element is disconnected from the DOM.
* Removes the keydown event listener.
*/
disconnectedCallback() {
this.removeEventListener('keydown', this.handleKeyDown);
}
/**
* Sets up the focus trap by adding trap start and trap end elements.
* Focuses the trap start element to initiate the focus trap.
*/
setupTrap() {
// check to see it there are any focusable children
const focusableElements = getFocusableElements(this);
// exit if there aren't any
if (focusableElements.length === 0) return;
// create trap start and end elements
this.trapStart = document.createElement('focus-trap-start');
this.trapEnd = document.createElement('focus-trap-end');
// add to DOM
this.prepend(this.trapStart);
this.append(this.trapEnd);
}
/**
* Handles the keydown event. If the Escape key is pressed, the focus trap is exited.
*
* @param {KeyboardEvent} e - The keyboard event object.
*/
handleKeyDown = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.exitTrap();
}
};
/**
* Exits the focus trap by hiding the current container and shifting focus
* back to the trigger element that opened the trap.
*/
exitTrap() {
const container = this.closest('[aria-hidden="false"]');
if (!container) return;
container.setAttribute('aria-hidden', 'true');
const trigger = document.querySelector(
`[aria-expanded="true"][aria-controls="${container.id}"]`
);
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
}
}
class FocusTrapStart extends HTMLElement {
/**
* Called when the element is connected to the DOM.
* Sets the tabindex and adds the focus event listener.
*/
connectedCallback() {
this.setAttribute('tabindex', '0');
this.addEventListener('focus', this.handleFocus);
}
/**
* Called when the element is disconnected from the DOM.
* Removes the focus event listener.
*/
disconnectedCallback() {
this.removeEventListener('focus', this.handleFocus);
}
/**
* Handles the focus event. If focus moves backwards from the first focusable element,
* it is cycled to the last focusable element, and vice versa.
*
* @param {FocusEvent} e - The focus event object.
*/
handleFocus = (e) => {
const trap = this.closest('focus-trap');
const focusableElements = getFocusableElements(trap);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement =
focusableElements[focusableElements.length - 1];
if (e.relatedTarget === firstElement) {
lastElement.focus();
} else {
firstElement.focus();
}
};
}
class FocusTrapEnd extends HTMLElement {
/**
* Called when the element is connected to the DOM.
* Sets the tabindex and adds the focus event listener.
*/
connectedCallback() {
this.setAttribute('tabindex', '0');
this.addEventListener('focus', this.handleFocus);
}
/**
* Called when the element is disconnected from the DOM.
* Removes the focus event listener.
*/
disconnectedCallback() {
this.removeEventListener('focus', this.handleFocus);
}
/**
* Handles the focus event. When the trap end is focused, focus is shifted back to the trap start.
*/
handleFocus = () => {
const trap = this.closest('focus-trap');
const trapStart = trap.querySelector('focus-trap-start');
trapStart.focus();
};
}
customElements.define('focus-trap', FocusTrap);
customElements.define('focus-trap-start', FocusTrapStart);
customElements.define('focus-trap-end', FocusTrapEnd);
/**
* Custom element that creates an accessible modal dialog panel with focus management
* @extends HTMLElement
*/
class DialogPanel extends HTMLElement {
#handleTransitionEnd;
#scrollPosition = 0;
/**
* Clean up event listeners when component is removed from DOM
*/
disconnectedCallback() {
const _ = this;
if (_.contentPanel) {
_.contentPanel.removeEventListener(
'transitionend',
_.#handleTransitionEnd
);
}
// Ensure body scroll is restored if component is removed while open
document.body.classList.remove('overflow-hidden');
this.#restoreScroll();
}
/**
* Saves current scroll position and locks body scrolling
* @private
*/
#lockScroll() {
const _ = this;
// Save current scroll position
_.#scrollPosition = window.pageYOffset;
// Apply fixed position to body
document.body.classList.add('overflow-hidden');
document.body.style.top = `-${_.#scrollPosition}px`;
}
/**
* Restores scroll position when dialog is closed
* @private
*/
#restoreScroll() {
const _ = this;
// Remove fixed positioning
document.body.classList.remove('overflow-hidden');
document.body.style.removeProperty('top');
// Restore scroll position
window.scrollTo(0, _.#scrollPosition);
}
/**
* Initializes the dialog panel, sets up focus trap and overlay
*/
constructor() {
super();
const _ = this;
_.id = _.getAttribute('id');
_.setAttribute('role', 'dialog');
_.setAttribute('aria-modal', 'true');
_.setAttribute('aria-hidden', 'true');
_.contentPanel = _.querySelector('dialog-content');
_.focusTrap = document.createElement('focus-trap');
_.triggerEl = null;
// Create a handler for transition end events
_.#handleTransitionEnd = (e) => {
if (
e.propertyName === 'opacity' &&
_.getAttribute('aria-hidden') === 'true'
) {
_.contentPanel.classList.add('hidden');
// Dispatch afterHide event - dialog has completed its transition
_.dispatchEvent(
new CustomEvent('afterHide', {
bubbles: true,
detail: { triggerElement: _.triggerEl },
})
);
}
};
// Ensure we have labelledby and describedby references
if (!_.getAttribute('aria-labelledby')) {
const heading = _.querySelector('h1, h2, h3');
if (heading && !heading.id) {
heading.id = `${_.id}-title`;
}
if (heading?.id) {
_.setAttribute('aria-labelledby', heading.id);
}
}
_.contentPanel.parentNode.insertBefore(
_.focusTrap,
_.contentPanel
);
_.focusTrap.appendChild(_.contentPanel);
_.focusTrap.setupTrap();
// Add modal overlay
_.prepend(document.createElement('dialog-overlay'));
_.#bindUI();
_.#bindKeyboard();
}
/**
* Binds click events for showing and hiding the dialog
* @private
*/
#bindUI() {
const _ = this;
// Handle trigger buttons
document.addEventListener('click', (e) => {
const trigger = e.target.closest(`[aria-controls="${_.id}"]`);
if (!trigger) return;
if (trigger.getAttribute('data-prevent-default') === 'true') {
e.preventDefault();
}
_.show(trigger);
});
// Handle close buttons
_.addEventListener('click', (e) => {
if (!e.target.closest('[data-action="hide-dialog"]')) return;
_.hide();
});
// Add transition end listener
_.contentPanel.addEventListener(
'transitionend',
_.#handleTransitionEnd
);
}
/**
* Binds keyboard events for accessibility
* @private
*/
#bindKeyboard() {
this.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.hide();
}
});
}
/**
* Shows the dialog and traps focus within it
* @param {HTMLElement} [triggerEl=null] - The element that triggered the dialog
* @fires DialogPanel#beforeShow - Fired before the dialog starts to show
* @fires DialogPanel#show - Fired when the dialog has been shown
* @returns {boolean} False if the show was prevented by a beforeShow event handler
*/
show(triggerEl = null) {
const _ = this;
_.triggerEl = triggerEl || false;
// Dispatch beforeShow event - allows preventing the dialog from opening
const beforeShowEvent = new CustomEvent('beforeShow', {
bubbles: true,
cancelable: true,
detail: { triggerElement: _.triggerEl },
});
const showAllowed = _.dispatchEvent(beforeShowEvent);
// If event was canceled (preventDefault was called), don't show the dialog
if (!showAllowed) return false;
// Remove the hidden class first to ensure content is rendered
_.contentPanel.classList.remove('hidden');
// Give the browser a moment to process before starting animation
requestAnimationFrame(() => {
// Update ARIA states
_.setAttribute('aria-hidden', 'false');
if (_.triggerEl) {
_.triggerEl.setAttribute('aria-expanded', 'true');
}
// Lock body scrolling and save scroll position
_.#lockScroll();
// Focus management
const firstFocusable = _.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) {
requestAnimationFrame(() => {
firstFocusable.focus();
});
}
// Dispatch show event - dialog is now visible
_.dispatchEvent(
new CustomEvent('show', {
bubbles: true,
detail: { triggerElement: _.triggerEl },
})
);
});
return true;
}
/**
* Hides the dialog and restores focus
* @fires DialogPanel#beforeHide - Fired before the dialog starts to hide
* @fires DialogPanel#hide - Fired when the dialog has started hiding (transition begins)
* @fires DialogPanel#afterHide - Fired when the dialog has completed its hide transition
* @returns {boolean} False if the hide was prevented by a beforeHide event handler
*/
hide() {
const _ = this;
// Dispatch beforeHide event - allows preventing the dialog from closing
const beforeHideEvent = new CustomEvent('beforeHide', {
bubbles: true,
cancelable: true,
detail: { triggerElement: _.triggerEl },
});
const hideAllowed = _.dispatchEvent(beforeHideEvent);
// If event was canceled (preventDefault was called), don't hide the dialog
if (!hideAllowed) return false;
// Restore body scroll and scroll position
_.#restoreScroll();
// Update ARIA states
if (_.triggerEl) {
// remove focus from modal panel first
_.triggerEl.focus();
// mark trigger as no longer expanded
_.triggerEl.setAttribute('aria-expanded', 'false');
}
// Set aria-hidden to start transition
// The transitionend event handler will add display:none when complete
_.setAttribute('aria-hidden', 'true');
// Dispatch hide event - dialog is now starting to hide
_.dispatchEvent(
new CustomEvent('hide', {
bubbles: true,
detail: { triggerElement: _.triggerEl },
})
);
return true;
}
}
/**
* Custom element that creates a clickable overlay for the dialog
* @extends HTMLElement
*/
class DialogOverlay extends HTMLElement {
constructor() {
super();
this.setAttribute('tabindex', '-1'); // Changed to -1 as it shouldn't be focusable
this.setAttribute('aria-hidden', 'true');
this.dialogPanel = this.closest('dialog-panel');
this.#bindUI();
}
#bindUI() {
this.addEventListener('click', () => {
this.dialogPanel.hide();
});
}
}
/**
* Custom element that wraps the content of the dialog
* @extends HTMLElement
*/
class DialogContent extends HTMLElement {
constructor() {
super();
this.setAttribute('role', 'document'); // Optional: helps with document structure
}
}
if (!customElements.get('dialog-panel')) {
customElements.define('dialog-panel', DialogPanel);
}
if (!customElements.get('dialog-overlay')) {
customElements.define('dialog-overlay', DialogOverlay);
}
if (!customElements.get('dialog-content')) {
customElements.define('dialog-content', DialogContent);
}
exports.DialogContent = DialogContent;
exports.DialogOverlay = DialogOverlay;
exports.DialogPanel = DialogPanel;
exports.default = DialogPanel;
Object.defineProperty(exports, '__esModule', { value: true });
}));
//# sourceMappingURL=dialog-panel.js.map