Éléments
Button
Un bouton est une cible d’appui. Il gère le comportement : les appuis, le retour visuel d’appui via les styles d’état de l’app, les états désactivé et sélectionné, le focus clavier et son rôle pour les lecteurs d’écran. L’apparence appartient à l’app : un Button n’a ni couleurs, ni tailles, ni marges internes, ni libellé propres. Un même composant devient ainsi un bouton principal, un bouton d’icône, un onglet, une bascule de barre d’outils ou la ligne d’une liste, selon les styles et les enfants que vous lui donnez.
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 provient de nsh/base, le crate nsh-base en Rust. En TSX, son apparence passe par class et
style, avec les variantes d’état active:, hover:, focus:, selected: et disabled: (ou les
activeStyle, hoverStyle, focusStyle, selectedStyle et disabledStyle correspondants). En Rust, button() en crée un
comme view() crée une vue : un setter par prop (.disabled(..), .selected(..)), un écouteur par événement (.on_press(..)),
les méthodes de style typées (.px_4(), .bg(..)) pour son apparence, les méthodes d’état (.active(..), .hover(..),
.focus(..), .selected_style(..), .disabled_style(..)) pour ses états et .child(..) pour son contenu.
#Props
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | Aucun appui et aucun retour visuel d’appui ; les styles disabled: s’appliquent et les lecteurs d’écran l’annoncent comme grisé. |
selected | .selected(..) | boolean | false | Sélectionné : une bascule activée, l’onglet courant. Les lecteurs d’écran l’annoncent. L’app détient la valeur et la stylise. |
selectedStyle | .selected_style(|s| ..) | StyleProp<ViewStyle> | aucun | Le style lorsque selected est vrai et que le bouton n’est pas désactivé, sous forme de classes selected:. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Ce que disent les lecteurs d’écran lorsque le contenu ne suffit pas, par exemple pour une icône. |
role | .role(..) | AccessibilityRole | "button" | Ce qu’il est pour les lecteurs d’écran lorsqu’aucun composant nsh/base ne correspond à ce contrôle : "link" (il ouvre une URL), … |
hitSlop | .hit_slop(..) | number | Partial<Edges> | aucun | Jusqu’où, hors de ses limites, les appuis comptent encore, en points : un nombre pour tous les côtés, ou les côtés nommés. Comme le hitSlop d’une vue. |
children | .child(..) | JSX.Element | aucun | Le contenu : du texte, une icône, n’importe quelle disposition. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | Son apparence, en totalité. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | aucun | Le style lorsqu’il est pressé, survolé, focalisé ou désactivé, sous forme de classes <state>:. Les états suivants l’emportent sur les précédents ; disabled l’emporte sur tous. |
En Rust, chaque prop est un setter qui accepte une valeur, un signal ou une closure ; les props omises gardent leur valeur par défaut :
button()
.disabled(move || saving.get())
.accessibility_label("Save")
#Événements
| Événement | Rust | Description |
|---|---|---|
onPress | .on_press(|| ..) | Le bouton a été pressé : un toucher, un clic, Return ou Espace lorsqu’il a le focus clavier, ou l’activation par un lecteur d’écran. |
onPressDown | .on_press_down(|| ..) | Un appui a commencé sur le bouton. |
onPressUp | .on_press_up(|| ..) | L’appui s’est terminé : relâché, éloigné ou annulé. |
onPressDown et onPressUp servent à un retour visuel d’appui qui vous est propre, comme un ressort ou une surbrillance que les styles d’état n’expriment pas. Un bouton désactivé n’en signale aucun.
#Style
L’apparence d’un bouton appartient à votre app : c’est elle qui décide ce qu’est un bouton principal ou secondaire. Une paire de petits composants permet de garder cette décision en un seul endroit :
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>
);
}Les variantes d’état sont appliquées nativement : un appui assombrit le bouton sur le thread principal, sans exécuter de JS ni de Rust.
En Rust, les méthodes d’état correspondent aux mêmes variantes : .active(..), .hover(..), .focus(..), .selected_style(..) et
.disabled_style(..) suivent les états propres du bouton. Les couleurs de votre thème fonctionnent de la même façon : bg-primary en TSX,
theme().primary en Rust (voir Theme tokens).
#Désactivé
Un bouton désactivé ne reçoit aucun appui et ne donne aucun retour visuel, et les lecteurs d’écran l’annoncent comme grisé. Son apparence une fois désactivé vous appartient, avec des classes disabled: ou 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>#Une bascule avec selected
selected fait d’un bouton une bascule : le bouton gras d’une barre d’outils, une puce de filtre, l’onglet courant. L’app détient la valeur,
l’inverse dans onPress et stylise l’état sélectionné ; les lecteurs d’écran annoncent le bouton comme sélectionné.
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>Une rangée d’onglets se construit avec Tabs et un contrôle activé / désactivé avec Switch, issus de nsh/base : ils gèrent le rôle, l’état sélectionné et le clavier propres à leur type, ce que ne fait pas un Button avec role="tab" ou role="switch".
#Une petite cible avec hitSlop
Un bouton d’icône peut être plus petit qu’un doigt. hitSlop fait compter les appuis qui tombent près de lui, en points au-delà de chaque côté,
sans changer sa disposition ni son apparence : un nombre pour tous les côtés, ou les côtés nommés ({ top: 8, bottom: 8 }), comme
les accepte le hitSlop d’une vue.
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>#Accessibilité
- Un bouton est un seul élément pour les lecteurs d’écran, avec le rôle
"button"sauf indication contraire derole. Son libellé est son contenu textuel ; donnezaccessibilityLabellorsque le contenu est une icône ou en dit trop peu. disabledetselectedsont annoncés comme la plateforme les annonce (VoiceOver, TalkBack, UI Automation, l’arbre d’accessibilité du navigateur).- Sur macOS et le web, un bouton est une étape du focus clavier : Tab l’atteint, Return et Espace l’actionnent, et
les styles
focus:montrent où se trouve le focus.
Voir Accessibility pour les libellés, les rôles et les annonces en général.
#Notes par plateforme
- iOS, Android et Mac Catalyst : un appui est un toucher qui se termine sur le bouton ; s’éloigner l’annule, comme avec les boutons du système.
- Ordinateurs de bureau (macOS, Windows, Linux, GPUI) et web : un appui est un clic. Sur macOS et le web, Return et Espace
actionnent aussi le bouton qui a le focus clavier. Les styles
hover:s’appliquent avec une souris ou le pointeur d’un trackpad. - Chaque plateforme dessine ce que l’app stylise : le retour visuel d’appui est
active:/activeStyleou une animation de votre cru.