跳到主要内容
menumenu tanreiexpand_morecontrasttranslate
tanreiexpand_more
开始使用WebReactSwiftUIJetpack ComposeFlutterReact Native预设你自己的设计系统与 AI 代理一起使用 Tanrei品牌设计令牌设计系统与定制Theme Studio颜色字体排印图标形状层级动效交互状态行为布局无障碍文字颜色令牌字阶令牌形状令牌层级令牌动效令牌状态令牌间距与度量令牌组件按钮主操作按钮分段按钮按钮组带菜单的按钮工具栏复制按钮复选框单选按钮开关纸片(Chip)菜单滑块滑块字段日历DateTimePicker字段组单选组复选框组下拉选择框下拉选择字段菜单栏文件上传文件选择器 进阶放置区文本输入框 进阶文本字段纸片字段字段组合框 进阶组合框字段数字输入框 进阶数字字段颜色输入框 进阶颜色字段一次性验证码输入框 进阶一次性验证码字段嵌入式文本输入框 进阶聊天输入栏卡片分隔线模态层 进阶对话框列表列表行底部面板侧边面板轮播代码块表格弹出框步骤折叠面板描述列表图示应用外壳页头树视图时间线图片预览调整手柄可调整大小的框滚动区域徽标进度指示器计量条数字滚动器加载指示器消息条(Snackbar)工具提示键盘快捷键标签(Tag)行内代码提示块(Callout)头像空状态标签组(Tag group)骨架屏文件项色块跑马灯文本标签页标签页视图应用栏导航栏导航抽屉搜索链接分页上一页/下一页链接步骤条面包屑目录链接卡片FAB 菜单状态Tanrei 是如何构建的参与贡献许可与致谢
首页组件底部面板

底部面板

从底边向上带出补充内容或一项简短的任务:可以盖在屏幕下部而让其余部分照常可用,也可以作为模态的一步,把其他一切变暗并挡住,直到它被关闭。

content_copy以 Markdown 格式查看
类别
容器
令牌
comp.bottom-sheet
层级
组合
构成自
Modal layer

概览

Web
tanrei-bottom-sheet
schedule
SwiftUI
tanreiBottomSheet
schedule
Jetpack Compose
TanreiBottomSheet
schedule
Flutter
TanreiBottomSheet
schedule
React Native
BottomSheet
schedule

生成自 docs/status/status.json, specs/bottom-sheet.spec.json

模态面板:用按钮打开它

保存照片

选择照片的保存位置。之后可以在“设置”中更改。

完成

用法

底部面板是受控组件,它从不自行关闭。把 open 设为 true 即可打开它。当用户要求它离开时,面板触发 close 并带上原因,由你把 open 设回 false。SwiftUI 是唯一的例外:在那里 open 是一个绑定,面板先把 false 写入绑定,再调用 onClose。如果你让 open 保持为 true,面板就留在原处;拖动之后,它会回落到最低的高度。在 Web 上这一点与对话框不同:面板会取消原生的 cancel 事件,改为触发 close,所以在你关闭它之前,Escape 不会把它关闭。

close 带有以下五种原因之一,大小写遵循各平台的写法:

原因 用户做了什么 适用的形态
close 按下了面板的关闭按钮(closable) 标准和模态
drag 在低于面板最低高度一半的位置松开拖动,或从那个高度向下快速滑动 标准和模态
scrim 按下了模态面板周围变暗的区域 模态
escape 按下了 Escape 模态
back 使用了平台的返回操作:Android 的返回按钮或返回手势、iOS 的无障碍“退出”手势。在 Web 上,不是由按键发出的关闭请求(例如浏览器中的 Android 返回手势)也算作 back。 模态

只有当 dismissible 为 true 时,面板才会触发 close,并且每次请求恰好触发一次。当它为 false 时,遮罩、Escape 和返回操作都不起作用,向下拖动后面板会停在最低的高度。关闭按钮是例外:无论 dismissible 是什么,它都以原因 close 请求关闭,因为它是你自己选择画出的出口。

面板通过 variant 提供两种形态。模态是默认形态,是一个模态浮层:它让整个窗口变暗,把焦点留在里面,并在关闭时把焦点交还。标准形态是一个布局区域,而不是浮层:它盖住你放置它的父元素的底部,不绘制遮罩,用户可以继续操作它上方的内容。

面板里放什么由你决定。面板按自己的内边距为内容留出边距(如果是行自带内边距的列表,把 inset 设为 false),裁剪内容,为它提供内容颜色,并让它滚动。给它一个 headline,它就会在内容上方绘制页眉:标题(一个标题元素,label 为空时也是面板的名称),以及在打开 closable 时,一个以 closeLabel 命名的关闭按钮——一个 small 按钮,在默认系统中是 tonal,与 Tanrei 的每个关闭按钮一样。页眉下方按 dividerVisibility 绘制一条分隔线,内容滚动时页眉保持不动。closable 默认关闭,因为手柄以及模态面板的遮罩就是面板的出口;在标准面板或没有手柄的面板中请把它打开。

面板打开时应当获得焦点的元素,用平台自己的方式标记:在 Web 上是 autofocus,在 SwiftUI 上是 .defaultFocus 或 @AccessibilityFocusState,在 Compose 上是作为 initialFocus 传入的 FocusRequester,在 Flutter 上是 autofocus: true,在 React Native 上是 initialFocusRef。没有标记时,焦点落在面板本身,因此最先朗读的是它的标签。

<tanrei-button id="save" variant="outlined">保存照片</tanrei-button>

<!-- 模态是默认形态。closedby="none" 相当于把 dismissible 设为 false。 -->
<tanrei-bottom-sheet id="save-sheet" label="保存照片" handle-label="调整面板高度">
  <p>选择照片的保存位置。</p>
  <tanrei-button variant="text" autofocus>完成</tanrei-button>
</tanrei-bottom-sheet>

<!-- 标准形态:盖住其包含块的底部。flush 相当于把 inset 设为 false;no-handle 相当于把 handle 设为 false。 -->
<div class="map" style="position: relative">
  <tanrei-bottom-sheet variant="standard" open label="附近的地点" handle-label="调整面板高度" flush>
    <tanrei-list>…</tanrei-list>
  </tanrei-bottom-sheet>
</div>

<script type="module">
  import '@tanrei/web/components/bottom-sheet';
  const sheet = document.querySelector('#save-sheet');
  document.querySelector('#save').addEventListener('click', () => (sheet.open = true));
  sheet.querySelector('tanrei-button').addEventListener('click', () => (sheet.open = false));
  // 面板从不自行关闭。detail.reason 是 drag、scrim、escape 或 back。
  sheet.addEventListener('close', (e) => (sheet.open = false));
  // 用户把它移到了另一个高度。面板已经设置好 detent。
  sheet.addEventListener('detentchange', (e) => console.log(e.detail.detent));
</script>
import { BottomSheet, Button } from '@tanrei/react';

<BottomSheet
  open={open}
  label="保存照片"
  handleLabel="调整面板高度"
  onClose={() => setOpen(false)}
  onDetentChange={(e) => setDetent(e.detail.detent)}
>
  <p>选择照片的保存位置。</p>
  <Button variant="text" onClick={() => setOpen(false)}>完成</Button>
