Modal
This is a WAI-ARIA compliant modal. It has the following interactions implemented:
- Escape key closes the modal
- Clicking outside the modal closes the modal
- Focus is trapped within the modal
- Maintains focus on the first focusable element within the modal
- Maintains focus on the modal when it is closed and re-opened
Live islandRuns locally in this page
Page shell
<style>
.dialog__backdrop {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5); /* semi-transparent black */
display: flex;
justify-content: center;
align-items: center;
}
.dialog__backdrop--hidden {
display: none;
}
</style>
<cami-modal></cami-modal>
<!-- <script src="./build/cami.cdn.js"></script> -->
<!-- CDN version below -->
<script src="https://unpkg.com/cami@0.4/build/cami.cdn.js"></script>
<script type="module" src="./island.js"></script>
Island source
const { html, ReactiveElement } = cami
class DialogElement extends ReactiveElement {
isOpen = false;
lastFocusedElement = null;
focusableElements = [];
get dialog() {
const dialog = this.querySelector('dialog');
if (!dialog)
throw new Error('Dialog markup has not rendered');
return dialog;
}
openDialog() {
this.isOpen = true;
this.dialog.setAttribute('open', '');
this.lastFocusedElement = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
this.focusFirstElement();
}
closeDialog() {
this.isOpen = false;
this.dialog.removeAttribute('open');
this.lastFocusedElement?.focus();
}
focusFirstElement() {
this.focusableElements = Array.from(this.dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'));
this.focusableElements[0]?.focus();
}
template() {
return html `
<aside @click=${() => this.closeDialog()} class="dialog__backdrop ${this.isOpen ? '' : 'dialog__backdrop--hidden'}">
<dialog @click=${(event) => event.stopPropagation()} role="dialog" aria-modal=${this.isOpen} aria-labelledby="dialog-title">
<article>
<h2 id="dialog-title">Hi! I'm a Modal</h2>
<label>Add Label Here <input></label>
<button @click=${() => this.closeDialog()} aria-label="Close Modal">Close</button>
</article>
</dialog>
</aside>
<button @click=${() => this.openDialog()}>Show Modal</button>
`;
}
onConnect() {
this.addEventListener('keydown', (event) => {
if (event.key === 'Escape')
this.closeDialog();
if (event.key !== 'Tab' || this.focusableElements.length === 0)
return;
const first = this.focusableElements[0];
const last = this.focusableElements.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
}
else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
}
}
customElements.define('cami-modal', DialogElement);
import { html, ReactiveElement } from 'cami'
class DialogElement extends ReactiveElement {
isOpen: boolean = false
lastFocusedElement: HTMLElement | null = null
focusableElements: HTMLElement[] = []
private get dialog(): HTMLDialogElement {
const dialog = this.querySelector<HTMLDialogElement>('dialog')
if (!dialog) throw new Error('Dialog markup has not rendered')
return dialog
}
openDialog(): void {
this.isOpen = true
this.dialog.setAttribute('open', '')
this.lastFocusedElement = document.activeElement instanceof HTMLElement
? document.activeElement
: null
this.focusFirstElement()
}
closeDialog(): void {
this.isOpen = false
this.dialog.removeAttribute('open')
this.lastFocusedElement?.focus()
}
focusFirstElement(): void {
this.focusableElements = Array.from(this.dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
))
this.focusableElements[0]?.focus()
}
template(): ReturnType<typeof html> {
return html`
<aside @click=${() => this.closeDialog()} class="dialog__backdrop ${this.isOpen ? '' : 'dialog__backdrop--hidden'}">
<dialog @click=${(event: MouseEvent) => event.stopPropagation()} role="dialog" aria-modal=${this.isOpen} aria-labelledby="dialog-title">
<article>
<h2 id="dialog-title">Hi! I'm a Modal</h2>
<label>Add Label Here <input></label>
<button @click=${() => this.closeDialog()} aria-label="Close Modal">Close</button>
</article>
</dialog>
</aside>
<button @click=${() => this.openDialog()}>Show Modal</button>
`
}
onConnect(): void {
this.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape') this.closeDialog()
if (event.key !== 'Tab' || this.focusableElements.length === 0) return
const first = this.focusableElements[0]
const last = this.focusableElements.at(-1)
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
})
}
}
declare global {
interface HTMLElementTagNameMap {
'cami-modal': DialogElement
}
}
customElements.define('cami-modal', DialogElement)