다크 모달 컴포넌트

접근성을 갖춘 다크 테마 모달 다이얼로그. 키보드 트랩, ESC 닫기, 스크롤 잠금 포함.

Gist
<button class="btn-open-modal" data-modal="demo-modal">모달 열기</button>

<div class="modal" id="demo-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" hidden>
  <div class="modal-backdrop"></div>
  <div class="modal-container">
    <div class="modal-header">
      <h2 class="modal-title" id="modal-title">모달 제목</h2>
      <button class="modal-close" aria-label="닫기">&times;</button>
    </div>
    <div class="modal-body">
      <p>모달 본문 내용을 여기에 작성하세요. 접근성을 위해 포커스가 모달 내부에 갇힙니다.</p>
    </div>
    <div class="modal-footer">
      <button class="btn btn-ghost modal-close-btn">취소</button>
      <button class="btn btn-primary">확인</button>
    </div>
  </div>
</div>

<style>
:root {
  --modal-bg: #161b22; --modal-border: #30363d; --modal-overlay: rgba(0,0,0,0.7);
  --text: #e6edf3; --text-muted: #8b949e; --accent: #58a6ff; --radius: 12px;
}
.btn-open-modal, .btn {
  display: inline-flex; align-items: center; padding: 0.5rem 1.25rem;
  border-radius: 6px; font-size: 0.875rem; font-weight: 500;
  border: 1px solid transparent; cursor: pointer; transition: all 0.15s;
}
.btn-open-modal { background: #1f6feb; color: #fff; border-color: #58a6ff; }
.btn-primary { background: #1f6feb; color: #fff; border-color: #58a6ff; }
.btn-primary:hover { background: #58a6ff; color: #000; }
.btn-ghost { background: #21262d; color: var(--text-muted); border-color: var(--modal-border); }
.btn-ghost:hover { background: #30363d; color: var(--text); }

.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal[hidden] { display: none; }
.modal-backdrop { position: absolute; inset: 0; background: var(--modal-overlay); backdrop-filter: blur(4px); }
.modal-container {
  position: relative; width: min(520px, calc(100vw - 2rem));
  background: var(--modal-bg); border: 1px solid var(--modal-border);
  border-radius: var(--radius); box-shadow: 0 20px 60px rgba(0,0,0,0.5);
  display: flex; flex-direction: column; max-height: 90vh;
}
.modal-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--modal-border);
}
.modal-title { font-size: 1.125rem; font-weight: 600; color: var(--text); }
.modal-close {
  width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
  background: none; border: none; border-radius: 6px; color: var(--text-muted);
  font-size: 1.25rem; cursor: pointer; transition: all 0.15s;
}
.modal-close:hover { background: #30363d; color: var(--text); }
.modal-body { padding: 1.5rem; overflow-y: auto; color: var(--text-muted); line-height: 1.6; flex: 1; }
.modal-footer {
  padding: 1rem 1.5rem; border-top: 1px solid var(--modal-border);
  display: flex; justify-content: flex-end; gap: 0.75rem;
}
</style>

<script>
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';

function openModal(modal) {
  modal.hidden = false;
  document.body.style.overflow = 'hidden';
  modal.querySelector(FOCUSABLE)?.focus();
}

function closeModal(modal) {
  modal.hidden = true;
  document.body.style.overflow = '';
}

document.querySelectorAll('[data-modal]').forEach(btn => {
  btn.addEventListener('click', () => {
    const m = document.getElementById(btn.dataset.modal);
    if (m) openModal(m);
  });
});

document.querySelectorAll('.modal').forEach(modal => {
  modal.querySelector('.modal-backdrop')?.addEventListener('click', () => closeModal(modal));
  modal.querySelectorAll('.modal-close, .modal-close-btn').forEach(b => b.addEventListener('click', () => closeModal(modal)));

  modal.addEventListener('keydown', e => {
    if (e.key === 'Escape') { closeModal(modal); return; }
    if (e.key !== 'Tab') return;
    const focusable = [...modal.querySelectorAll(FOCUSABLE)];
    const first = focusable[0], last = focusable[focusable.length - 1];
    if (e.shiftKey ? document.activeElement === first : document.activeElement === last) {
      e.preventDefault();
      (e.shiftKey ? last : first).focus();
    }
  });
});
</script>