问题
最近在项目里遇到两个来自 Radix Dialog 的控制台提示:

它们不是 "功能错误",但属于 无障碍(a11y)级别的警告:Radix 在开发环境主动提醒你对话框缺少 "可被屏幕阅读器正确理解" 的关键语义。
本文基于 coco-app(React 18 + TS + Tailwind + shadcn-ui)里真实踩坑的修复过程总结。
1. 背景:Radix Dialog 需要什么语义?
一个可访问的 Dialog 至少需要:
- 可访问名称(accessible name) :告诉读屏软件 "这个弹窗叫啥"
- 对应:
<DialogTitle />(内部映射到aria-labelledby)
- 对应:
- 可访问描述(accessible description) :告诉读屏软件 "这个弹窗在说啥/要用户做啥"
- 对应:
<DialogDescription />(内部映射到aria-describedby)
- 对应:
Radix 的示例结构也是这个顺序(Title + Description + 内容 + Close 等)。
2. 报错 1:为什么必须要 DialogTitle?
2.1 报错含义
DialogContent requires a DialogTitle...
意思是:你的 <DialogContent /> 里没有提供标题,导致 Dialog 没有可访问名称。读屏用户打开弹窗时,不知道这是 "更新提示" 还是 "删除确认"。
2.2 coco-app 的修复方式
我们在 UpdateApp 弹窗中增加了一个 "对视觉隐藏、对读屏可见" 的标题:
- 文件:
src/components/UpdateApp/index.tsx:164 - 代码形态:
tsx
<DialogTitle className="sr-only">{t("update.title")}</DialogTitle>
为什么用 sr-only?
- Tailwind 的
sr-only能达到UI 不变,读屏可读 的效果(有些 shadcn 模板会有现成的VisuallyHidden组件)。
3. 报错 2:为什么必须要 DialogDescription / aria-describedby?
3.1 报错含义
Warning: Missing Description or aria-describedby={undefined} for {DialogContent}.
意思是:Dialog 没有可访问描述,或者你显式把 aria-describedby 置为 undefined 但又没有描述节点关联上。
Radix 的逻辑大致是:
- 你提供
<DialogDescription />:Radix 自动把它的 id 绑定到aria-describedby - 你不提供
<DialogDescription />:Radix 会提醒你 "缺描述",避免读屏用户只听到标题但不知道要做什么
3.2 coco-app 的修复方式
我们把原先展示更新说明的 div 替换为 DialogDescription(UI class 不变,只换组件语义):
- 文件:
src/components/UpdateApp/index.tsx:179-193 - 代码形态:
tsx
<DialogDescription className="text-sm leading-5 py-2 text-foreground text-center">
{updateInfo ? ... : t("update.date")}
</DialogDescription>
这样 Radix 就能自动生成正确的 aria-describedby,warning 消失。
4. "洁癖":它不是 bug,但是就是在控制台报红了...
shadcn-ui 的 Dialog 本质是对 Radix Dialog 的一层轻封装(项目里对应 src/components/ui/dialog.tsx),它不会强制你必须写 Title/Description。
4.1 为什么更容易踩坑?
因为 UI 上你可能觉得:
- 我已经有图标(logo)
- 我已经有一段说明文字(div/p)
- 我不想显示标题
但 视觉上满足 ≠ 语义上满足 。读屏依赖的是 aria-labelledby/aria-describedby 的关联,而不是你页面里有没有一个看起来像标题的 div。
5. 最推荐的写法、更标准的写法
5.1 标题不想显示:用 sr-only
tsx
<DialogTitle className="sr-only">{t("xxx.title")}</DialogTitle>
5.2 描述存在:用 DialogDescription
tsx
<DialogDescription className="sr-only">
{t("xxx.description")}
</DialogDescription>
是否一定要隐藏 Description?
- 不一定。像更新弹窗这种 "正文就是描述",直接用
DialogDescription包住正文最自然。
6. 也可以手动 aria-describedby 吗?可以,但更容易出错
你当然可以自己写:
tsx
<DialogContent aria-describedby="my-desc">
<div id="my-desc">...</div>
</DialogContent>
但坑在于:
- id 可能忘了写 / 重复
- 条件渲染导致节点不在 DOM(aria 指向不存在的 id)
- 重构时删掉了
id没发现 - 多弹窗复用组件时 id 冲突
所以在 shadcn/Radix 体系里,优先使用 DialogTitle / DialogDescription 让 Radix 负责关联更稳。
7. 真正的"坑点清单"(建议以后 review 的时候对照)
- 只写了
<DialogContent />,把标题/正文都塞进普通div - 标题用视觉元素表达(比如 logo 或大号文本),但没用
DialogTitle - 描述是条件渲染的,导致有时没有
DialogDescription - 想隐藏标题却直接不写(应该隐藏而不是删除)
8. coco-app 里的落地实践(最终结论)
在 coco-app 里,我们最终遵循了一个简单规则:
- 每个
DialogContent内部都应该有且只有一个语义标题:DialogTitle - 只要弹窗有 "说明性文本",优先用
DialogDescription承载 - 如果 UI 不需要展示标题/描述:用
sr-only隐藏(而不是不写)