<main id="main" class="main">
  <h1 class="title">ダイアログの実装例</h1>
  <p class="desc">Escapeの押下でダイアログを閉じる</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">
    <h2 class="dialog__title">ダイアログです</h2>
    <p 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 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^="-"])'
];

const main = document.getElementById("main");
const dialog = document.getElementById("dialog");
const openTriggers = [
  ...document.querySelectorAll(`*[data-open-trigger="dialog"]`)
];
const closeTriggers = [
  ...document.querySelectorAll(`*[data-close-trigger="dialog"]`)
];
const focusableElements = [
  ...dialog.querySelectorAll(FOCUSABLE_ELEMENTS.join(","))
];
let focusBeforeElement = null;

const handleDialogOpen = () => {
  if (!dialog.classList.contains("__hidden")) return;

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

  bgScrollBehavior("fix");
  noSelectContents(true);
};
const handleDialogClose = () => {
  if (dialog.classList.contains("__hidden")) return;

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

  bgScrollBehavior("scroll");
  noSelectContents(false);
};
const handleKeydownDiaogContainer = (e) => {
  const firstFocusableElement = focusableElements[0];
  const lastFocusableElement = focusableElements[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") {
    handleDialogClose();
  }
};

const 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);
  }
};

const noSelectContents = (bool) => {
  if (bool) {
    main.classList.add("user-select-none");
  } else {
    main.classList.remove("user-select-none");
  }
};

openTriggers.forEach((trigger) => {
  trigger.addEventListener("click", handleDialogOpen);
});
closeTriggers.forEach((trigger) => {
  trigger.addEventListener("click", handleDialogClose);
});
dialog.addEventListener("keydown", handleKeydownDiaogContainer);
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.