元素

Button

按钮是一个按压目标。它负责行为:按压、通过应用的状态样式呈现的按压反馈、禁用和选中状态、键盘焦点,以及它对屏幕阅读器而言的角色。外观由应用决定:Button 没有自己的颜色、尺寸、内边距或文字,所以同一个组件可以按你给它的样式和子元素,成为主按钮、图标按钮、标签页、工具栏开关或列表的一行。

el-button.tsx
el-button.tsx运行中
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),
        )
}

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(..)booleanfalse不响应按压,也没有按压反馈;disabled: 样式生效,屏幕阅读器会报告它不可用。
selected.selected(..)booleanfalse选中:打开的开关、当前标签页。屏幕阅读器会报告。值由应用持有,样式也由应用决定。
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)))
}

状态变体在原生侧应用:按下时按钮在主线程上变暗,不需要运行任何 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()))
}

#用 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))
}

标签行用 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 或你自己的动画。