---
/**
* APG Dialog (Modal) Pattern - Astro Implementation
*
* A window overlaid on the primary window, rendering the content underneath inert.
* Uses native <dialog> element with Web Components for enhanced control.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
*/
export interface Props {
/** Dialog title (required for accessibility) */
title: string;
/** Optional description text */
description?: string;
/** Trigger button text */
triggerText: string;
/** Close on overlay click */
closeOnOverlayClick?: boolean;
/** Additional CSS class for trigger button */
triggerClass?: string;
/** Additional CSS class for dialog */
class?: string;
}
const {
title,
description,
triggerText,
closeOnOverlayClick = true,
triggerClass = '',
class: className = '',
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `dialog-${Math.random().toString(36).substring(2, 11)}`;
const titleId = `${instanceId}-title`;
const descriptionId = `${instanceId}-description`;
---
<apg-dialog data-close-on-overlay={closeOnOverlayClick}>
<!-- Trigger Button -->
<button type="button" class={`apg-dialog-trigger ${triggerClass}`.trim()} data-dialog-trigger>
{triggerText}
</button>
<!-- Native Dialog Element -->
<dialog
class={`apg-dialog ${className}`.trim()}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
data-dialog-content
>
<div class="apg-dialog-header">
<h2 id={titleId} class="apg-dialog-title">
{title}
</h2>
<button type="button" class="apg-dialog-close" data-dialog-close aria-label="Close dialog">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{
description && (
<p id={descriptionId} class="apg-dialog-description">
{description}
</p>
)
}
<div class="apg-dialog-body">
<slot />
</div>
</dialog>
</apg-dialog>
<script>
class ApgDialog extends HTMLElement {
private trigger: HTMLButtonElement | null = null;
private dialog: HTMLDialogElement | null = null;
private closeButton: HTMLButtonElement | null = null;
private closeOnOverlayClick = true;
connectedCallback() {
this.trigger = this.querySelector('[data-dialog-trigger]');
this.dialog = this.querySelector('[data-dialog-content]');
this.closeButton = this.querySelector('[data-dialog-close]');
if (!this.trigger || !this.dialog) {
console.warn('apg-dialog: required elements not found');
return;
}
this.closeOnOverlayClick = this.dataset.closeOnOverlay !== 'false';
// Attach event listeners
this.trigger.addEventListener('click', this.open);
this.closeButton?.addEventListener('click', this.close);
this.dialog.addEventListener('click', this.handleDialogClick);
this.dialog.addEventListener('close', this.handleClose);
}
disconnectedCallback() {
this.trigger?.removeEventListener('click', this.open);
this.closeButton?.removeEventListener('click', this.close);
this.dialog?.removeEventListener('click', this.handleDialogClick);
this.dialog?.removeEventListener('close', this.handleClose);
}
private open = () => {
if (!this.dialog) return;
this.dialog.showModal();
// Focus first focusable element (close button by default)
const focusableElements = this.getFocusableElements(this.dialog);
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('dialogopen', {
bubbles: true,
})
);
};
private close = () => {
this.dialog?.close();
};
private handleClose = () => {
// Return focus to trigger
this.trigger?.focus();
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('dialogclose', {
bubbles: true,
})
);
};
private handleDialogClick = (e: Event) => {
// Close on backdrop click (clicking the dialog element itself, not its contents)
if (this.closeOnOverlayClick && e.target === this.dialog) {
this.close();
}
};
private getFocusableElements(container: HTMLElement): HTMLElement[] {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelectors)).filter(
(el) => el.offsetParent !== null
);
}
}
// Register the custom element
if (!customElements.get('apg-dialog')) {
customElements.define('apg-dialog', ApgDialog);
}
</script>