要素
Button
ボタンは押下の対象です。押下、アプリの状態スタイルによる押下フィードバック、無効状態と選択状態、キーボードフォーカス、スクリーンリーダー向けのロールといった振る舞いを担います。見た目はアプリ側で決めます。Button は色、サイズ、パディング、ラベルを一切持たないため、与えるスタイルと子要素に応じて、1つのコンポーネントがプライマリボタン、アイコンボタン、タブ、ツールバーのトグル、リストの行のいずれにもなります。
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),
)
}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 は Rust の nsh-base クレートである nsh/base から提供されます。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(..) が用意されています。
#プロパティ
| プロパティ | Rust | 型 | 既定値 | 説明 |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | 押下も押下フィードバックも発生しません。disabled: スタイルが適用され、スクリーンリーダーは無効(dimmed)状態として読み上げます。 |
selected | .selected(..) | boolean | false | 選択状態です。オンになっているトグルや現在のタブなどに使います。スクリーンリーダーもそのように読み上げます。値はアプリが保持し、スタイルもアプリが指定します。 |
selectedStyle | .selected_style(|s| ..) | StyleProp<ViewStyle> | なし | selected かつ無効でないときのスタイルで、selected: クラスとして指定します。 |
accessibilityLabel | .accessibility_label(..) | string | なし | アイコンなどコンテンツだけでは不十分な場合に、スクリーンリーダーが読み上げる内容です。 |
role | .role(..) | AccessibilityRole | "button" | そのコントロールに該当する nsh/base コンポーネントがない場合に、スクリーンリーダーに伝える種類です。たとえば "link"(URL を開く)などがあります。 |
hitSlop | .hit_slop(..) | number | Partial<Edges> | なし | 境界の外側どこまでを押下として扱うかをポイント単位で指定します。全辺共通の1つの数値か、辺ごとの指定を渡せます。view の hitSlop と同じです。 |
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 または Space、スクリーンリーダーによるアクティベートが該当します。 |
onPressDown | .on_press_down(|| ..) | ボタン上で押下が始まりました。 |
onPressUp | .on_press_up(|| ..) | 押下が終わりました。指を離した、ボタンの外へ移動した、またはキャンセルされた場合です。 |
onPressDown と onPressUp は、スプリングアニメーションや状態スタイルでは表現できないハイライトなど、独自の押下フィードバックのためのものです。無効状態のボタンはこれらをいずれも通知しません。
#スタイル設定
ボタンの見た目はアプリ側のものです。何をプライマリボタンやセカンダリボタンとするかはアプリが決めます。小さなコンポーネントを組にすると、その決定を1か所にまとめられます。
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)))
}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 です(テーマトークンを参照)。
#無効状態
無効状態のボタンは押下を受け付けず、フィードバックも返さず、スクリーンリーダーは無効(dimmed)として読み上げます。無効時の見た目は 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 のコンポーネントで、それぞれの種類に応じたロール、選択状態、キーボード操作を備えています。Button に role="tab" や role="switch" を指定しても、これらは得られません。
#hitSlop による小さなタップ対象
アイコンボタンは指より小さいことがあります。hitSlop を指定すると、レイアウトや見た目を変えずに、各辺の外側にあるポイント単位の範囲での押下も有効になります。全辺共通の1つの数値か、辺ごとの指定({ top: 8, bottom: 8 })を渡せます。これは view の hitSlop と同じ指定方法です。
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 hitSlop={12} accessibilityLabel="Close" onPress={() => open.set(false)} class="size-5 items-center justify-center">
<Text>×</Text>
</Button>#アクセシビリティ
- ボタンはスクリーンリーダーにとって1つの要素で、
roleで別途指定しない限りロールは"button"です。ラベルはテキストコンテンツになります。コンテンツがアイコンの場合や情報が足りない場合はaccessibilityLabelを指定してください。 disabledとselectedは、各プラットフォームの通常の方法で読み上げられます(VoiceOver、TalkBack、UI Automation、ブラウザーのアクセシビリティツリー)。- macOS と Web では、ボタンはキーボードフォーカスの停止位置になります。Tab で移動でき、Return と Space で押下でき、
focus:スタイルでフォーカスの位置を示せます。
ラベル、ロール、読み上げ全般については、アクセシビリティを参照してください。
#プラットフォームごとの注記
- iOS、Android、Mac Catalyst: 押下は、ボタン上で終わるタップです。システム標準のボタンと同様に、外へ移動するとキャンセルされます。
- デスクトップ(macOS、Windows、Linux、GPUI)と Web: 押下はクリックです。macOS と Web では、キーボードフォーカスのあるボタンを Return や Space でも押下できます。
hover:スタイルは、マウスやトラックパッドのポインターの下で適用されます。 - どのプラットフォームでも、描画されるのはアプリが指定したスタイルです。押下フィードバックは
active:/activeStyleか、独自のアニメーションで実現します。