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

HTML:

  <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@latest/build/cami.cdn.js"></script>
<script type="module">
  const { html, ReactiveElement } = cami;

  class DialogElement extends ReactiveElement {
    isOpen = false;
    lastFocusedElement = null;
    focusableElements = [];

    openDialog() {
      this.isOpen = true;
      this.querySelector('dialog').setAttribute('open', '');
      this.lastFocusedElement = document.activeElement;
      this.focusFirstElement();
    }

    closeDialog() {
      this.isOpen = false;
      this.querySelector('dialog').removeAttribute('open');
      this.lastFocusedElement && this.lastFocusedElement.focus();
    }

    focusFirstElement() {
      const dialog = this.querySelector('dialog');
      this.focusableElements = Array.from(dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'));
      const hasFocusables = this.focusableElements.length > 0;
      hasFocusables && 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</label>
              <input></input>
              <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) => {
        const preventDefault = () => { event.preventDefault(); return true; };
        const isEscape = event.key === 'Escape';
        const isTab = event.key === 'Tab';
        const isShiftTab = isTab && event.shiftKey;
        const isTabAtStart = isTab && document.activeElement === this.focusableElements[0];
        const isTabAtEnd = isTab && document.activeElement === this.focusableElements[this.focusableElements.length - 1];

        isEscape && this.closeDialog();
        isTabAtStart && preventDefault() && this.focusableElements[this.focusableElements.length - 1]?.focus();
        isTabAtEnd && preventDefault() && this.focusFirstElement();
      });
    }
  }

  customElements.define('cami-modal', DialogElement);
</script>