用 Claude Code 安全实现模态对话框:React、dialog 与无障碍
用 Claude Code 实现模态对话框,覆盖 dialog 元素、React 代码、焦点管理、常见失败和测试。
模态对话框是在当前页面上方临时打开的小窗口,用来让用户完成一个短任务或做一个明确决定。它不只是一个居中的漂亮盒子。真正重要的是:打开后背景页面不能继续操作,键盘焦点要进入对话框,关闭后焦点要回到打开它的按钮。
如果只让 Claude Code “做一个好看的 modal”,它很容易生成看起来没问题但实际不可用的实现:Escape 不能关闭,Tab 跑到背景页面,屏幕阅读器读不到标题,或者手机上底部按钮被截断。本文把需求拆成可执行的实现简报、可复制运行的代码、真实用例、失败清单和检查方法。
评审时请对照官方资料:浏览器原生能力看 MDN <dialog> 元素,键盘行为看 WAI-ARIA APG Modal Dialog Pattern,焦点顺序和焦点可见性看 WCAG Focus Order 与 Focus Visible。站内相关主题还有 Claude Code 无障碍实现、Radix UI 实践、命令面板 和 Toast 通知。
先判断是否应该使用模态框
适合模态框的场景通常很短,而且需要暂停当前页面:删除确认、取消订阅、修改权限、邀请成员、结账前登录、命令面板。用户完成后应该能马上回到原页面。
不适合的场景包括长表单、完整协议、多步骤流程、强行弹出的广告,以及用户稍后再看也没有损失的信息。动手前先回答四个问题:这个操作是否重要到需要打断页面?打开后第一个焦点在哪里?哪些操作会关闭?320px 宽的手机上是否能看到并点击最后一个按钮?
几个术语先用白话说明。焦点是“键盘现在所在的位置”。焦点陷阱是“让 Tab 只在对话框内部移动”。inert 是“让背景不再可交互”。ARIA 是“给辅助技术说明 UI 语义的属性”。把这些写进 Claude Code 的任务里,生成结果会更容易审查。
flowchart TD
A["用户点击触发按钮"] --> B["用 dialog.showModal() 打开"]
B --> C["把焦点移到标题或第一个操作"]
C --> D["检查 Tab、Shift+Tab、Escape"]
D --> E["区分确认、取消和背景点击"]
E --> F["关闭后焦点回到触发按钮"]
给 Claude Code 的实现简报
先写行为约束,再谈样式。下面这段可以直接粘贴,只需要替换文件路径。
请在现有 React + TypeScript 页面中添加模态对话框。
要求:
- 先阅读现有按钮、表单、CSS 和测试,再修改代码。
- 优先使用 HTML dialog 元素;如果不适合,请说明原因。
- 打开后将焦点移到标题或第一个有意义的操作。
- 分别处理 Escape、取消、确认和背景点击。
- 关闭后将焦点还给打开它的按钮。
- 使用 aria-labelledby;有简短说明时使用 aria-describedby。
- 不要移除 outline;使用 :focus-visible 显示焦点。
- 320px 宽度下内容和底部按钮不能被截断。
- 在交付说明中列出失败例和手动检查步骤。
允许修改:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts
可直接运行的 HTML 基线
保存为 modal-demo.html 后用浏览器打开。这个例子不依赖框架,可以看到 showModal()、close()、背景点击和焦点返回。
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dialog demo</title>
<style>
body {
font-family: system-ui, sans-serif;
line-height: 1.7;
padding: 2rem;
}
button {
font: inherit;
border: 0;
border-radius: 6px;
padding: 0.7rem 1rem;
cursor: pointer;
}
.danger {
background: #dc2626;
color: white;
}
dialog {
width: min(calc(100vw - 2rem), 28rem);
border: 0;
border-radius: 8px;
padding: 0;
box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}
dialog::backdrop {
background: rgb(15 23 42 / 0.58);
}
.modal-body {
padding: 1.25rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
</style>
</head>
<body>
<main>
<h1>项目设置</h1>
<p>只有会打断用户的重要操作才放进模态框。</p>
<button id="open-dialog" class="danger" type="button">
删除项目
</button>
</main>
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<div class="modal-body">
<h2 id="dialog-title" tabindex="-1">确定删除这个项目吗?</h2>
<p>此操作无法撤销。如有需要,请先导出数据。</p>
<div class="button-row">
<button id="cancel-dialog" type="button">取消</button>
<button id="confirm-delete" class="danger" type="button">
删除
</button>
</div>
</div>
</dialog>
<script>
const openButton = document.querySelector("#open-dialog");
const dialog = document.querySelector("#confirm-dialog");
const title = document.querySelector("#dialog-title");
const cancelButton = document.querySelector("#cancel-dialog");
const confirmButton = document.querySelector("#confirm-delete");
openButton.addEventListener("click", () => {
dialog.showModal();
title.focus();
});
cancelButton.addEventListener("click", () => dialog.close("cancel"));
confirmButton.addEventListener("click", () => {
console.log("delete project");
dialog.close("confirm");
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
dialog.close("backdrop");
}
});
dialog.addEventListener("close", () => {
openButton.focus();
console.log(`closed by: ${dialog.returnValue || "unknown"}`);
});
</script>
</body>
</html>
作为模态框打开时,应调用 showModal(),不要只手动添加 open 属性。只加 open 容易让背景仍可交互,语义就变了。
React 复用组件
实际项目里,删除确认、账单修改、邀请成员和短表单最好共用一个经过评审的底座。下面的组件可用于 Vite、React SPA 或 Next.js Client Component。
import * as React from "react";
import "./modal-dialog.css";
type ModalDialogProps = {
open: boolean;
title: string;
description?: string;
closeOnBackdrop?: boolean;
onClose: () => void;
children: React.ReactNode;
footer: React.ReactNode;
};
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function ModalDialog({
open,
title,
description,
closeOnBackdrop = true,
onClose,
children,
footer,
}: ModalDialogProps) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const titleRef = React.useRef<HTMLHeadingElement>(null);
const openerRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
openerRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
dialog.showModal();
window.requestAnimationFrame(() => {
const preferred = dialog.querySelector<HTMLElement>("[data-autofocus]");
const firstFocusable = dialog.querySelector<HTMLElement>(
focusableSelector,
);
(preferred ?? firstFocusable ?? titleRef.current)?.focus();
});
}
if (!open && dialog.open) {
dialog.close();
}
}, [open]);
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleClose() {
onClose();
openerRef.current?.focus();
}
function handleClick(event: MouseEvent) {
if (event.target === dialog && closeOnBackdrop) {
onClose();
}
}
dialog.addEventListener("close", handleClose);
dialog.addEventListener("click", handleClick);
return () => {
dialog.removeEventListener("close", handleClose);
dialog.removeEventListener("click", handleClick);
};
}, [closeOnBackdrop, onClose]);
return (
<dialog
ref={dialogRef}
className="app-modal"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
>
<div className="app-modal__body">
<div className="app-modal__header">
<h2 id={titleId} ref={titleRef} tabIndex={-1}>
{title}
</h2>
<button
type="button"
className="app-modal__icon"
aria-label="关闭对话框"
onClick={onClose}
>
x
</button>
</div>
{description ? (
<p id={descriptionId} className="app-modal__description">
{description}
</p>
) : null}
<div className="app-modal__content">{children}</div>
<div className="app-modal__footer">{footer}</div>
</div>
</dialog>
);
}
.app-modal {
width: min(calc(100vw - 32px), 520px);
max-height: calc(100vh - 32px);
border: 0;
border-radius: 8px;
padding: 0;
color: #0f172a;
box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}
.app-modal::backdrop {
background: rgb(15 23 42 / 0.58);
}
.app-modal__body {
display: grid;
gap: 16px;
padding: 24px;
}
.app-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.app-modal__footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
}
.app-modal__icon {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
@media (max-width: 480px) {
.app-modal__footer {
flex-direction: column-reverse;
}
.app-modal__footer button {
width: 100%;
}
}
三个真实用例
| 用例 | 为什么适合模态框 | 让 Claude Code 额外处理 |
|---|---|---|
| 删除、取消套餐、修改角色 | 操作难以撤销,需要暂停确认 | 危险文案、防重复提交、审计日志 |
| 邀请、账单、短设置表单 | 不离开当前上下文即可完成 | 校验、提交中状态、成功后的焦点 |
| 命令面板或快速搜索 | 不跳转页面就能执行操作 | 方向键、aria-activedescendant、空状态 |
短表单失败时不要直接关闭模态框,要在框内显示并朗读错误。命令面板则需要把“对话框外壳”和“列表选择行为”分开审查。
Promise 确认辅助函数
后台界面经常需要“确认后再继续”。下面的辅助函数复用 ModalDialog,调用侧可以写 await confirmDialog(...)。
import * as React from "react";
import { createRoot } from "react-dom/client";
import { ModalDialog } from "./ModalDialog";
type ConfirmDialogOptions = {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
};
export function confirmDialog(
options: ConfirmDialogOptions,
): Promise<boolean> {
return new Promise((resolve) => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function finish(result: boolean) {
root.unmount();
container.remove();
resolve(result);
}
function ConfirmHost() {
return (
<ModalDialog
open
title={options.title}
description={options.message}
closeOnBackdrop={false}
onClose={() => finish(false)}
footer={
<>
<button type="button" onClick={() => finish(false)}>
{options.cancelLabel ?? "取消"}
</button>
<button
type="button"
data-autofocus
className={options.danger ? "danger" : "primary"}
onClick={() => finish(true)}
>
{options.confirmLabel ?? "确认"}
</button>
</>
}
>
<p>继续之前请再次确认内容。</p>
</ModalDialog>
);
}
root.render(<ConfirmHost />);
});
}
常见失败与检查
第一,关闭控件只能用鼠标。请使用真正的 button,图标按钮要有可访问名称,并检查 Enter 和 Space。
第二,为了视觉简洁删除标题。即使标题不显示,对话框也需要可访问名称。用 aria-labelledby 连接标题,说明文字简短时再用 aria-describedby。
第三,CSS 里写 outline: none 却没有替代方案。键盘用户会失去当前位置。请使用 :focus-visible。
第四,层层叠加模态框。嵌套会让焦点返回、Escape 含义和用户责任变得混乱。优先使用更清楚的文案、撤销功能或单个确认步骤。
第五,移动端高度不足。用 max-height 和 overflow: auto,并在 320px 宽度下确认底部按钮可见可点。
Playwright 冒烟测试
自动化不能替代屏幕阅读器检查,但能发现很多焦点回归问题。
import { expect, test } from "@playwright/test";
test("modal opens, closes, and returns focus", async ({ page }) => {
await page.goto("/settings");
const trigger = page.getByRole("button", { name: "删除项目" });
await trigger.click();
const dialog = page.getByRole("dialog", {
name: "确定删除这个项目吗?",
});
await expect(dialog).toBeVisible();
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
手动检查时,不使用鼠标,只用 Tab、Shift+Tab、Enter、Space 和 Escape。再用 NVDA 或 VoiceOver 检查标题、说明和按钮名称,最后看窄屏手机布局。
CTA 与收益化
模态框经常靠近收益路径:购买确认、咨询表单、邮件订阅、培训申请。越靠近收入,越要克制。不要用广告式弹窗打断读者;有时内联区块、toast 或普通页面更可信。
如果团队需要把 Claude Code、CLAUDE.md、无障碍 UI 审查和 React 改造流程一起整理,可以使用 Claude Code 培训与咨询。个人开发者可以从 产品列表 和 免费速查表 开始,先把提示词、评审和测试固定下来。
实测结果
Masa 在一个小型 React 设置页上验证这个模式时,最有效的不是更复杂的动画,而是把“关闭后焦点回到触发按钮”“危险操作不因背景点击关闭”“320px 宽度下底部按钮可点”写进 Claude Code 的完成条件。这样生成的差分更容易审查,发布前返工明显减少。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。