元素
Button
按钮是一个按压目标。它负责行为:按压、通过应用的状态样式呈现的按压反馈、禁用和选中状态、键盘焦点,以及它对屏幕阅读器而言的角色。外观由应用决定:Button 没有自己的颜色、尺寸、内边距或文字,所以同一个组件可以按你给它的样式和子元素,成为主按钮、图标按钮、标签页、工具栏开关或列表的一行。
import { mount, signal, Text, View } from "nsh";
import { Button, Switch } from "nsh/base";
import { desktop, Inspector, Panel, size, Stage, tint, ui, wide } from "./palette";
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors`;
const thumb = desktop ? "size-4 rounded-full transition-transform checked:translate-x-4" : "size-5.5 rounded-full transition-transform checked:translate-x-5";
function App() {
const presses = signal(0);
const pressing = signal(false);
const disabled = signal(false);
const bold = signal(true);
const italic = signal(false);
const last = signal("–");
// A pointer gets compact buttons as wide as their labels; a phone, 44-point ones.
const chip = () =>
desktop
? { ...ui().raised, borderRadius: 8, height: size.control, paddingHorizontal: 12, alignItems: "center" as const, justifyContent: "center" as const }
: { ...ui().raised, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 10, alignItems: "center" as const };
const label = () => ({ color: ui().text, fontSize: desktop ? 13 : 15, fontWeight: "600" as const });
const counter = () => (
<Button
onPress={() => {
presses.update((n) => n + 1);
last.set(`counter ${presses()}`);
}}
onPressDown={() => pressing.set(true)}
onPressUp={() => pressing.set(false)}
disabled={disabled()}
style={
desktop
? { ...ui().accentFill, alignSelf: "flex-start", borderRadius: 8, height: size.control, paddingHorizontal: 14, justifyContent: "center" }
: { ...ui().accentFill, borderRadius: 12, padding: 14, alignItems: "center" }
}
activeStyle={{ opacity: 0.7 }}
hoverStyle={{ borderColor: ui().text }}
focusStyle={{ borderColor: ui().info, borderWidth: 2 }}
disabledStyle={{ opacity: 0.35 }}
>
<Text style={{ color: ui().onAccent, fontSize: desktop ? 14 : 16, fontWeight: "600" }}>Pressed {presses()}×</Text>
</Button>
);
const pressState = () => `${pressing() ? "onPressDown: pressing…" : "onPressUp: released"} · disabled ${String(disabled())}`;
const disabler = () => (
<Switch
checked={disabled()}
onChange={(next) => disabled.set(next)}
class={`flex-row items-center gap-2 active:opacity-70 ${desktop && !wide() ? "self-start" : ""}`}
trackClass={track}
trackStyle={disabled() ? ui().accentFill : ui().inset}
thumbClass={thumb}
thumbStyle={disabled() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={label()}>{wide() ? (disabled() ? "Enable it" : "Disable it") : disabled() ? "Enable the button above" : "Disable the button above"}</Text>
</Switch>
);
const toggle = (name: string, on: () => boolean, flip: () => void, text: () => object, glyph: string) => (
<Button
selected={on()}
onPress={flip}
accessibilityLabel={name}
style={{ ...chip(), width: desktop ? size.control : 48, paddingHorizontal: desktop ? 0 : 14 }}
selectedStyle={{ backgroundColor: tint(ui().accent, 0.25), backgroundImage: "none", borderColor: ui().accent }}
activeStyle={{ opacity: 0.7 }}
>
<Text style={text()}>{glyph}</Text>
</Button>
);
const toggles = () => (
<>
{toggle("Bold", bold, () => bold.set(!bold()), () => ({ ...label(), fontWeight: "800" }), "B")}
{toggle("Italic", italic, () => italic.set(!italic()), () => ({ ...label(), fontStyle: "italic" }), "I")}
</>
);
const caption = (text: string) => <Text style={{ fontSize: 12, color: ui().muted }}>{text}</Text>;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>button</Text>
<Text style={{ color: ui().muted }}>{desktop ? "Click, hover, or Tab to a button and press Space" : "Tap; move your finger away to cancel"}</Text>
{() =>
wide() ? (
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage width={360} gap={24}>
<View style={{ gap: 8 }}>
{caption("onPress, onPressDown, onPressUp")}
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
{counter()}
{disabler()}
</View>
</View>
<View style={{ gap: 8 }}>
{caption("selected: toggles")}
<View style={{ flexDirection: "row", gap: 8 }}>{toggles()}</View>
</View>
</Stage>
<Inspector>
<Panel title="press · disabled">
<Text style={{ fontSize: 13, color: ui().text }}>{pressState()}</Text>
</Panel>
<Panel title="selected">
<Text style={{ fontSize: 13, color: ui().text }}>
bold {String(bold())} · italic {String(italic())}
</Text>
</Panel>
<Panel title="last onPress">
<Text style={{ fontSize: 13, color: ui().up }}>{last()}</Text>
</Panel>
</Inspector>
</View>
) : (
<View style={{ gap: 12 }}>
{counter()}
<Text style={{ color: ui().muted, fontSize: 13 }}>{pressState()}</Text>
{disabler()}
<Text style={{ color: ui().muted, marginTop: 6 }}>selected: toggles</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
{toggles()}
<Text style={{ color: ui().muted, alignSelf: "center", flex: 1 }}>
bold {String(bold())} · italic {String(italic())}
</Text>
</View>
<Text style={{ color: ui().up }}>last onPress: {last()}</Text>
</View>
)
}
</View>
);
}
mount(App);use nsh_base::button;
use nsh_core::ui::prelude::*;
fn counter() -> impl IntoElement {
let count = signal(0_u32);
button()
.on_press(move || count.update(|n| *n += 1))
.self_center()
.px_5()
.py_3()
.rounded_xl()
.bg(rgb(0x2563eb))
.active(|s| s.bg(rgb(0x1d4ed8)))
.child(
text(move || format!("Pressed {}", count.get()))
.text_color(white())
.font_weight(FontWeight::SEMIBOLD),
)
}import { mount, signal, Text } from "nsh";
import { Button } from "nsh/base";
function App() {
const count = signal(0);
return (
<Button
onPress={() => count.update((n) => n + 1)}
class="self-center rounded-xl bg-blue-600 px-5 py-3 active:bg-blue-700"
>
<Text class="text-base font-semibold text-white">{`Pressed ${count()}`}</Text>
</Button>
);
}
mount(App);Button 来自 nsh/base,在 Rust 中来自 nsh-base crate。在 TSX 中,它的外观是 class 和 style,配合状态变体 active:、hover:、focus:、selected: 和 disabled:(或对应的 activeStyle、hoverStyle、focusStyle、selectedStyle 和 disabledStyle)。在 Rust 中,button() 像 view() 创建视图那样创建按钮:每个属性一个设置方法(.disabled(..)、.selected(..)),每个事件一个监听方法(.on_press(..)),类型化的样式方法(.px_4()、.bg(..))决定外观,状态方法(.active(..)、.hover(..)、.focus(..)、.selected_style(..)、.disabled_style(..))设置各状态的样式,.child(..) 添加内容。
#属性
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | 不响应按压,也没有按压反馈;disabled: 样式生效,屏幕阅读器会报告它不可用。 |
selected | .selected(..) | boolean | false | 选中:打开的开关、当前标签页。屏幕阅读器会报告。值由应用持有,样式也由应用决定。 |
selectedStyle | .selected_style(|s| ..) | StyleProp<ViewStyle> | 无 | selected 且未禁用时的样式,与 selected: 类相同。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 内容不足以说明时(比如只有图标),屏幕阅读器读出的文字。 |
role | .role(..) | AccessibilityRole | "button" | 当 nsh/base 没有对应的组件时,它对屏幕阅读器而言是什么:"link"(打开一个 URL)等。 |
children | .child(..) | JSX.Element | 无 | 内容:文字、图标或任意布局。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 它的全部外观。 |
activeStyle、hoverStyle、focusStyle、disabledStyle | .active(|s| ..)、.hover(..)、.focus(..)、.disabled_style(..) | StyleProp<ViewStyle> | 无 | 按下、悬停、获得焦点或禁用时的样式,与对应的 <state>: 类相同。后面的状态优先,disabled 优先于所有状态。 |
在 Rust 中,每个属性是一个设置方法,接受值、信号或闭包;没有设置的属性保留默认值:
button()
.disabled(move || saving.get())
.accessibility_label("Save")
#事件
| 事件 | Rust | 说明 |
|---|---|---|
onPress | .on_press(|| ..) | 按钮被按下:轻点、单击、拥有键盘焦点时按 Return 或空格,或屏幕阅读器激活它。 |
onPressDown | .on_press_down(|| ..) | 一次按压在它上面按下。 |
onPressUp | .on_press_up(|| ..) | 按压结束:松开、移开或被取消。 |
onPressDown 和 onPressUp 用于你自己的按压反馈,比如状态样式表达不了的弹簧效果或高亮。禁用的按钮不会报告这些事件。
#样式
按钮的外观属于你的应用:什么是主按钮、什么是次按钮,由应用决定。用两个小组件就能把这个决定集中在一处:
use nsh_base::button;
use nsh_core::ui::prelude::*;
/// A filled button, darker while pressed.
fn primary(label: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
button()
.on_press(on_press)
.items_center()
.rounded_lg()
.px_4()
.py_3()
.bg(rgb(0x2563eb))
.active(|s| s.bg(rgb(0x1d4ed8)))
.disabled_style(|s| s.opacity(0.4))
.child(text(label).text_color(white()).font_weight(FontWeight::SEMIBOLD))
}
/// An outlined button.
fn secondary(label: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
button()
.on_press(on_press)
.items_center()
.rounded_lg()
.px_4()
.py_3()
.border_1()
.border_color(rgb(0xd4d4d4))
.hover(|s| s.bg(rgb(0xfafafa)))
.active(|s| s.bg(rgb(0xf5f5f5)))
.child(text(label).text_color(rgb(0x171717)))
}import { Text } from "nsh";
import { Button } from "nsh/base";
function Primary(props: { label: string; onPress: () => void; disabled?: boolean }) {
return (
<Button
onPress={props.onPress}
disabled={props.disabled}
class="items-center rounded-lg bg-blue-600 px-4 py-3 active:bg-blue-700 disabled:opacity-40"
>
<Text class="font-semibold text-white">{props.label}</Text>
</Button>
);
}
function Secondary(props: { label: string; onPress: () => void }) {
return (
<Button
onPress={props.onPress}
class="items-center rounded-lg border border-neutral-300 px-4 py-3 active:bg-neutral-100 hover:bg-neutral-50"
>
<Text class="text-neutral-900">{props.label}</Text>
</Button>
);
}状态变体在原生侧应用:按下时按钮在主线程上变暗,不需要运行任何 JS 或 Rust。在 Rust 中,状态方法就是这些变体:.active(..)、.hover(..)、.focus(..)、.selected_style(..) 和 .disabled_style(..) 跟随按钮自身的状态。主题颜色的用法相同:TSX 中写 bg-primary,Rust 中写 theme().primary(见主题 token)。
#禁用
禁用的按钮不响应按压,也没有反馈,屏幕阅读器会报告它不可用。它禁用时的外观由你决定,使用 disabled: 类或 disabledStyle:
fn save(saving: Signal<bool>) -> impl IntoElement {
button()
.disabled(saving)
.on_press(move || saving.set(true))
.rounded_lg()
.px_4()
.py_3()
.bg(rgb(0x2563eb))
.disabled_style(|s| s.opacity(0.4))
.child(text("Save").text_color(white()))
}<Button disabled={saving()} onPress={save} class="rounded-lg bg-blue-600 px-4 py-3 disabled:opacity-40">
<Text class="text-white">Save</Text>
</Button>#用 selected 做开关
selected 让按钮成为一个开关:工具栏的加粗按钮、筛选标签、当前标签页。值由应用持有,在 onPress 中切换,并为选中状态设置样式;屏幕阅读器会报告按钮已选中。
fn bold(on: Signal<bool>) -> impl IntoElement {
button()
.selected(on)
.accessibility_label("Bold")
.on_press(move || on.update(|on| *on = !*on))
.size(px(40.0))
.items_center()
.justify_center()
.rounded_md()
.selected_style(|s| s.bg(rgb(0xdbeafe)))
.child(text("B").font_weight(FontWeight::BOLD))
}<Button
selected={bold()}
onPress={() => bold.set(!bold())}
accessibilityLabel="Bold"
class="size-10 items-center justify-center rounded-md selected:bg-blue-100"
>
<Text class="font-bold">B</Text>
</Button>标签行用 Tabs,开关用 Switch(均来自 nsh/base):它们自带该类控件的角色、选中状态和键盘行为,用 role="tab" 或 role="switch" 的 Button 做不到这些。
#无障碍
- 对屏幕阅读器而言,按钮是一个元素,角色为
"button",除非role另有指定。它的标签是它的文字内容;内容是图标或说明不足时,设置accessibilityLabel。 disabled和selected按平台的方式播报(VoiceOver、TalkBack、UI Automation、浏览器的无障碍树)。- 在 macOS 和 Web 上,按钮是键盘焦点的一站:Tab 可以到达它,Return 和空格按下它,
focus:样式显示焦点所在。
标签、角色和播报的一般用法见无障碍。
#平台说明
- iOS、Android 和 Mac Catalyst:一次按压是在按钮上结束的轻点;移开会取消它,与系统自己的按钮一致。
- 桌面平台(macOS、Windows、Linux、GPUI)和 Web:一次按压是一次单击。在 macOS 和 Web 上,Return 和空格也会按下拥有键盘焦点的按钮。
hover:样式在鼠标或触控板指针下生效。 - 每个平台都只绘制应用设定的样式:按压反馈是
active:/activeStyle或你自己的动画。