DEV Community

WDSEGA
WDSEGA

Posted on

Component Deep Dive #47: Modal Dialog - Focus Trap and Scroll Lock Are Non-Negotiable

组件详解#47:模态对话框Modal Dialog,focus trap和滚动锁缺一不可

弹窗谁都会写,但一个"对的"弹窗需要处理焦点陷阱、滚动锁定、键盘关闭、遮罩点击关闭。少一样都不专业。今天我们用原生<dialog>元素实现一个完整的Modal。

组件是什么

Modal Dialog(模态对话框)是一个覆盖在页面上的弹出层,用户必须先与对话框交互才能返回主页面。与Toast/Notification不同,Modal是"模态"的——它会阻断对底层页面的操作。

完整代码

HTML

<dialog id="myModal" class="modal">
  <div class="modal-box">
    <div class="modal-header">
      <h2>确认删除</h2>
      <button class="modal-close" aria-label="关闭">&times;</button>
    </div>
    <div class="modal-body">
      <p>此操作不可撤销,确定要删除吗?</p>
    </div>
    <div class="modal-footer">
      <button class="btn-cancel">取消</button>
      <button class="btn-confirm">确认删除</button>
    </div>
  </div>
</dialog>

<button onclick="document.getElementById('myModal').showModal()">打开弹窗</button>
Enter fullscreen mode Exit fullscreen mode

CSS

/* 遮罩层(原生dialog::backdrop) */
.modal::backdrop {
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(2px);
}

/* 对话框本体 */
.modal {
  border: none;
  border-radius: 12px;
  padding: 0;
  max-width: 480px;
  width: 90%;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  animation: modalIn 0.25s ease;
}

@keyframes modalIn {
  from { opacity: 0; transform: scale(0.95) translateY(10px); }
  to   { opacity: 1; transform: scale(1) translateY(0); }
}

.modal-box { padding: 0; }

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 18px 24px;
  border-bottom: 1px solid #e2e8f0;
}

.modal-header h2 { margin: 0; font-size: 18px; }

.modal-close {
  background: none; border: none; font-size: 24px;
  cursor: pointer; color: #94a3b8; padding: 0; line-height: 1;
}
.modal-close:hover { color: #1e293b; }

.modal-body { padding: 20px 24px; color: #475569; line-height: 1.6; }

.modal-footer {
  display: flex; justify-content: flex-end; gap: 10px;
  padding: 16px 24px; border-top: 1px solid #e2e8f0;
}

.btn-cancel, .btn-confirm {
  padding: 8px 20px; border-radius: 8px; font-size: 14px;
  cursor: pointer; border: none; transition: all 0.2s;
}
.btn-cancel { background: #f1f5f9; color: #475569; }
.btn-cancel:hover { background: #e2e8f0; }
.btn-confirm { background: #ef4444; color: #fff; }
.btn-confirm:hover { background: #dc2626; }
Enter fullscreen mode Exit fullscreen mode

JavaScript(焦点陷阱 + ESC关闭 + 遮罩关闭):

const modal = document.getElementById('myModal');
const closeBtn = modal.querySelector('.modal-close');
const cancelBtn = modal.querySelector('.btn-cancel');

// 关闭函数
function closeModal() { modal.close(); }
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);

// 点击遮罩区域关闭(不点击对话框本身)
modal.addEventListener('click', function(e) {
  if (e.target === modal) closeModal();
});

// 焦点陷阱:Tab键不跑出对话框
modal.addEventListener('keydown', function(e) {
  if (e.key !== 'Tab') return;
  const focusable = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  if (focusable.length === 0) return;
  const first = focusable[0];
  const last = focusable[focusable.length - 1];
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault(); last.focus();
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault(); first.focus();
  }
});
Enter fullscreen mode Exit fullscreen mode

关键技术点

  1. 原生<dialog>元素showModal()方法打开模态对话框,浏览器自动处理遮罩层(::backdrop)、焦点管理、ESC键关闭。比手动实现div遮罩方案可靠得多。
  2. 焦点陷阱(Focus Trap):Tab键不能跑出对话框。用keydown监听Tab,在第一个和最后一个可聚焦元素之间循环。
  3. body滚动锁:原生<dialog>showModal()时会自动锁定底层页面的滚动,无需额外代码。如果用div方案,需要document.body.style.overflow = 'hidden'

常见坑点

  • show() vs showModal()show()打开的是非模态对话框(不锁底层),showModal()才是模态。别用错。
  • 动画闪烁:原生<dialog>打开时默认无动画。用CSS animation配合[open]属性选择器,或在JS中用requestAnimationFrame触发。
  • ESC关闭行为覆盖:原生<dialog>按ESC会自动关闭。如果需要阻止(如表单未保存),监听cancel事件并e.preventDefault()

变体拓展

// 阻止ESC关闭(表单未保存时)
modal.addEventListener('cancel', function(e) {
  if (hasUnsavedChanges) {
    e.preventDefault();
    showWarning('请先保存或放弃更改');
  }
});
Enter fullscreen mode Exit fullscreen mode

想要83个完整组件源码?访问 Web Component Dictionary 获取完整版。


Component Deep Dive #47: Modal Dialog — Focus Trap and Scroll Lock Are Non-Negotiable

Anyone can write a popup, but a "correct" modal needs focus trapping, scroll locking, keyboard close, and backdrop click close. Miss any one and it's unprofessional. Today we'll use the native <dialog> element.

Key Technical Points

  1. Native <dialog>: showModal() opens a modal dialog — the browser handles backdrop (::backdrop), focus management, and ESC-to-close automatically. Far more reliable than manual div overlay.
  2. Focus Trap: Tab key must not escape the dialog. Listen for Tab and cycle between first and last focusable elements.
  3. Body scroll lock: Native <dialog> with showModal() automatically locks underlying page scroll — no extra code needed. With div approach, you need document.body.style.overflow = 'hidden'.

Common Pitfalls

  • show() vs showModal(): show() opens a non-modal dialog (no background lock); showModal() is modal. Don't mix them up.
  • Animation flicker: Native <dialog> has no open animation by default. Use CSS animation with [open] selector, or trigger with requestAnimationFrame in JS.
  • ESC close override: Native <dialog> auto-closes on ESC. To prevent (e.g., unsaved form), listen to cancel event and e.preventDefault().

Want all 83 complete component source codes? Visit Web Component Dictionary for the full version.

Top comments (0)