</BottomSheet>
import Tanrei

// 它是加在被面板覆盖的视图上的修饰符,与 .sheet 一样。关闭时先把 false 写入绑定,
// 再带着原因调用 onClose。用户选择的高度会写入 detent。
PhotoView(photo)
    .tanreiBottomSheet(isPresented: $saving, detent: $detent,
                       handleLabel: "调整面板高度", label: "保存照片") {
        SaveTargets(photo)
        TanreiButton("完成", variant: .text) { saving = false }
    }

// 标准形态:位于被覆盖视图底部的浮层。
MapView()
    .tanreiBottomSheet(isPresented: $placesShown, variant: .standard,
                       handleLabel: "调整面板高度", label: "附近的地点") {
        PlacesList()
    }
import io.github.chnotchy.tanrei.components.TanreiBottomSheet
import io.github.chnotchy.tanrei.components.TanreiButton
import io.github.chnotchy.tanrei.tokens.TanreiButtonVariant

// 用 visible 控制,而不是只在打开时才调用它,这样退出动效才能播放。
// 在 onClose 中把它设为 false;onClose 会收到原因。
TanreiBottomSheet(
    visible = saving,
    onClose = { saving = false },
    detent = detent,
    onDetentChange = { detent = it },
    handleLabel = "调整面板高度",
    contentDescription = "保存照片",
) {
    SaveTargets(photo)
    TanreiButton(text = "完成", onClick = { saving = false }, variant = TanreiButtonVariant.Text)
}
import 'package:tanrei/tanrei.dart';

// 模态面板是覆盖整个页面的 Stack 的最后一个子元素。在 onClose 中把 open 设为 false。
Stack(
  children: <Widget>[
    const Positioned.fill(child: PhotoPage()),
    TanreiBottomSheet(
      open: saving,
      onClose: (TanreiSheetCloseReason reason) => setState(() => saving = false),
      detent: detent,
      onDetentChanged: (value) => setState(() => detent = value),
      handleSemanticLabel: '调整面板高度',
      semanticLabel: '保存照片',
      child: const SaveTargets(),
    ),
  ],
),
import { useRef, useState } from 'react';
import type { View } from 'react-native';
import { BottomSheet, Button } from '@tanrei/react-native';

const done = useRef<View>(null);
const [open, setOpen] = useState(false);

// 受控:在 onClose 中把 open 设为 false;onClose 会收到原因('drag'、'scrim'、'escape' 或 'back')。
// 模态面板呈现在一个透明的 Modal 中。面板的 insets 属性会替换 TanreiProvider 的 insets 中它所指定的那几条边。
<BottomSheet
  open={open}
  onClose={(reason) => setOpen(false)}
  detent={detent}
  onDetentChange={setDetent}
  handleLabel="调整面板高度"
  accessibilityLabel="保存照片"
  initialFocusRef={done}
>
  <SaveTargets />
  <Button ref={done} variant="text" onPress={() => setOpen(false)}>完成</Button>
</BottomSheet>

规格

盖在父元素底部的标准面板:向下拖动即可关闭

地图

显示附近的地点

步行十分钟之内,有三家咖啡馆和一家面包店正在营业。

构成

部件 说明
1 scrim · 可选 The modal layer's scrim over everything behind a modal sheet (MDL-1). It blocks presses and, while the sheet is dismissible, a press on it asks the sheet to close. A standard sheet draws none.
2 container The raised surface docked to the bottom edge of the area it covers, centred and no wider than its maximum width, its top corners rounded and its bottom corners square. Its color reaches under the bottom system inset.
3 drag-handle · 可选 The control at the top of the sheet: a Handle without a value, always drawn, that carries hover, focus and press on its box, with the grip centred in it. Activating it moves the sheet between its two heights.
4 drag-handle.grip · 可选 The Handle's line: the short rounded bar drawn in the handle's box that says the sheet can be dragged.
5 header · 可选 The row below the drag handle's row (or at the top without one) that holds the headline and the close button. Drawn while headline is not empty or closable is true; it stays in place while the content scrolls.
6 headline · 可选 What the sheet is about ("Share photo"), a heading. It names the sheet where label is empty.
7 close-button · 可选 An icon-only Button of the small size showing the close glyph at the end of the header, in the variant comp.bottom-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.
8 divider · 可选 A hairline across the sheet 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 content Whatever the product puts in the sheet: a list of choices, a form, details. The sheet insets it (unless inset is false), clips it, hands it a content color and scrolls it; it does not lay it out.
10 focus-ring · 可选 Gap-then-ring indicator around the drag handle's box, shown for keyboard focus.

属性

属性 类型 默认值 说明
variant standard | modal "modal" Standard sits over the lower part of the content while the content above stays usable (a layout region); modal dims and blocks everything else and holds focus until it closes (a modal overlay). Both read the same container tokens: the scrim and the behaviour set them apart. Not a token axis.
open boolean false Whether the sheet is shown. The product opens it, and closes it in answer to close (or from its own controls); on SwiftUI, where it is a binding, the sheet writes false itself.
detent partial | full "partial" The height the sheet rests at while its content is taller than it: partial takes comp.bottom-sheet.container.partial-fraction of the area; full reaches up to comp.bottom-sheet.container.margin-block-start below the area's top. A sheet whose content is shorter than its partial height has one height, and detent changes nothing. The person moves between the two by dragging or with the drag handle (detentChange).
dismissible boolean true Whether a drag below the sheet's lowest height asks it to close and, for a modal sheet, whether Escape, the platform's back and a press on the scrim do too. Set it to false only when the person must finish the sheet's task with one of its own controls.
handle boolean true Draws the drag handle at the top of the sheet. Without it the sheet can still be dragged by its padding and content (BSH-9), but a keyboard or switch user has no way to change its height, so leave it on for a sheet whose content can be taller than its partial height.
handleLabel string "" The drag handle's accessible name ("Resize sheet"); its expanded state is exposed with it. Required while the handle is interactive (BSH-11).
inset boolean true Insets the content by comp.bottom-sheet.container.padding. Set it to false for content that pads itself or reaches the edges (a list whose rows carry their own padding).
label string "" Names the sheet for assistive technology ("Share photo"): the modal dialog, or the standard sheet's region. Required unless headline names it.
headline string "" What the sheet is about ("Share photo"), drawn in its header (BSH-28). While label is empty it is the sheet's accessible name.
closable boolean false Draws the close button at the end of the header (BSH-29). False by default: the drag handle and, for a modal sheet, the scrim are the sheet's ways out. Set it where a person who cannot drag needs one they can see — a standard sheet, or a sheet without a handle.
closeLabel string "" The close button's accessible name ("Close sharing"). Required while closable is true.
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. scroll: only while the content is scrolled away from its top. auto takes comp.bottom-sheet.divider.visibility, the design system's answer (always in the default system).

插槽

插槽 说明
default The content. A fixture's content string is plain text, which takes sys.typescale.body in the content color (BSH-8).

事件

事件 说明
close The sheet asks to be closed: its close button (BSH-29), a released drag below its lowest height, or for a modal sheet a press on the scrim, Escape or the platform's back. Carries which of these it was, named close, drag, scrim, escape or back (in the platform's casing). The close button asks whatever dismissible says; the others only while dismissible is true.
detentChange The person moved the sheet to its other height, by a drag or with the drag handle. Carries the new detent (partial or full). Not emitted when the product sets detent.

