Dialog (Modal)
A window overlaid on the primary window, rendering the content underneath inert.
🤖 AI Implementation GuideDemo
Basic Dialog
A simple modal dialog with title, description, and close functionality.
Without Description
A dialog with only a title and content.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
dialog | Dialog container | Indicates the element is a dialog window |
WAI-ARIA dialog role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Description |
|---|---|---|---|---|
aria-modal | dialog | true | Yes | Indicates this is a modal dialog |
aria-labelledby | dialog | ID reference to title element | Yes | References the dialog title |
aria-describedby | dialog | ID reference to description | No | References optional description text |
Focus Management
| Event | Behavior |
|---|---|
| Dialog opens | Focus moves to first focusable element inside the dialog |
| Dialog closes | Focus returns to the element that triggered the dialog |
| Focus trap | Tab/Shift+Tab cycles through focusable elements within the dialog only |
| Background | Content outside dialog is made inert (not focusable or interactive) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus to next focusable element within dialog. When focus is on the last element, moves to first. |
| Shift + Tab | Move focus to previous focusable element within dialog. When focus is on the first element, moves to last. |
| Escape | Close the dialog and return focus to trigger element |
Additional Notes
- The dialog title is required for accessibility and should clearly describe the purpose of the dialog
- Page scrolling is disabled while the dialog is open
- Clicking the overlay (background) closes the dialog by default
- The close button has an accessible label for screen readers
Source Code
Dialog.astro
---
/**
* 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> Usage
Example
---
import Dialog from './Dialog.astro';
---
<Dialog
title="Dialog Title"
description="Optional description text"
triggerText="Open Dialog"
>
<p>Dialog content goes here.</p>
</Dialog> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | Dialog title (for accessibility) |
description | string | - | Optional description text |
triggerText | string | required | Text for the trigger button |
closeOnOverlayClick | boolean | true | Close when clicking overlay |
triggerClass | string | - | Additional CSS class for trigger button |
class | string | - | Additional CSS class for dialog |
Slots
| Slot | Description |
|---|---|
default | Dialog content |
Custom Events
| Event | Description |
|---|---|
dialogopen | Fired when the dialog opens |
dialogclose | Fired when the dialog closes |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
Escape key | Closes the dialog |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="dialog" | Dialog element has dialog role |
aria-modal="true" | Indicates modal behavior |
aria-labelledby | References the dialog title |
aria-describedby | References description (when provided) |
High Priority: Focus Management
| Test | Description |
|---|---|
Initial focus | Focus moves to first focusable element on open |
Focus restore | Focus returns to trigger on close |
Focus trap | Tab cycling stays within dialog (via native dialog) |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Low Priority: Props & Behavior
| Test | Description |
|---|---|
closeOnOverlayClick | Controls overlay click behavior |
defaultOpen | Initial open state |
onOpenChange | Callback fires on open/close |
className | Custom classes are applied |
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
Resources
- WAI-ARIA APG: Dialog (Modal) Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist