Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

Button

Button — это цель для нажатия. Он отвечает за поведение: нажатия, отклик на нажатие через стили состояний приложения, состояния disabled и selected, фокус клавиатуры и свою роль для скринридеров. Внешний вид определяет приложение: у Button нет собственных цветов, размеров, отступов или подписи, поэтому один и тот же компонент может стать основной кнопкой, кнопкой-иконкой, вкладкой, переключателем панели инструментов или строкой списка, в зависимости от переданных стилей и дочерних элементов.

el-button.tsx
el-button.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Button, Switch } from "nsh/base";
import { button, buttonText, desktop, Inspector, Panel, size, Stage, ui, wide } from "./palette";

const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`;
const thumb = desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none 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("–");
  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()}
      {...button("accent")}
    >
      <Text style={buttonText("accent")}>Pressed {presses()}×</Text>
    </Button>
  );
  const pressState = () => `${pressing() ? "onPressDown: pressing…" : "onPressUp: released"} · disabled ${String(disabled())}`;
  const disabler = () => (
    <Switch
      checked={disabled()}
      onChange={({ checked }) => disabled.set(checked)}
      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}
      {...button("secondary", { on, align: "flex-start", style: { width: size.control, paddingHorizontal: 0 } })}
    >
      <Text style={{ ...text(), color: buttonText("secondary", { on }).color }}>{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::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, крейта nsh-base для Rust. В TSX его внешний вид задают class и style с вариантами состояний active:, hover:, focus:, selected: и disabled: (или соответствующими activeStyle, hoverStyle, focusStyle, selectedStyle и disabledStyle). В Rust button() создаёт кнопку так же, как view() создаёт view: по сеттеру на каждый проп (.disabled(..), .selected(..)), по слушателю на каждое событие (.on_press(..)), типизированные методы стиля (.px_4(), .bg(..)) для внешнего вида, методы состояний (.active(..), .hover(..), .focus(..), .selected_style(..), .disabled_style(..)) для состояний и .child(..) для содержимого.

#Props

ПропRustТипПо умолчаниюОписание
disabled.disabled(..)booleanfalseНет нажатий и нет отклика на нажатие; применяются стили disabled:, а скринридеры сообщают, что элемент неактивен.
selected.selected(..)booleanfalseВыбрано: включённый переключатель, текущая вкладка. Скринридеры сообщают об этом. Значением владеет приложение, оно же задаёт стиль.
selectedStyle.selected_style(|s| ..)StyleProp<ViewStyle>нетСтиль, пока selected и элемент не disabled, в виде классов selected:.
accessibilityLabel.accessibility_label(..)stringнетЧто говорят скринридеры, когда содержимого недостаточно, например для иконки.
role.role(..)AccessibilityRole"button"Чем элемент является для скринридеров, когда ни один компонент nsh/base не соответствует этому контролу: "link" (открывает URL), …
hitSlop.hit_slop(..)number | Partial<Edges>нетНа каком расстоянии за границами элемента нажатия всё ещё засчитываются, в пунктах: одно число для всех сторон или указанные стороны по отдельности. Как hitSlop у view.
children.child(..)JSX.ElementнетСодержимое: текст, иконка, любой layout.
class, styleметоды стиляstring, StyleProp<ViewStyle>нетВнешний вид целиком.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>нетСтиль в состояниях pressed, hovered, focused или disabled, в виде классов <state>:. Более поздние состояния имеют приоритет; disabled перекрывает все остальные.

В Rust каждый проп — это сеттер, принимающий значение, сигнал или замыкание; пропы, которые вы не указали, сохраняют значения по умолчанию:

button()
    .disabled(move || saving.get())
    .accessibility_label("Save")

#События

СобытиеRustОписание
onPress.on_press(|| ..)Кнопку нажали: касание, клик, Return или Space при фокусе клавиатуры на кнопке, либо активация скринридером.
onPressDown.on_press_down(|| ..)Нажатие началось на кнопке.
onPressUp.on_press_up(|| ..)Нажатие закончилось: отпущено, уведено в сторону или отменено.

onPressDown и onPressUp нужны для собственного отклика на нажатие, например пружинной анимации или подсветки, которые стили состояний выразить не могут. Кнопка в состоянии disabled не сообщает ни об одном из этих событий.

#Стилизация

Внешний вид кнопки определяет ваше приложение: именно оно решает, что такое основная или второстепенная кнопка. Пара небольших компонентов позволяет хранить это решение в одном месте:

use nsh_base::button;
use nsh_core::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(..) следуют собственным состояниям кнопки. Цвета вашей темы работают так же: bg-primary в TSX, theme().primary в Rust (см. Theme tokens).

#Disabled

Кнопка в состоянии disabled не принимает нажатия и не даёт отклика, а скринридеры объявляют её как неактивную. Её внешний вид в этом состоянии определяете вы, с помощью классов 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 и стилизует состояние selected; скринридеры объявляют кнопку как выбранную.

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: они отвечают за роль, состояние selected и поведение клавиатуры своего типа, чего Button с role="tab" или role="switch" не делает.

#Маленькая цель с hitSlop

Кнопка-иконка может быть меньше пальца. hitSlop заставляет засчитываться нажатия рядом с ней, в пунктах за каждой стороной, не меняя её layout и внешний вид: одно число для всех сторон или указанные стороны по отдельности ({ top: 8, bottom: 8 }), как их принимает hitSlop у view.

fn close(open: Signal<bool>) -> impl IntoElement {
    button()
        .hit_slop(EdgeInsets::all(px(12.0)))
        .accessibility_label("Close")
        .on_press(move || open.set(false))
        .size(px(20.0))
        .items_center()
        .justify_center()
        .child(text("×"))
}

#Доступность

  • Для скринридеров кнопка — один элемент с ролью "button", если role не указывает иное. Её подпись — это её текстовое содержимое; задайте accessibilityLabel, когда содержимое — иконка или говорит слишком мало.
  • disabled и selected объявляются так, как их объявляет платформа (VoiceOver, TalkBack, UI Automation, дерево доступности браузера).
  • На macOS и в вебе кнопка является остановкой фокуса клавиатуры: до неё можно дойти клавишей Tab, Return и Space нажимают её, а стили focus: показывают, где находится фокус.

Общие сведения о подписях, ролях и объявлениях см. в разделе Accessibility.

#Примечания по платформам

  • iOS, Android и Mac Catalyst: нажатие — это касание, заканчивающееся на кнопке; если отвести палец в сторону, нажатие отменяется, как у системных кнопок.
  • Десктопы (macOS, Windows, Linux, GPUI) и веб: нажатие — это клик. На macOS и в вебе Return и Space также нажимают кнопку, у которой есть фокус клавиатуры. Стили hover: применяются при наведении мыши или указателя трекпада.
  • Каждая платформа рисует то, что задаёт стиль приложения: отклик на нажатие — это active: / activeStyle или ваша собственная анимация.