跳到正文
册页

浮层

Dialog - 对话框

一个模态表面:portal、遮罩、居中面板。

用法

TSX
import { Dialog } from '@misoto22/folio'

说明

Radix Dialog 的根节点、触发器和关闭按钮,以带类型的原样透传再导出。

结构

Dialog anatomy
部件说明
遮罩必填铺满视口的那层 --scrim,停在 --z-overlay(200)。点击外部落在的就是它,而页面钉在这一档以下的所有东西都会被它盖住——包括停在 100 的 FloatingIconButton。锚定面板不在此列:那些停在 220,正是为了让在这个对话框里打开的 Select 依然够得到。
面板必填居中的那个盒子,停在 --z-modal(210),宽度上限 min(92vw, 32rem),高度上限 85vh,超过就自己滚正文。它靠一次 translate 让自己居中,而这件事对里面任何 fixed 定位的东西都有后果。
标题必填title,渲染成 Radix 的 Title。它一直在:不传 title 时会渲染一个视觉上隐藏的标题,念的就是「Dialog」这个词本身,同时开发环境会警告 DIALOG_TITLE_MISSING——这个兜底存在的意义,是让没名字的模态发不出去,不是让它发得出去。
描述description,标题底下轻声的一行。它和标题共用一个外层容器,所以 hideTitle 是把两个一起藏起来。
关闭顶部末端角上那个 36px 的 X,在 showClose 为 true(默认值)时渲染,并且自带一个 aria-label「Close」。

实践建议

推荐

  • 设了 hideTitle 也照样要传 title:完全不传时,兜底的无障碍名称就是「Dialog」这个字面字符串,于是应用里每一个没名字的模态,播报出来都是同一句话——而且它一路还能通过自动化可访问性检查,所以这件事由开发环境发警告,而不是留给评审。
  • 取消的那个控件要用 DialogClose 包住,别自己翻一个 state——这样关闭走的是 Radix,焦点会被送回触发器,而不是被丢在文档最开头。
  • 装得下多少就放多少。面板到 32rem × 85vh 就停住,再多就自己滚正文——所以一张长到要滚动的表单,其实已经是一个 Sheet(它拿的是整个视口的高度),或者干脆是一个页面。
  • 除非面板自己给了出口,否则别关掉 showClose:另外两条出路只有 Escape 和遮罩,两个都看不见——所以在一个装满内容的对话框上写 showClose={false},是一间门上没有标记的房间。

避免

  • 元素本身没有定位的 OverlayContainer,会把错的那个盒子交给对话框:一旦指定 container,面板就从 fixed 切成 absolute,而一个没有定位的 container 把它送到的是最近那个有定位的祖先——通常就是页面,看起来就像 container 被无视了。
  • 同时开着的两个对话框,先后由 DOM 决定,不是由层级决定:两个都停在 --z-modal,所以后挂载的那个画在先来的那个上面——在对话框之上召出来的命令面板压在上面,只是因为它是第二个打开的;把这个顺序倒过来,画面也跟着倒过来。
  • 面板靠一次 transform 让自己居中,于是它成了每一个 position: fixed 后代的包含块——扔进对话框里的 FloatingIconButton 会钉在面板的角上,而不是屏幕的角上。

示例

a destructive confirm

一个问题、两个答案,面板里再没有别的。两个控件都包在 DialogClose 里,而不是自己手动翻状态:这样关闭走的是 Radix,它会把焦点交还给触发器,而不是把焦点丢在文档顶上。title 是给这个模态命名的东西——不写它,兜底的无障碍名称就是字面的「Dialog」,于是一个应用里每一个没命名的模态,播报出来都是同一个东西。答案是「是」或「否」时拿这个形状;任何更长的东西,都已经变成一个 Sheet 了。

a hidden title

一块自己画页头的面板——标题上方一个引题,旁边一枚徽标——这是那行朴素的标题表达不了的。hideTitle 阻止那一行渲染两遍,而 title 仍然为读屏软件命名这个对话框,所以即便在这里也要传它:完全不给 title,无障碍名称就退回字面的「Dialog」。hideTitle 会把 description 一并带走,因为两者共用一层容器,所以任何读者必须读到的东西都放进 children。showClose 保持开着,就像它的默认值一样:除此之外只有 Escape 和遮罩两条出路,而它们都是看不见的。

or a sheet

同一个模态的两种长度。Dialog 到 32rem × 85vh 就打住,超出的部分在自己的正文里滚动,这对一个问题是对的,对一份表单是错的:读者失去了页面边缘这个锚,还得在一个盒子里滚另一个盒子。Sheet 是同一个组件停靠到边上——同样的遮罩、同样的焦点陷阱、同样的滚动锁定——但有整个视口的高度可花。所以这个选择只关乎内容需要多大地方,别的什么都不关乎。两者都通过 DialogClose 或 SheetClose 关闭,好让焦点回到触发器上。

组成部分

在调用处组合,而不是通过 props 配置——所以这个组件没有预料到的布局,依然表达得出来。

DialogContent

一块模态表面:portal → 遮罩 → 居中面板。

焦点陷阱、Escape 键、滚动锁定和 aria-modal 的接线都归 Radix 管——这些每一项手搓的对话框都会微妙地做错,通常是把焦点留在了底下的页面里。

不管显不显示,Radix 都要求有一个 Dialog.Title,所以一个没有可见标题的对话框仍然会渲染一个隐藏的,而不是交付一个没有名字的模态——同时它会在开发环境发出警告,因为它渲染出来的那个兜底就是“Dialog”这个词,而一个能通过无障碍检查的占位,正是这个问题得以熬过代码评审的方式。

它会 portal 到外层 OverlayContainer 指名的那个元素里,并且在有这么一个容器时,从按视口定位切换成按容器定位。一个 fixed 的面板不管被 portal 到哪里都会盖满整页,所以只认容器、不做这次切换,等于只挪了标记而画面纹丝不动。

DialogContent props
属性类型默认值说明
classNamestring—
descriptionReactNode标题下面的副标题。
hideTitlebooleanfalse保留给辅助技术的标题,但在视觉上隐藏它。 用于那些看得见的人一眼就明白用途的表面——命令面板、图片灯箱——在那里印一个标题只是多余的家具。标题本身从来不是可选的:Radix 要求有一个,而一个没有可访问名称的模态,会把读屏用户丢进一个无名的区域里。
showClosebooleantrue显示右上角的关闭控件(默认 true)。
titleReactNode标题文字。即使设了 `hideTitle` 也要传。 Radix 要求有标题,所以不传就会渲染一个隐藏的兜底,内容是“Dialog”这个词——它能满足自动化的无障碍检查,同时把应用里每一个没名字的模态都播报成同一样东西。开发环境会把这件事说出来。

同时接受 Omit<ComponentProps<typeof DialogPrimitive.Content>, 'title' | 'className'> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。

再导出

Dialog = DialogPrimitive.Root

Radix Dialog 的根节点、触发器和关闭按钮,以带类型的原样透传再导出。

DialogTrigger = DialogPrimitive.Trigger
DialogClose = DialogPrimitive.Close

键盘操作

Dialog keyboard interactions
按键作用
Escape关闭对话框,焦点回到它来的那个触发器。
Tab在对话框内部循环;打开期间焦点出不去。

无障碍

  • 焦点陷阱、Escape、滚动锁定和 aria-modal 都由 Radix 负责。
  • 没有可见标题的对话框仍然会渲染一个隐藏标题,而不是交付一个没有名字的模态。