<main class="main">
  <h1 class="title">ダイアログの実装例</h1>
  <p class="desc">おまけ:コードをまとめる</p>
  <div class="container">
    <button data-open-trigger="dialog">ダイアログを開く</button>
  </div>
</main>
<!-- //.main -->
<div id="dialog" class="dialog __hidden">
  <div class="dialog__bglayer" data-close-trigger="dialog"></div>
  <div class="dialog__container" role="dialog" aria-modal="true" aria-labelledby="dialog-title" aria-describeby="dialog-desc">
    <h2 id="dialog-title" class="dialog__title">ダイアログです</h2>
    <p id="dialog-desc" class="dialog__description">これはダイアログのサンプルです。</p>
    <div class="dialog__action">
      <button data-close-trigger="dialog">ダイアログを閉じる</button>
    </div>
  </div>
</div>
<!-- //.dialog -->
:root {
  --scroll-y: 0;
}
* {
  box-sizing: border-box;
}
.main {
  padding: 2rem;
  min-height: 100vh;
}
.container {
  display: flex;
  align-items: center;
  margin-top: 2rem;
}
.title {
  font-size: 1.25rem;
  font-weight: 600;
}
.desc {
  font-size: 1rem;
  margin-top: 1rem;
}
.dialog {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  transition: opacity 0.2s ease-out;
  opacity: 1;
  visibility: visible;
}
.dialog.__hidden {
  opacity: 0;
  visibility: hidden;
  transition: all 0.2s ease-out;
}
.dialog__bglayer {
  position: absolute;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.75);
  z-index: -1;
}
.dialog__container {
  background-color: #fff;
  max-width: min(500px, 100%);
  max-height: 100vh;
  padding: 2rem;
  border-radius: 5px;
  overflow-y: scroll;
}
.dialog__title {
  font-size: 1rem;
  font-weight: 600;
}
.dialog__description {
  margin-top: 1rem;
  font-size: 1rem;
}
.dialog__action {
  margin-top: 2rem;
}
[data-open-trigger]:focus,
[data-close-trigger]:focus {
  outline: 2px solid #2962ff;
}
.fixed {
  position: fixed;
  top: var(--scroll-y);
  left: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
.user-select-none {
  -webkit-user-select: none;
  user-select: none;
}
const dialogControl = () => {
  const FOCUSABLE_ELEMENTS = [
    "a[href]",
    "area[href]",
    'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
    "select:not([disabled]):not([aria-hidden])",
    "textarea:not([disabled]):not([aria-hidden])",
    "button:not([disabled]):not([aria-hidden])",
    "iframe",
    "object",
    "embed",
    "[contenteditable]",
    '[tabindex]:not([tabindex^="-"])'
  ];

  class Dialog {
    constructor({ dialogId, openTrigger, closeTrigger, mainContents }) {
      this.dialog = document.getElementById(dialogId);
      this.openTriggers = [...document.querySelectorAll(openTrigger)];
      this.closeTriggers = [...document.querySelectorAll(closeTrigger)];
      this.mainContents = [...document.querySelectorAll(mainContents)];
      this.focusableElements = [
        ...this.dialog.querySelectorAll(FOCUSABLE_ELEMENTS.join(","))
      ];
      this.focusBeforeElement = null;
      // bind
      this.open = this.open.bind(this);
      this.close = this.close.bind(this);
      this.registerEvents = this.registerEvents.bind(this);
      this.keydownDiaogContainer = this.keydownDiaogContainer.bind(this);

      // addEventListeners
      this.registerEvents();
    }

    registerEvents() {
      this.openTriggers.forEach((trigger) => {
        trigger.addEventListener("click", this.open);
      });
      this.closeTriggers.forEach((trigger) => {
        trigger.addEventListener("click", this.close);
      });
      this.dialog.addEventListener("keydown", this.keydownDiaogContainer);
    }

    open() {
      if (!this.dialog.classList.contains("__hidden")) return;

      this.dialog.classList.remove("__hidden");
      this.focusBeforeElement = document.activeElement;
      this.focusableElements[0].focus();

      this.bgScrollBehavior("fix");
      this.noSelectContents(true);

      this.mainContents.forEach((contet) => {
        contet.setAttribute("aria-hidden", "true");
      });
    }

    close() {
      if (this.dialog.classList.contains("__hidden")) return;

      this.dialog.classList.add("__hidden");
      this.focusBeforeElement.focus();
      this.focusBeforeElement = null;

      this.bgScrollBehavior("scroll");
      this.noSelectContents(false);

      this.mainContents.forEach((contet) => {
        contet.setAttribute("aria-hidden", "false");
      });
    }

    keydownDiaogContainer(e) {
      const firstFocusableElement = this.focusableElements[0];
      const lastFocusableElement = this.focusableElements[
        this.focusableElements.length - 1
      ];

      if (e.code === "Tab") {
        if (e.shiftKey) {
          if (document.activeElement === firstFocusableElement) {
            e.preventDefault();
            lastFocusableElement.focus();
          }
        } else {
          if (document.activeElement === lastFocusableElement) {
            e.preventDefault();
            firstFocusableElement.focus();
          }
        }
      }
      if (e.code === "Escape") {
        this.close();
      }
    }

    bgScrollBehavior(state) {
      const isFixed = state === "fix";

      if (isFixed) {
        const scrollY = document.documentElement.scrollTop;
        document.body.classList.add("fixed");
        document.documentElement.style.setProperty(
          "--scroll-y",
          `${scrollY * -1}px`
        );
      } else {
        const scrollY = parseInt(
          document.documentElement.style.getPropertyValue("--scroll-y") || "0"
        );
        document.body.classList.remove("fixed");
        window.scrollTo(0, scrollY * -1);
      }
    }

    noSelectContents(bool) {
      if (bool) {
        this.mainContents.forEach((contet) => {
          contet.classList.add("user-select-none");
        });
      } else {
        this.mainContents.forEach((contet) => {
          contet.classList.remove("user-select-none");
        });
      }
    }

    destroy() {
      this.openTriggers.forEach((trigger) => {
        trigger.removeEventListener("click", this.open);
      });
      this.closeTriggers.forEach((trigger) => {
        trigger.removeEventListener("click", this.close);
      });
      this.dialog.removeEventListener("keydown", this.keydownDiaogContainer);
    }
  }

  let dialog = null;

  const init = ({ dialogId, openTrigger, closeTrigger, mainContents }) => {
    if (dialog) return console.error("Dialog was already instantiated.");
    dialog = new Dialog({ dialogId, openTrigger, closeTrigger, mainContents });
  };

  const open = () => {
    if (!dialog) return console.error("Dialog is not instantiated.");

    dialog.open();
  };

  const close = () => {
    if (!dialog) return console.error("Dialog is not instantiated.");

    dialog.close();
  };

  const destroy = () => {
    if (!dialog) return console.error("Dialog is not instantiated.");
    dialog.destroy();
  };

  return { init, open, close, destroy };
};

const dialog = dialogControl();
dialog.init({
  dialogId: "dialog",
  openTrigger: `*[data-open-trigger="dialog"]`,
  closeTrigger: `*[data-close-trigger="dialog"]`,
  mainContents: ".main"
});
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.