模态层
每个模态表面下面的那一层。它把所呈现内容背后的窗口变暗,让那里的一切变为惰性;把焦点移进来、留在里面、关闭时交还;并在用户按下 Escape、使用平台的返回操作或按下变暗区域时请求关闭。只需调整它一次,所有模态表面都会随之改变。
进阶
大多数产品直接使用包含此组件的Bottom sheet, DateTimePicker, Dialog, Image preview, Navigation drawer, Search, Side sheet。
- 类别
- 容器
- 令牌
comp.modal-layer- 层级
- 基础
- Web
tanrei-modal-layerclock_loader_40 - SwiftUI
tanreiModalLayerradio_button_unchecked - Jetpack Compose
TanreiModalLayerradio_button_unchecked - Flutter
TanreiModalLayerradio_button_unchecked - React Native
ModalLayerradio_button_unchecked
生成自 docs/status/status.json, specs/modal-layer.spec.json
包着你自己的卡片的模态层:用按钮打开它
24 张照片已准备好导出。
每个模态表面下面都有同样的一层。它把表面背后的窗口变暗,让页面看起来被暂时搁在一旁。它让那里的一切变为惰性:背后的任何东西都无法按下、无法获得焦点,屏幕阅读器也读不到,页面也不会滚动。它把焦点移到表面里,在表面打开期间把焦点留在那里,并在表面关闭时把焦点交还。当用户按下 Escape、使用平台的返回操作或按下变暗区域时,它会请求关闭。
模态层就是这一层,被做成了一个独立的组件。对话框、模态形态的侧边面板和底部面板、模态的导航抽屉、图片预览、模态的 DateTimePicker,以及搜索的模态视图,都是用它构建的。它们都遵循它的规则,而不是各有一套规则。
它是一个高级组件。你会在两种情况下用到它:
- 一次调整所有模态表面的样式。 上面这些组件的遮罩令牌和动效令牌,指向的都是这一层的令牌
comp.modal-layer.*。在产品的层里设置它们,所有模态表面就会一起改变:变暗区域的颜色和不透明度、对背后内容的模糊,以及这一层出现和消失所用的时长。 - 构建 Tanrei 没有的模态表面:命令面板、占满整个窗口的界面、你自己的查看器。把你的表面放进这一层,它的行为就与产品中所有其他模态表面一致。
需要用户做决定,就用对话框。需要一块面板,就用模态的侧边面板或底部面板。它们已经会为自己命名、放置操作,并提供出路。
在 Web 上,它是 <tanrei-modal-layer>。其他平台尚未实现它。
这一层是受控组件。把 open 设为 true 即可显示它。当用户请求关闭时,它会触发 close 并附上原因(escape、back 或 scrim),然后保持打开:由你把 open 设回 false。为它提供 label,这是屏幕阅读器朗读的名称。
这一层除了变暗区域之外,自己什么也不绘制。你的内容保留它自己的颜色、形状和内边距,这一层按内容本身的尺寸把它放在窗口中央。要放在别处,就用平台自己的方式(在 Web 上是 ::part(container))。
<tanrei-button id="open-palette" variant="text">命令</tanrei-button>
<tanrei-modal-layer label="命令">
<div class="palette">
<!-- autofocus 标记初始焦点。没有它时,焦点移到这一层的容器上。 -->
<input autofocus aria-label="搜索命令">
<ul>…</ul>
</div>
</tanrei-modal-layer>
<style>
/* 容器由你自己放置:这里放在靠近窗口顶部的位置。 */
tanrei-modal-layer::part(container) { margin-block-start: 15vh; }
</style>
<script type="module">
import '@tanrei/web/components/modal-layer';
const layer = document.querySelector('tanrei-modal-layer');
document.querySelector('#open-palette').addEventListener('click', () => (layer.open = true));
// Escape、返回操作或按下变暗区域。这一层从不自行关闭。
layer.addEventListener('close', (event) => {
console.log(event.detail.reason); // 'escape'、'back' 或 'scrim'
layer.open = false;
});
</script>// 进阶组件(用于构成其他组件的部件)从 advanced 入口导入。
import { ModalLayer } from '@tanrei/react/advanced';
<ModalLayer open={open} label="命令" onClose={() => setOpen(false)}>
<CommandPalette />
</ModalLayer>尚未实现。计划中的形式是一个视图修饰符:.tanreiModalLayer(isPresented:, accessibilityLabel:, onClose:) { … }。
尚未实现。计划中的形式是 TanreiModalLayer(visible, onCloseRequest, contentDescription) { … }。
尚未实现。计划中的形式是 TanreiModalLayer(open:, onClose:, semanticLabel:, child: …)。
尚未实现。计划中的形式是 <ModalLayer visible onClose accessibilityLabel>…</ModalLayer>。
产品在自己的层文件里设置这一层的令牌,所有模态表面都会随之改变:
{
"tokens": {
"tanrei.comp.modal-layer.scrim.color": { "$value": "{tanrei.sys.color.scrim}" },
"tanrei.comp.modal-layer.scrim.blur": { "$value": { "value": 8, "unit": "px" }, "reason": "我们的模态表面会让背后的内容呈现磨砂效果。" }
}
}变暗区域的不透明度就是它颜色的 alpha 值:要让它更浅或更深,就改变这个颜色,或改变系统的 scrim 角色。要让某一个表面与众不同,就改为设置那个表面自己的令牌(comp.dialog.scrim.color、comp.dialog.motion.enter-duration)。在你设置之前,它指向的是这一层的令牌。
构成
| 部件 | 说明 |
|---|---|
1 scrim · 可选 | The dim over the whole window behind the container, to every edge and under every system inset. It blocks presses and, while the layer is dismissible, a press on it asks the layer to close. Not drawn while scrim is false. |
2 container | The box the content is in, placed in the window's safe area. It draws nothing of its own — no color, edge, shadow, shape or padding — takes focus when nothing in the content is marked for it, and is the modal dialog assistive technology reads. |
属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
open | boolean | false | Whether the layer is presented. The product opens it, and closes it in answer to close (or from its content's own controls); on SwiftUI, where it is a binding, the layer writes false itself. |
dismissible | boolean | true | Whether Escape, the platform's back and a press on the scrim ask the layer to close (MDL-6). Set it to false only when the person must finish with one of the content's own controls, and give the content one. |
scrim | boolean | true | Whether the layer dims what lies behind its container. False for content that covers the whole window with a fill of its own (a full-window preview): the layer then draws nothing behind it and still blocks everything there (MDL-2). |
label | string | "" | Names the container for assistive technology ("Export photos"): the modal dialog's name. Never drawn. Required. |
插槽
| 插槽 | 说明 |
|---|---|
default | The content: the surface the product presents modally (a panel, a card, a view over the whole window). The layer places its container (MDL-11) and draws, styles and sizes nothing of the content. A fixture's content string is plain text. |
事件
| 事件 | 说明 |
|---|---|
close | The layer asks to be closed: Escape, the platform's back or a press on the scrim. Carries which of these it was, named escape, back or scrim (in the platform's casing). Emitted only while dismissible is true. |
状态
行为
以 EARS 形式写成的需求。每个平台的实现都按这些 ID 逐条测试。需求原文取自规格文件,原样以英文呈现。
| ID | 需求 |
|---|---|
MDL-1 | WHEN open becomes true THE modal layer SHALL present its container above everything in its window — every region, every overlay not opened from inside it (MDL-9) and every modal layer opened before it (MDL-8) — and, WHILE scrim is true, SHALL draw its scrim over the whole window behind the container, to every edge and under every system inset, filled with comp.modal-layer.scrim.color (its alpha is the dim) and, WHILE comp.modal-layer.scrim.blur is above 0, with what lies behind it blurred by a Gaussian blur of that radius (the radius of CSS's blur()); WHERE the platform cannot blur what lies behind a window it SHALL draw the color alone. WHILE scrim is false it SHALL draw nothing behind the container and SHALL still block what is behind as MDL-2 says. A component built from the modal layer draws the scrim from its own scrim tokens, which alias these two (comp.dialog.scrim.color and comp.dialog.scrim.blur, and so on). |
MDL-2 | WHILE open is true THE modal layer SHALL make everything outside its container inert — not focusable, not pressable, hidden from assistive technology — and SHALL keep the content behind it from scrolling without moving it (where a scroll bar takes room, that room stays while the scroll is held), so that a press, a scroll or a key anywhere outside the container reaches nothing behind it; WHEN it closes THE modal layer SHALL restore what is behind to what it was before: its scroll position, and what was focusable, pressable and exposed. |
MDL-3 | WHEN open changes from false to true, and WHEN the layer is mounted with open true, THE modal layer SHALL move focus to the element in its content that the product marked for initial focus (the platform's own mark, named in the platform notes) or, without one, to the element the component built from it names (the navigation drawer's selected destination, NVD-19) or else to its container, so that assistive technology reads its name first and the next Tab reaches the first focusable element. Focus SHALL move when the layer opens, not when its motion ends. |
MDL-4 | WHILE open is true THE modal layer SHALL keep focus inside its container: Tab from the last focusable element in it SHALL move to the first, Shift+Tab from the first (or from the container itself) SHALL move to the last, the container SHALL keep focus WHILE nothing in it is focusable, and screen-reader navigation SHALL NOT leave it. |
MDL-5 | WHEN the layer closes THE modal layer SHALL return focus to the element that had it when the layer opened or, where that element no longer exists, cannot take focus or is inert, to the platform's default for a closed overlay; focus SHALL move when the layer closes, not when its motion ends. |
MDL-6 | WHILE dismissible is true and open is true, WHEN the user presses Escape, uses the platform's back (Android's back gesture or button, the iOS accessibility escape gesture) or presses the scrim — a press that starts and ends outside the container — THE modal layer SHALL emit close exactly once with the reason escape, back or scrim, and SHALL stay open until open becomes false: the product sets it from close (through the binding where open is one, SwiftUI), unless the component built from the modal layer closes itself as its own spec says (DLG-5, NVD-20). Escape SHALL reach the layer only after everything inside its container has heard it: a key an element inside takes (a field clearing its text, a menu closing) SHALL NOT also ask the layer to close. WHILE dismissible is false THE modal layer SHALL ignore these inputs and emit nothing, and a press on the scrim SHALL still reach nothing behind it. |
MDL-7 | WHEN the layer opens THE modal layer SHALL fade its scrim and its container in from transparent over comp.modal-layer.motion.enter-duration with comp.modal-layer.motion.enter-easing, and WHEN it closes SHALL fade both out over comp.modal-layer.motion.exit-duration with comp.modal-layer.motion.exit-easing, keeping them drawn until the fade ends. A component built from the modal layer SHALL fade the scrim in step with its own surface, over its own enter and exit motion, and moves its surface as its own spec says (a dialog grows, a sheet slides). The enter and exit tokens of a surface that takes the modal role (a dialog, a modal picker, an image preview) alias these four, so that one value sets the tempo of every such surface of a product; a sheet (a bottom sheet, a side sheet, a modal navigation drawer) takes the sheet role (tanrei.sys.motion.role.appear.sheet.enter and .exit) instead, so that one value sets the tempo of every sheet. WHERE motion is reduced THE modal layer SHALL fade without moving or growing anything, or SHALL appear and disappear at once WHERE the component built from it says so or on a platform whose reduced-motion setting removes every animation (Android's). |
MDL-8 | WHEN a modal layer opens while another is open THE modal layer SHALL stack above it with its own scrim, make the first one inert with the rest of the window, and WHEN it closes return focus to the element inside the first one that had it; Escape, the platform's back and a press on the scrim SHALL reach only the topmost layer. |
MDL-9 | WHEN an overlay that is not modal opens from an element inside the container — a menu, a tooltip, a popover, a select's list, a docked picker — THE modal layer SHALL let it be presented above the layer and operated while the rest of the window is inert, and SHALL NOT count a press inside it as a press on the scrim: the overlay belongs to the layer as POP-25 says. |
MDL-10 | THE modal layer SHALL expose its container as a modal dialog named by label, everything outside it being hidden (MDL-2); a component built from it names the dialog by its own headline or label and may expose it as an alert dialog (DLG-14). WHILE label is empty THE modal layer SHALL still open, and in debug builds SHALL report that it has no accessible name. |
MDL-11 | THE modal layer SHALL draw nothing of its own but the scrim: its container has no color, edge, shadow, shape or padding and hands its content no color or text style, so that plain text given as content takes what the window around it gives. It SHALL lay the container out at the size of its content, centred in the window's safe area and above the on-screen keyboard while one is shown, and never larger than that area, the content scrolling inside it where it is larger; the product places or sizes the container otherwise in the platform's own way (named in the platform notes). A component built from the modal layer places and draws its own surface (DLG-7, SSH-7, BSH-6, IMP-1). |
MDL-12 | THE modal layer SHALL take its layout direction from where it is used and SHALL have no direction setting of its own; it SHALL mirror nothing itself, and its content follows the direction. |
MDL-13 | WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE modal layer SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on its dialog (MDL-10) — an id reference by the elements it names in the host's tree or a tree around it, a description after its own. WHILE label is not empty it is the dialog's name, over a name given so; WHILE label is empty, a name given so is the dialog's name and SHALL NOT be reported as having none in debug builds. WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE modal layer SHALL take it off that element. |
各平台名称对照
对等并不意味着 API 完全相同。同一份契约,由各平台以自己的惯用方式表达。
| 规格 | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
|---|---|---|---|---|---|
| 组件 | tanrei-modal-layer | tanreiModalLayer | TanreiModalLayer | TanreiModalLayer | ModalLayer |
open · 属性 | open | isPresented | visible | open | visible |
dismissible · 属性 | closedby | dismissible | dismissible | dismissible | dismissible |
scrim · 属性 | scrim | scrim | scrim | scrim | scrim |
label · 属性 | label | accessibilityLabel | contentDescription | semanticLabel | accessibilityLabel |
close · 事件 | close | onClose | onCloseRequest | onClose | onClose |
| 备注 | dismissible is HTML's own closedby attribute, as on <tanrei-dialog> and <tanrei-bottom-sheet>: any (the default) or none. scrim is a boolean property that defaults to true; its attribute is the inverse no-scrim (an HTML boolean attribute cannot default to true). close is a CustomEvent whose detail.reason is escape, scrim or back (back when the native cancel arrives without an Escape key, as Android's back gesture sends it); the product sets open to false from it. The content is the default slot. The container is a native <dialog> in the shadow root opened with showModal(), in the top layer over ::backdrop, the scrim (backdrop-filter blurs what is behind it); it is the container part, which a product places or sizes with ::part(container) (MDL-11). The fade out (MDL-7) keeps the closed <dialog> in the top layer in every browser, as it does every Tanrei modal overlay: closing closes the modal <dialog> at once (focus goes back, nothing is inert any more) and shows it as a manual popover, inert and taking no pointer, marked data-tanrei-leaving, until its fade and its scrim's have finished. The autofocus attribute on a slotted element marks initial focus. Debug builds (import.meta.env.DEV or the package's dev export) report an empty label (MDL-10). Snapshot harness: a 640 × 640 viewport with a surface page, the layer opened by open; the screenshot is the viewport. | Planned: a view modifier, as .sheet is: .tanreiModalLayer(isPresented: Binding<Bool>, dismissible:, scrim:, accessibilityLabel:, onClose:, content:), presented as tanreiDialog is (on iOS .fullScreenCover with a clear presentation background and the system transition off; Tanrei draws the scrim and the fade; on macOS an overlay of the modified view with the modal trait). Closing writes false to isPresented and then calls onClose with the reason. Initial focus is .defaultFocus; Escape is .onKeyPress(.escape) and the escape gesture .accessibilityAction(.escape). The blur is a material behind the scrim where the radius is above 0. The container is placed with the content's own frame and alignment. | Planned: TanreiModalLayer(visible, onCloseRequest: (TanreiModalLayerCloseReason) -> Unit, dismissible, scrim, contentDescription, content), drawn in a Dialog window of its own (as TanreiDialog and the modal sheets are) with the window's own dim off and usePlatformDefaultWidth = false; the window traps focus, answers back and hides what is behind from TalkBack. The blur is the window's blur-behind radius on Android 12 and later, and none before (MDL-1). The caller sets visible to false from onCloseRequest. The container is placed with a Modifier on the content. | Planned: TanreiModalLayer(open:, onClose:, dismissible:, scrim:, semanticLabel:, child:), a controlled overlay in the Overlay over the page with a ModalBarrier drawn in the layer's color and a BackdropFilter for the blur, a FocusScope that traps focus, BlockSemantics behind it and a PopScope for back. The container is placed with the child's own Align. | Planned: <ModalLayer visible onClose dismissible scrim accessibilityLabel>{content}</ModalLayer>, presented in a transparent Modal with animationType="none", statusBarTranslucent and navigationBarTranslucent; onRequestClose (Android's back, and Escape on a hardware keyboard) is close with the reason back or escape. React Native cannot blur what is behind a window without a native module of its own, so the scrim is the color alone (MDL-1). The container is placed with the content's own style. |
令牌
| 令牌 | 引用 | 解析值(浅色) |
|---|---|---|
--tanrei-comp-modal-layer-motion-enter-duration | sys.motion.role.appear.modal.enter | 350ms |
--tanrei-comp-modal-layer-motion-enter-easing | sys.motion.easing.enter | cubic-bezier(0.2, 0, 0, 1) |
--tanrei-comp-modal-layer-motion-exit-duration | sys.motion.role.appear.modal.exit | 300ms |
--tanrei-comp-modal-layer-motion-exit-easing | sys.motion.easing.exit | cubic-bezier(0.3, 0, 1, 1) |
--tanrei-comp-modal-layer-scrim-blur | — | 0px |
--tanrei-comp-modal-layer-scrim-color | sys.color.scrim |
测试样例
作为基准的渲染结果。所有平台都以相同的 ID 为每个样例截取快照,以便相互比较。
open
open: true label: Export photos 单独绘制在 640 × 640 的窗口中no-scrim
open: true scrim: false label: Export photos 单独绘制在 640 × 640 的窗口中生成自 specs/modal-layer.spec.json, tokens/comp/modal-layer.tokens.json, design/tokens/
| 这一层 | 你的内容 |
|---|---|
把背后的窗口一直到各个边缘都变暗(关闭 scrim 时则什么也不绘制)。 |
自己的颜色、形状、阴影和内边距。这一层一样也不添加。 |
| 让背后的一切变为惰性,并阻止页面滚动。 | 比窗口还高时,自己的滚动。 |
| 把焦点移进来,留在里面,关闭时交还。 | 如果有应当最先获得焦点的元素,就标记它。 |
| 在 Escape、平台的返回操作和按下变暗区域时请求关闭。 | 自己的出路:“关闭”“取消”或“完成”按钮。 |
| 按内容的尺寸把容器放在窗口中央。 | 如果不放在中央,要放在哪里。 |
如果内容用自己的填充覆盖整个窗口,例如深色背景上的查看器,就关闭 scrim。这时已经没有什么可以变暗了。这一层仍然挡住背后的一切,在内容之外按下仍然会请求关闭。
- 用
label为这一层命名。焦点移进来时,屏幕阅读器会朗读它。 - 在内容里放一条看得见的出路。不是每个人都知道 Escape,也不是每台设备都有返回操作。
- 通过
comp.modal-layer.*改变产品的模态表面,让它们保持一致。
- 不要用光秃秃的一层来搭建对话框或面板。使用对话框或面板,它们已经提供了标题、操作以及操作的位置。
- 内容里没有出路时,不要关闭
dismissible。否则用户将无法离开。 - 不要把它用于不应挡住页面的东西。弹出框、菜单或消息条都让页面保持可用。
- 角色
dialog- 标注
- The container is a modal dialog named by label; everything outside it is hidden from assistive technology while the layer is open. The scrim is not exposed. A component built from the modal layer names the dialog by its own headline or label, and the dialog may be an alert dialog.
- 最小目标
- 即使外观尺寸更小,点击区域也满足最小目标尺寸。 minimum 32px · pointer 44px · spacing 8px · touch 48px
键盘操作
| 按键 | 动作 |
|---|---|
| Tab / Shift+Tab | Move focus through the focusable elements in the container, wrapping from the last to the first and back; focus never leaves the layer. |
| Escape | Asks the layer to close (close, reason escape) while dismissible is true; with stacked layers, only the topmost. A key an element inside takes first goes no further. |
备注
- The modal layer is what every modal surface of Tanrei has in common: the dialog, the modal side sheet, the modal bottom sheet, the modal navigation drawer, the image preview, the modal date-time picker and the search's modal views are each built from it and cite its rules. Restyle their scrim and their tempo once, through comp.modal-layer.*.
- Use it alone for a modal surface Tanrei does not have — a command palette, a takeover over the whole window, a viewer of your own. For a decision use the Dialog; for a panel, the modal side sheet or bottom sheet: they already hold focus, name themselves and place their actions.
- Give the content a way out of its own (a Close, Cancel or Done button). Escape, back and the scrim are not found by everyone, and with dismissible false they do nothing.
- The layer has no target of its own; what the product puts in it meets the minimum target itself.
- Inert, focus moved in, kept and given back: the WAI-ARIA Authoring Practices' modal dialog pattern and HTML's modal dialog. The web presents the container with a modal <dialog> element (showModal gives the top layer and inertness); the other platforms present it in a window or a route of its own with the system's dim switched off, so that every platform draws the layer's own scrim.
生成自 specs/modal-layer.spec.json, tokens/sys/measure.tokens.json
- 容器是一个模态对话框,以
label为名称。它打开期间,它之外的一切都对辅助技术隐藏。 - 这一层打开时,焦点会移进来,即使它在书写时就是打开的也是如此。焦点移到你标记的元素上,否则移到容器上,这样最先朗读的是名称。
- Tab 和 Shift+Tab 在这一层内循环。焦点永远不会到达背后的页面。
- 关闭时,焦点交还到原来的位置。如果那个元素已经不在了,焦点落在平台安排的位置。
- 只有在里面的一切都处理过 Escape 之后,Escape 才会请求关闭。如果有一个按 Escape 清空文本的输入框,或一个随之关闭的菜单,这一层就保持打开。
- 从另一层里面打开的层叠在它上面。只有最上面的那一层响应 Escape、返回操作和它的变暗区域。
- 提供一个说明这个表面用途的
label。 - 当表单或搜索输入框是这个表面存在的理由时,标记最先获得焦点的元素。
- 为内容提供一个能关闭它的可见控件;除非有这样的控件,否则让
dismissible保持开启。