Skip to content

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)