对话框
当任务缺少某个决定或几项信息就无法继续时,让用户停下来作答:它出现在一个表面上,把背后的一切变暗并挡住,直到用户作答或将它关闭。
- 类别
- 容器
- 令牌
comp.dialog- 层级
- 组合
- 构成自
- Modal layer
- Web
tanrei-dialogschedule - SwiftUI
tanreiDialogschedule - Jetpack Compose
TanreiDialogschedule - Flutter
TanreiDialogschedule - React Native
Dialogschedule
生成自 docs/status/status.json, specs/dialog.spec.json
规格已修订,尚未覆盖所有平台
本页描述的是规格当前所定的对话框:每个对话框至少提供一个操作;操作是一行占满宽度的 tonal 按钮,按各自的任务放置;图标位于向起始侧对齐的标题旁边;在 compact 窗口中,对话框位于底部。Web 组件已经实现这次修订,本页的演示就是目前发布的 Web 组件;在修订通过评审、基准图记录完成之前,它在上表中仍标记为“待跟进”。SwiftUI、Compose、Flutter 和 React Native 在跟上之前仍提供上一版:图标位于居中的标题上方,text 变体的操作按给定顺序在末尾侧排成一行,操作可以省略,对话框在任何窗口中都居中。
确认对话框:用按钮打开它
对话框是受控组件。把 open 设为 true 即可打开它;要关闭它,就在某个操作的处理函数中,或在响应 dismiss 时,把 open 设回 false。操作本身从不关闭对话框,所以你可以先做校验、显示进度,或在出错时让它保持打开。
对话框由可选的图标、标题、可选的辅助文本、可选的自定义内容以及操作组成。对话框是一次对话:它总是至少提供一个回答。操作是 Tanrei 的按钮,每个都标记为 confirming(完成任务:保存、删除、允许)或 dismissive(退出任务:取消、以后再说)。对话框按这些标记来放置操作,所以你可以按任意顺序给出它们;没有设置 variant 的操作,对话框会把它画成 tonal。
把 width 设为 small、medium 或 large,对话框就采用固定宽度,而不是随内容决定宽度。对话框没有关闭按钮:不作回答就离开的路,是“取消”这样的取消类操作。
对话框打开时应当获得焦点的元素,用平台自己的方式标记:在 Web 上是 autofocus,在 SwiftUI 上是 .defaultFocus 或 @AccessibilityFocusState,在 Compose 上是作为 initialFocus 传入的 FocusRequester,在 Flutter 上是 autofocus: true,在 React Native 上是 initialFocusRef。没有标记时,焦点落在对话框本身,因此最先朗读的是它的标题。
Web 和 React 的示例展示的是当前的规格。SwiftUI、Compose、Flutter 和 React Native 待跟进:它们仍提供对话框的上一版,其中没有 confirming 和 dismissive 标记,也没有默认的 tonal。因此它们的示例为每个操作指定 text 变体,并把确认类操作放在最后;在这些平台跟上之前,示例展示的都是那一版。
<tanrei-button id="discard-draft" variant="text">放弃草稿</tanrei-button>
<!-- closedby="none" 相当于把 dismissible 设为 false。 -->
<tanrei-dialog headline="放弃草稿?"
supporting-text="你正在撰写的消息将从此设备上删除。">
<!-- dismissive 放在起始侧,confirming 放在末尾侧。没有 variant 时,两者都是 tonal。 -->
<!-- autofocus 标记初始焦点:破坏性最小的那个操作。 -->
<tanrei-button slot="actions" dismissive autofocus>取消</tanrei-button>
<tanrei-button slot="actions" confirming>放弃</tanrei-button>
</tanrei-dialog>
<!-- 标题旁边有一个图标,并且有一个操作指定了自己的 variant。 -->
<tanrei-dialog headline="删除项目?" alert>
<span slot="icon">delete</span>
<tanrei-button slot="actions" dismissive variant="text">取消</tanrei-button>
<tanrei-button slot="actions" confirming variant="filled" destructive>删除</tanrei-button>
</tanrei-dialog>
<!-- 表单:“保存”旁边放“取消”,并使用固定宽度。对话框没有关闭按钮。 -->
<tanrei-dialog headline="编辑显示名称" width="small">
<tanrei-text-input label="显示名称" autofocus></tanrei-text-input>
<tanrei-button slot="actions" dismissive>取消</tanrei-button>
<tanrei-button slot="actions" confirming>保存</tanrei-button>
</tanrei-dialog>
<script type="module">
import '@tanrei/web/components/dialog';
const dialog = document.querySelector('tanrei-dialog');
const cancel = dialog.querySelector('[dismissive]');
const discard = dialog.querySelector('[confirming]');
document.querySelector('#discard-draft').addEventListener('click', () => (dialog.open = true));
// 操作从不关闭对话框:由你在它的处理函数中关闭。
cancel.addEventListener('click', () => (dialog.open = false));
discard.addEventListener('click', () => { discardDraft(); dialog.open = false; });
// 按下 Escape 或按下背景会触发 cancel,与原生 <dialog> 相同;
// e.detail.reason 说明是哪一种:escape、back 或 scrim。
// 随后对话框会自行关闭,除非你取消了该事件。
dialog.addEventListener('cancel', (e) => { if (unsaved()) e.preventDefault(); });
// 每次关闭之后都会触发 close。
dialog.addEventListener('close', onDialogClosed);
</script>import { Button, Dialog } from '@tanrei/react';
// Escape 和背景会让对话框自行关闭(先 onCancel,再 onClose):在 onClose 中让 open 保持同步。
<Dialog
open={open}
headline="放弃草稿?"
supportingText="你正在撰写的消息将从此设备上删除。"
onClose={() => setOpen(false)}
>
<Button slot="actions" dismissive onClick={() => setOpen(false)}>取消</Button>
<Button slot="actions" confirming onClick={discard}>放弃</Button>
</Dialog>import Tanrei
// 它是一个修饰符,与 .alert 和 .sheet 一样。关闭请求会把 false 写入绑定。
TanreiButton("放弃草稿", variant: .text) { showDiscard = true }
.tanreiDialog("放弃草稿?", isPresented: $showDiscard,
supportingText: "你正在撰写的消息将从此设备上删除。",
actions: {
TanreiButton("取消", variant: .text) { showDiscard = false }
TanreiButton("放弃", variant: .text) { discardDraft(); showDiscard = false }
})import io.github.chnotchy.tanrei.components.TanreiButton
import io.github.chnotchy.tanrei.components.TanreiDialog
import io.github.chnotchy.tanrei.tokens.TanreiButtonVariant
// 用 visible 控制,而不是只在打开时才调用它,这样退出动效才能播放。
val cancel = remember { FocusRequester() }
TanreiDialog(
visible = open,
onDismissRequest = { open = false },
headline = "放弃草稿?",
supportingText = "你正在撰写的消息将从此设备上删除。",
initialFocus = cancel,
actions = {
TanreiButton(text = "取消", onClick = { open = false }, modifier = Modifier.focusRequester(cancel), variant = TanreiButtonVariant.Text)
TanreiButton(text = "放弃", onClick = { discardDraft(); open = false }, variant = TanreiButtonVariant.Text)
},
)import 'package:tanrei/tanrei.dart';
// showTanreiDialog 会推入一个模态路由。Navigator.pop 关闭它;用户关闭时,future 以 null 完成。
final bool? discard = await showTanreiDialog<bool>(
context: context,
barrierDismissible: true, // 规格中的 dismissible:Escape、返回和遮罩
builder: (BuildContext context) => TanreiDialog(
headline: '放弃草稿?',
supportingText: '你正在撰写的消息将从此设备上删除。',
actions: <Widget>[
TanreiButton(
variant: TanreiButtonVariant.text,
autofocus: true,
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TanreiButton(
variant: TanreiButtonVariant.text,
onPressed: () => Navigator.pop(context, true),
child: const Text('放弃'),
),
],
),
);import { useRef, useState } from 'react';
import type { View } from 'react-native';
import { Button, Dialog } from '@tanrei/react-native';
const cancel = useRef<View>(null);
const [open, setOpen] = useState(false);
// 命名沿用 React Native 的 Modal:由 visible 控制,onRequestClose 就是规格中的 dismiss。
<Dialog
visible={open}
headline="放弃草稿?"
supportingText="你正在撰写的消息将从此设备上删除。"
onRequestClose={() => setOpen(false)}
initialFocusRef={cancel}
actions={
<>
<Button ref={cancel} variant="text" onPress={() => setOpen(false)}>取消</Button>
<Button variant="text" onPress={discard}>放弃</Button>
</>
}
/>警告对话框:一打开就被朗读的破坏性操作确认
构成
| 部件 | 说明 |
|---|---|
1 scrim | The modal layer's scrim over everything behind the dialog (MDL-1). It blocks presses and, when the dialog is dismissible, dismisses it when pressed. |
2 container | The raised surface that holds everything else: centred in the window, or, in a compact window, with the bottom sheet's appearance at its bottom edge (DLG-17). It is a dialog in both places: the bottom sheet's look, not its drag. Its width follows its widest part, or the size width names (DLG-27). |
3 header | The row at the top: the icon and the headline, and nothing else (DLG-26, DLG-28). By default the icon sits at the headline's start and both are aligned to the start; a system may put the icon above a centred headline instead (DLG-9). |
4 icon · 可选 | A glyph that marks the kind of decision (a warning, a deletion), at the headline's start. |
5 headline | The question or statement the dialog is about, aligned to the start. It is the dialog's accessible name. |
6 scroll · 可选 | The area between the header and the actions that holds the supporting text and the content, and scrolls them when the dialog does not fit (DLG-11). It reaches the container's inline edges whatever inline padding the container has, so the dividers cross the whole container and nothing overflows it; inside, it insets its parts by that same padding. |
7 supporting-text · 可选 | What will happen, or what the person needs to know to decide. It is the dialog's accessible description. |
8 content · 可选 | Whatever else the decision needs: a list to choose from, a field. The dialog insets it and hands it a content color; it does not lay it out. |
9 divider · 可选 | Hairlines between the header and the scrolling content and between the content and the actions, drawn only while the content scrolls: for as long as it does in the default system, or only while content lies beyond each line under dividerVisibility scroll. |
10 actions | The answers: one or more Tanrei Buttons, each marked confirming or dismissive, laid out as a full-width Button group (a row that the actions share equally, or a column when they do not fit). A Button whose variant the product did not set is tonal in the default system. Required: a dialog asks something, so it always offers an answer. |
属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
open | boolean | false | Whether the dialog is presented. The product opens it, and closes it from an action's handler or in answer to dismiss. |
headline | string | "" | The question or statement ("Discard draft?"). Required: it is the accessible name. |
supportingText | string | "" | What will happen, or what the person needs to know. It is the accessible description. |
alert | boolean | false | Marks a dialog that interrupts with something urgent (a destructive confirmation, a lost connection): assistive technology reads its headline and supporting text as soon as it opens, and the web exposes it as an alertdialog. |
dismissible | boolean | true | Whether Escape, the platform's back and a press on the scrim ask to close the dialog (dismiss). Set it to false only when the person must choose one of the actions; give such a dialog a dismissive action. A dialog has no close button (DLG-28): content that needs a way out and nothing more gets a dismissive action (Cancel, or Close where there is nothing to cancel), or belongs in a side sheet or a bottom sheet. |
width | content | small | medium | large | "content" | What decides the container's width while the dialog is centred (DLG-7). content: as wide as its widest part, between comp.dialog.container.min-width and comp.dialog.container.max-width (DLG-7). small, medium, large: as wide as comp.dialog.width.small, comp.dialog.width.medium or comp.dialog.width.large (400, 560 and 800 in the default system) whatever it holds, for a form or content whose width should not follow its text (DLG-27). Never wider than the window less its margins; as a sheet in a compact window every value is placed as DLG-17 says. A structural choice drawn by code, not a token axis. |
compactPresentation | sheet | own | "sheet" | How the dialog is presented in a compact window, one narrower than comp.dialog.compact.window-width. sheet: with the bottom sheet's appearance at the window's bottom edge (DLG-17, DLG-18), as every dialog and every popover of Tanrei is there; it stays a dialog. own: centred in every window, as DLG-7 places it, for a dialog whose look must not change with the window (a short alert that should read as one). A structural choice drawn by code, not a token axis. |
dividerVisibility | auto | always | scroll | "auto" | When the dividers below the header and above the actions are drawn while the supporting text and the content scroll between them. always: both, for as long as that part scrolls, wherever it is scrolled to. scroll: each only while content lies beyond it — below the header while the part is not at its top, above the actions while more of it lies below. auto takes comp.dialog.divider.visibility, the design system's answer (always in the default system). Under every choice a dialog whose parts all fit draws none. |
插槽
| 插槽 | 说明 |
|---|---|
default | The custom content, below the supporting text. |
icon | A glyph at the headline's start (DLG-9). |
actions | The action items: Tanrei Buttons, one at least (DLG-19), each marked confirming or dismissive. The dialog places them (DLG-10) and gives those without a variant its own (DLG-20). A fixture fills it through its items. |
事件
| 事件 | 说明 |
|---|---|
dismiss | The person asked to close the dialog without an action: 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. |
条目
该组件重复排列的条目种类。测试样例按顺序列出条目;各平台以各自的惯用方式为每个种类命名。
action
One answer at the foot of the dialog: a Tanrei Button, placed by the dialog as a full-width Button group places its items and given the dialog's variant unless the product set one.
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
variant | string | "" | The Button's variant (filled, tonal, elevated, outlined, text, or one a system added). Empty, the default: the dialog gives it the variant comp.dialog.actions.variant names, tonal in the default system (DLG-20). Set it to give one action another look (a filled Delete beside a text Cancel); a Delete that destroys something also sets the Button's own destructive, which the dialog leaves as set (BTN-40). |
disabled | boolean | false | As the Button's disabled. A disabled action keeps its place and its share of the width. |
confirming | boolean | false | The action completes the task (OK, Save, Delete, Allow). Placed at the end of a row and at the top of a column (BGR-13, BGR-14). It decides the place only, never the look (BGR-19). |
dismissive | boolean | false | The action backs out of the task (Cancel, Not now, Keep editing). Placed at the start of a row and at the bottom of a column (BGR-13, BGR-14). It decides the place only, never the look (BGR-19). |
| 插槽 | 说明 |
|---|---|
label | The Button's label, as its default slot. A fixture item's content is this label. |
| 事件 | 说明 |
|---|---|
click | The action was activated, as the Button's click. The dialog does not close by itself (DLG-6). |
| 规格 | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
|---|---|---|---|---|---|
| 条目 | tanrei-button | TanreiButton | TanreiButton | TanreiButton | Button |
variant · 属性 | variant | variant | variant | variant | variant |
disabled · 属性 | disabled | disabled | enabled | onPressed | disabled |
confirming · 属性 | confirming | tanreiGroupPlacement(.confirming) | Modifier.confirming() | TanreiButtonGroupItem.placement | confirming |
dismissive · 属性 | dismissive | tanreiGroupPlacement(.dismissive) | Modifier.dismissive() | TanreiButtonGroupItem.placement | dismissive |
click · 事件 | click | action | onClick | onPressed | onPress |
| 备注 | A tanrei-button child with slot="actions": its label is its content. confirming and dismissive are boolean attributes that the dialog reads, as the button group does. A Button without a variant attribute takes the dialog's variant through the internal controller the group already gives its items (DLG-20); the attribute, once written, wins. | A TanreiButton in the actions: builder. The flags are the button group's .tanreiGroupPlacement(_:) modifier (one enum: both cannot be set). A Button created with variant: nil reads the dialog's variant from an environment value the dialog sets (internal to the package). | A TanreiButton in the actions lambda, whose receiver is the button group's scope, so the flags are its Modifier.confirming() and Modifier.dismissive(). enabled is disabled's inverse. variant: TanreiButtonVariant? = null takes the dialog's from a CompositionLocal (internal to the library). | A TanreiButton in actions:; a null onPressed disables it. The flags are the button group's TanreiButtonGroupItem(placement:, child:) (one enum: both cannot be set). A Button with variant: null takes the dialog's from an InheritedWidget (internal to the package). | A Button in the actions prop (an element or a fragment of them). confirming and dismissive are props the dialog reads from its actions, as the button group reads them. A Button with no variant takes the dialog's from a React context (internal to the package). |
状态
行为
以 EARS 形式写成的需求。每个平台的实现都按这些 ID 逐条测试。需求原文取自规格文件,原样以英文呈现。
| ID | 需求 |
|---|---|
DLG-1 | WHEN open becomes true THE dialog SHALL present its container on its modal layer (MDL-1, MDL-2): above everything in its window, with the layer's scrim in comp.dialog.scrim.color and comp.dialog.scrim.blur over the whole window behind it, everything outside it inert and the content behind it kept from scrolling, until it closes. |
DLG-2 | WHEN the dialog opens THE dialog SHALL move focus as its modal layer does (MDL-3): to the element in it that the product marked for initial focus (the platform's own mark, named in the platform notes) or, without one, to its container, so that assistive technology reads the headline first and the next Tab reaches the first focusable element. |
DLG-3 | WHILE the dialog is open THE dialog SHALL keep focus inside it as its modal layer does (MDL-4): Tab and Shift+Tab wrap between its last and first focusable elements, and screen-reader navigation does not leave it. |
DLG-4 | WHEN the dialog closes THE dialog SHALL return focus and restore the content behind it as its modal layer does (MDL-2, MDL-5). |
DLG-5 | WHILE dismissible is true, WHEN the user presses Escape, uses the platform's back or presses the scrim, as its modal layer answers them (MDL-6), THE dialog SHALL emit dismiss once with the reason escape, back or scrim; the product closes it by setting open to false, except on the web, where the dialog closes itself unless dismiss is cancelled, as a native dialog does. WHILE dismissible is false THE dialog SHALL ignore these inputs and emit nothing. |
DLG-6 | WHEN an action is activated THE dialog SHALL NOT close by itself; the product closes it from the action's handler by setting open to false. |
DLG-7 | WHILE the dialog is not presented as a sheet (DLG-17) — in a window at least comp.dialog.compact.window-width wide, or while compactPresentation is own — THE dialog SHALL centre its container in the window's safe area, at least comp.dialog.container.margin from every edge and from the on-screen keyboard, as wide as width names (DLG-27) — WHILE width is content, as wide as its widest part (its text, its content, or its actions in one row at their own widths) between comp.dialog.container.min-width and comp.dialog.container.max-width — but never wider than the window less the margins. There THE dialog SHALL draw its container in comp.dialog.container.color with comp.dialog.container.elevation and comp.dialog.container.shape corners eased by sys.shape.corner-smoothing (0 is a quarter circle, 0.6 the iOS continuous corner; a platform with only the OS curve draws it for any amount above 0). |
DLG-8 | THE dialog SHALL inset its parts by comp.dialog.container.padding on every side and stack the header (the icon and the headline, DLG-9), the supporting text and the content comp.dialog.container.gap apart, leaving out a part that is empty, with the actions comp.dialog.actions.margin-block-start below the last of them. It SHALL draw the headline in comp.dialog.headline.typescale and comp.dialog.headline.color, wrapping onto more lines rather than truncating, and the supporting text in comp.dialog.supporting-text.typescale and comp.dialog.supporting-text.color, and SHALL hand the content comp.dialog.content.color as the platform's inherited foreground (the web's inherited color, SwiftUI's foreground style, Compose's content color, Flutter's default text and icon style, React Native's Tanrei text context); plain text given as content, where the platform must style it, SHALL take sys.typescale.body. THE dialog SHALL set the headline's letter-spacing by the script of its text, as every title is set (PRS-20): no negative letter-spacing WHERE its language is Chinese, Japanese or Korean, and none WHERE its script joins its letters or stacks marks on them. |
DLG-9 | WHILE the icon slot is filled THE dialog SHALL draw the icon at comp.dialog.icon.size in comp.dialog.icon.color, with the family and fill of an unselected icon, sys.icon.weight and an optical size clamped between sys.icon.optical-size.min and sys.icon.optical-size.max, and place it as comp.dialog.icon.placement says: WHILE it is inline, at the start of the header, comp.dialog.header.gap before the headline and centred on the headline's first line, so that a headline that wraps keeps the icon beside its first line; WHILE it is above, above the headline, comp.dialog.container.gap apart, with the icon and the headline centred. The supporting text and the content SHALL be aligned to the start in either placement, and WHILE the icon slot is empty the headline SHALL be aligned to the start whatever the placement. |
DLG-10 | THE dialog SHALL lay out its actions as a standard Button group lays out its items (BGR-12 to BGR-19), comp.dialog.actions.gap apart, in the order the group gives them — dismissive at the start and confirming at the end of a row, confirming on top and dismissive at the bottom of a column — with focus and reading order following that order (BGR-15). WHILE comp.dialog.actions.arrangement is fill it SHALL make them a full-width group across the container's inner width, the actions sharing a row equally (BGR-17) or each as wide as the column; WHILE it is end it SHALL keep them at their own widths, the row aligned to the end, and a column as wide as its widest action and aligned to the end. It SHALL place them in a row WHILE every action's label fits its share on one line (fill) or the row at its own widths fits the inner width (end), and otherwise in a column, re-deciding when the dialog's width or the text size changes. |
DLG-11 | WHEN the dialog's parts do not fit in the height its placement leaves THE dialog SHALL keep the header and the actions in place and scroll the supporting text and the content between them, and SHALL draw a divider of comp.dialog.divider.width in comp.dialog.divider.color across the container below the header and another above the actions: WHILE its divider visibility (DLG-24) is always, both for as long as that part scrolls, wherever it is scrolled to, at its top included; WHILE it is scroll, the one below the header only WHILE the scrolled part is not at its top and the one above the actions only WHILE more of it lies below. WHILE everything fits THE dialog SHALL draw no divider. |
DLG-12 | WHEN the header and the actions alone leave no room for the scrolled part (large text on a short window) THE dialog SHALL scroll all of its parts together instead, so that nothing is cut off or unreachable. |
DLG-13 | WHEN the dialog opens centred in the window (DLG-7) THE dialog SHALL fade its scrim in with its container, as its modal layer says (MDL-7), and make its container appear as every modal surface appears: from sys.motion.appear.opacity, sys.motion.appear.scale, sys.motion.appear.distance and sys.motion.appear.blur to whole, grown about its centre and travelling up into place, over comp.dialog.motion.enter-duration with comp.dialog.motion.enter-easing; WHEN it closes there it SHALL take the container back the same way to where it started while fading the scrim out, over comp.dialog.motion.exit-duration with comp.dialog.motion.exit-easing. A motion turned round SHALL go on from the opacity and the size it has reached. WHERE sys.behavior.appearance is unfold the scale SHALL apply on both axes, from the centre: a centred container has no anchored edge. WHERE motion is reduced it SHALL only fade the container in and out, over sys.motion.role.reduced, with no scale, distance or blur, or appear and disappear at once on a platform whose reduced-motion setting removes every animation (Android's). WHEN it is mounted with open true THE dialog SHALL appear without its motion while the document has not yet been first painted, and with it when it is mounted after that. Focus SHALL move (DLG-2, DLG-4) when the dialog opens or closes, not when the motion ends. |
DLG-14 | THE dialog SHALL expose its container as its modal layer's modal dialog (MDL-10) — an alert dialog WHILE alert is true — named by headline and described by supportingText; WHEN alert is true and the dialog opens it SHALL have assistive technology read the headline and the supporting text at once; WHILE headline is empty it SHALL still open, and in debug builds SHALL report that it has no accessible name. |
DLG-15 | WHEN a dialog opens while another is open THE dialog SHALL stack above it as modal layers stack (MDL-8): with its own scrim, the first one inert as well, focus returned on closing to the element inside the first dialog that had it, and Escape dismissing only the topmost dialog. |
DLG-16 | WHEN the layout direction is right-to-left THE dialog SHALL mirror its layout — the icon at the right of the headline, the headline and the supporting text aligned to the right, and its actions placed as a Button group places them right to left (BGR-8: dismissive at the right, confirming at the left of a row; a column's order unchanged) — and SHALL NOT mirror its content itself: the content follows the layout direction. |
DLG-17 | WHILE the window is narrower than comp.dialog.compact.window-width (a compact window) and compactPresentation is sheet THE dialog SHALL present its container with the bottom sheet's appearance (BSH-7): at the window's bottom edge, as wide as the window but no wider than comp.bottom-sheet.container.max-width and centred in it, whatever width names, filled with comp.bottom-sheet.container.color, raised by comp.bottom-sheet.container.elevation, its top corners rounded by comp.bottom-sheet.container.shape eased by sys.shape.corner-smoothing and its bottom corners square, its color reaching under the bottom system inset and its parts above it, sitting on the on-screen keyboard while it is shown, and never taller than the window below its top system inset less comp.bottom-sheet.container.margin-block-start. Its parts SHALL keep their own tokens and layout (DLG-8 to DLG-12, DLG-20), inside comp.dialog.container.padding. It SHALL stay a dialog there — DLG-1 to DLG-6, DLG-8 to DLG-12 and DLG-14 to DLG-16 hold unchanged, its scrim the dialog's own — and SHALL draw no drag handle, follow no drag and close on no swipe, because it has one height and its exits are its actions, Escape, back and the scrim. WHILE compactPresentation is own THE dialog SHALL be centred in every window as DLG-7 says. WHEN the window's width crosses comp.dialog.compact.window-width, or compactPresentation changes, while the dialog is open THE dialog SHALL take the other presentation at once, without motion, keeping focus where it is. WHERE comp.dialog.compact.window-width is 0 no window is compact. WHEN compactPresentation names a value the dialog does not define THE dialog SHALL treat it as sheet and in debug builds report the unknown value. |
DLG-18 | WHEN the dialog opens as a sheet in a compact window (DLG-17) THE dialog SHALL fade its scrim in (MDL-7) while sliding its container up from below the window's bottom edge into its place, as a modal bottom sheet opens (BSH-1), over comp.bottom-sheet.motion.enter-duration with comp.bottom-sheet.motion.enter-easing; WHEN it closes there it SHALL fade the scrim out while sliding the container down out of view, over comp.bottom-sheet.motion.exit-duration with comp.bottom-sheet.motion.exit-easing. WHERE motion is reduced it SHALL fade in and out in place without sliding, or appear and disappear at once on a platform whose reduced-motion setting removes every animation (Android's). Focus SHALL move as DLG-13 says. |
DLG-19 | IF the dialog opens with no action THE dialog SHALL still open and lay out its other parts, and in debug builds SHALL report as an error that a dialog must offer at least one action; a platform whose API can require an action (a required, non-empty list or builder of actions) SHALL require it, and one that can count its actions only when it opens SHALL count them then. It SHALL report likewise a dialog whose actions are all disabled while dismissible is false, which leaves the person no way out. |
DLG-20 | WHILE an action's variant is empty THE dialog SHALL give that Button the variant comp.dialog.actions.variant names, through the channel a Button reads from the component that places it, so that the Button renders from that variant's tokens and states as it would with the variant set; WHILE an action's variant is set THE dialog SHALL leave it as set. THE dialog SHALL NOT change an action's size or tokens, and SHALL NOT change its look or emphasis for its confirming or dismissive (BGR-19). |
DLG-21 | THE dialog SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the dialog is shown THE dialog SHALL draw itself for the new direction at once, without a motion of its own, keeping its open state and its focus. |
DLG-22 | WHERE the layout direction is right-to-left THE dialog SHALL draw the glyph of its icon slot mirrored left to right exactly when the system's icon set marks that glyph's name as mirrored or the product forces mirroring for that use, and unmirrored when the product forbids it: the icon helper mirrors the glyph inside its own box, about the box's vertical centre line, changing neither its place, size, color and fill nor the order DLG-16 gives. THE dialog SHALL NOT mirror a glyph itself, and SHALL NOT mirror content the product gives as an image or a view of its own rather than by a glyph's name. |
DLG-23 | THE dialog SHALL lay out its headline and its supporting text as text of its own direction, isolated from the text around it: each takes the base direction of its first strong character, or the layout direction where it has none, so that a left-to-right word, a number with its sign or unit, a telephone number and an address keep their own order in a right-to-left layout and reorder nothing beside them; THE dialog SHALL still place each and align its lines by the layout direction, not by the text's own. |
DLG-24 | THE dialog SHALL draw its dividers (DLG-11) as its dividerVisibility names: WHILE dividerVisibility is auto, as comp.dialog.divider.visibility names, which follows sys.behavior.divider-visibility unless a design system sets the dialog apart; WHILE dividerVisibility is always or scroll, as that value names, whatever the tokens say. WHEN dividerVisibility, or the token it follows, changes THE dialog SHALL draw the other form at once, moving no part and keeping the scroll position and focus; WHEN dividerVisibility names a value the dialog does not define THE dialog SHALL treat it as auto and in debug builds report the unknown value. A platform that cannot draw a choice SHALL fall back to the first value, always, and state that in its README. |
DLG-26 | THE dialog SHALL draw its header as one row that holds the icon and the headline as DLG-9 places them, across the container's inner width, and nothing at its end; WHILE headline is empty and the icon slot is empty THE dialog SHALL draw no header, leaving it out as DLG-8 leaves out an empty part. |
DLG-27 | WHILE the dialog is centred (DLG-7) and width is small, medium or large THE dialog SHALL make its container as wide as comp.dialog.width.small, comp.dialog.width.medium or comp.dialog.width.large, whatever its parts would ask and not held between comp.dialog.container.min-width and comp.dialog.container.max-width, but never wider than the window's safe area less comp.dialog.container.margin on each side; its headline and supporting text SHALL wrap to that width, its content SHALL take it, and its actions SHALL be placed in a row or a column as DLG-10 decides at that width. WHILE width is content THE dialog SHALL be as wide as DLG-7 says. WHILE it is a sheet THE dialog SHALL place its container as DLG-17 says, whatever width names, because a narrow window leaves no room for a choice of width. WHEN width changes while the dialog is open THE dialog SHALL take the new width at once, without a motion of its own, keeping its scroll position and focus; WHEN width names a value the dialog does not define THE dialog SHALL treat it as content and in debug builds report the unknown value. |
DLG-28 | THE dialog SHALL draw no close button in its header: a dialog asks for a decision, and its way out without one is its dismissive action and, WHILE dismissible is true, Escape, the platform's back and the scrim (DLG-5). THE dialog SHALL have no prop that draws one; WHERE a platform can be given a property the dialog does not define and the product gives one that asks for a close button (the web's closable and close-label attributes), THE dialog SHALL draw nothing for it and in debug builds SHALL report that a dialog has no close button. |
DLG-29 | 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 dialog 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 (DLG-14) — an id reference by the elements it names in the host's tree or a tree around it, a description after its own. WHILE headline is not empty it is the dialog's name, over a name given so; WHILE headline 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 dialog SHALL take it off that element. |
各平台名称对照
对等并不意味着 API 完全相同。同一份契约,由各平台以自己的惯用方式表达。
| 规格 | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
|---|---|---|---|---|---|
| 组件 | tanrei-dialog | tanreiDialog | TanreiDialog | TanreiDialog | Dialog |
open · 属性 | open | isPresented | visible | showTanreiDialog | visible |
headline · 属性 | headline | headline | headline | headline | headline |
supportingText · 属性 | supportingText | supportingText | supportingText | supportingText | supportingText |
alert · 属性 | alert | alert | alert | alert | alert |
dismissible · 属性 | closedby | dismissible | dismissible | barrierDismissible | dismissible |
width · 属性 | width | width | width | width | width |
compactPresentation · 属性 | compactPresentation | compactPresentation | compactPresentation | compactPresentation | compactPresentation |
dividerVisibility · 属性 | dividerVisibility | dividerVisibility | dividerVisibility | dividerVisibility | dividerVisibility |
dismiss · 事件 | cancel | isPresented | onDismissRequest | showTanreiDialog | onRequestClose |
| 备注 | supportingText is the supporting-text attribute. dismissible is HTML's own closedby attribute: any (the default: Escape and a press on the backdrop) or none. cancel is a CustomEvent whose detail.reason is escape, back or scrim, cancelable as on a native <dialog>; close follows every close. There is no closable or close-label attribute (DLG-28): given one, the dialog draws nothing for it, and debug builds report it. width is the width attribute (content, small, medium, large), and compactPresentation the compact-presentation attribute (sheet, own). Slots: default, icon, actions. The actions are laid out by the button group's placement (BGR-13 to BGR-17) inside the shadow root: the dialog's shadow root uses manual slot assignment, assigns its icon and content children to their slots, and assigns each action to one of three action slots in the order of the orientation, so focus and the accessibility tree follow the placed order (BGR-15); CSS order is not used. Compact (DLG-17): a media query cannot read a custom property, so the dialog reads the resolved comp.dialog.compact.window-width through a probe and matches it with matchMedia('(width < …px)'), re-placing at once on change; the sheet's appearance is the shared one the popover draws too (src/primitives/compact-sheet.ts), read from comp.bottom-sheet. Debug builds (import.meta.env.DEV or the package's dev export) report a dialog opened with no assigned action (DLG-19). Snapshot harness: a 640 × 640 viewport, and 360 × 640 for the compact- fixtures, with the dialog opened by open; the screenshot is the viewport, since the dialog and its backdrop are in the top layer. dividerVisibility is the divider-visibility attribute (auto, always, scroll); auto reads --tanrei-comp-dialog-divider-visibility from the cascade with a style query, and where the browser has none the dialog writes the name it read on its <dialog> when it lays out. | Lagging: ships the earlier revision — the icon above a centred headline, text actions in a row at the end, optional actions, centred in every window, the 480 × 640 harness. The target: a view modifier, as .alert and .sheet are: .tanreiDialog(_ headline:, isPresented: Binding<Bool>, supportingText:, alert:, dismissible:, icon:, actions:, content:). Dismissal writes false to the binding, SwiftUI's idiom. It is presented with .fullScreenCover, with a clear presentation background and the system transition off (Tanrei draws the scrim and the motion); that presentation makes the rest of the app inaccessible to VoiceOver, as accessibilityViewIsModal does. Initial focus is .defaultFocus or an @AccessibilityFocusState the product passes; the iOS escape gesture is .accessibilityAction(.escape). actions: is a required builder; the dialog counts its subviews through a variadic view and reports an empty one in debug builds (DLG-19), and lays them out with the button group's Layout. Compact is the window's width (a GeometryReader at the presentation's root) against the token, not the horizontal size class, so every platform switches at the same width. Snapshot harness: TanreiDialogSurface in place in a 640 × 640 frame, and 360 × 640 for the compact- fixtures. Not yet: the headline's letter-spacing by the script of its text (DLG-8, PRS-20). Not built on this platform yet: dividerVisibility (auto, always, scroll; DLG-24) — the dividers still follow the scroll position, as scroll does. Not built on this platform yet: width (DLG-27), and the reason dismiss carries. Not built on this platform yet: DLG-17 and DLG-18 as they are written now — the bottom sheet's appearance and motion in a compact window — and compactPresentation. | Lagging: ships the earlier revision — the icon above a centred headline, text actions in a row at the end, optional actions, centred in every window. The target: TanreiDialog(visible, onDismissRequest, headline, supportingText, alert, dismissible, initialFocus: FocusRequester?, icon, actions, content) with actions a required parameter; the group's Layout sees its measurables and reports none in debug builds (DLG-19). visible rather than calling it only while open, so that the exit motion can run before the window goes. Presented in a Dialog window with usePlatformDefaultWidth = false and the window's own dim off (Tanrei draws the scrim). dismissible sets dismissOnBackPress and dismissOnClickOutside. The headline is the window's paneTitle. Compact is LocalWindowInfo.current.containerSize against the token, not a window size class, so every platform switches at the same width. Snapshot harness: renders TanreiDialogContent (the surface and the scrim) in place in a 640 × 640 dp box, and 360 × 640 for the compact- fixtures; the window itself is covered by instrumented tests. Not yet: the headline's letter-spacing by the script of its text (DLG-8, PRS-20). Not built on this platform yet: dividerVisibility (auto, always, scroll; DLG-24) — the dividers still follow the scroll position, as scroll does. Not built on this platform yet: width (DLG-27), and the reason dismiss carries. Not built on this platform yet: DLG-17 and DLG-18 as they are written now — the bottom sheet's appearance and motion in a compact window — and compactPresentation. | Lagging: ships the earlier revision — the icon above a centred headline, text actions in a row at the end, optional actions, centred in every window, the 480 × 640 golden. The target: showTanreiDialog<T>(context:, barrierDismissible:, builder:) pushes a modal route that draws the scrim as its barrier, and returns a Future<T?>; Navigator.pop(context, value) from an action closes it, and a dismissal completes the future with null. TanreiDialog(headline:, supportingText:, alert:, icon:, required actions:, child:) is the surface the builder returns; actions is asserted non-empty (DLG-19). Initial focus is autofocus: true on a focusable widget. The route is marked scopesRoute and namesRoute, so assistive technology stays inside it. Compact is MediaQuery.sizeOf(context).width against the token. Snapshot harness: a golden of the whole 640 × 640 surface after showTanreiDialog, and 360 × 640 for the compact- fixtures, as the overlay is part of the widget tree. Not yet: the headline's letter-spacing by the script of its text (DLG-8, PRS-20). Not built on this platform yet: dividerVisibility (auto, always, scroll; DLG-24) — the dividers still follow the scroll position, as scroll does. Not built on this platform yet: width (DLG-27), and the reason dismiss carries. Not built on this platform yet: DLG-17 and DLG-18 as they are written now — the bottom sheet's appearance and motion in a compact window — and compactPresentation. | Lagging: ships the earlier revision — the icon above a centred headline, text actions in a row at the end, optional actions, centred in every window, the 480 × 640 harness. The target: named as React Native's Modal: visible and onRequestClose (Android's back and, here, Escape on a hardware keyboard and a press on the scrim). Presented in a transparent Modal with animationType="none" (Tanrei draws the scrim and the motion), statusBarTranslucent and navigationBarTranslucent. Initial focus is an initialFocusRef the product passes, moved with AccessibilityInfo.setAccessibilityFocus. actions is required in the types, and development builds report it empty when the dialog becomes visible (DLG-19). Compact is useWindowDimensions().width against the token. Snapshot harness: renders DialogSurface (the surface and the scrim) in place in a 640 × 640 view, and 360 × 640 for the compact- fixtures, since a Modal is a native window the snapshot does not include. Not yet: the headline's letter-spacing by the script of its text (DLG-8, PRS-20). Not built on this platform yet: dividerVisibility (auto, always, scroll; DLG-24) — the dividers still follow the scroll position, as scroll does. Not built on this platform yet: width (DLG-27), and the reason dismiss carries. Not built on this platform yet: DLG-17 and DLG-18 as they are written now — the bottom sheet's appearance and motion in a compact window — and compactPresentation. |
令牌
| 令牌 | 引用 | 解析值(浅色) |
|---|---|---|
--tanrei-comp-dialog-actions-arrangement | — | 0 |
--tanrei-comp-dialog-actions-gap | sys.space.space200 | 16px |
--tanrei-comp-dialog-actions-margin-block-start | sys.space.space300 | 24px |
--tanrei-comp-dialog-actions-variant | — | 1 |
--tanrei-comp-dialog-compact-window-width | — | 600px |
--tanrei-comp-dialog-container-color | sys.color.surface-bright | |
--tanrei-comp-dialog-container-elevation | sys.elevation.level3 | 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 1px 0px rgb(0 0 0 / 0.04), 0px 4px 8px -4px rgb(0 0 0 / 0.04), 0px 16px 24px -8px rgb(0 0 0 / 0.06) |
--tanrei-comp-dialog-container-gap | sys.space.space200 | 16px |
--tanrei-comp-dialog-container-margin | sys.space.space200 | 16px |
--tanrei-comp-dialog-container-max-width | — | 560px |
--tanrei-comp-dialog-container-min-width | — | 280px |
--tanrei-comp-dialog-container-padding | sys.space.space300 | 24px |
--tanrei-comp-dialog-container-shape | sys.shape.corner.large | 12px |
--tanrei-comp-dialog-content-color | sys.color.on-surface | |
--tanrei-comp-dialog-divider-color | sys.color.outline-variant | |
--tanrei-comp-dialog-divider-visibility | sys.behavior.divider-visibility | 0 |
--tanrei-comp-dialog-divider-width | sys.border.hairline | 1px |
--tanrei-comp-dialog-header-gap | sys.space.space150 | 12px |
--tanrei-comp-dialog-headline-color | sys.color.on-surface | |
--tanrei-comp-dialog-icon-color | sys.color.secondary | |
--tanrei-comp-dialog-icon-placement | — | 0 |
--tanrei-comp-dialog-icon-size | sys.icon.medium | 24px |
--tanrei-comp-dialog-motion-enter-duration | comp.modal-layer.motion.enter-duration | 350ms |
--tanrei-comp-dialog-motion-enter-easing | comp.modal-layer.motion.enter-easing | cubic-bezier(0.2, 0, 0, 1) |
--tanrei-comp-dialog-motion-exit-duration | comp.modal-layer.motion.exit-duration | 300ms |
--tanrei-comp-dialog-motion-exit-easing | comp.modal-layer.motion.exit-easing | cubic-bezier(0.3, 0, 1, 1) |
--tanrei-comp-dialog-scrim-blur | comp.modal-layer.scrim.blur | 0px |
--tanrei-comp-dialog-scrim-color | comp.modal-layer.scrim.color | |
--tanrei-comp-dialog-supporting-text-color | sys.color.on-surface-variant | |
--tanrei-comp-dialog-width-large | — | 800px |
--tanrei-comp-dialog-width-medium | — | 560px |
--tanrei-comp-dialog-width-small | — | 400px |
--tanrei-comp-dialog-headline-typescale | sys.typescale.heading-5 | 500 16px/1.5 |
--tanrei-comp-dialog-supporting-text-typescale | sys.typescale.body | 400 14px/1.5714 |
测试样例
作为基准的渲染结果。所有平台都以相同的 ID 为每个样例截取快照,以便相互比较。
basic
open: true headline: Discard draft? supportingText: The message you were writing will be del… items: 2 单独绘制在 640 × 640 的窗口中icon
open: true headline: Delete project? supportingText: Everything in the project, including its… items: 2 slots: icon 单独绘制在 640 × 640 的窗口中content
open: true headline: Share with the team supportingText: People you add can view and comment. items: 2 单独绘制在 640 × 640 的窗口中scrolling
open: true headline: Terms of service supportingText: Please read these terms before you conti… items: 2 单独绘制在 640 × 640 的窗口中single-action
open: true headline: Your session has expired supportingText: Sign in again to keep working. Nothing y… items: 1 单独绘制在 640 × 640 的窗口中stacked
open: true headline: Allow access to your location? supportingText: Nearby stores and delivery times use you… items: 3 单独绘制在 640 × 640 的窗口中action-variants
open: true headline: Delete 3 photos? supportingText: They are removed from every device signe… items: 2 单独绘制在 640 × 640 的窗口中width-small
open: true headline: Discard draft? supportingText: The message you were writing will be del… width: small items: 2 单独绘制在 640 × 640 的窗口中width-large
open: true headline: Share with the team supportingText: People you add can view and comment. width: large items: 2 单独绘制在 640 × 640 的窗口中compact
open: true headline: Discard draft? supportingText: The message you were writing will be del… items: 2 单独绘制在 360 × 640 的窗口中compact-stacked
open: true headline: Allow access to your location? supportingText: Nearby stores and delivery times use you… items: 3 slots: icon 单独绘制在 360 × 640 的窗口中compact-own
open: true headline: Discard draft? supportingText: The message you were writing will be del… compactPresentation: own items: 2 单独绘制在 360 × 640 的窗口中生成自 specs/dialog.spec.json, tokens/comp/dialog.tokens.json, design/tokens/
当任务没有回答就无法继续时,使用对话框:确认一件无法撤销的事、请求一项权限,或在不离开页面的情况下收集两三项信息。它会让用户停下来,所以只在值得让用户停下来时才用。
对于可以撤销的操作,不要问“确定吗?”,而是用消息条报告刚刚发生的事,并可附带“撤销”。不挡住屏幕的简短选择,用菜单。较长的表单和复杂的任务,放到单独的页面上。
| 部件 | 设置方式 | 说明 |
|---|---|---|
| 图标 | icon 插槽 | 可选。标明决定属于哪一类的图标,例如 delete。它位于标题第一行的起始处,与标题相距 12;标题换行时,它仍留在第一行旁边。 |
| 标题 | headline |
必填。提问或陈述,例如“放弃草稿?”。始终向起始侧对齐。它是对话框的无障碍名称。 |
| 辅助文本 | supportingText |
将会发生什么,或用户做决定时需要知道的事。始终向起始侧对齐。它是对话框的描述。 |
| 内容 | 默认插槽 | 做决定还需要的其他东西:供选择的列表、一个输入框。对话框为它留出内边距并提供内容颜色,但不负责它的布局。 |
| 操作 | actions 插槽 | 必填:至少一个 Tanrei 按钮,每个都标记为 confirming 或 dismissive。对话框把它们作为占满宽度的按钮组放置,并把没有 variant 的操作画成 tonal。 |
当各部件在窗口中放不下时,页眉和操作留在原位,辅助文本和内容在两者之间滚动。只要它们在滚动(包括停在顶端时),页眉与它们之间、操作与它们之间就各有一条细的分隔线。把 dividerVisibility 设为 scroll(在 Web 上是 divider-visibility),每条分隔线就只在它另一侧有内容被滚出视野时才显示;cupertino 和 m3-like 为所有对话框选择了这种方式。如果文本很大,连这样也放不下,整个对话框就一起滚动,不显示分隔线。
在 Web 上,滚动的部分是 ::part(scroll)。无论你给 ::part(container) 设置怎样的左右内边距,这个部分都会延伸到容器的两侧边缘,并在内部保留同样的内边距,所以分隔线仍然横贯整个对话框,也不会有内容溢出。如果你的内容用 position: sticky 把一行固定在底部,请告诉滚动的部分这一行有多高,这样获得焦点时被滚动到视野中的字段就不会被这一行挡住:
tanrei-dialog::part(scroll) { scroll-padding-block-end: 56px; }只有标题、没有辅助文本的对话框是允许的:标题里的提问加上它的回答,就是一次对话。对话框不能是一句没有任何东西可回答的陈述。
各操作平分容器的内部宽度,彼此相距 16。
- 按任务放置。 排成一行时,取消类操作在起始侧,确认类操作在末尾侧。排成一列时,确认类操作在最上面,取消类操作在最下面。两种标记都没有的操作,保持它在两者之间的给定位置。焦点和屏幕阅读器遵循放置后的顺序,所以你不必手动给按钮排序。
- 放得下就排成行,否则排成列。 只要每个标签都能在自己那一份宽度内排成一行,操作就保持一行。一旦有标签需要换行,它们就叠成一列,每个与对话框同宽。对话框的宽度或文本大小变化时,会重新判断。
- 默认是 tonal。 没有设置
variant的操作画成 tonal。标记只决定位置,从不决定外观。 - 换一种外观。 设置某个按钮的
variant,就能让那个操作换一种外观,例如 text 的“取消”配 filled 的“删除”。给删除或丢弃内容的操作(例如这个“删除”)设置destructive:它保持原有变体,并采用错误色。对话框从不覆盖你设置的 variant,也不覆盖destructive。如果产品希望所有对话框都换一种外观,应在产品的层里设置一次(actions.variant;如果要让操作保持各自的宽度并向末尾侧对齐,则设置actions.arrangement),而不是在每个按钮上设置。 - 至少一个。 没有操作的对话框仍会打开,调试构建会把它报告为错误。规格规定,凡是平台 API 能够把操作设为必填的地方都要这样做:SwiftUI 的
actions:构建器、Compose 的actions参数、Flutter 的actions列表和 React Native 的actions属性。这四者之中,目前只有 SwiftUI 的是必填的;其余三个在跟上之前仍接受没有操作的对话框。如果对话框的所有操作都被禁用,而dismissible又为 false,调试构建也会报告,因为这样就没有出路了。
“保存”旁边有“取消”的表单,small 宽度
对话框不会在标题行画出关闭按钮。对话框是在请求一个决定;不作决定就离开的路,是它的取消类操作,以及在 dismissible 为 true 时的 Escape、平台的返回操作和在遮罩上的按下。
- 表单或选择。 在确认类操作旁边放一个“取消”这样的取消类操作,如上例所示。
- 供阅读的内容。 放一个标签为“关闭”的操作(有可以取消的东西时用“取消”),并标记为
dismissive。如果内容不需要回答、只需要一条出路,就不要用对话框,改在侧边面板(它有关闭按钮)或底部面板(用户可以轻扫关闭)中展示。 - 较长的对话框。 内容滚动时,操作始终停在底部,出路一直可见。
- 在 Web 上。 给对话框设置
closable或close-label不会画出任何东西;调试构建会报告对话框没有关闭按钮。
默认情况下(width 为 content),对话框与其最宽的部分一样宽,在 280 到 560 之间。这时宽度随文本变化,适合确认。设置 width 可改用固定宽度,使表单在输入框和提示变化时保持宽度不变:
width |
宽度 | 用途 |
|---|---|---|
content |
最宽的部分,280 到 560 | 确认或简短的提问(默认) |
small |
400 | 一两个输入框 |
medium |
560 | 表单、选项列表 |
large |
800 | 需要空间的内容,例如表格或预览 |
固定宽度绝不会超过窗口宽度减去两侧各 16,所以在 640 宽的窗口中,large 对话框宽 608。在 compact 窗口(窄于 600)中,每种宽度都按默认方式放置:作为底部的面板,宽度与窗口相同,最宽 640。对话框打开时更改 width,会立即按新宽度绘制。产品可以在自己的层中为所有对话框更改这三种宽度(width.small、width.medium、width.large)。
当窗口宽度小于 600 时,对话框不再居中,而是采用底部面板的外观。这是 Tanrei 对 compact 窗口中每个对话框和每个弹出框的规则。对话框贴在窗口的底边(或屏幕键盘之上),宽度与窗口相同,最宽到 comp.bottom-sheet.container.max-width(640),使用底部面板的颜色和高度(elevation),上方是底部面板的圆角,下方是直角。它的颜色延伸到底部系统边距之下,里面的部件保留对话框自己的内边距和文字样式。只看宽度,所以横屏的手机不算 compact,它的对话框是居中的。
它在那里看起来像底部面板,但仍然是对话框。它只有一个高度,出口是它的操作、Escape、返回和遮罩,所以没有拖动手柄,不跟随拖动,也不会因轻扫而关闭。它的角色、焦点、惰性、遮罩和关闭方式与任何对话框相同。如果对话框打开期间窗口宽度越过 600,它会立即换到另一种位置,不带动效,焦点留在原处。
如果一个系统要让对话框在任何地方都居中,就把 compact 宽度设为 0。m3-like 和 cupertino 预设就是这样做的,因为它们各自的原版都把对话框居中。它们也保留了原版的 text 操作。要让某一个对话框在任何窗口中都居中,把它的 compactPresentation 设为 own(Web 上为 compact-presentation="own")。
对话框是模态浮层。它把焦点移进来,把焦点留在里面,并在关闭时把焦点交还。
- 打开。 当
open变为 true 时,对话框呈现在所在窗口的一切之上,居中(或位于 compact 窗口的底部),下面是一层让整个窗口变暗的遮罩。它之外的一切都变为惰性(inert):无法获得焦点,无法按下,辅助技术也无法到达。它背后的内容停止滚动。 - 焦点移入。 焦点移到你标记的元素上,否则移到对话框本身,这样辅助技术会先朗读标题,下一次按 Tab 就到达第一个可聚焦的元素。焦点在对话框打开时移动,而不是在动效结束时。
- 焦点限制在对话框之内。 在最后一个可聚焦的元素上按 Tab 会回到第一个,在第一个上按 Shift+Tab 会到最后一个。屏幕阅读器的导航不会离开对话框。
- 关闭。 当
dismissible为 true 时,Escape、平台的返回操作(Android 的返回按钮或返回手势、iOS 的无障碍“退出”手势)以及在遮罩上的按下,都会触发一次dismiss,然后由你关闭对话框。在 Web 上,除非你取消cancel事件,否则对话框会自行关闭,与原生<dialog>一样。当dismissible为 false 时,这些操作不起任何作用,只有操作能关闭对话框。位于 compact 窗口底部的对话框不会因轻扫而关闭。dismiss会带上理由:escape、back或scrim。 - 焦点交还。 对话框关闭时,焦点交还给它打开时持有焦点的那个元素。它背后的内容恢复原状。
- 叠放。 从一个对话框中打开的对话框叠在它上面,带有自己的遮罩,并让第一个对话框也变为惰性。Escape 只关闭最上面的那个;关闭后,焦点交还给第一个对话框中原先持有焦点的元素。
- 朗读。 对话框会被朗读为对话框,以标题为名称,以辅助文本为描述。带有
alert时,它是警告对话框,一打开辅助技术就会朗读标题和辅助文本。 - 动效。 遮罩淡入。居中的容器从略小于自身的尺寸放大,同时淡入。在 compact 窗口中,容器以底部面板的动效从底边之下升起到位,不放大;关闭时再滑下去。在减弱动态效果时,它只淡入淡出,不放大也不移动;Android 的这项设置会移除所有动画,所以在那里它立即出现。
各平台的呈现方式:在 Web 上是用 showModal() 打开的原生 <dialog>;在 iOS 的 SwiftUI 上是背景透明的 .fullScreenCover(macOS 没有全屏覆盖,在那里是覆盖在修饰符所附着的视图之上的浮层);在 Compose 上是 Dialog 窗口;在 Flutter 上是模态路由;在 React Native 上是透明的 Modal。每个平台都关掉了自带的变暗效果和转场,所以所有平台都根据令牌绘制出相同的遮罩和动效。
Tanrei 的对话框打开期间,尚未显示的消息条会等待,已经显示的消息条会暂停计时。对话框内的工具提示绘制在对话框之上。
- 把标题直接写成那个提问或陈述:“放弃草稿?”“删除项目?”。避免使用“确定吗?”。
- 用操作所做的事为它命名:“放弃”“删除”“退出登录”。避免使用“是”和“否”。
- 把每个操作都标记为
confirming或dismissive,让对话框来放置它们。 - 让每个对话框都有回答。只是告知的对话框,应当是消息条或页面上的一行文字。
- 对展示内容供阅读的对话框,放一个标签为“关闭”或“取消”的操作,或者改用侧边面板展示该内容。
- 在破坏性操作的确认中,把初始焦点标记在“取消”上。在表单中,标记第一个输入框。如果对话框的文本必须先被读到,就什么也不标记。
- 只为紧急的打断设置
alert:破坏性操作的确认、连接中断。
- 不要打开用户没有要求的对话框,除非任务确实无法继续。
- 不要在标题或内容中自行添加关闭按钮:对话框没有关闭按钮,它的出路是取消类操作。
- 不要把
dismissible设为 false,除非用户必须在这些操作中选择一个。使用开关控制和屏幕阅读器的用户依赖 Escape 和返回操作。无法关闭的对话框仍然需要一条看得见的出路,例如“取消”。 - 不要在对话框里放超过几项的信息。长表单放到页面上。
- 不要为了把确认类操作放在最后而手动给操作排序,也不要为了得到 tonal 而给每个按钮设置 variant:这两件事对话框都会做。
- 不要在
variant之外另行改变按钮的样式,也不要往对话框的边框区域添加控件。 - 不要把 compact 窗口底部的对话框当作底部面板。如果用户应当能够把它轻扫关闭,就使用底部面板。
- 角色
dialog- 标注
- The headline is the accessible name and the supporting text the description. The role is dialog, or alertdialog while alert is true (platforms without that distinction read the headline and supporting text on opening). Everything outside the dialog is hidden from assistive technology while it is open. Each action is named by its own label; confirming and dismissive are not announced, and the actions are read in the order they are placed (BGR-15).
- 最小目标
- 即使外观尺寸更小,点击区域也满足最小目标尺寸。 minimum 32px · pointer 44px · spacing 8px · touch 48px
键盘操作
| 按键 | 动作 |
|---|---|
| Tab / Shift+Tab | Move focus through the dialog's focusable elements, wrapping from the last to the first and back; focus never leaves the dialog. |
| Escape | Dismisses the dialog (emits dismiss with the reason escape) while dismissible is true; with nested dialogs, only the topmost. |
| Enter / Space | Activate the focused button (an action: its click). The dialog adds no default action to Enter. |
备注
- The dialog has no target of its own; its actions are Tanrei Buttons, which meet the minimum target themselves (BTN-5). comp.dialog.actions.gap keeps each action's whole target in a column in the default system.
- Initial focus: mark the least destructive action (Cancel) in a destructive confirmation, the first field in a form, and nothing in a dialog whose text must be read first. Without a mark, focus goes to the container (DLG-2).
- Every dialog offers an action (DLG-19). Use dismissible false sparingly: a person using a switch or a screen reader relies on Escape and back. A dialog that must not be dismissed still needs a visible way out, a dismissive action.
- A dialog has no close button (DLG-28). Its visible way out is a dismissive action, which a person meets in the reading order of the actions; Escape, back and the scrim dismiss it as well while it is dismissible.
- Placement is by task, not by code order: in a row the dismissive action is at the start and the confirming one at the end; in a column the confirming one is on top. Focus and screen readers follow the placed order, so a sighted and a screen-reader user meet the actions in the same order.
- In a compact window the dialog takes the bottom sheet's appearance (DLG-17), as every dialog and every popover of Tanrei does, and is still a modal dialog: the same role, focus, inertness and dismissal, and no drag. A person who expects a bottom sheet's swipe to close it uses Escape, back or the scrim, or a dismissive action. compactPresentation own keeps one dialog centred in every window.
- Text size: at 200% the dialog grows up to the window; its actions stack when a label no longer fits its share (DLG-10), its supporting text and content scroll (DLG-11), and on a very short window everything scrolls together (DLG-12).
- Web: a native `<dialog>` opened with `showModal()` inside the shadow root, so the rest of the page is inert and the dialog is in the top layer. `::backdrop` draws the scrim. The `autofocus` attribute on a slotted element marks initial focus. `form method="dialog"` closes it natively, and the native `close` event follows every close.
- Where a platform presents the dialog in a window of its own (Compose's `Dialog`, React Native's `Modal`), the platform's own window dim is switched off and Tanrei draws the scrim, so every platform draws the same color.
生成自 specs/dialog.spec.json, tokens/sys/measure.tokens.json
- 对话框以模态对话框的身份提供给辅助技术,
alert为 true 时则是警告对话框;它以标题为名称,以辅助文本为描述。带有alert时,一打开就会朗读标题和辅助文本。 - 对话框打开期间,它之外的一切都是惰性的,并对辅助技术隐藏。
- 打开时,焦点移到你标记的元素上,否则移到对话框本身。Tab 和 Shift+Tab 在对话框内循环。关闭时,焦点交还给它打开时持有焦点的那个元素。
- 当
dismissible为 true 时,Escape、平台的返回操作以及在遮罩上的按下会触发dismiss。对话框叠放时,只关闭最上面的那个。 - Enter 和 Space 激活当前聚焦的按钮。对话框不为 Enter 添加默认操作。
- 操作是 Tanrei 的按钮,它们自己就满足最小目标。叠成一列的操作之间相距 16,让每个目标保持完整。
- 焦点和屏幕阅读器按放置后的顺序遇到各个操作,与视力正常的用户看到的顺序相同。
- 在 compact 窗口的底部,对话框保持相同的角色、焦点、惰性和关闭方式。以为可以像底部面板那样轻扫的用户,可以用 Escape、返回操作、遮罩或取消类操作关闭它。
- 文本大小为 200% 时,对话框最多放大到与窗口同大;标签在自己那一份宽度内放不下时,操作叠成一列;辅助文本和内容可以滚动;在非常矮的窗口中,所有内容一起滚动。因此没有任何东西被截掉或无法到达。
- 没有标题或没有操作的对话框仍会打开,调试构建会报告缺少名称或缺少回答。如果对话框的所有操作都被禁用,而
dismissible又为 false,调试构建也会报告。
已知的平台差距:
- SwiftUI、Compose、Flutter 和 React Native:
dividerVisibility要等这些平台跟上。在此之前,它们的分隔线像scroll一样,只在另一侧有内容被滚出视野时出现。 - SwiftUI、Compose、Flutter 和 React Native:
width以及dismiss所带的理由,都在等待这些平台跟进。 - Web:Firefox 无法让正在关闭的对话框在淡出期间留在顶层(top layer),所以在那里对话框关闭时立即消失,没有退出时的淡出。
- Web:避让屏幕键盘这一点,尚未在带有屏幕键盘的设备上检查过。
- SwiftUI 没有对话框角色,也没有警告对话框角色。容器带有模态特征,以标题为名称,以辅助文本作为提示(hint)来描述,标题则带有标题(heading)特征。带有
alert时,对话框一打开,VoiceOver 就朗读标题和辅助文本。 - macOS 上的 SwiftUI 没有全屏覆盖:对话框只覆盖修饰符所附着的视图,所以请把它附着在窗口的根视图上。对话框打开期间该视图被禁用,其中的控件会在遮罩之下绘制禁用状态。
- SwiftUI 无法把键盘焦点移到对话框本身。没有标记时,VoiceOver 从标题开始。关闭时,iOS 会把 VoiceOver 焦点交还给发起呈现的元素,但无论在 iOS 还是 macOS 上,键盘焦点都不会交还。
- Compose 没有警告对话框角色。窗口是一个以标题为名称的对话框;带有
alert的对话框在打开时朗读标题和辅助文本,所用的 Android API 在 Android 16 中已被弃用。 - Compose:真实的对话框窗口、Tab 的循环、焦点交还给打开它的元素、返回手势和 TalkBack,都只在 Robolectric 上测试过,尚未在设备上测试。
- Flutter:当连标题和操作都放不下、整个对话框一起滚动时,这一切换要在一次布局之后才能决定,因此晚一帧显示。Flutter 没有描述属性,所以辅助文本成为容器的提示。除非你传入
barrierLabel,否则遮罩没有无障碍标签。 - React Native 无法得知先前是哪个元素持有焦点,所以关闭时不会把焦点交还给打开对话框的元素:焦点落在 iOS 和 Android 为已关闭的窗口安排的位置。没有
initialFocusRef时,只有屏幕阅读器会移动(移到标题);硬件键盘的焦点留在平台在新窗口中安排的位置。 - React Native:Tab 是否在对话框内循环由平台决定。这一点,以及屏幕阅读器焦点、返回操作、iOS 的“退出”手势和嵌套的对话框,都尚未在设备上检查过。
- React Native:只有当你把窗口的边衬区(insets)传给
TanreiProvider时,对话框才会保持在安全区域之内。
- 为每个对话框提供标题。它是对话框的名称。
- 为每个对话框提供至少一个操作,并把每个操作标记为
confirming或dismissive。在仍提供上一版的平台上,请自己把确认类操作放在最后。 - 自己关闭对话框:在每个操作的处理函数中,以及在响应
dismiss时(视平台而定,是onRequestClose、onDismissRequest、绑定或路由)。 - 在有帮助的地方标记初始焦点:破坏性操作的确认中标记“取消”,表单中标记第一个输入框。
- 为每个对话框提供一条看得见的出路,也就是“取消”这样的取消类操作,即使
dismissible为 false 也是如此。 - 在 React Native 上,传入
initialFocusRef。对话框关闭后,如果平台没有把焦点交还给打开它的元素,请自己把焦点移回去。 - 在 macOS 的 SwiftUI 上,把
.tanreiDialog附着在窗口的根视图上,让遮罩覆盖整个窗口。 - 在 Web 上,如果你的内容在底部固定了一行,请在
::part(scroll)上把scroll-padding-block-end设为这一行的高度,让获得焦点的元素不会被它挡住。