侧边面板
在末尾侧边缘的面板中放置补充内容或次要任务,带有标题、关闭按钮和可选的操作;在宽窗口中位于内容旁边,在 compact 窗口中盖在内容之上。
- 类别
- 容器
- 令牌
comp.side-sheet- 层级
- 组合
- 构成自
- Modal layer
- Web
tanrei-side-sheetschedule - SwiftUI
TanreiSideSheetschedule - Jetpack Compose
TanreiSideSheetschedule - Flutter
TanreiSideSheetschedule - React Native
SideSheetschedule
生成自 docs/status/status.json, specs/side-sheet.spec.json
规格已修订,尚未覆盖所有平台
本页描述的是规格当前所定的侧边面板的操作和关闭按钮,与对话框的相同。操作是一行占满宽度的 tonal 按钮,按各自的任务放置:每个都标记为 confirming 或 dismissive,模态面板必须至少提供一个。关闭按钮是一个 small 的 tonal 按钮,除非把 closable 设为 false,否则都会绘制。页眉下方与操作上方一样始终有一条分隔线;操作保持在屏幕键盘上方。Web 组件已经做到了这一切,演示也是如此;在修订通过评审之前,它在上表中仍标记为“待跟进”。SwiftUI、Compose、Flutter 和 React Native 同样待跟进:在跟上之前,它们提供上一版,用 standard 图标按钮来关闭,操作按给定顺序从起始侧边缘排成一行,在两种形态中都可以省略,彼此相距 8。
位于一行末尾的标准面板
搜索结果
显示最近 30 天内带有全部所选标签的结果。
侧边面板是受控组件,它从不自行关闭。把 open 设为 true 即可显示它。当用户要求它离开时,面板触发 close 并带上原因,由你把 open 设回 false。在 SwiftUI 上,open 是一个绑定,面板先把 false 写入绑定,再调用 onClose。如果拖动之后你让 open 保持为 true,面板会滑回原位。
close 带有以下五种原因之一,大小写遵循各平台的写法:
| 原因 | 用户做了什么 | 适用的形态 |
|---|---|---|
close |
激活了关闭按钮 | 标准和模态 |
scrim |
按下了模态面板旁边变暗的区域 | 模态 |
escape |
按下了 Escape | 模态 |
back |
使用了平台的返回操作:Android 的返回按钮或返回手势、iOS 的无障碍“退出”手势 | 模态 |
drag |
把面板朝它所在的边缘拖过了自身宽度的一半,或朝那个方向快速滑动 | 模态 |
标准面板忽略 Escape、返回操作和拖动。除非你自己的控件能关闭它,否则关闭按钮是用户关闭它的唯一方式。
面板通过 variant 提供两种形态,一个产品通常两种都用:窗口足够宽时用标准形态,不够宽时用模态形态。
- 标准是默认形态,是一个布局区域,而不是浮层。打开时,它在你放置它的那一行的末尾占据自己的宽度,旁边的内容随之调整大小。它不绘制遮罩,也从不把焦点留在里面。
- 模态形态是一个模态浮层。它带着遮罩从末尾侧边缘滑到内容之上,把焦点留在里面,并在关闭时把焦点交还。
面板会绘制一个页眉,其中有 headline;当 closable 为 true(默认值)时,还有一个以 closeLabel 命名的关闭按钮。你的内容位于页眉下方;放在 actions 插槽中的 Tanrei 按钮沿底部排列,每个都标记为 confirming(完成任务:应用、保存)或 dismissive(退出任务:取消)。面板按这些标记来放置操作,所以你可以按任意顺序给出它们;没有设置 variant 的操作,面板会把它画成 tonal。模态面板必须至少提供一个操作;标准面板可以没有。操作本身从不关闭面板:请在操作的处理函数中关闭它。
面板打开时应当获得焦点的元素,用平台自己的方式标记:在 Web 上是 autofocus,在 SwiftUI 上是 .defaultFocus 或 @AccessibilityFocusState,在 Compose 上是作为 initialFocus 传入的 FocusRequester,在 Flutter 上是 autofocus: true,在 React Native 上是 initialFocusRef。在 SwiftUI 上,.defaultFocus 移动键盘焦点,作为 initialFocus 传入的 @AccessibilityFocusState 绑定移动 VoiceOver 焦点。没有标记时,焦点落在面板本身,因此最先朗读的是它的标题。
Web 和 React 的示例展示的是当前的规格。SwiftUI、Compose、Flutter 和 React Native 待跟进:它们仍提供侧边面板的上一版,其中没有 confirming 和 dismissive 标记,也没有默认的 tonal。因此它们的示例为每个操作指定 text 变体,并把主要操作放在最前面;在这些平台跟上之前,示例展示的都是那一版。
<tanrei-button id="filters" variant="outlined">筛选</tanrei-button>
<!-- 标准形态:flex 行的最后一项。它是一个以标题命名的 <aside>。 -->
<div style="display: flex">
<main style="flex: 1">…</main>
<tanrei-side-sheet headline="筛选" close-label="关闭筛选">
<p>显示最近 30 天内带有全部所选标签的结果。</p>
<!-- confirming 放在行的末尾侧。没有 variant 时,两者都是 tonal。 -->
<tanrei-button slot="actions">重置</tanrei-button>
<tanrei-button slot="actions" confirming>应用</tanrei-button>
</tanrei-side-sheet>
</div>
<script type="module">
import '@tanrei/web/components/side-sheet';
const sheet = document.querySelector('tanrei-side-sheet');
document.querySelector('#filters').addEventListener('click', () => (sheet.open = true));
// 面板从不自行关闭。detail.reason 是 close、scrim、escape、back 或 drag。
sheet.addEventListener('close', (e) => (sheet.open = false));
// 在窄于 840px 的窗口中,同一个面板变成遮罩之上的模态 <dialog>。
matchMedia('(width < 840px)').addEventListener('change', (e) => (sheet.variant = e.matches ? 'modal' : 'standard'));
</script>import { Button, SideSheet } from '@tanrei/react';
<SideSheet
variant={wide ? 'standard' : 'modal'}
open={open}
headline="筛选"
closeLabel="关闭筛选"
onClose={() => setOpen(false)}
>
<p>显示最近 30 天内带有全部所选标签的结果。</p>
<Button slot="actions" onClick={reset}>重置</Button>
<Button slot="actions" confirming onClick={apply}>应用</Button>
</SideSheet>import Tanrei
// 标准形态:HStack 末尾的一个视图。关闭时先把 false 写入绑定,再调用 onClose。
HStack(spacing: 0) {
ResultsView()
TanreiSideSheet("筛选", isPresented: $filtersShown, closable: true, closeLabel: "关闭筛选") {
FilterForm()
} actions: {
TanreiButton("应用", variant: .text) { apply() }
TanreiButton("重置", variant: .text) { reset() }
}
}
// 模态形态:加在被它覆盖的视图上的修饰符,呈现方式与 tanreiDialog 相同。
ResultsView()
.tanreiSideSheet("筛选", isPresented: $filtersShown, closable: true, closeLabel: "关闭筛选") { … }import io.github.chnotchy.tanrei.components.TanreiButton
import io.github.chnotchy.tanrei.components.TanreiSideSheet
import io.github.chnotchy.tanrei.tokens.TanreiButtonVariant
import io.github.chnotchy.tanrei.tokens.TanreiSideSheetVariant
// 标准形态:Row 的最后一个子元素。模态形态:绘制在它自己的 Dialog 窗口中。
// 在 onClose 中把 visible 设为 false;onClose 会收到原因。
Row {
ResultsScreen(Modifier.weight(1f))
TanreiSideSheet(
visible = filtersShown,
onClose = { filtersShown = false },
headline = "筛选",
variant = if (wide) TanreiSideSheetVariant.Standard else TanreiSideSheetVariant.Modal,
closable = true,
closeLabel = "关闭筛选",
actions = {
TanreiButton(text = "应用", onClick = apply, variant = TanreiButtonVariant.Text)
TanreiButton(text = "重置", onClick = reset, variant = TanreiButtonVariant.Text)
},
) {
FilterForm()
}
}import 'package:tanrei/tanrei.dart';
// 标准形态:位于 Row 的末尾。模态面板则放在覆盖页面的 Stack 中。
Row(
children: <Widget>[
const Expanded(child: ResultsPage()),
TanreiSideSheet(
open: filtersShown,
onClose: (TanreiSheetCloseReason reason) => setState(() => filtersShown = false),
headline: '筛选',
showCloseButton: true,
closeButtonSemanticLabel: '关闭筛选',
actions: <Widget>[
TanreiButton(variant: TanreiButtonVariant.text, onPressed: apply, child: const Text('应用')),
TanreiButton(variant: TanreiButtonVariant.text, onPressed: reset, child: const Text('重置')),
],
child: const FilterForm(),
),
],
),import { View } from 'react-native';
import { Button, SideSheet } from '@tanrei/react-native';
// 标准形态:位于一行的末尾。模态面板呈现在一个透明的 Modal 中。
<View style={{ flex: 1, flexDirection: 'row' }}>
<Results style={{ flex: 1 }} />
<SideSheet
variant={wide ? 'standard' : 'modal'}
open={open}
onClose={(reason) => setOpen(false)}
headline="筛选"
closable
closeLabel="关闭筛选"
actions={
<>
<Button variant="text" onPress={apply}>应用</Button>
<Button variant="text" onPress={reset}>重置</Button>
</>
}
>
<FilterForm />
</SideSheet>
</View>模态面板:用按钮打开它
显示最近 30 天内带有全部所选标签的结果。
构成
构成
standard 之上。| 部件 | 说明 |
|---|---|
1 container | The panel at the end edge, the full height of its parent (standard) or of the window (modal). Standard: part of the layout. Modal: above the content, the corners that face the content rounded. Its color reaches under the system insets at its end, top and bottom. |
2 outline | The start edge of a standard sheet, which sets it apart from the content beside it. |
3 scrim · 可选 | The modal layer's scrim (MDL-1): it dims everything behind a modal sheet; a press on it asks the sheet to close. |
4 header · 可选 | The row at the top that holds the headline and the close button. It stays in place while the content scrolls. |
5 headline · 可选 | What the sheet is about ("Filters"). It is the sheet's accessible name. |
6 close-button · 可选 | An icon-only Button of the small size showing the close glyph at the end of the header, in the variant comp.side-sheet.close-button.variant names (tonal in the default system), as every host's close button is. The sheet places it and chooses its variant; everything else it reads from Button's own tokens. |
7 content | Whatever the product puts in the sheet. The sheet insets it, hands it a content color and scrolls it between the header and the actions; it does not lay it out. |
8 divider · 可选 | Hairlines across the sheet: above the actions, drawn while there are actions; and below the header, drawn whenever the header is in the default system, or only while the content is scrolled away from its top under dividerVisibility scroll. |
9 actions · 可选 | Tanrei Buttons at the bottom of the sheet, each marked confirming or dismissive, laid out as a full-width Button group: a row 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. At least one in a modal sheet; optional in a standard one. |
尺寸
尺寸
| 长度 | 令牌 | 值(px) |
|---|---|---|
container width | tanrei.comp.side-sheet.container.width | 320 |
container padding-inline | tanrei.comp.side-sheet.container.padding-inline | 24 |
standard 之上。属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
variant | standard | modal | "standard" | Standard sits beside the content and takes its width in the layout (a layout region); modal slides over the content with a scrim and holds focus until it closes (a modal overlay). One product uses both: standard where the window is wide enough, modal where it is not. A design system may add values. |
open | boolean | false | Whether the sheet is shown: for a standard sheet, whether it takes its place in the layout; for a modal one, whether it is presented. The product opens it, and closes it in answer to close; on SwiftUI, where it is a binding, the sheet writes false itself. |
headline | string | "" | What the sheet is about, drawn in its header and used as its accessible name. Required. |
closable | boolean | true | Draws the close button at the end of the header. True by default, in both variants: a standard sheet has no other way for the person to close it, and a modal one shows a way out where the person reads. Set it to false only where the product's own control, or one of the sheet's actions, closes the sheet. |
closeLabel | string | "" | The close button's accessible name ("Close filters"). Required while closable is true, which is the default. |
dividerVisibility | auto | always | scroll | "auto" | When the divider below the header is drawn. always: whenever the header is drawn, at rest as well as while the content is scrolled, as the divider above the actions always is. scroll: only while the content is scrolled away from its top. auto takes comp.side-sheet.divider.visibility, the design system's answer (always in the default system). |
插槽
| 插槽 | 说明 |
|---|---|
default | The content, below the header. A fixture's content string is plain text, which takes sys.typescale.body in the content color (SSH-10). |
actions | The action items: Tanrei Buttons, each marked confirming or dismissive; at least one in a modal sheet (SSH-16). The sheet places them (SSH-11) and gives those without a variant its own (SSH-17). A fixture fills it through its items. |
事件
| 事件 | 说明 |
|---|---|
close | The sheet asks to be closed: its close button, or for a modal sheet a press on the scrim, Escape, the platform's back or a drag toward its edge. Carries which of these it was, named close, scrim, escape, back or drag (in the platform's casing). |
条目
该组件重复排列的条目种类。测试样例按顺序列出条目;各平台以各自的惯用方式为每个种类命名。
action
One action at the foot of the sheet: a Tanrei Button, placed by the sheet as a full-width Button group places its items and given the sheet'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 sheet gives it the variant comp.side-sheet.actions.variant names, tonal in the default system (SSH-17). Set it to give one action another look. |
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 (Apply, Save, Show results). 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, Discard changes). 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 sheet does not close by itself (SSH-18). |
| 规格 | 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 sheet reads, as the button group does. A Button without a variant attribute takes the sheet's variant through the internal controller the group already gives its items (SSH-17); 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 sheet's variant from an environment value the sheet 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 sheet'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 sheet's from an InheritedWidget (internal to the package). | A Button in the actions prop. confirming and dismissive are props the sheet reads from its actions, as the button group reads them. A Button with no variant takes the sheet's from a React context (internal to the package). |
状态
行为
以 EARS 形式写成的需求。每个平台的实现都按这些 ID 逐条测试。需求原文取自规格文件,原样以英文呈现。
| ID | 需求 |
|---|---|
SSH-1 | WHILE variant is standard THE side sheet SHALL take its width in the layout at the end of the row the product places it in WHILE open is true, and no width and no presence to assistive technology or focus WHILE open is false; WHEN open changes it SHALL slide in or out at the end edge while the content beside it resizes, over comp.side-sheet.motion.enter-duration with comp.side-sheet.motion.enter-easing when opening and comp.side-sheet.motion.exit-duration with comp.side-sheet.motion.exit-easing when closing, and at once where motion is reduced. A standard sheet SHALL draw no scrim, SHALL NOT keep focus inside it, SHALL let Tab and Shift+Tab move focus out of it without closing it, and SHALL ignore Escape, the platform's back and drags (a standard sheet is a layout region, not an overlay). |
SSH-2 | WHEN open becomes true WHILE variant is modal THE side sheet SHALL slide in from the window's end edge over the content on its modal layer, fading the layer's scrim in comp.side-sheet.scrim.color and comp.side-sheet.scrim.blur in over the rest of the window in step with it (MDL-1, MDL-7), over comp.side-sheet.motion.enter-duration with comp.side-sheet.motion.enter-easing; WHEN open becomes false it SHALL slide out to the end edge from wherever it is and fade the scrim out, over comp.side-sheet.motion.exit-duration with comp.side-sheet.motion.exit-easing. WHERE motion is reduced it SHALL fade in and out without sliding, or appear and disappear at once on a platform whose reduced-motion setting removes every animation (Android's). WHILE it is open THE side sheet SHALL make everything behind it inert and keep focus inside it, as its modal layer does (MDL-2, MDL-4). |
SSH-3 | WHEN open changes from false to true THE side sheet SHALL move focus 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; WHEN it closes while focus is inside it THE side sheet SHALL return focus to the element that had it when the sheet opened or, where that element no longer exists or cannot take focus, to the platform's default. Focus SHALL move when the sheet opens or closes, not when its motion ends. This holds for both variants (a modal sheet moves and returns focus as its modal layer does, MDL-3, MDL-5); a standard sheet mounted with open true SHALL NOT move focus, and a modal sheet mounted with open true SHALL move focus in as if it had just opened, because it makes everything behind it unfocusable and focus must be somewhere it can reach. |
SSH-4 | WHILE variant is modal and the sheet is open, WHEN the user presses the scrim, presses Escape, uses the platform's back, as its modal layer answers them (MDL-6), or drags the sheet toward its end edge past half its width or flings it that way, THE side sheet SHALL emit close exactly once with the reason scrim, escape, back or drag; a drag SHALL follow the finger or pointer once it passes the platform's touch slop, and a released drag that does not ask to close SHALL slide the sheet back over comp.side-sheet.motion.enter-duration with comp.side-sheet.motion.enter-easing (at once where motion is reduced). The fling, the touch slop and the stillness after which a release is no fling are those BSH-9 names for the bottom sheet: the platform's own, or 0.5 points per millisecond, 8 points and 40 milliseconds where the platform names none. |
SSH-5 | WHILE closable is true THE side sheet SHALL draw at the end of its header an icon-only Button of the small size showing the close glyph and named by closeLabel, in the variant comp.side-sheet.close-button.variant names, given through the channel a Button reads from the component that places it, and otherwise unrestyled: it reads Button's own tokens, answers hover, focus and press as any Button does and keeps its own minimum target (BTN-5); WHEN the user activates it THE side sheet SHALL emit close exactly once with the reason close, in either variant. WHILE closable is true and closeLabel is empty it SHALL still draw the button, and in debug builds report that the button has no accessible name. |
SSH-6 | WHEN the side sheet has emitted close THE side sheet SHALL be closed through its binding where open is one (SwiftUI), and by the product setting open to false where open is a plain value (web, Compose, Flutter, React Native); a released drag that asked to close SHALL hold where it left the sheet for one frame, then slide out from there IF open has become false, or slide back IF open is still true (the product declined to close it). |
SSH-7 | THE side sheet SHALL be comp.side-sheet.container.width wide beside the end system inset — WHILE variant is modal never wider than the window less sys.target.touch, so that a strip of scrim stays pressable — and the full height of its parent (standard) or of the window (modal), filled with its variant's container color (comp.side-sheet.container.color.standard or comp.side-sheet.container.color.modal), raised by its variant's elevation (comp.side-sheet.container.elevation.standard, comp.side-sheet.container.elevation.modal), with the corners at its start edge rounded by its variant's shape (comp.side-sheet.container.shape.standard, comp.side-sheet.container.shape.modal) 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) and square corners at its end edge, and edged at its start by comp.side-sheet.outline.width of its variant's outline color (comp.side-sheet.outline.color.standard, comp.side-sheet.outline.color.modal); right-to-left layouts SHALL put it at the left edge, mirrored. |
SSH-8 | THE side sheet SHALL lay out, top to bottom: WHILE headline is not empty or closable is true, the header, a row at least comp.side-sheet.header.height high holding the headline from comp.side-sheet.container.padding-inline after the start edge and, WHILE closable is true, the close button ending comp.side-sheet.header.padding-inline-end before the end edge, comp.side-sheet.header.gap after the headline, both centred in the row; then the content, inset by comp.side-sheet.container.padding-inline from the start and end edges, starting directly below the header or, without one, comp.side-sheet.container.padding-block below the top edge; then, WHILE the sheet has at least one action, the divider and the actions pinned to the bottom (SSH-11), or else comp.side-sheet.container.padding-block of space below the content. It SHALL extend its container under the system insets at its end, top and bottom and place the header, content and actions inside them. |
SSH-9 | WHILE headline is not empty THE side sheet SHALL draw it in comp.side-sheet.headline.typescale and comp.side-sheet.headline.color, wrapping onto more lines rather than truncating and growing the header with it, and SHALL expose it as a heading. THE side sheet 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. |
SSH-10 | THE side sheet SHALL hand its content comp.side-sheet.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. WHILE the content is taller than the room between the header and the actions THE side sheet SHALL scroll it there, keeping the header and the actions in place, and SHALL let assistive technology and keyboard focus scroll it. |
SSH-11 | WHILE the sheet has at least one action THE side sheet SHALL draw a divider of comp.side-sheet.divider.width in comp.side-sheet.divider.color across its full width below the content, whether or not the content scrolls, and below it lay out its actions as a standard Button group lays out its items (BGR-12 to BGR-19), comp.side-sheet.actions.gap apart, comp.side-sheet.container.padding-inline from each side edge and comp.side-sheet.actions.padding-block above and below them, 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, unflagged actions between them in the order given — with focus and reading order following that order (BGR-15). WHILE comp.side-sheet.actions.arrangement is fill it SHALL make them a full-width group across the sheet's inner width, the actions sharing a row equally (BGR-17) or each as wide as the column; WHILE it is start it SHALL keep them at their own widths, the row aligned to the start edge, and a column as wide as its widest action and aligned to the start. 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 (start), and otherwise in a column, re-deciding when the sheet's width or the text size changes. WHEN the layout direction is right-to-left it SHALL place them as a Button group places them right to left (BGR-8). It SHALL hide the divider from assistive technology. |
SSH-12 | THE side sheet SHALL expose its container, WHILE variant is modal, as its modal layer's modal dialog (MDL-10) named by headline and, WHILE variant is standard, as a complementary region named by headline (the web's aside landmark); WHILE headline is empty it SHALL still render, and in debug builds report that it has no accessible name. |
SSH-13 | WHEN variant names a value that the active design system does not have THE side sheet SHALL render with the default value's tokens, and in debug builds report the unknown value. |
SSH-14 | WHILE the text size is enlarged THE side sheet SHALL keep its width, grow its header with the headline (its height is a minimum), scroll its content (SSH-10), stack its actions once a label no longer fits its share (SSH-11), and SHALL NOT scale the close button beyond what the Button itself does. |
SSH-15 | WHEN a dialog, a menu or another modal sheet opens from a modal side sheet THE side sheet SHALL stay below it, become inert with the rest of the window, and on the other overlay's closing receive back the focus that overlay returns, as its modal layer does (MDL-8, MDL-9); Escape SHALL dismiss only the topmost overlay. |
SSH-16 | WHILE variant is modal, IF the sheet opens with no action THE side sheet SHALL still open and lay out its other parts, and in debug builds SHALL report as an error that a modal side sheet must offer at least one action; a platform whose modal sheet has an API of its own that can require an action (a required, non-empty builder or list of actions) SHALL require it there, and one that can count its actions only when the sheet opens SHALL count them then. WHILE variant is standard THE side sheet SHALL accept having no action and report nothing, and SHALL then draw neither the divider above the actions nor the actions (SSH-8). |
SSH-17 | WHILE an action's variant is empty THE side sheet SHALL give that Button the variant comp.side-sheet.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 side sheet SHALL leave it as set. THE side sheet SHALL NOT change an action's size or tokens, and SHALL NOT change its look or emphasis for its confirming or dismissive (BGR-19). |
SSH-18 | WHEN the user activates an action THE side sheet SHALL let the Button emit its own click and SHALL NOT close, emit close or otherwise answer it itself, whether the action is confirming, dismissive or neither; the product closes the sheet from the action's handler, as it does in answer to close. |
SSH-19 | WHILE the header is drawn THE side sheet SHALL draw a divider of comp.side-sheet.divider.width in comp.side-sheet.divider.color across its full width below the header, hidden from assistive technology: WHILE its divider visibility (SSH-23) is always, at rest as well as while the content is scrolled, as the divider above the actions is drawn whether or not the content scrolls (SSH-11); WHILE it is scroll, only WHILE the content is scrolled away from its top. WHILE there is no header it SHALL draw none there. It SHALL show and hide this divider at once, without motion. |
SSH-20 | WHILE the on-screen keyboard covers the bottom of the sheet THE side sheet SHALL keep its header in place, raise its divider and actions to sit directly above the keyboard, scroll the content in the room left between them, and scroll the focused element into that room; WHEN the keyboard hides it SHALL return the actions to the bottom edge. It SHALL move them as the platform moves content for its keyboard, and at once where motion is reduced. |
SSH-21 | THE side sheet 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 side sheet is shown THE side sheet SHALL draw itself for the new direction at once, without a motion of its own, keeping its open state and its focus. |
SSH-22 | THE side sheet SHALL lay out its headline 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 side sheet SHALL still place the headline and align its lines by the layout direction, not by the text's own. |
SSH-23 | THE side sheet SHALL draw the divider below its header (SSH-19) as its dividerVisibility names: WHILE dividerVisibility is auto, as comp.side-sheet.divider.visibility names, which follows sys.behavior.divider-visibility unless a design system sets the side sheet apart; WHILE dividerVisibility is always or scroll, as that value names, whatever the tokens say. WHEN dividerVisibility, or the token it follows, changes THE side sheet SHALL draw the other form at once, moving no part and keeping the scroll position and focus; WHEN dividerVisibility names a value the side sheet does not define THE side sheet 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. |
SSH-24 | 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 side sheet 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 container (SSH-12) — its dialog WHILE variant is modal, its complementary region otherwise — 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 container's name, over a name given so; WHILE headline is empty, a name given so is the container'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 side sheet SHALL take it off that element. |
各平台名称对照
对等并不意味着 API 完全相同。同一份契约,由各平台以自己的惯用方式表达。
| 规格 | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
|---|---|---|---|---|---|
| 组件 | tanrei-side-sheet | TanreiSideSheet | TanreiSideSheet | TanreiSideSheet | SideSheet |
variant · 属性 | variant | variant | variant | variant | variant |
open · 属性 | open | isPresented | visible | open | open |
headline · 属性 | headline | headline | headline | headline | headline |
closable · 属性 | closable | closable | closable | showCloseButton | closable |
closeLabel · 属性 | closeLabel | closeLabel | closeLabel | closeButtonSemanticLabel | closeLabel |
dividerVisibility · 属性 | dividerVisibility | dividerVisibility | dividerVisibility | dividerVisibility | dividerVisibility |
close · 事件 | close | onClose | onClose | onClose | onClose |
| 备注 | closable is a boolean property that defaults to true; its attribute is the inverse not-closable (an HTML boolean attribute cannot default to true), as the chip's removable is. closeLabel is the close-label attribute. close is a CustomEvent whose detail.reason is close, scrim, escape, back or drag; the product sets open to false from it. Slots: default and actions. The actions are laid out by the button group's placement (BGR-13 to BGR-17) inside the shadow root: the sheet's shadow root uses manual slot assignment, assigns its content children to the default slot, 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. Modal: a <dialog> in the shadow root opened with showModal(), its ::backdrop the scrim. Standard: an <aside> named by the headline, placed by the product as the last item of a flex row; its width animates. The close button is a <tanrei-button> in the shadow root, icon-only (the close glyph in its icon slot, its label from close-label), whose variant comes from the resolved comp.side-sheet.close-button.variant through the internal controller the group gives its items. The autofocus attribute on a slotted element marks initial focus. Debug builds (import.meta.env.DEV or the package's dev export) report a modal sheet opened with no assigned action (SSH-16). The keyboard's room (SSH-20) is the visualViewport's height. Insets are env(safe-area-inset-*). Snapshot harness: a 720 × 480 viewport with a surface page; the standard sheet at the end of a row whose other child fills the rest of the width; the modal sheet opened by open, the screenshot being the viewport. dividerVisibility is the divider-visibility attribute (auto, always, scroll); auto reads --tanrei-comp-side-sheet-divider-visibility from the cascade with a style query, and where the browser has none the sheet writes the name it read on its header when it renders. | Lagging: ships the earlier revision — a standard icon button to close, the actions a row from the start edge in the order given, optional in both variants, 8 apart. The target: standard TanreiSideSheet(_ headline:, isPresented: Binding<Bool>, closable:, closeLabel:, initialFocus:, onClose:) { content } actions: { … }, a view the product places at the end of an HStack, with actions: optional. Modal: the modifier .tanreiSideSheet(_ headline:, isPresented:, …, content:, actions:) on the view it covers, with actions: a required builder whose subviews the sheet counts through a variadic view, reporting an empty one in debug builds (SSH-16); presented as tanreiDialog is (on iOS .fullScreenCover with a clear presentation background and the system transition off; Tanrei draws the scrim, the sheet and the drag), and on macOS, which has no full-screen cover, as an overlay of the modified view with the modal trait. Both lay out the actions with the button group's Layout. Closing writes false to isPresented and then calls onClose with the reason. The close button is an icon-only TanreiButton whose variant the sheet sets from its token. Initial focus is .defaultFocus, or an @AccessibilityFocusState binding the product passes as initialFocus (without it, VoiceOver starts at the headline); Escape is .onKeyPress(.escape) and the escape gesture .accessibilityAction(.escape). The keyboard's safe area lifts the actions (SSH-20). Snapshot harness: renders TanreiSideSheetSurface (the sheet and, for a modal sheet, the scrim) in place in a 720 × 480 frame over a surface page. Not yet: the headline's letter-spacing by the script of its text (SSH-9, PRS-20). Not built on this platform yet: dividerVisibility (auto, always, scroll; SSH-23) — the divider below the header is still drawn only while the content is scrolled from its top, as scroll does. | Lagging: ships the earlier revision — a standard icon button to close, the actions a row from the start edge in the order given, optional in both variants, 8 apart. The target: TanreiSideSheet(visible, onClose: (TanreiSheetCloseReason) -> Unit, headline, variant, closable, closeLabel, initialFocus: FocusRequester?, actions: (@Composable () -> Unit)?, content); the caller sets visible to false from onClose. The actions lambda's receiver is the button group's scope, not RowScope, so the flags are its Modifier.confirming() and Modifier.dismissive() and no inert weight or align is offered. Its Layout sees the actions' measurables and, for a modal sheet, reports none in debug builds (SSH-16); variant is a parameter, so the type cannot require actions for the modal form alone. Standard: a composable the product places at the end of a Row; its width animates. Modal: drawn in a Dialog window of its own, as TanreiDialog and the modal drawer are, with the window's own dim off (Tanrei draws the scrim); the window traps focus, answers back and hides the content behind from TalkBack, and the sheet sets a pane title from the headline. The close button is an icon-only TanreiButton whose variant the sheet sets from its token. The keyboard's room is WindowInsets.ime (SSH-20). Snapshot harness: renders TanreiSideSheetContent (the sheet and, for a modal sheet, the scrim) in place in a 720 × 480 dp box over a surface page. Not yet: the headline's letter-spacing by the script of its text (SSH-9, PRS-20). Not built on this platform yet: dividerVisibility (auto, always, scroll; SSH-23) — the divider below the header is still drawn only while the content is scrolled from its top, as scroll does. | Lagging: ships the earlier revision — a standard icon button to close, the actions a row from the start edge in the order given, optional in both variants, 8 apart. The target: TanreiSideSheet(variant:, open:, onClose: ValueChanged<TanreiSheetCloseReason>, headline:, showCloseButton:, closeButtonSemanticLabel:, actions: [...], child:), controlled: the caller sets open to false from onClose. A modal sheet asserts actions non-empty when it opens (SSH-16); the actions are laid out with the button group's render object. Standard: placed at the end of a Row. Modal: a controlled overlay in a Stack over the page, as the modal navigation drawer, with its own scrim and slide, a FocusScope that keeps focus in, the page's semantics blocked, back through PopScope and Escape through Shortcuts/Actions. The close button is an icon-only TanreiButton whose variant the sheet sets from its token; the headline is wrapped in Semantics(header: true). The keyboard's room is MediaQuery.viewInsetsOf(context).bottom (SSH-20). Snapshot harness: a golden of the whole 720 × 480 surface over a surface page. Not yet: the headline's letter-spacing by the script of its text (SSH-9, PRS-20). Not built on this platform yet: dividerVisibility (auto, always, scroll; SSH-23) — the divider below the header is still drawn only while the content is scrolled from its top, as scroll does. | Lagging: ships the earlier revision — a standard icon button to close, the actions a row from the start edge in the order given, optional in both variants, 8 apart. The target: controlled: open, onClose(reason); the caller sets open to false from it. actions is a React node (a Button or a fragment of them); development builds report it empty when a modal sheet opens (SSH-16); the types cannot tie it to variant. The content is children. Standard: a View the product places at the end of a row (flexDirection: 'row'). Modal: presented in a transparent Modal with animationType="none", statusBarTranslucent and navigationBarTranslucent; onRequestClose (Android back, and Escape on a hardware keyboard) is close with the reason back or escape. Drags with PanResponder. The close button is an icon-only Button whose variant the sheet sets from its token; the headline has the header role. The keyboard's height comes from Keyboard events (SSH-20). Insets come from the sheet's own insets prop, falling back to TanreiProvider's insets; the package takes no safe-area dependency. Snapshot harness: renders SideSheetSurface in place in a 720 × 480 view over a surface page. The initial-focus mark is an initialFocusRef prop, as on the bottom sheet and the dialog (SSH-3). Not yet: the headline's letter-spacing by the script of its text (SSH-9, PRS-20). Not built on this platform yet: dividerVisibility (auto, always, scroll; SSH-23) — the divider below the header is still drawn only while the content is scrolled from its top, as scroll does. |
令牌
| 令牌 | 引用 | 解析值(浅色) |
|---|---|---|
--tanrei-comp-side-sheet-actions-arrangement | — | 0 |
--tanrei-comp-side-sheet-actions-gap | sys.space.space200 | 16px |
--tanrei-comp-side-sheet-actions-padding-block | sys.space.space200 | 16px |
--tanrei-comp-side-sheet-actions-variant | — | 1 |
--tanrei-comp-side-sheet-close-button-variant | — | 1 |
--tanrei-comp-side-sheet-container-color-modal | sys.color.surface-container-low | |
--tanrei-comp-side-sheet-container-color-standard | sys.color.surface | |
--tanrei-comp-side-sheet-container-elevation-modal | sys.elevation.level2 | 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 2px 0px rgb(0 0 0 / 0.04) |
--tanrei-comp-side-sheet-container-elevation-standard | sys.elevation.level0 | |
--tanrei-comp-side-sheet-container-padding-block | sys.space.space300 | 24px |
--tanrei-comp-side-sheet-container-padding-inline | sys.space.space300 | 24px |
--tanrei-comp-side-sheet-container-shape-modal | sys.shape.corner.large | 12px |
--tanrei-comp-side-sheet-container-shape-standard | sys.shape.corner.none | 0px |
--tanrei-comp-side-sheet-container-width | — | 320px |
--tanrei-comp-side-sheet-content-color | sys.color.on-surface | |
--tanrei-comp-side-sheet-divider-color | sys.color.outline-variant | |
--tanrei-comp-side-sheet-divider-visibility | sys.behavior.divider-visibility | 0 |
--tanrei-comp-side-sheet-divider-width | sys.border.hairline | 1px |
--tanrei-comp-side-sheet-header-gap | sys.space.space100 | 8px |
--tanrei-comp-side-sheet-header-height | — | 64px |
--tanrei-comp-side-sheet-header-padding-inline-end | sys.space.space150 | 12px |
--tanrei-comp-side-sheet-headline-color | sys.color.on-surface | |
--tanrei-comp-side-sheet-motion-enter-duration | sys.motion.role.appear.sheet.enter | 400ms |
--tanrei-comp-side-sheet-motion-enter-easing | sys.motion.easing.enter | cubic-bezier(0.2, 0, 0, 1) |
--tanrei-comp-side-sheet-motion-exit-duration | sys.motion.role.appear.sheet.exit | 350ms |
--tanrei-comp-side-sheet-motion-exit-easing | sys.motion.easing.exit | cubic-bezier(0.3, 0, 1, 1) |
--tanrei-comp-side-sheet-outline-color-modal | — | |
--tanrei-comp-side-sheet-outline-color-standard | sys.color.outline-variant | |
--tanrei-comp-side-sheet-outline-width | sys.border.hairline | 1px |
--tanrei-comp-side-sheet-scrim-blur | comp.modal-layer.scrim.blur | 0px |
--tanrei-comp-side-sheet-scrim-color | comp.modal-layer.scrim.color | |
--tanrei-comp-side-sheet-headline-typescale | sys.typescale.heading-5 | 500 16px/1.5 |
测试样例
作为基准的渲染结果。所有平台都以相同的 ID 为每个样例截取快照,以便相互比较。
variant: standard (2)
standard
open: true headline: Filters closable: true closeLabel: Close filters 单独绘制在 720 × 480 的窗口中standard-actions
open: true headline: Filters closable: false items: 2 单独绘制在 720 × 480 的窗口中variant: modal (4)
modal
variant: modal open: true headline: Filters closable: true closeLabel: Close filters items: 2 单独绘制在 720 × 480 的窗口中modal-single-action
variant: modal open: true headline: Filters closable: true closeLabel: Close filters items: 1 单独绘制在 720 × 480 的窗口中modal-stacked
variant: modal open: true headline: Edit profile closable: true closeLabel: Close profile editor items: 2 单独绘制在 720 × 480 的窗口中action-variants
variant: modal open: true headline: Filters closable: true closeLabel: Close filters items: 2 单独绘制在 720 × 480 的窗口中生成自 specs/side-sheet.spec.json, tokens/comp/side-sheet.tokens.json, design/tokens/
侧边面板用于放置辅助用户当前工作、但不取而代之的内容:列表的筛选条件、所选行的详情、所选对象的属性。在宽窗口中,它位于内容旁边,用户可以一边更改筛选条件,一边看到结果随之变化。在 compact 窗口中,同一个面板滑到内容之上,用户用完它或把它关闭之后再回去。
在手机上,底部面板往往更合适:它从拇指够得着的那条边升起。任务缺了它就无法继续的决定,使用对话框;产品的各个目的地,使用位于起始侧边缘的导航抽屉。
| 变体 | 它是什么 | 用于 |
|---|---|---|
| 标准(默认) | 布局区域:位于行末尾的面板,起始侧边缘有一条细线把它与内容隔开。它打开和关闭时,旁边的内容随之调整大小。它不绘制遮罩,用户在内容中操作时它保持打开。 | 宽窗口,面板可以在内容旁边保持打开。 |
| 模态 | 模态浮层:一个抬升的面板,朝向内容一侧的角是圆角,从末尾侧边缘滑到遮罩之上。它背后的一切都是惰性(inert)的,焦点留在里面,直到它关闭。 | compact 窗口,以及用户继续之前必须完成或关闭的任务。 |
标准面板是布局的一部分,就像标准导航抽屉或应用栏:焦点离开它从不会把它关闭,因为如果 Tab 一移走面板就关闭,它就无法用键盘操作了。它的模态形态是一个浮层,模态浮层所承诺的一切都适用。
模态面板从不会与窄窗口一样宽。它至少比窗口窄一个触摸目标的宽度,所以总有一条遮罩可供按下。设计系统可以添加变体。
| 部件 | 设置方式 | 说明 |
|---|---|---|
| 标题 | headline |
必填。说明面板是关于什么的,例如“筛选”。它是面板的无障碍名称,也是一个标题(heading);它会换行,而不是被截断。 |
| 关闭按钮 | closable、closeLabel |
位于页眉末尾的 small 纯图标按钮,带有 close 图标,在默认系统中是 tonal 的,与 Tanrei 的每个关闭按钮一样(在 m3-like 中是不带底色的字形)。默认会绘制;当你自己的控件或某个操作负责关闭面板时,把 closable 设为 false(not-closable 属性)。面板负责绘制它,并把它连接到 close;你为它提供标签。 |
| 内容 | 默认插槽 | 面板的用途所在。面板为它留出内边距,为它提供内容颜色,并让它在页眉和操作之间滚动。 |
| 操作 | actions 插槽 | Tanrei 按钮,每个都标记为 confirming 或 dismissive,位于一条分隔线之下。在模态面板中必填,在标准面板中可选。面板负责放置它们,并把没有 variant 的操作画成 tonal;除此之外,它从不改变它们的样式。 |
各操作平分面板的内部宽度,彼此相距 16。排成一行时,取消类操作在起始侧,确认类操作在末尾侧,没有标记的操作按给定顺序排在两者之间。当某个标签在自己那一份宽度内需要换行时,它们叠成一列,确认类操作在最上面。焦点和屏幕阅读器遵循放置后的顺序。设置某个按钮的 variant,就能让那个操作换一种外观;如果产品希望所有面板都换一种外观,应在产品的层里设置一次(actions.variant;如果要让操作在起始侧排成紧凑的一行,则设置 actions.arrangement;关闭按钮则用 close-button.variant)。
内容滚动时,页眉和操作留在原位。操作上方的分隔线始终绘制;在默认系统中,页眉下方的同样一条分隔线也始终绘制:两者都是留在页面旁边的面板的边缘。把 dividerVisibility 设为 scroll(在 Web 上是 divider-visibility),页眉下方的分隔线就只在内容从顶部滚开时出现;cupertino 和 m3-like 为所有面板选择了这种方式。屏幕键盘遮住面板底部时,操作上移到紧贴键盘上方的位置,内容在剩下的空间里滚动。面板的颜色延伸到末尾侧、顶部和底部的系统边衬区之下,它的页眉、内容和操作则保持在边衬区之内。在从右到左的布局中,面板位于左边缘,呈镜像。
- 打开标准面板。 它在末尾侧边缘滑入,旁边的内容同时让出空间。焦点移到你标记的元素上,否则移到面板,因此最先朗读的是它的标题。一开始就处于打开状态的面板不移动焦点。
- 在标准面板旁边操作。 Tab 从面板移到内容中,面板保持打开。Escape、返回操作和拖动对它不起作用。
- 打开模态面板。 它从末尾侧边缘滑入,同时遮罩在窗口的其余部分之上淡入。焦点像标准面板那样移入,并留在里面:在最后一个可聚焦的元素上按 Tab 会回到第一个,在第一个上按 Shift+Tab 会到最后一个。它背后的一切都是惰性的。一开始就处于打开状态的模态面板也会把焦点移进来,因为它背后的任何东西都无法获得焦点。
- 关闭。 面板向末尾侧边缘滑出;如果是模态面板,遮罩同时淡出。如果焦点原先在面板里面,它就交还给面板打开时持有焦点的那个元素。
- 拖动模态面板。 手指移动超过平台的触摸容差(touch slop)之后,面板开始跟随。朝末尾侧边缘拖过自身宽度的一半后松开,或朝那个方向快速滑动,会请求它以原因
drag关闭。更短、更慢的拖动会让它滑回原位。快速滑动和触摸容差与底部面板所用的相同。 - 叠放。 从模态侧边面板中打开的对话框、菜单或另一个模态面板叠在它上面。Escape 只关闭最上面的那个。
- 不能从边缘轻扫打开。 面板由按钮打开,从不由屏幕边缘的轻扫打开。
- 在减弱动态效果时,标准面板立即打开和关闭,模态面板只淡入淡出,不滑动。
- 写一个简短的标题,点明面板的内容:“筛选”“详情”“属性”。
- 保留标准面板默认绘制的关闭按钮,除非你自己的某个控件(例如一个可切换的“筛选”按钮)能关闭它。
- 在两种形态中使用相同的内容,让产品只凭窗口宽度在两者之间切换。
- 把每个操作都标记为
confirming或dismissive,让面板来放置它们。 - 为模态面板提供一个完成任务的操作,例如“应用”,而不只是退出的途径。
- 不要在宽度足以容纳标准面板的窗口中使用模态侧边面板。
- 不要把产品的导航放进侧边面板。请使用导航抽屉。
- 不要在
variant之外另行改变关闭按钮或 actions 插槽中按钮的样式,也不要手动给操作排序。 - 不要打开用户没有要求的侧边面板。
- 角色
dialog- 标注
- The headline is the sheet's accessible name and is exposed as a heading: the modal dialog's name, or the standard sheet's complementary region's. The close button is named by closeLabel. 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). The dividers are decorative.
- 最小目标
- 即使外观尺寸更小,点击区域也满足最小目标尺寸。 minimum 32px · pointer 44px · spacing 8px · touch 48px
键盘操作
| 按键 | 动作 |
|---|---|
| Tab / Shift+Tab | Moves focus through the close button, the content's focusable elements and the actions, in that order, the actions in the order they are placed (BGR-15). In an open modal sheet it wraps and never leaves the sheet; from an open standard sheet it moves on to the content, and the sheet stays open. |
| Enter / Space | Activates the focused close button (close, reason close) or action (its click; the sheet does not close by itself, SSH-18). The sheet adds no default action to Enter. |
| Escape | Asks an open modal sheet to close (close, reason escape); with stacked overlays, only the topmost. A standard sheet ignores it. |
备注
- The sheet has no target of its own; its close button and its actions are Tanrei Buttons, which meet the minimum target themselves (BTN-5). comp.side-sheet.actions.gap keeps each action's whole target in a column in the default system.
- A modal sheet offers at least one action (SSH-16): it holds focus until the person is done, so a person using a screen reader or a switch finds a way to finish the task where they finish reading it, not only a way out. The close button, Escape, back and the scrim still dismiss it. A standard sheet sits beside the content, which the person keeps working in, so it may have no action.
- 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.
- Dragging is never the only way (WCAG 2.5.7): a modal sheet also closes with its close button, Escape, back or a press on the scrim.
- The standard form is a layout region, not an overlay: opening it moves focus in and closing it returns focus, and focus leaving it never closes it. The modal form is a modal overlay. The web presents it with a modal <dialog> element (showModal gives inertness and the top layer; its cancel event is cancelled and becomes close with the reason escape); Compose draws it in a window of its own (a Dialog, as TanreiDialog and the modal drawer are) and Flutter as a controlled overlay in a Stack over the page, each with a scrim, trapping focus and answering back; SwiftUI presents it as the dialog is, with a full-screen cover on iOS; React Native presents it in a transparent Modal.
- Opening a modal sheet by a swipe from the screen edge is left out, as for the drawer: the product opens it from a button.
生成自 specs/side-sheet.spec.json, tokens/sys/measure.tokens.json
- 模态面板以模态对话框的身份提供给辅助技术,以标题为名称。标准面板是一个以标题为名称的补充区域(complementary),在 Web 上是 aside 地标。
- 标题以标题(heading)的身份提供给辅助技术。分隔线对辅助技术隐藏。
- 打开时,焦点移到你标记的元素上,否则移到面板。关闭时,焦点交还给面板打开时持有焦点的那个元素。两种形态都是如此。
- 在模态面板中,Tab 和 Shift+Tab 在面板内循环,它背后的一切都是惰性的,并对辅助技术隐藏;Escape、返回操作以及在遮罩上的按下会请求它关闭。浮层叠放时,只关闭最上面的那个。
- 从标准面板出发,Tab 会继续移到内容上,面板保持打开。它忽略 Escape 和返回操作。
- 关闭按钮是一个以
closeLabel命名的 Tanrei 纯图标按钮,操作是 Tanrei 按钮。它们各自都满足最小目标;叠成一列的操作之间相距 16,让每个目标保持完整。 - 焦点和屏幕阅读器按放置后的顺序遇到各个操作,与视力正常的用户看到的顺序相同。操作保持在屏幕键盘上方,所以用户在面板中输入时仍然够得着它们。
- Enter 和 Space 激活当前聚焦的关闭按钮或操作。面板不为 Enter 添加默认操作。
- 拖动从来不是唯一的关闭方式:关闭按钮、Escape、返回操作和遮罩也都可以。
- 文本变大时,面板的宽度不变,页眉随标题一起变高,内容可以滚动。
- 调试构建会报告没有标题的面板、没有
closeLabel的关闭按钮、打开时没有任何操作的模态面板,以及设计系统中不存在的变体。
已知的平台差距:
- SwiftUI、Compose、Flutter 和 React Native:
dividerVisibility要等这些平台跟上。在此之前,它们像scroll一样,只在内容被滚动时才在页眉下方画出分隔线。 - Web:在 Firefox 中,模态面板立即离开,而不是滑出,与对话框的情况相同。触摸拖动和真实的安全区域边衬区尚未被测试覆盖。
- SwiftUI 既没有对话框角色,也没有补充区域角色:面板是一个以标题为名称的容器,处于模态时带有模态特征。SwiftUI 无法把键盘焦点交给面板本身:没有标记时,VoiceOver 移到标题上,键盘焦点留在原处。关闭时,键盘焦点不会交还给任何元素;在 iOS 上,模态覆盖会把 VoiceOver 焦点交还,标准面板则不会。标准面板在滑动时不会被裁剪,所以请把它放在窗口那一行的末尾侧边缘。在 macOS 上,模态面板只覆盖修饰符所附着的视图,所以请把它附着在窗口的根视图上。
- SwiftUI:内容中的纯文本采用正文字体,但不带它的行高,所以它的位置与其他平台并不完全一致。Escape 和 iOS 的“退出”手势已经接好,但没有测试实际按下它们。
- Compose:模态面板关闭的过程中,它的窗口一直持有焦点,直到退出动效结束。在它跟上之前,操作是在
RowScope中调用的,但面板自己为它们布局,所以weight和各种对齐方式都不起作用。它的拖动只在 Robolectric 下验证过,没有在设备上验证。 - Flutter:标准面板是一个有名称的容器,而不是补充区域地标,因为 Flutter 3.32 没有这样的角色。面板尚未在设备上用 TalkBack 和 VoiceOver 检查过。
- React Native 无法得知先前是哪个元素持有焦点,所以面板关闭时,焦点落在平台安排的位置,而不是回到打开它的元素。Modal、拖动、宽度动画和边衬区都只在 Jest 中验证过,从未在设备上验证。
- 在
m3-like下,面板保留默认系统的宽度和标准形态那道细线画出的边线:要等保真度参考覆盖到侧边面板之后,才会采用 Material 的值。
- 为每个面板提供
headline。它是面板的名称。 - 为默认绘制的关闭按钮提供
closeLabel,说明它关闭的是什么,例如“关闭筛选”。 - 自己关闭面板:在响应
close时(Web 上的close事件,Compose、Flutter 和 React Native 上的onClose)把open设为 false。在 SwiftUI 上,面板会替你把 false 写入绑定。 - 为标准面板提供关闭它的方式:它的关闭按钮,或你自己的一个带标签的控件。
- 为模态面板提供至少一个操作,把每个操作标记为
confirming或dismissive,并在操作的处理函数中关闭面板。 - 用一个看得见的、有自己标签的按钮来打开面板。
- 在有帮助的地方标记初始焦点,例如第一个筛选条件。