- Web
tanrei-button-with-menuschedule - SwiftUI
TanreiButtonWithMenuradio_button_unchecked - Jetpack Compose
TanreiButtonWithMenuradio_button_unchecked - Flutter
TanreiButtonWithMenuradio_button_unchecked - React Native
ButtonWithMenuradio_button_unchecked
生成自 docs/status/status.json, specs/button-with-menu.spec.json
拆分按钮的去向
这个组件不叫拆分按钮(split button)。“拆分按钮”说的是它的样子,即一个被切成两半的按钮,而连接式的按钮组也是这个样子;这个组件真正的不同之处,在于它的后半边会打开菜单。它的行为 ID 带有这个名称的 SPB 前缀。
| 之前 | 现在 |
|---|---|
拆分按钮(split-button) |
带菜单的按钮(button-with-menu) |
comp.split-button.*、--tanrei-comp-split-button-* |
comp.button-with-menu.*、--tanrei-comp-button-with-menu-* |
<tanrei-split-button>、TanreiSplitButton(Web 的类) |
<tanrei-button-with-menu>、TanreiButtonWithMenu |
SplitButton(React、React Native) |
ButtonWithMenu |
TanreiSplitButton(SwiftUI、Compose、Flutter) |
TanreiButtonWithMenu |
Material 3 把对应的组件称为 split button;在 m3-like 预设中,带菜单的按钮就是那个组件。
原生平台尚未改名
规格、令牌、Web 元素(<tanrei-button-with-menu>)和 React 包装组件(ButtonWithMenu)已经使用“带菜单的按钮”这个名称,Web 绘制的也是这一版。SwiftUI、Compose、Flutter 和 React Native 提供的仍是名为拆分按钮的上一版,所以本页中它们的代码使用 TanreiSplitButton 和 SplitButton;各平台为组件改名时,这些代码会随之更新。
这一版带来了以下几点,目前只有 Web 这样绘制:
- 两个半边以一条分隔线或一条间隙连接。设计系统为所有组件统一选择一次,设计系统也可以让这个组件单独不同,而
separation(auto、divider、gap)则设置某一个实例。默认系统画分隔线。 - 前半边在图标一侧的内边距小一级,两个圆形的外端则各多一点内边距。
- 菜单打开时,后半边保持自己的形状;只有箭头表明打开状态。
trailingWidth(default、narrow)画出更窄的后半边:箭头加上两侧各一小段留白,圆形的外端和默认宽度一样再多一点内边距。
变体
带有菜单:请按下后半边
带菜单的按钮是连接成一个形状的两个按钮。前半边执行主操作。后半边带有箭头(chevron),打开一个列出相关操作的菜单。菜单的内容就是菜单自己的各种项(操作、复选项、单选项和分组),传入的方式与各平台传入菜单项的方式相同。
<tanrei-button-with-menu label="保存" menu-label="更多保存选项">
<span slot="icon">save</span>
<tanrei-menu-item slot="menu" label="另存为…"></tanrei-menu-item>
<tanrei-menu-item slot="menu" label="保存副本"></tanrei-menu-item>
</tanrei-button-with-menu>
<script type="module">
import '@tanrei/web/components/button-with-menu';
import '@tanrei/web/components/menu';
const split = document.querySelector('tanrei-button-with-menu');
// 只有前半边的 click 会到达这个元素。
split.addEventListener('click', save);
// 后半边只是请求打开或关闭;open 由你来设置。
split.addEventListener('toggle', (e) => (split.open = e.detail.open));
// close 带有菜单关闭的原因:escape、outside、tab、focus-out 或 item(Web 没有 back)。
split.addEventListener('close', () => (split.open = false));
// 菜单项的 click 止于带菜单的按钮:请在菜单项本身上监听。
split.querySelector('[label="另存为…"]').addEventListener('click', saveAs);
</script>
<!-- 单独让这个实例以间隙连接,并使用窄的后半边。省略 separation 则遵循设计系统。 -->
<tanrei-button-with-menu variant="tonal" separation="gap" trailing-width="narrow" label="合并" menu-label="更多合并选项">
<tanrei-menu-item slot="menu" label="压缩并合并"></tanrei-menu-item>
<tanrei-menu-item slot="menu" label="变基并合并"></tanrei-menu-item>
</tanrei-button-with-menu>
<!-- 除了按下,悬停 400 ms 后也会打开;并使用自己的图标,关闭时和打开时各一个。 -->
<tanrei-button-with-menu label="分享" menu-label="更多分享选项" hover-open hover-delay="400">
<span slot="chevron">more_vert</span>
<span slot="chevron-open">close</span>
<tanrei-menu-item slot="menu" label="复制链接"></tanrei-menu-item>
<tanrei-menu-item slot="menu" label="邮件"></tanrei-menu-item>
</tanrei-button-with-menu>import { ButtonWithMenu, MenuItem } from '@tanrei/react';
// onClick 属于前半边。MenuItem 的 onClick 是注册在菜单项元素上的监听器,
// 所以在 ButtonWithMenu 内部也会触发;它收到的是原生的 MouseEvent。
<ButtonWithMenu
label="保存"
menuLabel="更多保存选项"
open={open}
onClick={save}
onToggle={(e) => setOpen(e.detail.open)}
onClose={() => setOpen(false)}
>
<MenuItem slot="menu" label="另存为…" onClick={saveAs} />
<MenuItem slot="menu" label="保存副本" onClick={saveCopy} />
</ButtonWithMenu>
// 单独让这个实例以间隙连接,并使用窄的后半边。
<ButtonWithMenu variant="tonal" separation="gap" trailingWidth="narrow" label="合并" menuLabel="更多合并选项"
open={mergeOpen} onClick={merge} onToggle={(e) => setMergeOpen(e.detail.open)} onClose={() => setMergeOpen(false)}>
<MenuItem slot="menu" label="压缩并合并" onClick={squash} />
<MenuItem slot="menu" label="变基并合并" onClick={rebase} />
</ButtonWithMenu>
// 悬停时也会打开;hoverDelay 为 null 时采用系统的延迟。
<ButtonWithMenu label="分享" menuLabel="更多分享选项" hoverOpen hoverDelay={400} open={shareOpen}
onToggle={(e) => setShareOpen(e.detail.open)} onClose={() => setShareOpen(false)}>
<span slot="chevron">more_vert</span>
<span slot="chevron-open">close</span>
<MenuItem slot="menu" label="复制链接" onClick={copyLink} />
</ButtonWithMenu>import Tanrei
// 和 Button 一样,标题在前。后半边和菜单都会写入这个绑定。
TanreiSplitButton("保存", menuLabel: "更多保存选项", isMenuPresented: $showsSaveOptions, action: save) {
TanreiMenuItem("另存为…", action: saveAs)
TanreiMenuItem("保存副本", action: saveCopy)
}
// 悬停时也会打开(macOS,以及使用指针的 iPadOS);hoverDelay 为 nil 时采用系统的延迟。
// chevron: 和 openChevron: 各接收一个图标构建器,分别对应 chevron 和 chevron-open 插槽。
TanreiSplitButton("分享", menuLabel: "更多分享选项", isMenuPresented: $showsShareOptions,
opensOnHover: true, hoverDelay: .milliseconds(400), action: share) {
TanreiMenuItem("复制链接", action: copyLink)
}import io.github.chnotchy.tanrei.components.TanreiMenuItem
import io.github.chnotchy.tanrei.components.TanreiSplitButton
import io.github.chnotchy.tanrei.primitives.TanreiText
import io.github.chnotchy.tanrei.tokens.TanreiSplitButtonVariant
// 和菜单一样采用状态提升:menuExpanded 由你保存。
TanreiSplitButton(
text = "保存",
onClick = ::save,
menuContentDescription = "更多保存选项",
menuExpanded = expanded,
onMenuExpandedChange = { expanded = it },
onMenuDismissRequest = { expanded = false }, // 收到一个 TanreiMenuCloseReason
variant = TanreiSplitButtonVariant.Tonal,
icon = { TanreiText("save") },
) {
TanreiMenuItem(text = "另存为…", onClick = ::saveAs)
TanreiMenuItem(text = "保存副本", onClick = ::saveCopy)
}
// 鼠标或触控笔悬停时也会打开;hoverDelayMillis 为 null 时采用系统的延迟。
TanreiSplitButton(
text = "分享",
onClick = ::share,
menuContentDescription = "更多分享选项",
menuExpanded = shareExpanded,
onMenuExpandedChange = { shareExpanded = it }, // 按下、按键或悬停
onMenuDismissRequest = { shareExpanded = false },
chevron = { TanreiText("more_vert") },
openChevron = { TanreiText("close") },
openOnHover = true,
hoverDelayMillis = 400,
) {
TanreiMenuItem(text = "复制链接", onClick = ::copyLink)
}import 'package:tanrei/tanrei.dart';
// 带菜单的按钮通过控制器打开和关闭菜单;onMenuToggle 和 onMenuClose 报告这一变化。
TanreiSplitButton(
label: '保存',
menuSemanticLabel: '更多保存选项',
onPressed: save, // onPressed 为 null 时两个半边都被禁用
menuController: saveOptions,
menuChildren: <Widget>[
TanreiMenuItem(onPressed: saveAs, child: const Text('另存为…')),
TanreiMenuItem(onPressed: saveCopy, child: const Text('保存副本')),
],
);
// 悬停时也会打开,触摸时绝不会;chevron 和 openChevron 各接收一个图标 widget。
TanreiSplitButton(
label: '分享',
menuSemanticLabel: '更多分享选项',
onPressed: share,
openOnHover: true,
hoverDelay: const Duration(milliseconds: 400), // 为 null 时采用系统的延迟
menuChildren: <Widget>[
TanreiMenuItem(onPressed: copyLink, child: const Text('复制链接')),
],
);import { SplitButton } from '@tanrei/react-native';
// 受控:在 onMenuToggle 中设置 menuVisible,在 onMenuDismiss 中把它设回 false;后者会收到关闭的原因。
<SplitButton
label="保存"
icon="save"
menuAccessibilityLabel="更多保存选项"
menuVisible={open}
onPress={save}
onMenuToggle={setOpen}
onMenuDismiss={() => setOpen(false)}
menuItems={[
{ label: '另存为…', onPress: saveAs },
{ label: '保存副本', onPress: saveCopy },
]}
/>
// 悬停时也会打开,仅限指针;chevron 和 openChevron 是图标的名称。
<SplitButton
label="分享"
menuAccessibilityLabel="更多分享选项"
chevron="more_vert"
openChevron="close"
hoverOpen
hoverDelay={400}
menuVisible={shareOpen}
onPress={share}
onMenuToggle={setShareOpen}
onMenuDismiss={() => setShareOpen(false)}
menuItems={[{ label: '复制链接', onPress: copyLink }]}
/>两个半边用两种方式之一连接。用分隔线时,它们彼此相接,相接处的圆角是直角,接缝上有一条线。用间隙时,它们之间隔着一条窄间隙,彼此相对的圆角收得更小。设计系统为所有连接部件的组件统一选择一次(默认系统画分隔线),设计系统也可以让这个组件单独不同,而 separation(auto、divider、gap)则设置某一个实例。两个半边总是并排放置:没有纵向的形态。
以分隔线和以间隙连接
默认情况下,后半边把箭头放在一个边长等于按钮高度的正方形里,圆形的外端再多一点内边距。把 trailingWidth 设为 narrow 后,它就是附在主操作上的一个细长把手:箭头两侧各有一小段留白,圆形的外端也和默认宽度一样多一点内边距,让箭头相对弧线看起来居中(默认系统中为 32 / 36 / 36,即同尺寸 narrow 纯图标按钮的宽度再加上这段内边距)。无论取哪种宽度,前半边都一样。
后半边的宽度:default 和 narrow,各种尺寸
窄的后半边画出来比最小目标窄,但点击区域并不窄:目标向外延伸,越过末尾侧的边缘。请在那里为它留出空间,就像在 narrow 纯图标 Button 旁边一样。
如果带菜单的按钮的主操作会删除或丢弃一些可能无法找回的东西(删除,菜单里还有“删除所有版本”),就给它设置 destructive。两个半边都保持其变体的强调程度,并在其中使用错误色,就像同一变体的破坏性按钮一样。Filled 用错误色填充,Tonal 用错误容器色填充;Elevated 和 Outlined 用错误色绘制标签和箭头,Outlined 的边框和分隔线也用这个颜色。禁用时,它与同一变体的任何禁用的带菜单的按钮看起来一样。
destructive 只标记两个半边,从不标记菜单项。菜单中的某个选项可能会删除主操作不删除的东西,反之亦然,所以请给每个会删除东西的菜单项单独设置 destructive。
破坏性的带菜单的按钮:各个变体,以及以间隙连接的
尺寸、图标、打开和禁用
构成
属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
variant | filled | tonal | elevated | outlined | "filled" | Emphasis, as Button's variants of the same names. A design system may add values. |
destructive | boolean | false | Marks a main action that deletes or discards something a person may not get back (Delete, with Delete all in the menu): both halves keep their variant's emphasis and take the error role's color in it, as a destructive Button of the same variant does (SPB-34). Use it for that kind of action alone, never for emphasis. It adds a color, not a meaning: the label still says what the action does (SPB-35). The menu's items are not marked by it: an item that deletes carries its own destructive. |
size | small | medium | large | "medium" | Visual height (32 / 40 / 56), matching Button's heights. The hit area always meets the minimum target. A design system may add values. |
separation | auto | divider | gap | "auto" | How the two halves are joined. divider: they touch, and a line is drawn on the seam. gap: they stand a narrow gap apart, with tighter corners where they face each other. Auto takes comp.button-with-menu.container.separation, the design system's answer (divider in the default system). The halves always sit side by side: there is no vertical form. |
trailingWidth | default | narrow | "default" | How wide the trailing half is. default holds the chevron in a square of the height (32 / 40 / 56); narrow makes the half a slim handle on the main action: the chevron with a small inset on each side, and the padding at the rounded outer end (32 / 36 / 36), as the default width takes it too. The hit area meets the minimum target at both. |
label | string | "" | The leading half's visible text and accessible name ("Save"). Required. |
menuLabel | string | "" | The trailing half's accessible name ("More save options"). Required: the chevron alone is not a name. |
open | boolean | false | Whether the menu is shown. Where open is a plain value (web, Compose, React Native) the product sets it from toggle and sets it to false from close; where it is a binding or a controller (SwiftUI, Flutter) the button with menu opens and closes the menu through it (SPB-2, SPB-16). |
disabled | boolean | false | Prevents interaction with both halves and removes them from the focus order. |
hoverOpen | boolean | false | Also opens the menu when a fine pointer rests on the trailing half for hoverDelay, and closes that menu when the pointer leaves the trailing half and the menu (SPB-22 to SPB-25). A press and the keyboard still open it as without. Never applies to touch. |
hoverDelay | number | null | How long, in milliseconds, a fine pointer rests on the trailing half before the menu opens on hover. Null takes comp.button-with-menu.motion.hover-open-delay. Ignored unless hoverOpen is true. |
插槽
| 插槽 | 说明 |
|---|---|
icon | The leading half's icon (a glyph of the system's icon set). |
chevron | The trailing half's glyph in place of expand_more (one glyph of the system's icon set). Alone, it is turned half a turn while the menu is open, as the default is; with chevron-open it is the closed form only (SPB-21). |
chevron-open | The trailing half's glyph while the menu is open (one glyph of the system's icon set). When it is filled, the trailing half cross-fades between the closed glyph and this one instead of turning (SPB-21). |
menu | The menu's content: the menu's own item kinds (item, checkbox-item, radio-item and group, as the menu spec defines them), which the button with menu presents in a Tanrei Menu. Fixtures cannot fill it (SPB-15). |
事件
| 事件 | 说明 |
|---|---|
click | The leading half was activated by pointer, touch, keyboard or assistive technology. |
toggle | The trailing half asks to open or close the menu: by a press or a key on it (SPB-2), or, with hoverOpen on, by a fine pointer resting on it or leaving it and the menu (SPB-22, SPB-23). Carries the open value it asks for. |
close | The menu asks to be closed; carries the menu's reason: escape, back, outside, tab, focus-out, item or anchor-hidden (in the platform's casing); anchor-hidden when the button with menu is no longer drawn while its menu is open (MNU-65). |
状态
行为
以 EARS 形式写成的需求。每个平台的实现都按这些 ID 逐条测试。需求原文取自规格文件,原样以英文呈现。
| ID | 需求 |
|---|---|
SPB-1 | WHEN the user activates the leading half by pointer, touch, Enter or Space THE button with menu SHALL emit click exactly once, and SHALL NOT open or close the menu. |
SPB-2 | WHEN the user activates the trailing half by pointer, touch, Enter or Space THE button with menu SHALL emit toggle exactly once with the opposite of open, and WHEN the user presses Down Arrow on the trailing half while open is false it SHALL emit toggle with true; the menu SHALL then open or close through open: through its binding or controller where open is one (SwiftUI, Flutter), and by the product setting open where open is a plain value (web, Compose, React Native). A menu that is open from hover is the exception: activating the trailing half then keeps it open and emits no toggle (SPB-24). |
SPB-3 | WHILE disabled is true THE button with menu SHALL NOT emit click, toggle or close, SHALL NOT make either half focusable, SHALL expose the disabled state on both halves and SHALL present no menu, whatever open says; it SHALL NOT open the menu on hover, whatever hoverOpen says. |
SPB-4 | WHILE a half is hovered, keyboard-focused or pressed THE button with menu SHALL render that half's container, label, icon or chevron, outline, elevation and opacity from the component tokens of its variant in that half's highest-precedence active state, with precedence pressed over focused over hovered, and SHALL keep the other half at its own state (neither half is hovered or pressed while the pointer or the press is on the other); focus gained by pointer or touch SHALL NOT select the focused state. |
SPB-5 | WHEN a half receives keyboard focus THE button with menu SHALL show the focus ring around that half's own shape, without animation; focus gained by pointer or touch SHALL NOT show it. |
SPB-6 | WHERE a half is smaller than the minimum target THE button with menu SHALL extend that half's hit area on both axes to sys.target.touch for touch input and sys.target.pointer for fine pointers without changing its visual size — above and below, and outward past the button with menu's start edge for the leading half and past its end edge for the trailing half — and toward the other half no further than the middle of the gap between them, or the seam where they touch, so that the two never overlap; a narrow trailing half SHALL reach its target the same way and SHALL NOT be drawn wider for it; a platform that cannot tell the input type SHALL use sys.target.touch. |
SPB-7 | WHILE disabled is true THE button with menu SHALL render both halves' containers, label, icon, chevron, outline, elevation and opacity from the disabled component tokens of its variant. A disabled button with menu SHALL draw its closed form (the trailing half's closed shape and the closed chevron: the chevron slot's glyph, or expand_more when that slot is empty, unturned), whatever open says. |
SPB-8 | WHEN variant or size names a value that the active design system does not have THE button with menu SHALL render with the default value's tokens, and WHEN separation or trailingWidth names a value it does not define THE button with menu SHALL render with that prop's default (auto, default); in debug builds it SHALL report the unknown value. |
SPB-9 | WHEN a half of the button with menu is pressed by pointer, touch, Enter or Space THE half 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. |
SPB-10 | WHILE pressed THE half 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; label, icon, chevron, outline, elevation, opacity and shape still come from the pressed tokens. Under none every part stays with the state under the press. The ripple grows over comp.button-with-menu.motion.feedback-duration with comp.button-with-menu.motion.state-easing, fades over comp.button-with-menu.motion.state-duration with comp.button-with-menu.motion.state-easing once the press has ended and press-hold has passed, and appears whole where motion is reduced. |
SPB-11 | THE half SHALL ease its container 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 its 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. |
SPB-12 | THE button with menu SHALL draw its two halves in one row, joined as its separation names (SPB-30): the leading half at least the height of its size (comp.button-with-menu.container.height.medium for medium, and so on) and as wide as its content, holding its icon, when it has one, and then its label, comp.button-with-menu.container.gap.medium apart, padded at the start by comp.button-with-menu.leading.padding-inline-start-with-icon.medium when it has an icon and by comp.button-with-menu.leading.padding-inline-start.medium when it has none, at the end by comp.button-with-menu.leading.padding-inline-end.medium, and above and below by at least comp.button-with-menu.container.padding-block.medium; and the trailing half as tall as the leading half, holding the chevron centred between comp.button-with-menu.trailing.padding-inline.medium on each side WHILE trailingWidth is default and between comp.button-with-menu.trailing.padding-inline-narrow.medium on each side WHILE trailingWidth is narrow (and so on for each size). Each half SHALL then take the outer padding of SPB-32. THE button with menu SHALL NOT stack its halves: it has no vertical form. |
SPB-13 | THE button with menu SHALL round the leading half's start corners and the trailing half's end corners by comp.button-with-menu.container.shape, capped at half that half's height (so that the default system's sys.shape.corner.full draws round ends) and eased by sys.shape.corner-smoothing. WHILE its separation is gap it SHALL round the corners where the halves face each other by comp.button-with-menu.container.inner-shape and, WHILE open is true, the trailing half's by comp.button-with-menu.container.inner-shape-open, each capped and eased the same way; WHILE its separation is divider those corners SHALL be square, open or not. WHILE open is true the chevron SHALL show its open form (SPB-21). WHEN open changes THE button with menu SHALL change the chevron, and the trailing half's inner corners where the two tokens differ, over comp.button-with-menu.motion.open-duration with comp.button-with-menu.motion.open-easing, and at once where motion is reduced. THE button with menu SHALL NOT change the shape of a half for any other reason: where comp.button-with-menu.container.inner-shape-open has the value of comp.button-with-menu.container.inner-shape, as in the default system, the trailing half keeps its shape while the menu is open and the chevron alone shows it. Each half SHALL clip its fill, state layer and ripple to its own shape, and draw its outline and its elevation's ring as SPB-26 says. |
SPB-14 | THE button with menu SHALL set the label in the text style of its size (comp.button-with-menu.label.typescale.medium, and so on) and the label color of its variant and the leading half's state (comp.button-with-menu.filled.label.color for filled at rest, and so on), SHALL NOT truncate it (it stays on one line while it fits, wraps when the layout gives it less width, and the halves grow in height with it), and SHALL draw the icon and the chevron (expand_more, or a chevron slot's glyph, SPB-21) at the icon size of its size (comp.button-with-menu.icon.size.medium, and so on) through the icon helper, each in the label color of its own half's state, with the family and fill of an unselected icon, sys.icon.weight, and an optical size clamped between sys.icon.optical-size.min and sys.icon.optical-size.max. |
SPB-15 | WHEN open becomes true THE button with menu SHALL present the items of its menu slot as a Tanrei Menu, which then behaves as MNU-1 to MNU-29 say, anchored to the whole button with menu with placement bottom-end (below it with the end edges aligned, flipping as MNU-2 says), with the trailing half as the element focus returns to when the menu closes: an operated overlay that is not modal, as the menu is; a menu that opens from hover SHALL NOT take focus (SPB-22). WHEN open becomes false THE button with menu SHALL remove the menu. |
SPB-16 | WHEN the menu asks to be closed (MNU-5, MNU-6) THE button with menu SHALL emit close once with the menu's reason and SHALL be closed: through its binding or controller where open is one (SwiftUI, Flutter), and by the product setting open to false where open is a plain value (web, Compose, React Native); WHEN an action item of the menu is activated THE button with menu SHALL let the item emit its click first and then emit close with the reason item. A press on the trailing half while the menu is open is not a press outside: it SHALL emit toggle with false (SPB-2), or keep a menu that is open from hover open (SPB-24), and emit no close. |
SPB-17 | THE button with menu SHALL expose two buttons in reading order, each its own Tab stop: the leading half named by label, and the trailing half named by menuLabel, exposing that it opens a menu and whether the menu is open where the platform has those properties, however it opened; the icon and the chevron (expand_more or a chevron slot's glyph) SHALL be hidden, and the button with menu SHALL expose no element of its own around them. |
SPB-18 | THE button with menu SHALL cast the blurred layers of each half's elevation, of its variant and that half's state (comp.button-with-menu.elevated.container.elevation for elevated at rest, and so on), outside that half without clipping them, and draw the elevation's ring inside the half (SPB-26); WHEN the elevation or the colors of a half change between states THE button with menu SHALL animate the change over comp.button-with-menu.motion.state-duration with comp.button-with-menu.motion.state-easing, changing them at once where motion is reduced. |
SPB-19 | WHEN the layout direction is right-to-left THE button with menu SHALL put the trailing half on the left, mirror the order of the leading half's icon and label and apply its paddings by logical start and end, SHALL present its menu with the end edges aligned at the left (the menu's left edge on the button with menu's left edge, below the trailing half: SPB-15 and MNU-2), SHALL NOT mirror the chevron expand_more or change the way it turns, SHALL leave a glyph of the icon slot or of a chevron slot to the icon helper (SPB-28), and SHALL grow a ripple from the point of contact as in left-to-right. |
SPB-20 | WHILE label is not empty THE button with menu SHALL expose it as the leading half's accessible name, over a name the product gives it on the element (aria-label or aria-labelledby). WHILE label is empty and the product gives the button with menu a name of its own on the element — aria-label, or aria-labelledby naming an element — THE button with menu SHALL expose that name as the leading half's accessible name. WHILE label is empty and the product gives it none, or menuLabel is empty, THE button with menu SHALL still render, and in debug builds report the half that has no accessible name; WHILE the menu slot holds no item THE button with menu SHALL still emit toggle and draw the trailing half's open form, SHALL present no menu, and in debug builds report the empty menu. |
SPB-21 | WHILE neither the chevron slot nor the chevron-open slot holds a glyph THE button with menu SHALL draw the icon set's expand_more as the chevron, turned half a turn while open is true; WHILE only the chevron slot holds a glyph THE button with menu SHALL draw that glyph in its place, turned the same way; WHILE the chevron-open slot holds a glyph THE button with menu SHALL draw it unturned while open is true and, while open is false, the chevron slot's glyph (or expand_more when that slot is empty) unturned, and WHEN open changes SHALL cross-fade from one to the other instead of turning. Each slot SHALL hold at most one glyph: the button with menu SHALL draw the first and in debug builds report the rest. |
SPB-22 | WHERE hoverOpen is true, WHEN a fine pointer (a mouse, a trackpad, or a pen that hovers) enters the trailing half while open is false and stays on it for hoverDelay milliseconds, or for comp.button-with-menu.motion.hover-open-delay when hoverDelay is null, THE button with menu SHALL emit toggle with true once, and WHEN open then becomes true the menu SHALL be open from hover: THE button with menu SHALL present it without moving focus into it (in place of MNU-1's move to the first item), focus staying where it was, and a pointer on an item SHALL render the item hovered without moving focus to it (in place of MNU-8). The pointer leaving the trailing half before the delay has passed, a press on either half, or open becoming true by other means SHALL cancel the wait. Touch, and pointer events a platform synthesises from touch, SHALL never open the menu on hover. WHERE hoverDelay is negative or not a number THE button with menu SHALL wait for comp.button-with-menu.motion.hover-open-delay, and in debug builds report the value. |
SPB-23 | WHILE the menu is open from hover, WHEN the pointer has been off both the trailing half and the menu's surface for comp.button-with-menu.motion.hover-close-delay THE button with menu SHALL emit toggle with false once and SHALL NOT move focus; a pointer that comes back onto either within that time SHALL keep the menu open, and a press outside both SHALL close it as MNU-5 says. WHILE the menu is open for any other reason (a press, a key, or the product setting open) THE button with menu SHALL NOT close it when the pointer leaves. |
SPB-24 | WHEN the user activates the trailing half by pointer, touch, Enter, Space or Down Arrow while the menu is open from hover THE button with menu SHALL NOT emit toggle, SHALL keep the menu open and SHALL move focus to its first enabled item as MNU-1 says; from then on the menu SHALL be open as if that activation had opened it: the pointer leaving no longer closes it (SPB-23), MNU-8 applies again, and the next activation of the trailing half emits toggle with false (SPB-2). |
SPB-25 | WHILE the menu is open from hover, WHEN the user presses Escape or the platform's back, wherever focus is, THE button with menu SHALL emit close once with the reason escape or back, SHALL be closed as SPB-16 says, SHALL NOT move focus, and SHALL keep that key from reaching anything else, so that what hover showed can be dismissed without moving the pointer (WCAG 1.4.13); a later Escape reaches the page as usual. |
SPB-26 | THE button with menu SHALL draw each half's outline (comp.button-with-menu.outline.width.medium wide at the medium size, and so on, in the outline color of its variant and that half's state) and the ring of its elevation (the layer with no offset, no blur and a positive spread, which the default system gives every level above sys.elevation.level0 and which a platform otherwise draws as a border) inside that half's own edge, the outline over the ring. WHILE its separation is gap neither SHALL reach into the comp.button-with-menu.container.inner-gap between the halves, so that the two halves' edges never meet, however narrow that gap is; WHILE its separation is divider each half SHALL draw neither along the edge where the halves touch, so that the outline and the ring run once around the joined shape and the divider (SPB-31) is the only line on the seam. Only the elevation's blurred layers SHALL fall outside a half (SPB-18). |
SPB-27 | THE button with menu 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 button with menu is shown THE button with menu SHALL draw itself for the new direction at once, without a motion of its own, keeping its open state and its focus, an open menu placed again by the new direction. |
SPB-28 | WHERE the layout direction is right-to-left THE button with menu SHALL draw the glyph of its icon slot and a glyph of its chevron or chevron-open slot mirrored left to right exactly when the system's icon set marks that glyph's name as mirrored or the product forces mirroring for that use, and unmirrored when the product forbids it: the icon helper mirrors the glyph inside its own box, about the box's vertical centre line, changing neither its place, size, color and fill nor the order SPB-19 gives. THE button with menu SHALL NOT mirror a glyph itself, and SHALL NOT mirror content the product gives as an image or a view of its own rather than by a glyph's name. The chevron it draws itself (expand_more) is not marked and is never mirrored, and a mirrored chevron-slot glyph is turned while open as an unmirrored one is (SPB-21). |
SPB-29 | THE button with menu SHALL lay out its label 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 button with menu SHALL still place the label and align its lines by the layout direction, not by the text's own. |
SPB-30 | THE button with menu SHALL join its two halves as its separation names: WHILE separation is auto, as comp.button-with-menu.container.separation names, which follows sys.behavior.separation unless a design system sets it apart; WHILE separation is divider or gap, as that value names, whatever the tokens say. Under gap the halves SHALL stand comp.button-with-menu.container.inner-gap apart with nothing drawn between them and the inner corners of SPB-13; under divider the halves SHALL touch, with square inner corners and the divider of SPB-31 on the seam. WHEN separation, or the token it follows, changes THE button with menu SHALL draw the other form at once, without animation, keeping its open state and its focus. A platform that cannot draw a choice SHALL fall back to the first value, divider, and state that in its README. |
SPB-31 | WHILE its separation is divider THE button with menu SHALL draw on the seam between its halves one line comp.button-with-menu.divider.thickness thick, in the divider color of its variant (comp.button-with-menu.filled.divider.color for filled at rest, and so on), centred on the seam, over the halves' fills and state layers, from comp.button-with-menu.divider.inset below the top of the seam to the same distance above its bottom. The line SHALL take no space of its own, SHALL NOT take a press (a press on it goes to the half under the point), SHALL NOT be exposed to assistive technology, and SHALL keep its resting color while either half is hovered, focused or pressed; WHILE disabled is true it SHALL take the disabled divider color of its variant. A half's state layer, ripple and focus ring SHALL still follow that half's own shape. |
SPB-32 | THE button with menu SHALL add comp.button-with-menu.container.outer-inset to the leading half's start padding, under either separation and at either trailing width, and to the trailing half's end padding, under either separation and at either trailing width, so that content beside a rounded outer end looks as far from it as from the tighter corner on its other side: the leading half SHALL be wider by that amount with its icon and label moved inward by it, and the trailing half SHALL be wider by that amount with its chevron centred in what is left, half of it nearer the leading half. WHILE trailingWidth is narrow THE trailing half SHALL be as wide as the chevron's box, comp.button-with-menu.trailing.padding-inline-narrow.medium on each side of it (of its own size) and the outer padding, with the chevron centred in what is left: a slim half beside a full end needs the compensation as much as a wide one, its chevron being nearer the curve, so it is one outer padding wider than a narrow icon-only Button of its size (BTN-12) where the two components' tokens agree, as in the default system. In a right-to-left layout the start is the right, as SPB-19 says. |
SPB-33 | WHERE the component that places the button with menu keeps one Tab stop among its items and makes each half a stop of its own (a toolbar, TBR-25), through the internal channel a Button reads from the component that places it (BTN-10), THE button with menu SHALL let that component say which half, if either, is its Tab stop and move focus to either half, SHALL take the other half out of the Tab order while it does, and SHALL keep each half's own name, role, state, keys and activation. WHILE no component does so THE button with menu SHALL keep both halves in the Tab order, as SPB-17 says. It adds nothing to the public surface: no prop, attribute or styling property says which half is a stop. |
SPB-34 | WHILE destructive is true THE button with menu SHALL render both halves' containers, label, icon, chevron and outline, and the divider, from the destructive parts of its variant (comp.button-with-menu.filled.destructive.container.color, and so on for each variant), each half in its own state from the destructive parts of that state, and its elevation, shape and opacity as it would without destructive; WHILE destructive is false it SHALL render from the variant's own parts. In every shipped system the destructive parts are those of a destructive Button of the same variant (BTN-40) — filled: the error color with the on-error color on it; tonal: the error container with the on-error-container color on it; elevated and outlined: the variant's own container with the label, the icon and the chevron in the error color, and an outlined half's edge and divider in it too — and disabled they are those of the variant's plain disabled look, so that a disabled destructive button with menu looks like any disabled button with menu of its variant. THE button with menu SHALL NOT pass destructive to the items of its menu: an item is drawn destructive by its own destructive alone (MNU-18), since an option of the menu may delete where the main action does not, and the reverse. |
SPB-35 | THE button with menu SHALL NOT make color the only sign that its main action is destructive: WHILE destructive is true it SHALL keep both halves' roles, accessible names, states and keyboard behaviour exactly as they are without it, and SHALL expose nothing more to assistive technology on the web, where no role or state says it; WHERE the platform has a destructive role of its own (SwiftUI's ButtonRole.destructive) THE button with menu SHALL give it to the leading half, whose press performs the action, and not to the trailing half, which only opens the menu. Its label says what it destroys. |
SPB-36 | 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 button with menu 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 the element that carries its role (the leading half carries them) — an id reference by the elements it names in the host's tree or a tree around it, an error message as part of the description while the button with menu is invalid — except those the button with menu owns (aria-haspopup, aria-expanded, aria-controls, aria-disabled), which it SHALL move and not expose. 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 button with menu SHALL take it off that element. WHEN the product sets an aria- attribute the button with menu owns THE button with menu SHALL report it in debug builds. |
各平台名称对照
对等并不意味着 API 完全相同。同一份契约,由各平台以自己的惯用方式表达。
| 规格 | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
|---|---|---|---|---|---|
| 组件 | tanrei-button-with-menu | TanreiButtonWithMenu | TanreiButtonWithMenu | TanreiButtonWithMenu | ButtonWithMenu |
variant · 属性 | variant | variant | variant | variant | variant |
destructive · 属性 | destructive | role | destructive | isDestructiveAction | destructive |
size · 属性 | size | size | size | size | size |
separation · 属性 | separation | separation | separation | separation | separation |
trailingWidth · 属性 | trailingWidth | trailingWidth | trailingWidth | trailingWidth | trailingWidth |
label · 属性 | label | title | text | label | label |
menuLabel · 属性 | menuLabel | menuLabel | menuContentDescription | menuSemanticLabel | menuAccessibilityLabel |
open · 属性 | open | isMenuPresented | menuExpanded | menuController | menuVisible |
disabled · 属性 | disabled | disabled | enabled | onPressed | disabled |
hoverOpen · 属性 | hoverOpen | opensOnHover | openOnHover | openOnHover | hoverOpen |
hoverDelay · 属性 | hoverDelay | hoverDelay | hoverDelayMillis | hoverDelay | hoverDelay |
click · 事件 | click | action | onClick | onPressed | onPress |
toggle · 事件 | toggle | isMenuPresented | onMenuExpandedChange | onMenuToggle | onMenuToggle |
close · 事件 | close | isMenuPresented | onMenuDismissRequest | onMenuClose | onMenuDismiss |
| 备注 | <tanrei-button-with-menu label="Save" menu-label="More save options" hover-open hover-delay="400"> with optional glyph elements in slot="icon", slot="chevron" and slot="chevron-open" (the trailing half draws expand_more through the icon helper when both chevron slots are empty), and the menu's item elements (tanrei-menu-item, tanrei-menu-checkbox-item, tanrei-menu-radio-item, tanrei-menu-group) as children with slot="menu", which it forwards into a tanrei-menu in its shadow root. Only the leading half's click reaches listeners on the element; the trailing half fires toggle (a CustomEvent whose detail.open is the value asked for, not the popover's ToggleEvent, which does not leave the shadow root), for a press, a key or a hover alike, and the menu's close fires close with detail.reason. The product sets open from both. hoverDelay is a number of milliseconds; unset (null) takes the token. Hover opening listens for pointerenter / pointerleave with pointerType mouse or pen, and opens the menu through the menu's internal no-focus channel (SPB-22); Escape for a menu open from hover is a capturing keydown on the document. Each half draws its outline as an inside border and its elevation ring as an inset box-shadow layer, the blurred layers staying outside (SPB-26). The trailing half carries aria-haspopup="menu", aria-expanded and aria-controls, and is the menu's focus-return target. separation (auto, divider, gap) and trailing-width (default, narrow) are attributes; auto reads the name in --tanrei-comp-button-with-menu-container-separation. Under divider the halves touch, the rule is drawn over the seam, and neither half draws a border or an inset ring on its inner edge. destructive is a boolean attribute; it draws both halves from the variant's destructive parts, exposes nothing more (SPB-35), and is not passed to the menu's items. | TanreiButtonWithMenu(_ title:, icon:, menuLabel:, isMenuPresented: Binding<Bool>, opensOnHover: Bool = false, hoverDelay: Duration? = nil, action:, chevron:, openChevron:) { TanreiMenuItem … }: the title first, as Button(_:action:); chevron and openChevron are optional glyph builders (the chevron and chevron-open slots); the trailing view builder is the menu's content, presented as TanreiMenu is. The trailing half writes the binding (toggle), and so does the menu when it closes (close); a product that needs the close reason reads the menu's. disabled follows .disabled(_:). Hover uses .onContinuousHover (macOS, and iPadOS with a pointer); a nil hoverDelay takes the token. SwiftUI's Menu(primaryAction:) is not used: neither half can be tokenised. Ships the previous revision today (6 px corners, a fixed turning chevron, no hover opening); the rest is owed, including a check of SPB-26 on this platform. Also owed: the component's current name, separation and trailingWidth, the divider (SPB-31), the outer padding (SPB-32), a trailing half that keeps its shape while open (SPB-13), the tighter padding beside the icon, and the new fixtures; reading comp.button-with-menu.container.separation as a typed choice needs the native token emitters to write a component's choice of kind as a typed enumeration, which they do not yet. Not built on this platform yet: role: ButtonRole? = nil, as SwiftUI's own Button(role:) takes it: .destructive is destructive (SPB-34), and the role is given to the leading half alone (SPB-35). | TanreiButtonWithMenu(text, onClick, menuContentDescription, menuExpanded, onMenuExpandedChange, onMenuDismissRequest, variant, size, enabled, icon, chevron, openChevron, openOnHover = false, hoverDelayMillis: Long? = null) { TanreiMenuItem(…) }: chevron and openChevron are optional glyph lambdas (the chevron and chevron-open slots); the content lambda is the menu's, shown in the TanreiMenu popup. State hoisting: the caller keeps menuExpanded, sets it from onMenuExpandedChange (a press, a key or a hover) and to false from onMenuDismissRequest, which carries the reason. Hover reads PointerEventType.Enter / Exit for PointerType.Mouse and Stylus only; a null delay takes the token. The trailing half is Role.Button with a content description, and its expanded state as expand / collapse semantics actions. enabled is the inverse flag. Ships the previous revision today (6 px corners, a fixed turning chevron, no hover opening); the rest is owed, including a check of SPB-26 on this platform. Also owed: the component's current name, separation and trailingWidth, the divider (SPB-31), the outer padding (SPB-32), a trailing half that keeps its shape while open (SPB-13), the tighter padding beside the icon, and the new fixtures; reading comp.button-with-menu.container.separation as a typed choice needs the native token emitters to write a component's choice of kind as a typed enumeration, which they do not yet. Not built on this platform yet: destructive: Boolean = false (SPB-34); Compose has no destructive role, so it draws and adds no semantics (SPB-35). | TanreiButtonWithMenu(label:, icon:, chevron:, openChevron:, menuSemanticLabel:, onPressed:, menuController:, menuChildren: [TanreiMenuItem(…)], onMenuToggle:, onMenuClose:, openOnHover: false, hoverDelay: null, variant:, size:), built with the widgets library: chevron and openChevron are optional glyph widgets (the chevron and chevron-open slots); menuChildren are the menu's items, shown through TanreiMenuAnchor. The controller is a TanreiMenuController (the Tanrei menu's own), passed or created internally; it is open, so the button with menu opens and closes the menu itself; onMenuToggle and onMenuClose report it. Hover uses a MouseRegion and ignores PointerDeviceKind.touch; hoverDelay is a Duration?, null taking the token. A null onPressed disables it. Ships the previous revision today (6 px corners, a fixed turning chevron, no hover opening); the rest is owed, including a check of SPB-26 on this platform. Also owed: the component's current name, separation and trailingWidth, the divider (SPB-31), the outer padding (SPB-32), a trailing half that keeps its shape while open (SPB-13), the tighter padding beside the icon, and the new fixtures; reading comp.button-with-menu.container.separation as a typed choice needs the native token emitters to write a component's choice of kind as a typed enumeration, which they do not yet. Not built on this platform yet: isDestructiveAction: false, the widgets library's name for it (SPB-34); it draws and adds no semantics (SPB-35). | <ButtonWithMenu label icon chevron openChevron menuAccessibilityLabel menuVisible hoverOpen hoverDelay onPress onMenuToggle onMenuDismiss menuItems={[…]} variant size>: chevron and openChevron are optional glyph names (the chevron and chevron-open slots); menuItems is the Menu's items array (the union of its four kinds), shown by a Menu on the overlay layer that TanreiProvider renders. Controlled: the caller sets menuVisible from onMenuToggle and to false from onMenuDismiss, which carries the reason. Hover uses Pressable's onHoverIn / onHoverOut, which fire only for a pointer (iPadOS, Android with a mouse, web); hoverDelay is in milliseconds, undefined taking the token. The trailing half has the button role with accessibilityState.expanded. Ships the previous revision today (6 px corners, a fixed turning chevron, no hover opening); the rest is owed, including a check of SPB-26 on this platform. Also owed: the component's current name, separation and trailingWidth, the divider (SPB-31), the outer padding (SPB-32), a trailing half that keeps its shape while open (SPB-13), the tighter padding beside the icon, and the new fixtures; reading comp.button-with-menu.container.separation as a typed choice needs the native token emitters to write a component's choice of kind as a typed enumeration, which they do not yet. Not built on this platform yet: destructive (SPB-34); it draws and adds no accessibility props (SPB-35). |
令牌
| 令牌 | 引用 | 解析值(浅色) |
|---|---|---|
--tanrei-comp-button-with-menu-container-gap-large | sys.space.space100 | 8px |
--tanrei-comp-button-with-menu-container-gap-medium | sys.space.space100 | 8px |
--tanrei-comp-button-with-menu-container-gap-small | sys.space.space100 | 8px |
--tanrei-comp-button-with-menu-container-height-large | — | 56px |
--tanrei-comp-button-with-menu-container-height-medium | — | 40px |
--tanrei-comp-button-with-menu-container-height-small | — | 32px |
--tanrei-comp-button-with-menu-container-inner-gap | sys.space.space25 | 2px |
--tanrei-comp-button-with-menu-container-inner-shape | sys.shape.corner.small | 6px |
--tanrei-comp-button-with-menu-container-inner-shape-open | comp.button-with-menu.container.inner-shape | 6px |
--tanrei-comp-button-with-menu-container-outer-inset | sys.space.space50 | 4px |
--tanrei-comp-button-with-menu-container-padding-block-large | — | 6px |
--tanrei-comp-button-with-menu-container-padding-block-medium | — | 6px |
--tanrei-comp-button-with-menu-container-padding-block-small | — | 6px |
--tanrei-comp-button-with-menu-container-separation | sys.behavior.separation | 0 |
--tanrei-comp-button-with-menu-container-shape | sys.shape.corner.full | 9999px |
--tanrei-comp-button-with-menu-disabled-container-shape | — | 9999px |
--tanrei-comp-button-with-menu-disabled-opacity | — | 1 |
--tanrei-comp-button-with-menu-divider-inset | sys.space.space100 | 8px |
--tanrei-comp-button-with-menu-divider-thickness | sys.border.hairline | 1px |
--tanrei-comp-button-with-menu-elevated-container-color | sys.color.surface-container-lowest | |
--tanrei-comp-button-with-menu-elevated-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-button-with-menu-elevated-destructive-container-color | comp.button.elevated.destructive.container.color | |
--tanrei-comp-button-with-menu-elevated-destructive-divider-color | comp.button-with-menu.elevated.divider.color | |
--tanrei-comp-button-with-menu-elevated-destructive-label-color | comp.button.elevated.destructive.label.color | |
--tanrei-comp-button-with-menu-elevated-destructive-outline-color | comp.button.elevated.destructive.outline.color | |
--tanrei-comp-button-with-menu-elevated-destructive-state-layer-color | comp.button-with-menu.elevated.destructive.label.color | |
--tanrei-comp-button-with-menu-elevated-disabled-container-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-container-elevation | — | |
--tanrei-comp-button-with-menu-elevated-disabled-destructive-container-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-destructive-label-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-divider-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-label-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-disabled-state-layer-color | — | |
--tanrei-comp-button-with-menu-elevated-divider-color | sys.color.outline-variant | |
--tanrei-comp-button-with-menu-elevated-focused-container-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-container-elevation | — | 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 2px 0px rgb(0 0 0 / 0.04) |
--tanrei-comp-button-with-menu-elevated-focused-destructive-container-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-destructive-label-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-divider-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-label-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-focused-state-layer-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-container-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-container-elevation | — | 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 2px 0px rgb(0 0 0 / 0.04) |
--tanrei-comp-button-with-menu-elevated-hovered-destructive-container-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-destructive-label-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-divider-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-label-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-hovered-state-layer-color | — | |
--tanrei-comp-button-with-menu-elevated-label-color | sys.color.on-surface | |
--tanrei-comp-button-with-menu-elevated-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-container-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-container-elevation | — | 0px 0px 0px 1px rgb(0 0 0 / 0.08), 0px 1px 2px 0px rgb(0 0 0 / 0.04) |
--tanrei-comp-button-with-menu-elevated-pressed-destructive-container-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-destructive-label-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-divider-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-label-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-outline-color | — | |
--tanrei-comp-button-with-menu-elevated-pressed-state-layer-color | — | |
--tanrei-comp-button-with-menu-elevated-state-layer-color | comp.button-with-menu.elevated.label.color | |
--tanrei-comp-button-with-menu-filled-container-color | sys.color.primary | |
--tanrei-comp-button-with-menu-filled-container-elevation | sys.elevation.level0 | |
--tanrei-comp-button-with-menu-filled-destructive-container-color | comp.button.filled.destructive.container.color | |
--tanrei-comp-button-with-menu-filled-destructive-divider-color | comp.button-with-menu.filled.divider.color | |
--tanrei-comp-button-with-menu-filled-destructive-label-color | comp.button.filled.destructive.label.color | |
--tanrei-comp-button-with-menu-filled-destructive-outline-color | comp.button.filled.destructive.outline.color | |
--tanrei-comp-button-with-menu-filled-destructive-state-layer-color | comp.button-with-menu.filled.destructive.label.color | |
--tanrei-comp-button-with-menu-filled-disabled-container-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-container-elevation | — | |
--tanrei-comp-button-with-menu-filled-disabled-destructive-container-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-destructive-label-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-divider-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-label-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-outline-color | — | |
--tanrei-comp-button-with-menu-filled-disabled-state-layer-color | — | |
--tanrei-comp-button-with-menu-filled-divider-color | sys.color.outline | |
--tanrei-comp-button-with-menu-filled-focused-container-color | — | |
--tanrei-comp-button-with-menu-filled-focused-container-elevation | — | |
--tanrei-comp-button-with-menu-filled-focused-destructive-container-color | — | |
--tanrei-comp-button-with-menu-filled-focused-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-filled-focused-destructive-label-color | — | |
--tanrei-comp-button-with-menu-filled-focused-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-filled-focused-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-filled-focused-divider-color | — | |
--tanrei-comp-button-with-menu-filled-focused-label-color | — | |
--tanrei-comp-button-with-menu-filled-focused-outline-color | — | |
--tanrei-comp-button-with-menu-filled-focused-state-layer-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-container-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-container-elevation | — | |
--tanrei-comp-button-with-menu-filled-hovered-destructive-container-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-destructive-label-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-divider-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-label-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-outline-color | — | |
--tanrei-comp-button-with-menu-filled-hovered-state-layer-color | — | |
--tanrei-comp-button-with-menu-filled-label-color | sys.color.on-primary | |
--tanrei-comp-button-with-menu-filled-outline-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-container-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-container-elevation | — | |
--tanrei-comp-button-with-menu-filled-pressed-destructive-container-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-destructive-label-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-divider-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-label-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-outline-color | — | |
--tanrei-comp-button-with-menu-filled-pressed-state-layer-color | — | |
--tanrei-comp-button-with-menu-filled-state-layer-color | comp.button-with-menu.filled.label.color | |
--tanrei-comp-button-with-menu-focused-container-shape | — | 9999px |
--tanrei-comp-button-with-menu-focused-opacity | — | 1 |
--tanrei-comp-button-with-menu-hovered-container-shape | — | 9999px |
--tanrei-comp-button-with-menu-hovered-opacity | — | 1 |
--tanrei-comp-button-with-menu-icon-size-large | sys.icon.medium | 24px |
--tanrei-comp-button-with-menu-icon-size-medium | sys.icon.medium | 24px |
--tanrei-comp-button-with-menu-icon-size-small | sys.icon.small | 20px |
--tanrei-comp-button-with-menu-leading-padding-inline-end-large | sys.space.space300 | 24px |
--tanrei-comp-button-with-menu-leading-padding-inline-end-medium | sys.space.space200 | 16px |
--tanrei-comp-button-with-menu-leading-padding-inline-end-small | sys.space.space200 | 16px |
--tanrei-comp-button-with-menu-leading-padding-inline-start-with-icon-large | sys.space.space200 | 16px |
--tanrei-comp-button-with-menu-leading-padding-inline-start-with-icon-medium | sys.space.space150 | 12px |
--tanrei-comp-button-with-menu-leading-padding-inline-start-with-icon-small | sys.space.space150 | 12px |
--tanrei-comp-button-with-menu-leading-padding-inline-start-large | sys.space.space300 | 24px |
--tanrei-comp-button-with-menu-leading-padding-inline-start-medium | sys.space.space200 | 16px |
--tanrei-comp-button-with-menu-leading-padding-inline-start-small | sys.space.space200 | 16px |
--tanrei-comp-button-with-menu-motion-feedback-duration | sys.motion.role.feedback | 450ms |
--tanrei-comp-button-with-menu-motion-hover-close-delay | sys.motion.duration.medium2 | 300ms |
--tanrei-comp-button-with-menu-motion-hover-open-delay | sys.motion.duration.medium2 | 300ms |
--tanrei-comp-button-with-menu-motion-open-duration | sys.motion.role.appear.anchored.enter | 300ms |
--tanrei-comp-button-with-menu-motion-open-easing | sys.motion.easing.spatial | cubic-bezier(0.2, 0, 0, 1) |
--tanrei-comp-button-with-menu-motion-state-duration | sys.motion.role.state.default | 250ms |
--tanrei-comp-button-with-menu-motion-state-easing | sys.motion.easing.state | cubic-bezier(0.2, 0, 0, 1) |
--tanrei-comp-button-with-menu-outline-width-large | sys.border.hairline | 1px |
--tanrei-comp-button-with-menu-outline-width-medium | sys.border.hairline | 1px |
--tanrei-comp-button-with-menu-outline-width-small | sys.border.hairline | 1px |
--tanrei-comp-button-with-menu-outlined-container-color | — | |
--tanrei-comp-button-with-menu-outlined-container-elevation | sys.elevation.level0 | |
--tanrei-comp-button-with-menu-outlined-destructive-container-color | comp.button.outlined.destructive.container.color | |
--tanrei-comp-button-with-menu-outlined-destructive-divider-color | comp.button-with-menu.outlined.destructive.outline.color | |
--tanrei-comp-button-with-menu-outlined-destructive-label-color | comp.button.outlined.destructive.label.color | |
--tanrei-comp-button-with-menu-outlined-destructive-outline-color | comp.button.outlined.destructive.outline.color | |
--tanrei-comp-button-with-menu-outlined-destructive-state-layer-color | comp.button-with-menu.outlined.destructive.label.color | |
--tanrei-comp-button-with-menu-outlined-disabled-container-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-container-elevation | — | |
--tanrei-comp-button-with-menu-outlined-disabled-destructive-container-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-destructive-label-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-divider-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-label-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-outline-color | — | |
--tanrei-comp-button-with-menu-outlined-disabled-state-layer-color | — | |
--tanrei-comp-button-with-menu-outlined-divider-color | comp.button-with-menu.outlined.outline.color | |
--tanrei-comp-button-with-menu-outlined-focused-container-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-container-elevation | — | |
--tanrei-comp-button-with-menu-outlined-focused-destructive-container-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-destructive-label-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-divider-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-label-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-outline-color | — | |
--tanrei-comp-button-with-menu-outlined-focused-state-layer-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-container-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-container-elevation | — | |
--tanrei-comp-button-with-menu-outlined-hovered-destructive-container-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-destructive-label-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-divider-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-label-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-outline-color | — | |
--tanrei-comp-button-with-menu-outlined-hovered-state-layer-color | — | |
--tanrei-comp-button-with-menu-outlined-label-color | sys.color.on-surface | |
--tanrei-comp-button-with-menu-outlined-outline-color | sys.color.outline-variant | |
--tanrei-comp-button-with-menu-outlined-pressed-container-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-container-elevation | — | |
--tanrei-comp-button-with-menu-outlined-pressed-destructive-container-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-destructive-label-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-divider-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-label-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-outline-color | — | |
--tanrei-comp-button-with-menu-outlined-pressed-state-layer-color | — | |
--tanrei-comp-button-with-menu-outlined-state-layer-color | comp.button-with-menu.outlined.label.color | |
--tanrei-comp-button-with-menu-pressed-container-shape | — | 9999px |
--tanrei-comp-button-with-menu-pressed-opacity | — | 1 |
--tanrei-comp-button-with-menu-tonal-container-color | sys.color.secondary-container | |
--tanrei-comp-button-with-menu-tonal-container-elevation | sys.elevation.level0 | |
--tanrei-comp-button-with-menu-tonal-destructive-container-color | comp.button.tonal.destructive.container.color | |
--tanrei-comp-button-with-menu-tonal-destructive-divider-color | comp.button-with-menu.tonal.divider.color | |
--tanrei-comp-button-with-menu-tonal-destructive-label-color | comp.button.tonal.destructive.label.color | |
--tanrei-comp-button-with-menu-tonal-destructive-outline-color | comp.button.tonal.destructive.outline.color | |
--tanrei-comp-button-with-menu-tonal-destructive-state-layer-color | comp.button-with-menu.tonal.destructive.label.color | |
--tanrei-comp-button-with-menu-tonal-disabled-container-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-container-elevation | — | |
--tanrei-comp-button-with-menu-tonal-disabled-destructive-container-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-destructive-label-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-divider-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-label-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-disabled-state-layer-color | — | |
--tanrei-comp-button-with-menu-tonal-divider-color | sys.color.outline-variant | |
--tanrei-comp-button-with-menu-tonal-focused-container-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-container-elevation | — | |
--tanrei-comp-button-with-menu-tonal-focused-destructive-container-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-destructive-label-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-divider-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-label-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-focused-state-layer-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-container-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-container-elevation | — | |
--tanrei-comp-button-with-menu-tonal-hovered-destructive-container-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-destructive-label-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-divider-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-label-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-hovered-state-layer-color | — | |
--tanrei-comp-button-with-menu-tonal-label-color | sys.color.on-secondary-container | |
--tanrei-comp-button-with-menu-tonal-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-container-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-container-elevation | — | |
--tanrei-comp-button-with-menu-tonal-pressed-destructive-container-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-destructive-divider-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-destructive-label-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-destructive-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-destructive-state-layer-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-divider-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-label-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-outline-color | — | |
--tanrei-comp-button-with-menu-tonal-pressed-state-layer-color | — | |
--tanrei-comp-button-with-menu-tonal-state-layer-color | comp.button-with-menu.tonal.label.color | |
--tanrei-comp-button-with-menu-trailing-padding-inline-narrow-large | sys.space.space50 | 4px |
--tanrei-comp-button-with-menu-trailing-padding-inline-narrow-medium | sys.space.space50 | 4px |
--tanrei-comp-button-with-menu-trailing-padding-inline-narrow-small | sys.space.space50 | 4px |
--tanrei-comp-button-with-menu-trailing-padding-inline-large | sys.space.space200 | 16px |
--tanrei-comp-button-with-menu-trailing-padding-inline-medium | sys.space.space100 | 8px |
--tanrei-comp-button-with-menu-trailing-padding-inline-small | sys.space.space75 | 6px |
--tanrei-comp-button-with-menu-label-typescale-large | sys.typescale.label | 500 14px/1.4286 |
--tanrei-comp-button-with-menu-label-typescale-medium | sys.typescale.label | 500 14px/1.4286 |
--tanrei-comp-button-with-menu-label-typescale-small | sys.typescale.label | 500 14px/1.4286 |
测试样例
作为基准的渲染结果。所有平台都以相同的 ID 为每个样例截取快照,以便相互比较。
variant: filled (27)
filled-medium
variant: filled label: Save menuLabel: More save options filled-small
variant: filled size: small label: Save menuLabel: More save options filled-large
variant: filled size: large label: Save menuLabel: More save options filled-icon
variant: filled label: Save menuLabel: More save options slots: icon filled-icon-small
variant: filled size: small label: Save menuLabel: More save options slots: icon filled-icon-large
variant: filled size: large label: Save menuLabel: More save options slots: icon filled-open
variant: filled label: Save menuLabel: More save options open: true 单独绘制在 352 × 128 的窗口中filled-disabled
variant: filled label: Save menuLabel: More save options disabled: true filled-chevron-custom
variant: filled label: Share menuLabel: More share options slots: chevron, chevron-open filled-chevron-custom-open
variant: filled label: Share menuLabel: More share options open: true slots: chevron, chevron-open 单独绘制在 352 × 128 的窗口中filled-chevron-turned-open
variant: filled label: Save menuLabel: More save options open: true slots: chevron 单独绘制在 352 × 128 的窗口中filled-opens-on-hover
variant: filled label: Save menuLabel: More save options hoverOpen: true hoverDelay: 500 filled-divider
variant: filled separation: divider label: Save menuLabel: More save options filled-gap
variant: filled separation: gap label: Save menuLabel: More save options filled-gap-open
variant: filled separation: gap label: Save menuLabel: More save options open: true 单独绘制在 352 × 128 的窗口中filled-gap-disabled
variant: filled separation: gap label: Save menuLabel: More save options disabled: true filled-narrow
variant: filled trailingWidth: narrow label: Save menuLabel: More save options filled-narrow-small
variant: filled size: small trailingWidth: narrow label: Save menuLabel: More save options filled-narrow-large
variant: filled size: large trailingWidth: narrow label: Save menuLabel: More save options filled-gap-narrow
variant: filled separation: gap trailingWidth: narrow label: Save menuLabel: More save options filled-narrow-open
variant: filled trailingWidth: narrow label: Save menuLabel: More save options open: true 单独绘制在 352 × 128 的窗口中filled-narrow-rtl
variant: filled trailingWidth: narrow label: Save menuLabel: More save options direction: rtl filled-destructive
variant: filled destructive: true label: Delete menuLabel: More delete options filled-destructive-gap
variant: filled destructive: true separation: gap label: Delete menuLabel: More delete options filled-destructive-narrow
variant: filled destructive: true trailingWidth: narrow label: Delete menuLabel: More delete options filled-destructive-disabled
variant: filled destructive: true label: Delete menuLabel: More delete options disabled: true filled-destructive-open
variant: filled destructive: true label: Delete menuLabel: More delete options open: true 单独绘制在 352 × 128 的窗口中variant: tonal (3)
tonal-medium
variant: tonal label: Save menuLabel: More save options tonal-gap
variant: tonal separation: gap label: Save menuLabel: More save options tonal-destructive
variant: tonal destructive: true label: Delete menuLabel: More delete options variant: elevated (5)
elevated-medium
variant: elevated label: Save menuLabel: More save options elevated-open
variant: elevated label: Save menuLabel: More save options open: true 单独绘制在 352 × 128 的窗口中elevated-gap
variant: elevated separation: gap label: Save menuLabel: More save options elevated-gap-open
variant: elevated separation: gap label: Save menuLabel: More save options open: true 单独绘制在 352 × 128 的窗口中elevated-destructive
variant: elevated destructive: true label: Delete menuLabel: More delete options variant: outlined (7)
outlined-medium
variant: outlined label: Save menuLabel: More save options outlined-open
variant: outlined label: Save menuLabel: More save options open: true 单独绘制在 352 × 128 的窗口中outlined-disabled
variant: outlined label: Save menuLabel: More save options disabled: true outlined-gap
variant: outlined separation: gap label: Save menuLabel: More save options outlined-narrow
variant: outlined trailingWidth: narrow label: Save menuLabel: More save options outlined-gap-narrow
variant: outlined separation: gap trailingWidth: narrow label: Save menuLabel: More save options outlined-destructive
variant: outlined destructive: true label: Delete menuLabel: More delete options 生成自 specs/button-with-menu.spec.json, tokens/comp/button-with-menu.tokens.json, design/tokens/
带菜单的按钮用于有一种常用形式、另有几种变化形式的操作:“保存”和“另存为…”,“合并”和“压缩并合并”,“发送”和“定时发送”。常用形式只需按一次,变化形式也只多按一次。
如果没有哪个选项明显是常用的,就使用一个打开菜单的按钮或图标按钮。如果没有变化形式,就使用普通的按钮。如果是并排的一组各自独立的操作,就使用按钮组。
这些变体的强调程度与 Button 的相同,预算也相同:每个视图只有一个强的操作。
| 变体 | 强调程度 | 用于 |
|---|---|---|
| Filled(默认) | 最高 | 视图的主操作。 |
| Tonal | 高 | 重要但不应与 filled 按钮争抢注意力的操作。 |
| Elevated | 中 | 位于杂乱或带图案的背景之上的带菜单的按钮。 |
| Outlined | 中 | filled 按钮旁边的次要操作。 |
没有 text 变体。没有填充也没有边框,就没有东西能显示两个半边在哪里相接,“分成两半”这一点也就看不出来了。
各尺寸的高度与 Button 相同,所以带菜单的按钮可以与同尺寸的按钮排在同一行。在默认系统中,外端是完全的圆形。两个半边相接处的样子取决于分隔方式:用分隔线时,它们以直角相接,接缝上有一条线,两个半边都不沿接缝画轮廓,所以 outlined 的带菜单的按钮,轮廓是环绕一周的一条线;用间隙时,每个半边都在自己的边缘之内画出自己的轮廓,以及 elevated 表面所带的那圈圆环,所以两者永不相触。
后半边绘制图标集中的 expand_more,并在菜单打开期间把它旋转半圈。在默认系统中,后半边在打开时保持自己的形状,所以只有箭头表明打开状态。设计系统可以为打开的半边规定它自己的内侧圆角:m3-like 预设在打开时把它变成圆形。
有两个插槽可以更换图标。每个插槽接收图标集中的一个图标:
| 填入的插槽 | 关闭时 | 打开时 |
|---|---|---|
| 都不填 | expand_more |
旋转后的 expand_more |
只填 chevron |
你的图标 | 旋转后的你的图标 |
chevron-open(无论是否有 chevron) |
chevron 的图标,或 expand_more |
chevron-open 的图标;两者交叉淡化,而不是旋转 |
没有方向的图标,例如 more_vert,不应当旋转:请给它配一个 chevron-open 图标,或者在两个插槽里放同一个图标。
设置 hoverOpen 后,当鼠标、触控板或悬停的触控笔在后半边上停留 hoverDelay 毫秒时,菜单也会打开。不设置 hoverDelay 则采用系统的延迟,在默认系统中是 300 ms。按下和键盘仍然像以前一样打开菜单,触摸则绝不会以悬停的方式打开它。
- 由悬停打开的菜单不接收焦点:焦点留在用户原来所在的地方,例如某个文本字段里。菜单项仍然显示悬停状态,点击其中一项仍然会激活它。
- 指针离开后半边和菜单两者并经过一小段宽限时间后,菜单关闭;所以指针可以移到菜单上,也可以滑出边缘再回来。以其他方式打开的菜单,绝不会因为指针离开而关闭。
- 由悬停打开的菜单显示期间,在后半边上按下或按键,菜单会保持打开,焦点移入其中。从这时起,它就是一个由按下打开的菜单:再按一次就关闭。
- 无论焦点在哪里,Escape 都会关闭由悬停打开的菜单。
- 悬停与按下一样,通过同一个 toggle 事件发出请求,所以设置
open的代码不需要改动。
在经常使用指针、并且菜单自动出现也受欢迎的地方使用悬停打开,例如桌面编辑器里的工具栏。它永远不能代替按下。
- 这个菜单就是 Tanrei 的 Menu,具备菜单提供的一切:焦点移入其中,方向键和键入查找可以在其中移动,它还会翻转和偏移以保持在屏幕之内。它在整个带菜单的按钮的下方打开,与其末尾侧的边缘对齐。
- 菜单打开期间,箭头显示它的打开形态(参见箭头)。
- 菜单关闭时,如果焦点原本在菜单里,焦点会交还给后半边,也就是打开它的那个按钮。
- 菜单打开时在后半边上按下,是切换,而不是在外部按下:它通过
toggle请求关闭,不发送close。某个操作项关闭菜单时,先到的是该项的 click,然后是原因为item的close。 - 由谁关闭菜单,取决于平台。在 Web、Compose 和 React Native 上,你根据 toggle 事件设置
open(menuExpanded、menuVisible),并根据 close 事件把它设回 false。在 SwiftUI 和 Flutter 上,带菜单的按钮通过它的绑定或控制器自己打开和关闭菜单。
- 前半边的标签写常用的那个操作,用动词:“保存”“合并”“发送”。
- 给后半边一个说明菜单里有什么的标签:写“更多保存选项”,不要写“更多”或“菜单”。
- 菜单里放主操作的变化形式。主操作本身不需要在那里重复。
- 让菜单保持简短。几项正合适;列表一长,就说明这些选项并不是同一个操作的变化形式。
- 除非某个实例必须不同,否则让
separation保持auto,这样产品中所有连接式控件的分隔方式都一致。 - 在菜单是次要的、宽度又紧张的地方使用窄的后半边,例如表格的行或密集的页眉。
- 不要把无关的操作放进菜单,例如“设置”或“帮助”。这些操作请使用带有自己菜单的图标按钮。
- 不要使用菜单里只有一项的带菜单的按钮。把两个操作都显示为按钮。
- 不要试图只禁用一个半边。
disabled对两个半边都生效。如果变化形式不可用,就禁用那些菜单项。 - 不要在后半边要做打开菜单以外的事情时使用带菜单的按钮。它的语义总是说明它会打开一个菜单。
- 角色
button- 标注
- The leading half is named by label, the trailing half by menuLabel, which should say what more the menu offers ("More save options"). The trailing half exposes that it opens a menu and whether it is open (aria-haspopup and aria-expanded on the web), however it opened. The icon and the chevron, whether the default or a slot's glyph, are decorative.
- 最小目标
- 即使外观尺寸更小,点击区域也满足最小目标尺寸。 minimum 32px · pointer 44px · spacing 8px · touch 48px
键盘操作
| 按键 | 动作 |
|---|---|
| Tab / Shift+Tab | Moves focus to the leading half, then to the trailing half. |
| Enter / Space on the leading half | Performs the main action (click). |
| Enter, Space / Down Arrow on the trailing half | Opens the menu (Enter and Space also close it); focus moves to the first enabled item. On a menu that opened on hover, keeps it open and moves focus to its first enabled item. |
| Escape (in the menu) | Closes the menu and returns focus to the trailing half. |
| Escape (a menu that opened on hover) | Closes the menu wherever focus is, and leaves focus where it was. |
备注
- Two buttons, two Tab stops: the trailing half is a menu button in the WAI-ARIA sense, and the menu is the Tanrei Menu with its own keyboard (MNU-7).
- The menu's items are the menu's own item kinds; their events (an item's click, a checkbox item's change, a group's change) are the menu's, and reach the product as they do from a menu anywhere.
- Opening on hover is an addition for fine pointers, never a replacement: every menu that hover opens can also be opened by a press and by the keyboard, and keyboard and screen reader use is unchanged. It meets WCAG 1.4.13: the pointer can move onto the menu (the close delay), Escape dismisses it without moving the pointer, and it stays until the pointer leaves or the person dismisses it. It does not take focus, so a person typing elsewhere is not interrupted.
- Text must remain readable at 200% text size: both halves grow in height together and the label never truncates.
- 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.
- The divider is decoration: it is not exposed and takes no press. The two halves are two buttons whether a line or a gap is between them.
- A narrow trailing half is drawn narrower than the minimum target; its hit area is not (SPB-6). Its target reaches outward past the end edge, so leave room for it there.
- In the default system the outline of the outlined variant, and the divider of the outlined, tonal and elevated variants, are the hairline (the divider's color), far below 3:1 against their ground. That is within WCAG 1.4.11: the leading half is identified by its label, the trailing half by its chevron, and each half answers hover, focus and press on its own shape. The divider of the filled variant keeps the functional outline role, because a hairline cannot be seen on that fill.
- destructive adds a color, never a meaning (WCAG 1.4.1, use of color): the error color tells people who see it that the main action deletes or discards something, and nothing tells a screen reader user so but the label. So the label names the action and what it acts on ("Delete project", not "OK"). Disabled, a destructive button with menu looks like any disabled one of its variant: a button that cannot be pressed does nothing.
生成自 specs/button-with-menu.spec.json, tokens/sys/measure.tokens.json
- 带菜单的按钮是按阅读顺序排列的两个按钮,各自是一个 Tab 停靠点。前半边以它的标签命名。后半边以菜单标签命名,并在平台有相应属性时,暴露“它会打开菜单”以及菜单是否已打开。没有任何元素把两者包在一起,所以屏幕阅读器读到的是两个按钮,仅此而已。
- 图标和箭头,包括
chevron或chevron-open插槽中的图标,都对辅助技术隐藏。无论菜单是通过按下、按键还是悬停打开的,后半边都会暴露菜单是否已打开。 - 在前半边上按 Enter 和 Space 执行主操作。在后半边上按 Enter、Space 或向下方向键打开菜单,并把焦点移到第一个可用的项上。此后菜单的表现与在别处相同,Escape 关闭它并把焦点交还给后半边。
- 由悬停打开的菜单让焦点留在原处;无论焦点在哪里,Escape 都能关闭它,不需要移动指针,所以悬停显示出来的内容总是可以关闭(WCAG 1.4.13)。这次 Escape 不会继续传递;下一次才照常到达页面。
- 每个半边各自响应悬停、按下和聚焦。另一个半边绝不会显示不属于自己的状态。
- 每个半边的点击区域向外侧以及上方和下方延伸到最小目标。朝向另一个半边时,它止于接缝或间隙的中点,所以两者永不重叠。窄的后半边朝前半边方向够不到的部分,改为向外侧延伸,所以它不必画得更宽也拥有完整的目标。
- 分隔线是装饰:它对辅助技术隐藏,也不接收按下。无论中间是一条线还是一条间隙,两个半边都是两个按钮。
- 在默认系统中,outlined 变体的轮廓,以及 outlined、tonal 和 elevated 变体的分隔线,都是细线,有意画得很淡:前半边靠它的标签来辨认,后半边靠它的箭头来辨认。filled 变体上的分隔线更醒目,因为在那种填充上看不见细线。
- 标签绝不截断。文本放大时它会换行,两个半边一起增高。
destructive只增加颜色,不增加别的:Web 没有表示破坏性操作的角色或状态,所以两个半边的角色、名称、状态和按键都保持不变。在有破坏性角色的 SwiftUI 上,前半边获得这个角色。- 禁用的带菜单的按钮把两个半边都暴露为禁用,把它们移出焦点顺序,不呈现菜单,并且无论
open是什么,都画成关闭的形态。 - 缺少标签或菜单标签,以及菜单里没有任何项,都会在调试构建中报告。
已知的平台差距:
- Web:只有前半边的 click 会到达注册在带菜单的按钮上的监听器。菜单项的 click 止于带菜单的按钮,所以注册在祖先元素上的监听器永远收不到它:请在菜单项上监听。菜单项的 React 包装组件把
onClick直接注册到元素上,正是出于这个原因。 - SwiftUI 没有说明“按钮会打开菜单”的属性,也没有展开状态。菜单的弹出层附着在后半边上;由于两个半边高度相同,而后半边位于这一行的末尾,它的底部末端也就是整个带菜单的按钮的底部末端。绑定既是切换也是关闭,所以关闭的原因不会传递出来。菜单插槽哪怕只空了一瞬间,例如对稍后才加载的项使用
ForEach,在调试构建中就是断言失败,并让调试中的应用停止。两个半边上的 Enter、Space 和向下方向键,焦点交还给后半边,以及 macOS 上菜单打开时点击后半边,都没有做端到端的测试。 - Flutter 没有说明“按钮会打开菜单”的属性。后半边暴露菜单是否已打开,但不暴露它会打开菜单。
- Compose 没有说明“按钮会打开菜单”的属性。后半边是一个按钮,以展开和收起两个动作来表达它的状态。菜单本身带有菜单页面上列出的 Compose 差距。
- React Native 同样没有说明“按钮会打开菜单”的属性。后半边暴露菜单是否已打开,但不暴露它会打开菜单。
- React Native:菜单打开时在后半边上按下、焦点交还给后半边,以及箭头的旋转,都只在没有真机的情况下测试过。
- 两个标签都要写。单独一个箭头不是名称,“保存”旁边的“更多”也没有说明菜单里有什么。
- 对于破坏性的带菜单的按钮,要让标签说明删除的是什么(“删除项目”,而不是“确定”):错误色只能告诉看得见它的人(WCAG 1.4.1,颜色的使用)。
- 在 Web、Compose 和 React Native 上,根据 toggle 事件设置
open,并根据 close 事件把它设为 false。带菜单的按钮只是发出请求。设置了hoverOpen时,悬停也通过同一个 toggle 事件发出请求。 - 菜单关闭时不要自己移动焦点。带菜单的按钮会把焦点交还给后半边。
- 给每个菜单项一个标签,包括显示图标的项。
- 在窄的后半边的末尾侧边缘之外留出空间,让它的目标不与下一个控件重叠。