状态

行为

以 EARS 形式写成的需求。每个平台的实现都按这些 ID 逐条测试。需求原文取自规格文件,原样以英文呈现。

ID 需求
BSH-1 WHEN open becomes true THE bottom sheet SHALL slide its container up from below the bottom edge of the area it covers to its height (BSH-6) — WHILE variant is modal over the whole window on its modal layer, fading the layer's scrim in comp.bottom-sheet.scrim.color and comp.bottom-sheet.scrim.blur in behind it in step (MDL-1, MDL-7); WHILE variant is standard over the bottom of the parent the product placed it in, with no scrim — over comp.bottom-sheet.motion.enter-duration with comp.bottom-sheet.motion.enter-easing; WHEN open becomes false THE bottom sheet SHALL slide it down out of view from wherever it is and fade the scrim out, 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). A sheet that is mounted with open true SHALL appear in place without motion.
BSH-2 WHILE variant is modal and the sheet is open THE bottom sheet SHALL make everything outside it inert, keep the content behind it from scrolling and keep focus inside it, as its modal layer does (MDL-2, MDL-4): a modal overlay.
BSH-3 WHEN open changes from false to true THE bottom 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 its label first; WHEN it closes while focus is inside it THE bottom 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 for a closed overlay. 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.
BSH-4 WHILE variant is standard THE bottom sheet SHALL draw no scrim, SHALL NOT make anything inert, SHALL NOT keep focus inside it, SHALL let Tab and Shift+Tab move focus between it and the content it covers without closing it, and SHALL ignore Escape and the platform's back: a standard sheet is a layout region, not an overlay. The product places it over the content it covers, in a parent that holds both; the sheet SHALL dock to that parent's bottom edge, SHALL NOT resize or pad the content, and SHALL let presses on the part of the parent above it reach the content.
BSH-5 WHILE dismissible is true, WHEN the user presses the scrim, presses Escape or uses the platform's back WHILE variant is modal, as its modal layer answers them (MDL-6), or releases a drag that asks to close (BSH-9) in either variant, THE bottom sheet SHALL emit close exactly once with the reason scrim, escape, back or drag, and SHALL then 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). WHILE dismissible is false THE bottom sheet SHALL ignore these inputs and emit nothing for them (its close button, BSH-29, still asks); a press on the scrim SHALL still reach nothing behind it.
BSH-6 THE bottom sheet SHALL be as tall as its drag handle's row, its header (BSH-28) and its content (with its insets), but no taller than its detent allows: WHILE detent is partial, comp.bottom-sheet.container.partial-fraction of the height of the area it covers; WHILE detent is full, that height less comp.bottom-sheet.container.margin-block-start. The area is the window below its top system inset for a modal sheet, and the parent the product placed it in for a standard sheet; WHILE the on-screen keyboard is shown the area SHALL end at the keyboard's top edge, so that the sheet sits on the keyboard and both heights are taken from the smaller area. The sheet has two heights only WHILE its content is taller than its partial height; otherwise it has one, and detent changes nothing.
BSH-7 THE bottom sheet SHALL draw its container at the bottom edge of the area, as wide as the area but no wider than comp.bottom-sheet.container.max-width and centred in it, filled with comp.bottom-sheet.container.color, raised by comp.bottom-sheet.container.elevation, with its top corners rounded by comp.bottom-sheet.container.shape and its bottom corners square; it SHALL extend its color under the bottom system inset and keep its content above that inset, and WHILE the on-screen keyboard is shown it SHALL sit on the keyboard (BSH-6) with its content above it.
BSH-8 THE bottom sheet SHALL lay out, top to bottom, the drag handle's row WHILE handle is true (BSH-12), the header WHILE there is one (BSH-28) and then its content. WHILE inset is true it SHALL inset the content by comp.bottom-sheet.container.padding from its start, end and bottom edges, and from its top edge WHILE handle is false and no header is drawn; WHILE inset is false the content SHALL reach the container's edges, below the handle's row and the header. It SHALL clip the content to the container's shape, SHALL hand it comp.bottom-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), and plain text given as content, where the platform must style it, SHALL take sys.typescale.body.
BSH-9 WHEN the user drags the sheet vertically — by its handle's row, by the padding around its content, or by its content where BSH-10 lets the drag reach the sheet — beyond the platform's touch slop THE bottom sheet SHALL follow the finger or pointer, never above its full height; WHEN the drag is released it SHALL settle at the next height in the direction of a fling, or else at the height nearest to where it was released, over comp.bottom-sheet.motion.enter-duration with comp.bottom-sheet.motion.enter-easing (at once where motion is reduced). A release below half of the sheet's lowest height, or a downward fling from its lowest height, SHALL ask to close WHILE dismissible is true (BSH-5, BSH-20); WHILE dismissible is false the sheet SHALL settle at its lowest height instead. A fling is a release faster than the platform's minimum fling velocity (Android's ViewConfiguration.getScaledMinimumFlingVelocity, Flutter's kMinFlingVelocity) or, where the platform names none (the web, SwiftUI, React Native), 0.5 points per millisecond; the touch slop is the platform's own (Android's ViewConfiguration.getScaledTouchSlop, Flutter's kTouchSlop) or, where the platform names none, 8 points. A release's velocity is the pointer's over its last moves: WHERE the pointer has not moved for longer than the time after which the platform's velocity tracker takes a pointer as stopped (40 milliseconds in Android's VelocityTracker, Jetpack Compose's VelocityTracker and Flutter's VelocityTracker) or, where the platform names none (the web, SwiftUI, React Native), 40 milliseconds before the release THE bottom sheet SHALL take the release as having no velocity, so that a drag that stops where it is wanted and is let go there is no fling, however fast it moved before it stopped. WHEN a drag ends at the height it did not start from THE bottom sheet SHALL set detent to it and emit detentChange exactly once with it.
BSH-10 WHILE the content is taller than the room the sheet gives it THE bottom sheet SHALL scroll the content inside the container, below the handle's row, and SHALL let assistive technology and keyboard focus scroll it at either height; a drag upward on the content SHALL first raise the sheet to its full height and then scroll the content, and a drag downward on the content SHALL first scroll the content to its top and then lower the sheet.
BSH-11 WHILE handle is true and the sheet has two heights THE bottom sheet SHALL make the drag handle one focus stop, a button named by handleLabel with the expanded state, true WHILE detent is full (its label and expanded, HDL-8), and WHEN the user activates it by pointer, touch, Enter or Space (its activate, HDL-7) THE bottom sheet SHALL move to its other height as BSH-9 settles and emit detentChange exactly once with the new detent; WHILE the sheet has one height the handle SHALL still be drawn at rest but SHALL NOT be focusable, pressable or exposed to assistive technology. WHILE the handle is interactive and handleLabel is empty it SHALL still render, and in debug builds report that the handle has no accessible name.
BSH-12 WHILE handle is true THE bottom sheet SHALL draw at its top a row holding the drag handle — a Handle of axis vertical and size medium, with no value and revealed — centred across the sheet with comp.bottom-sheet.handle.margin-block above and below it, and SHALL give that Handle its own tokens in place of the Handle's: the box comp.bottom-sheet.handle.container-width wide and comp.bottom-sheet.handle.container-height high with comp.bottom-sheet.handle.container-shape corners (HDL-3), the grip — the Handle's line — comp.bottom-sheet.handle.grip-width long and comp.bottom-sheet.handle.grip-height thick (HDL-1), and the container, line and state-layer colors of each state from the drag-handle control (comp.bottom-sheet.drag-handle.container.color, comp.bottom-sheet.drag-handle.grip.color, comp.bottom-sheet.drag-handle.state-layer.color, and so on for each state), its opacity in each state, and comp.bottom-sheet.motion.state-duration, comp.bottom-sheet.motion.state-easing and comp.bottom-sheet.motion.feedback-duration as its motion; in the default system each of them aliases the Handle's own. The row SHALL stay in place while the content scrolls.
BSH-13 WHILE the drag handle is hovered, keyboard-focused or pressed THE bottom sheet SHALL render the handle's box, grip and opacity from the component tokens of the highest-precedence active state, with precedence pressed over focused over hovered, and the grip at the Handle's length and thickness of a handle in use (HDL-4); focus gained by pointer or touch SHALL NOT select the focused state. A drag that starts on the handle SHALL keep it pressed while the sheet follows the drag, as a Handle is held for as long as a drag lasts (HDL-11).
BSH-14 WHEN the drag handle receives keyboard focus THE bottom sheet SHALL show the focus ring around the handle's box without animation; focus gained by pointer or touch SHALL NOT show it.
BSH-15 THE bottom sheet SHALL give the drag handle the Handle's hit area (HDL-5): at least sys.target.touch wide and high for touch input and sys.target.pointer for fine pointers, centred on its box, without changing its drawn size, and the Handle's vertical resize cursor; a platform that cannot tell the input type SHALL use sys.target.touch. A press on the rest of the handle's row drags the sheet as BSH-9 says.
BSH-16 WHEN the drag handle is pressed by pointer, touch, Enter or Space THE drag handle SHALL show the pressed state without delay and SHALL keep it visible for at least sys.motion.duration.press-hold, even when the press ends sooner (HDL-11).
BSH-17 WHILE pressed THE drag handle SHALL answer as sys.behavior.pressed-feedback names: overlay applies the pressed tokens to the whole container at once; ripple grows the pressed state layer from the point of contact (from the center for keyboard activation); dim applies the pressed opacity to the whole control and draws no state layer; none draws no answer to the press. Under ripple and dim only the container color stays with the state under the press; grip and opacity still come from the pressed tokens. Under none every part stays with the state under the press. The ripple grows over comp.bottom-sheet.motion.feedback-duration with comp.bottom-sheet.motion.state-easing, fades over comp.bottom-sheet.motion.state-duration with comp.bottom-sheet.motion.state-easing once the press has ended and press-hold has passed, and appears whole where motion is reduced.
BSH-18 THE bottom sheet SHALL ease its container's top corners and the drag handle's box corners 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 show the handle's keyboard focus as sys.behavior.focus-style names (ring or glow); a platform that cannot draw a choice SHALL fall back to the first value and state that in its README.
BSH-19 WHEN the drag handle's colors, or its grip's length and thickness, change between states THE bottom sheet SHALL animate them over comp.bottom-sheet.motion.state-duration with comp.bottom-sheet.motion.state-easing, and SHALL change them at once where motion is reduced.
BSH-20 WHEN a released drag asks to close THE bottom sheet SHALL emit close (BSH-5), hold where the drag left it for one frame, and then slide out from there as BSH-1 says IF open has become false, or settle back at its lowest height as BSH-9 says IF open is still true (the product declined to close it).
BSH-21 WHEN the product changes detent while the sheet is open THE bottom sheet SHALL move to the new height over comp.bottom-sheet.motion.enter-duration with comp.bottom-sheet.motion.enter-easing (at once where motion is reduced) and SHALL NOT emit detentChange; where detent is a plain value (web, Compose, Flutter, React Native), a height the person chose (BSH-9, BSH-11) SHALL stay until the product sets detent again, and where it is a binding (SwiftUI) the sheet SHALL write the new detent to it.
BSH-22 THE bottom sheet SHALL expose its container, WHILE variant is modal, as a modal dialog named by label and, WHILE variant is standard, as a region named by label (a landmark on the web) — by the headline WHILE label is empty (BSH-28); WHILE label and headline are both empty it SHALL still render, and in debug builds report that it has no accessible name. The drag handle's grip, the Handle's line, SHALL be hidden from assistive technology; the handle itself is exposed only as BSH-11 says.
BSH-23 WHEN a dialog, a menu or another modal sheet opens from a modal bottom sheet THE bottom 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.
BSH-24 WHILE the text size is enlarged THE bottom sheet SHALL grow with its content up to the height its detent allows and scroll the content beyond it (BSH-10), grow its header with a headline that wraps, and SHALL NOT scale the drag handle, nor the close button beyond what the Button itself does.
BSH-25 WHEN variant or detent names a value the bottom sheet does not define THE bottom sheet SHALL render with the default value, and in debug builds report the unknown value.
BSH-26 WHEN the layout direction is right-to-left THE bottom sheet SHALL keep its container and its drag handle centred, and its content SHALL follow the layout direction.
BSH-27 THE bottom 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 bottom sheet is shown THE bottom sheet SHALL draw itself for the new direction at once, without a motion of its own, keeping its open state, its height and its focus.
BSH-28 WHILE headline is not empty or closable is true THE bottom sheet SHALL draw a header below the drag handle's row (or at the top of the container without one): a row at least comp.bottom-sheet.header.height high holding the headline from comp.bottom-sheet.container.padding after the start edge, in comp.bottom-sheet.headline.typescale and comp.bottom-sheet.headline.color, wrapping onto more lines rather than truncating and growing the row with it, exposed as a heading, and, WHILE closable is true, the close button ending comp.bottom-sheet.header.padding-inline-end before the end edge and comp.bottom-sheet.header.gap after the headline, both centred in the row; below it, a divider comp.bottom-sheet.divider.width thick in comp.bottom-sheet.divider.color across the sheet's full width WHILE dividerVisibility (or, for auto, comp.bottom-sheet.divider.visibility) is always, or is scroll and the content is scrolled away from its top, shown and hidden at once. The header SHALL NOT scroll with the content, and a drag on it SHALL move the sheet as a drag on the handle's row does (BSH-9). WHILE headline is not empty it SHALL name the sheet where label is empty. A dividerVisibility the sheet does not define SHALL be drawn as auto, and reported in debug builds.
BSH-29 WHILE closable is true THE bottom sheet SHALL draw its close button as an icon-only Button of the small size showing the glyph close of the system's icon set and named by closeLabel, in the variant comp.bottom-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 bottom sheet SHALL emit close exactly once with the reason close, in either variant and whatever dismissible says, and SHALL then be closed as BSH-5 says. 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. THE bottom sheet SHALL draw no close mark of its own.
BSH-30 WHILE handle is false, dismissible is true and closable is false THE bottom sheet SHALL still render, and in debug builds report that it shows no way out a person can see: a drag, Escape and the scrim still close it, but nothing on screen says so to a person who cannot drag.
BSH-31 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 bottom 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 (BSH-22) — its dialog WHILE variant is modal, its 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 label or headline is not empty it is the container's name (BSH-22), over a name given so; WHILE both are 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 bottom sheet SHALL take it off that element.

各平台名称对照

对等并不意味着 API 完全相同。同一份契约,由各平台以自己的惯用方式表达。

规格 Web SwiftUI Jetpack Compose Flutter React Native
组件 tanrei-bottom-sheet tanreiBottomSheet TanreiBottomSheet TanreiBottomSheet BottomSheet
variant · 属性 variant variant variant variant variant
open · 属性 open isPresented visible open open
detent · 属性 detent detent detent detent detent
dismissible · 属性 closedby dismissible dismissible dismissible dismissible
handle · 属性 handle handle handle showHandle handle
handleLabel · 属性 handleLabel handleLabel handleLabel handleSemanticLabel handleLabel
inset · 属性 inset inset inset inset inset
label · 属性 label label contentDescription semanticLabel accessibilityLabel
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
detentChange · 事件 detentchange detent onDetentChange onDetentChanged onDetentChange
备注 dismissible is HTML's own closedby attribute, as on <tanrei-dialog>: any (the default) or none. handle and inset are boolean properties that default to true; their attributes are the inverses no-handle and flush (an HTML boolean attribute cannot default to true). handleLabel is the handle-label attribute, closeLabel the close-label attribute and dividerVisibility the divider-visibility attribute (auto reads --tanrei-comp-bottom-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). 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.bottom-sheet.close-button.variant through the internal channel a Button reads from its host; the headline is an <h2>. close is a CustomEvent whose detail.reason is close, drag, scrim, escape or back (back when a modal sheet's native cancel arrives without an Escape key, as Android's back gesture sends it), and detentchange one whose detail.detent is the new detent; the element sets detent itself before firing it. The product sets open to false from close. The content is the default slot. Drags use pointer events with touch-action: none on the handle's row and the top edge; the content scrolls natively and hands the drag over at its top (BSH-10). Fling 0.5 px/ms, slop 8 px. Insets are env(safe-area-inset-bottom); the on-screen keyboard is read from visualViewport. Snapshot harness: a 400 × 720 viewport with a surface page; the sheet opened by open; the screenshot is the viewport, since a modal sheet and its backdrop are in the top layer. The drag handle is a <tanrei-handle> in the shadow root, its container and line exported as the parts handle and grip; the sheet sets the Handle's custom properties to its own tokens on it (BSH-12), answers its activate, and keeps it inert while the sheet has one height (BSH-11). A view modifier on the view the sheet covers, as .sheet is: .tanreiBottomSheet(isPresented: Binding<Bool>, variant:, detent: Binding<TanreiBottomSheetDetent>, dismissible:, handle:, handleLabel:, inset:, label:, onClose: ((TanreiSheetCloseReason) -> Void)?, content:). Closing writes false to isPresented and then calls onClose with the reason; a height the person chose is written to detent (detentChange). Modal: on iOS presented with .fullScreenCover, a clear presentation background and the system transition off, as tanreiDialog is (that presentation hides the rest of the app from VoiceOver); Tanrei draws the scrim, the sheet and the drag with a DragGesture (fling from its velocity, 0.5 pt/ms). Not the native .sheet: its dim cannot take the scrim token, and on iPad in a regular width it becomes a centred form, not a bottom sheet. macOS, which has no full-screen cover, draws the modal sheet as an overlay of the modified view with the modal trait. Standard: .overlay(alignment: .bottom) of the modified view. Initial focus is .defaultFocus or an @AccessibilityFocusState the product passes; Escape is .onKeyPress(.escape) and the escape gesture .accessibilityAction(.escape). Snapshot harness: renders TanreiBottomSheetSurface (the sheet and, for a modal sheet, the scrim) in place in a 400 × 720 frame over a surface page, because a snapshot of the presenting view does not include a presented window. Not built on this platform yet: the header — headline, closable, closeLabel and dividerVisibility, the close reason close (BSH-28, BSH-29) — and the report of a sheet with no way out a person can see (BSH-30). TanreiBottomSheet(visible, onClose: (TanreiSheetCloseReason) -> Unit, variant, detent, onDetentChange, dismissible, handle, handleLabel, inset, contentDescription, initialFocus: FocusRequester?, content). visible rather than calling it only while open, so that the exit motion can run before the sheet goes; the caller sets visible to false from onClose. The sheet keeps a height the person chose until detent changes (BSH-21). Modal: drawn in a Dialog window of its own, as TanreiDialog and the modal drawer are, with usePlatformDefaultWidth = false, decorFitsSystemWindows = false and the window's own dim off (Tanrei draws the scrim); the window traps focus, answers back and hides the content behind from TalkBack; the sheet sets a pane title from contentDescription. Standard: fills its parent (a Box over the content) and draws the sheet at its bottom edge; the part above takes no touches. Drag with Modifier.draggable and nested scroll for BSH-10; fling and slop from ViewConfiguration. Snapshot harness: renders TanreiBottomSheetContent (the sheet and, for a modal sheet, the scrim) in place in a 400 × 720 dp box over a surface page; the window itself is covered by instrumented tests. Not built on this platform yet: the header — headline, closable, closeLabel and dividerVisibility, the close reason close (BSH-28, BSH-29) — and the report of a sheet with no way out a person can see (BSH-30). TanreiBottomSheet(variant:, open:, onClose: ValueChanged<TanreiSheetCloseReason>, detent:, onDetentChanged:, dismissible:, showHandle:, handleSemanticLabel:, inset:, semanticLabel:, child:), controlled: the caller sets open to false from onClose. The product places it in a Stack over what it covers — for a modal sheet, a Stack over the whole page, as the modal navigation drawer — not showModalBottomSheet, which needs Material. The modal form draws its scrim over the whole Stack, keeps focus in a FocusScope, blocks the page's semantics (BlockSemantics), names itself with Semantics(scopesRoute: true, namesRoute: true), answers back through PopScope and Escape through Shortcuts/Actions. Initial focus is autofocus: true on a focusable widget. Drag with a vertical drag recogniser; fling kMinFlingVelocity, slop kTouchSlop. Snapshot harness: a golden of the whole 400 × 720 surface with the sheet in a Stack over a surface page. Not built on this platform yet: the header — headline, closable, closeLabel and dividerVisibility, the close reason close (BSH-28, BSH-29) — and the report of a sheet with no way out a person can see (BSH-30). Controlled: open, onClose(reason), detent, onDetentChange; the caller sets open to false from onClose. Modal: presented in a transparent Modal with animationType="none", statusBarTranslucent and navigationBarTranslucent (Tanrei draws the scrim and the motion); onRequestClose (Android back, and Escape on a hardware keyboard) is close with the reason back or escape. Standard: a View positioned absolutely at the bottom of its parent. Drags with PanResponder (no gesture-handler dependency): slop 8 pt, fling 0.5 pt/ms. The on-screen keyboard is read from Keyboard events. Initial focus is an initialFocusRef the product passes, moved with AccessibilityInfo.setAccessibilityFocus. 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 BottomSheetSurface (the sheet and, for a modal sheet, the scrim) in place in a 400 × 720 view over a surface page, since a Modal is a native window the snapshot does not include. React Native cannot hand one gesture from the sheet to the content's native scroll view partway through, so BSH-10's hand-off takes two gestures there; the package takes no gesture-handler dependency (as it takes no safe-area one). Not built on this platform yet: the header — headline, closable, closeLabel and dividerVisibility, the close reason close (BSH-28, BSH-29) — and the report of a sheet with no way out a person can see (BSH-30).

令牌

令牌 引用 解析值(浅色)
--tanrei-comp-bottom-sheet-close-button-variant — 1
--tanrei-comp-bottom-sheet-container-color sys.color.surface-container-low #f9f9f9
--tanrei-comp-bottom-sheet-container-elevation 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-bottom-sheet-container-margin-block-start sys.space.space600 48px
--tanrei-comp-bottom-sheet-container-max-width — 640px
--tanrei-comp-bottom-sheet-container-padding sys.space.space200 16px
--tanrei-comp-bottom-sheet-container-partial-fraction — 0.5
--tanrei-comp-bottom-sheet-container-shape sys.shape.corner.large 12px
--tanrei-comp-bottom-sheet-content-color sys.color.on-surface #1b1b1b
--tanrei-comp-bottom-sheet-divider-color sys.color.outline-variant #000000 · 8%
--tanrei-comp-bottom-sheet-divider-visibility sys.behavior.divider-visibility 0
--tanrei-comp-bottom-sheet-divider-width sys.border.hairline 1px
--tanrei-comp-bottom-sheet-drag-handle-container-color comp.handle.standard.container.color #000000 · 0%
--tanrei-comp-bottom-sheet-drag-handle-focused-container-color — #848484 · 10%
--tanrei-comp-bottom-sheet-drag-handle-focused-grip-color — #848484
--tanrei-comp-bottom-sheet-drag-handle-focused-state-layer-color — #848484
--tanrei-comp-bottom-sheet-drag-handle-grip-color comp.handle.standard.line.color #848484
--tanrei-comp-bottom-sheet-drag-handle-hovered-container-color — #848484 · 8%
--tanrei-comp-bottom-sheet-drag-handle-hovered-grip-color — #4b4b4b
--tanrei-comp-bottom-sheet-drag-handle-hovered-state-layer-color — #848484
--tanrei-comp-bottom-sheet-drag-handle-pressed-container-color — #848484 · 10%
--tanrei-comp-bottom-sheet-drag-handle-pressed-grip-color — #4b4b4b
--tanrei-comp-bottom-sheet-drag-handle-pressed-state-layer-color — #848484
--tanrei-comp-bottom-sheet-drag-handle-state-layer-color comp.bottom-sheet.drag-handle.grip.color #848484
--tanrei-comp-bottom-sheet-focused-opacity — 1
--tanrei-comp-bottom-sheet-handle-container-height comp.handle.container.breadth.medium 24px
--tanrei-comp-bottom-sheet-handle-container-shape comp.handle.container.shape 9999px
--tanrei-comp-bottom-sheet-handle-container-width comp.handle.container.length.medium 48px
--tanrei-comp-bottom-sheet-handle-grip-height comp.handle.line.thickness.medium 4px
--tanrei-comp-bottom-sheet-handle-grip-width comp.handle.line.length.medium 32px
--tanrei-comp-bottom-sheet-handle-margin-block sys.space.space150 12px
--tanrei-comp-bottom-sheet-header-gap sys.space.space100 8px
--tanrei-comp-bottom-sheet-header-height — 64px
--tanrei-comp-bottom-sheet-header-padding-inline-end sys.space.space150 12px
--tanrei-comp-bottom-sheet-headline-color sys.color.on-surface #1b1b1b
--tanrei-comp-bottom-sheet-hovered-opacity — 1
--tanrei-comp-bottom-sheet-motion-enter-duration sys.motion.role.appear.sheet.enter 400ms
--tanrei-comp-bottom-sheet-motion-enter-easing sys.motion.easing.enter cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-bottom-sheet-motion-exit-duration sys.motion.role.appear.sheet.exit 350ms
--tanrei-comp-bottom-sheet-motion-exit-easing sys.motion.easing.exit cubic-bezier(0.3, 0, 1, 1)
--tanrei-comp-bottom-sheet-motion-feedback-duration comp.handle.motion.feedback-duration 450ms
--tanrei-comp-bottom-sheet-motion-state-duration comp.handle.motion.state-duration 200ms
--tanrei-comp-bottom-sheet-motion-state-easing comp.handle.motion.state-easing cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-bottom-sheet-pressed-opacity — 1
--tanrei-comp-bottom-sheet-scrim-blur comp.modal-layer.scrim.blur 0px
--tanrei-comp-bottom-sheet-scrim-color comp.modal-layer.scrim.color #000000 · 40%
--tanrei-comp-bottom-sheet-headline-typescale sys.typescale.heading-5 500 16px/1.5

测试样例

作为基准的渲染结果。所有平台都以相同的 ID 为每个样例截取快照,以便相互比较。

生成自 specs/bottom-sheet.spec.json, tokens/comp/bottom-sheet.tokens.json, design/tokens/

指南

何时使用

在 compact 屏幕上,内容旁边没有空间时,用底部面板来放置属于这个屏幕、但不是其主要对象的东西:地图上某一点附近的地点、分享照片的各种方式、一个简短的选择或表单。面板从离拇指最近的那条边升起。

在宽窗口中,同类内容请使用侧边面板,它可以位于内容旁边,而不是盖在内容之上。任务缺了它就无法继续的决定,使用对话框。产品的各个目的地使用导航抽屉,而不是底部面板。

从主操作引出多个操作。 当屏幕的主操作有几种相关的形式时(新建文档、文件夹或上传),主操作按钮会打开一个模态底部面板,把它们列出来。在 Tanrei 系统中,它取代了 FAB 菜单;Tanrei 系统不提供 FAB 菜单。

从主操作引出多个操作:用按钮打开面板

add文档文件夹上传文件

模态还是标准

形态 它是什么 用于
模态(默认) 模态浮层。面板向上滑入,下面是一层让整个窗口变暗的遮罩。它之外的一切都是惰性(inert)的,焦点留在里面,直到它关闭。 用户完成或关闭之后才回去的一步:分享、一个选择、一个简短的表单。
标准 布局区域。面板盖住父元素的底部,没有遮罩,它上方的内容照常可用。Tab 在面板和内容之间移动,焦点离开面板从不会把它关闭。 用户要与屏幕配合使用的内容,例如盖在地图上的地点列表。

两种形态读取相同的令牌:区别只在遮罩和行为,所以设计系统不会给它们不同的外观。

布局区域包括标准导航抽屉(包括它的收起形态)、应用栏和标准侧边面板,它们都位于内容的旁边或上方。标准底部面板则盖住父元素的一部分,但它同样是布局区域,因为起决定作用的是行为:它不是模态的,不绘制遮罩,打开期间内容照常可用。如果面板在焦点离开时就关闭,用户就无法一边用键盘操作它,一边操作它所说明的地图。

标准面板不会移动它所覆盖的内容,也不会为这些内容添加内边距。如果你的滚动内容的末尾必须在面板上方保持可见,请给这些内容加上相当于面板 partial 高度的内边距。

高度

detent 有两个值。取 partial 时,面板占它所覆盖区域的一部分,使用默认令牌时是一半。取 full 时,它向上延伸到距区域顶部一小段边距的位置。对模态面板来说,这个区域是窗口顶部边衬区以下的部分;对标准面板来说,是父元素。屏幕键盘显示期间,这个区域到键盘为止,所以面板坐落在键盘之上。

面板的高度不会超过它的内容。如果内容在 partial 高度之内就放得下,面板只有一个高度,detent 不起任何作用。当内容更高时,面板有两个高度,用户通过拖动或使用拖动手柄在两者之间切换。每次这样的切换都会触发 detentChange 并带上新的高度。在 Web、Compose、Flutter 和 React Native 上,面板保持用户选择的高度,直到你再次设置 detent。在 SwiftUI 上,它把高度写入 detent 绑定。你自己设置 detent 时,面板会移动,但不触发任何事件。

拖动手柄与手势

拖动手柄是面板顶部的一根短条,它所在的那一行在内容滚动时留在原位。handle 开启时,每个面板上都会绘制它。

  • 有地方可去时,它是一个控件。 当面板有两个高度时,手柄是一个以 handleLabel 命名的按钮,在 full 高度时处于展开状态。按下它,或按 Enter 或 Space,面板就移到另一个高度。当面板只有一个高度时,手柄以静止的样子绘制,表示面板可以拖动,但它无法获得焦点,也无法按下,辅助技术看不到它。
  • 它的目标。 手柄所在的整行都是它的点击区域,在每个方向上都不小于最小目标,尽管短条本身很小。
  • 拖动。 用户可以抓住手柄所在的行、内容周围的内边距或内容本身,上下拖动面板。手指或指针移动超过平台的触摸容差(touch slop;平台没有规定时为 8 点)之后,面板开始跟随,并且从不升到 full 高度之上。
  • 松开。 松开时,如果是快速滑动,面板停在滑动方向上的下一个高度;如果松开得较慢,则停在最近的高度。快速滑动指松开时的速度高于平台的最小快速滑动速度;平台没有规定时为每毫秒 0.5 点。
  • 拖动以关闭。 在低于最低高度一半的位置松开,或从最低高度向下快速滑动,会在 dismissible 为 true 时请求面板以原因 drag 关闭。面板会在拖动留下的位置停留一帧;如果你把 open 设为 false,它就滑出,否则回落到原位。当 dismissible 为 false 时,它停在最低的高度。
  • 会滚动的内容。 在内容上向上拖动,先把面板升到 full 高度,然后滚动内容。向下拖动,先把内容滚回顶部,然后降低面板。如果平台无法把同一个手势从面板交给内容的滚动,后一段就需要第二次拖动(见下文“已知的平台差距”)。无论面板处于哪个高度,键盘和辅助技术都能滚动内容。

拖动从来不是唯一的方式。手柄只需按一次,或通过键盘,就能改变高度;模态面板还可以通过遮罩、Escape 或返回操作关闭。标准面板,以及 dismissible 为 false 的模态面板,没有这样的第二种关闭方式,所以请给它一个能把它关闭的按钮。

打开、焦点与关闭

  • 打开。 面板从底边之下向上滑入。模态面板同时让覆盖整个窗口的遮罩淡入。如果面板显示时 open 已经为 true,它直接出现在原位,不带动效。此时标准面板不移动焦点。模态面板则像刚刚打开一样把焦点移进来,因为它背后的任何东西都已无法获得焦点。
  • 焦点移入。 两种形态在打开时都把焦点移到你标记的元素上,否则移到面板本身。焦点在面板打开时移动,而不是在动效结束时。
  • 焦点限制在面板之内,仅限模态。 在模态面板中,在最后一个可聚焦的元素上按 Tab 会回到第一个,在第一个上按 Shift+Tab 会到最后一个。屏幕阅读器的导航留在面板之内,背后的内容不滚动。标准面板什么也不限制:Tab 会继续移到内容上,面板保持打开。
  • 关闭。 如果面板关闭时焦点在它里面,焦点就交还给面板打开时持有焦点的那个元素。
  • 叠放。 从模态面板中打开的对话框、菜单或另一个模态面板叠在它上面。Escape 只关闭最上面的那个,该浮层交还的焦点回到面板。
  • 动效。 在减弱动态效果时,面板在原位淡入淡出,不滑动。Android 的这项设置会移除所有动画,所以在那里它立即出现、立即消失。

推荐

  • 让面板的任务一目了然:在内容顶部放一个标题;如果面板没有其他关闭方式,再放一个“完成”或“关闭”按钮。
  • 对于行自带内边距的列表,关闭 inset,让各行延伸到边缘。
  • 只要面板的内容可能高过 partial 高度,就保留手柄,让使用键盘和开关控制的用户也能改变它的高度。
  • 如果用户在面板打开期间需要用到它上方的内容,使用标准形态。

避免

  • 不要把 dismissible 设为 false,除非用户必须用面板自己的某个控件完成面板的任务。
  • 不要用模态面板来做必须打断用户的决定。请使用对话框。
  • 不要把产品的导航放进底部面板。请使用导航抽屉或导航栏。
  • 不要为分步骤的任务把面板叠在面板上。改为更换同一个面板的内容。

无障碍

角色
dialog
标注
The sheet is named by label, or by its headline where label is empty: a modal dialog while variant is modal, a region while it is standard. The headline is a heading; the close button is named by closeLabel; the divider is decorative. The drag handle, while the sheet has two heights, is a button named by handleLabel with the expanded state (true at full height); at one height it is hidden. The grip is decorative.
最小目标
即使外观尺寸更小,点击区域也满足最小目标尺寸。 minimum 32px · pointer 44px · spacing 8px · touch 48px

键盘操作

按键 动作
Tab / Shift+Tab Moves focus through the drag handle (while it is interactive), the close button (while closable is true) and the content's focusable elements, in that order. In an open modal sheet it wraps and never leaves the sheet; from a standard sheet it moves on to the content, and the sheet stays open.
Enter / Space On the drag handle, moves the sheet to its other height (partial or full). On the close button, asks the sheet to close (close, reason close).
Escape Asks an open modal sheet to close (close, reason escape) while dismissible is true; with stacked overlays, only the topmost. A standard sheet ignores it.

备注

  • Dragging is never the only way (WCAG 2.5.7): the drag handle changes the height with a single press or from the keyboard, and a modal sheet closes with Escape, back or a press on the scrim. A standard sheet, and a modal one whose dismissible is false, closes only by a drag, its close button or the product: set closable, or put a Close or Done button in its content.
  • The standard form is a layout region, although it covers the bottom of the content rather than pushing it aside: the person works in the content and in the sheet at once, so focus leaving it never closes it. The modal form is a modal overlay.
  • Web: the modal form is a native <dialog> opened with showModal() inside the shadow root, so the rest of the page is inert and the sheet is in the top layer; ::backdrop draws the scrim. Its cancel event (Escape) is cancelled and becomes close with the reason escape; the product then sets open to false. The standard form is a region (a <section> named by label) positioned at the bottom of its containing block. The `autofocus` attribute on a slotted element marks initial focus.
  • Where a platform presents the modal sheet in a window of its own (Compose's Dialog, React Native's Modal), the window's own dim and transition are switched off and Tanrei draws the scrim and the motion, so every platform draws the same color.
  • The sheet does not move or pad the content a standard sheet covers; a product whose content must stay visible above the sheet pads its scrolling content by the sheet's partial height.
  • How a state looks is a value decision of the active design system; components read the state's tokens and never compute the effect themselves.

生成自 specs/bottom-sheet.spec.json, tokens/sys/measure.tokens.json

组件负责的部分

  • 模态面板以模态对话框的身份提供给辅助技术,以 label 命名,label 为空时以 headline 命名。标准面板是一个以同样方式命名的区域(region),在 Web 上是一个地标。
  • 标题是一个标题元素。关闭按钮是一个以 closeLabel 命名的 Tanrei 纯图标按钮,最小目标、焦点环和状态都是按钮自己的;在两种形态中,无论 dismissible 是什么,它都以原因 close 请求面板关闭。页眉下方的分隔线是装饰性的。
  • 模态面板打开期间,它之外的一切都是惰性的,并对辅助技术隐藏,焦点和屏幕阅读器的导航都留在它里面。标准面板不让任何东西变为惰性。
  • 打开时,焦点移到你标记的元素上,否则移到面板。关闭时,焦点交还给面板打开时持有焦点的那个元素。两种形态都是如此。
  • 当 dismissible 为 true 时,Escape、平台的返回操作以及在遮罩上的按下会请求模态面板关闭。标准面板忽略 Escape 和返回操作。
  • 拖动手柄就是一个调整手柄,始终会绘制,并使用面板自己的令牌。当面板有两个高度时,它是一个可聚焦的按钮,以 handleLabel 命名,在 full 高度时为展开状态。Enter 和 Space 把面板移到另一个高度。只有一个高度时,手柄对辅助技术隐藏。短条本身始终隐藏。
  • 手柄的点击区域不小于最小目标,它的焦点环只在键盘聚焦时显示。
  • 内容在面板内滚动,无论面板处于哪个高度,键盘焦点和辅助技术都能滚动它。
  • 文本变大时,面板随内容一起变高,最高到它的 detent 允许的高度,超出的内容可以滚动。手柄不随文本缩放。
  • 面板的颜色延伸到底部的系统边衬区之下,它的内容则保持在该边衬区之上,也保持在屏幕键盘之上。
  • 调试构建会报告既没有 label 也没有 headline 的面板、没有 handleLabel 的可交互手柄、没有 closeLabel 的关闭按钮、没有手柄也没有关闭按钮却仍可被关闭的面板(屏幕上没有任何东西说明怎么关闭),以及未知的 variant、detent 或 dividerVisibility。

已知的平台差距:

  • 用鼠标在内容上拖动,会像触摸一样移动面板,所以只要面板还能移动,就无法通过向下拖动来选中面板中的文本。
  • SwiftUI、Compose、Flutter 和 React Native 还不绘制页眉:headline、closable、closeLabel、dividerVisibility 以及原因 close 目前只在 Web 上提供。
  • Web:在触摸时,由浏览器在手势开始时决定谁来接收它,所以面板与内容滚动之间的交接需要第二个手势。鼠标和触控笔在一次拖动之内就能完成交接。触摸拖动尚未被测试覆盖。在 Firefox 中,面板立即离开,而不是滑出,与对话框的情况相同。
  • SwiftUI 既没有对话框角色,也没有区域角色:面板是一个以 label 命名的容器,处于模态时带有模态特征。手柄没有展开状态,所以它的值会读作“expanded”或“collapsed”,而且只有英语。没有任何标记时,SwiftUI 无法把焦点移到面板本身;面板关闭时,键盘焦点不会交还给任何元素;在 iOS 上,模态覆盖会把 VoiceOver 焦点交还。在 macOS 上,模态面板只覆盖修饰符所附着的视图,所以请把它附着在窗口的根视图上。
  • iOS 上的 SwiftUI:面板低于 full 高度时,在内容上拖动会升起面板,下一次拖动才滚动内容;在那个高度内容不会滚动,对 VoiceOver 和键盘也是如此。内容中的纯文本采用正文字体,但不带它的行高,所以两行文本高 40 点,而其他平台绘制为 44 点。
  • SwiftUI:底部边衬区和屏幕键盘、硬件键盘、Escape 以及 iOS 的“退出”手势,都还没有在设备上运行过,也没有通过实际按键测试过。
  • Compose:面板自己负责滚动内容,所以请给它一个普通的列(column),而不是懒加载列表(lazy list)。模态面板关闭的过程中,它的窗口一直持有焦点,直到退出动效结束。它的拖动只在 Robolectric 下验证过,没有在设备上验证。
  • Flutter:标准面板是一个有名称的容器,而不是区域地标,因为 Flutter 3.32 没有这样的角色。面板尚未在设备上用 TalkBack 和 VoiceOver 检查过。
  • React Native 无法得知先前是哪个元素持有焦点,所以面板关闭时,焦点落在平台安排的位置,而不是回到打开它的元素,与对话框和导航抽屉的情况相同。没有 initialFocusRef 时,打开面板只会移动屏幕阅读器的焦点,移到面板上。
  • React Native 无法在手势中途把它从面板交给内容的滚动,所以那次拖动的剩余部分会丢失,下一次拖动才滚动内容。Modal、拖动、键盘避让和边衬区都只在 Jest 中验证过,从未在设备上验证。
  • 在 cupertino 下,短条用 Apple 的浅色轮廓颜色绘制,它相对于面板的对比度在浅色方案中是 1.8:1,在深色方案中是 2.2:1,低于控件边界所需的 3:1。中对比度和高对比度方案把它提高到 4.5:1 或更高。未达标的那两个数值是作为参考信息保留的测量值:Tanrei 的无障碍准则不适用于模仿预设。
  • 在 m3-like 下,处于 full 高度的面板保留默认系统的顶部边距:要等保真度参考覆盖到底部面板之后,才会采用 Material 的值。

需要你做的事

  • 为每个面板提供 label 或 headline,例如“保存照片”。它是对话框或区域的名称。
  • 为手柄提供 handleLabel,例如“调整面板高度”,使用你的产品的语言。
  • 自己关闭面板:在响应 close 时(Web 上的 close 事件,Compose、Flutter 和 React Native 上的 onClose)把 open 设为 false,并在你自己的“完成”或“关闭”按钮中这样做。在 SwiftUI 上,面板会替你把 false 写入绑定。
  • 为标准面板,以及 dismissible 为 false 的模态面板,提供一个看得见的、能把它关闭的按钮:面板自己的关闭按钮(closable,配上 closeLabel)或你自己的按钮。拖动不能是唯一的出路。
  • 在有帮助的地方标记初始焦点,例如简短表单的第一个输入框。
  • 在 React Native 上,传入 initialFocusRef;面板关闭后,自己把焦点移回打开它的元素;并把窗口的边衬区传给面板的 insets 或 TanreiProvider。