Elemente
Toggle
Ein Toggle ist eine Schaltfläche, die an oder aus bleibt: Fett in einer Text-Toolbar, ein Favoriten-Stern, eine Ansichtsoption. Toggle übernimmt das Verhalten eines Toggles: Drücken, Aktivierung per Tastatur, den deaktivierten Zustand und die Button-Rolle mit ihrem gedrückten Zustand für Screenreader, die ihn als Umschaltfläche ansagen. Eine ToggleGroup um mehrere Toggles ist eine Toolbar: Screenreader sagen sie als solche an, die Tastatur erreicht sie in einem Halt und wechselt mit den Pfeiltasten zwischen ihren Toggles. Das Aussehen liegt bei der App: Ein Toggle hat keine eigenen Farben, Größen, Icons oder Labels. Seine Kinder sind das Icon oder Label der App, und aria-pressed:-Klassen am Toggle oder group-aria-pressed:-Klassen auf seinem Inhalt folgen seinem Zustand.
import { Canvas, mount, signal, Text, View } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Split, tint, ui } from "./palette";
type Mark = "bold" | "italic" | "underline";
type Align = "left" | "center" | "right";
const marks: { id: Mark; label: string; glyph: string }[] = [
{ id: "bold", label: "Bold", glyph: "B" },
{ id: "italic", label: "Italic", glyph: "I" },
{ id: "underline", label: "Underline", glyph: "U" },
];
const aligns: { id: Align; label: string }[] = [
{ id: "left", label: "Align left" },
{ id: "center", label: "Align center" },
{ id: "right", label: "Align right" },
];
/** Three lines of a paragraph, set as `align` sets them. */
function Lines(props: { align: Align; color: string }) {
return (
<Canvas
style={{ pointerEvents: "none", width: 16, height: 16 }}
draw={(cx) => {
cx.strokeStyle = props.color;
cx.lineWidth = 1.6;
cx.lineCap = "round";
[12, 7, 10].forEach((w, i) => {
const x = props.align === "center" ? 8 - w / 2 : props.align === "right" ? 14 - w : 2;
cx.beginPath();
cx.moveTo(x, 4 + i * 4);
cx.lineTo(x + w, 4 + i * 4);
cx.stroke();
});
}}
/>
);
}
function App() {
const on = signal<Mark[]>(["bold"]);
const align = signal<Align>("left");
const starred = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 3));
const has = (m: Mark) => on().includes(m);
// A toolbar toggle: bare while off, raised while pressed (`pressedStyle`).
const look = (pressed: boolean) => ({
style: { width: desktop ? 28 : 34, height: desktop ? 28 : 34, borderRadius: 7, alignItems: "center", justifyContent: "center" } as const,
class: "transition-colors active:opacity-70 motion-reduce:transition-none",
pressedStyle: ui().raised,
hoverStyle: pressed ? undefined : hoverFill(),
focusStyle: focusRing(),
});
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toggle</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click a toggle, or Tab to the toolbar and move with the arrow keys" : "Tap a toggle to turn it on or off"}
</Text>
<Split gap={14} inspector={<Log title={`onChange · ${on().join(", ") || "none"}, ${align()}`} lines={events()} minHeight={76} />}>
{/* One toolbar: the marks any number at once, the alignment exactly one. */}
<ToggleGroup style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 2, padding: 3, borderRadius: 10, ...ui().inset }}>
<Each of={marks}>
{(m) => (
<Toggle
pressed={has(m().id)}
accessibilityLabel={m().label}
onChange={({ pressed }) => {
on.set(pressed ? [...on(), m().id] : on().filter((x) => x !== m().id));
log(`${m().label}: ${pressed}`);
}}
{...look(has(m().id))}
>
<Text
style={{
fontSize: 14,
color: has(m().id) ? ui().text : ui().muted,
fontWeight: m().id === "bold" ? "800" : "500",
fontStyle: m().id === "italic" ? "italic" : "normal",
textDecorationLine: m().id === "underline" ? "underline" : "none",
}}
>
{m().glyph}
</Text>
</Toggle>
)}
</Each>
<View style={{ width: 1, height: 16, marginHorizontal: 4, backgroundColor: ui().line }} />
<Each of={aligns}>
{(a) => (
<Toggle
pressed={align() === a().id}
accessibilityLabel={a().label}
onChange={({ pressed }) => {
// Exactly one: the pressed alignment stays pressed.
if (pressed) align.set(a().id);
log(`${a().label}: ${pressed}`);
}}
{...look(align() === a().id)}
>
<Lines align={a().id} color={align() === a().id ? ui().text : ui().muted} />
</Toggle>
)}
</Each>
</ToggleGroup>
{/* The text the toolbar formats: the app reads its own values. */}
<View style={{ ...ui().card, borderRadius: 14, padding: 14 }}>
<Text
style={{
fontSize: 15,
lineHeight: 22,
color: ui().text,
fontWeight: has("bold") ? "700" : "400",
fontStyle: has("italic") ? "italic" : "normal",
textDecorationLine: has("underline") ? "underline" : "none",
textAlign: align(),
}}
>
Every toggle reports the value it asks for; the app passes it back.
</Text>
</View>
{/* A toggle on its own, with a label: `aria-pressed:` tints it. */}
<Toggle
pressed={starred()}
onChange={({ pressed }) => {
starred.set(pressed);
log(`Star: ${pressed}`);
}}
class="transition-colors active:opacity-70 motion-reduce:transition-none"
style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 6, height: desktop ? 28 : 34, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: ui().line }}
pressedStyle={{ backgroundColor: tint(ui().accent, 0.16), borderColor: tint(ui().accent, 0.4) }}
hoverStyle={starred() ? undefined : hoverFill()}
focusStyle={focusRing()}
>
<Text style={{ fontSize: 14, color: starred() ? ui().accent : ui().muted }}>{starred() ? "★" : "☆"}</Text>
<Text style={{ fontSize: 13, fontWeight: "500", color: starred() ? ui().accent : ui().text }}>{starred() ? "Starred" : "Star"}</Text>
</Toggle>
</Split>
</View>
);
}
mount(App);use nsh_base::{theme, toggle, toggle_group};
use nsh_core::prelude::*;
fn formatting() -> impl IntoElement {
let bold = signal(true);
let italic = signal(false);
let item = move |label: &'static str, on: Signal<bool>| {
toggle()
.pressed(on)
.accessibility_label(label)
.on_change(move |pressed: &bool| on.set(*pressed))
.size_8()
.items_center()
.justify_center()
.rounded(move || theme().radius)
.pressed_style(|s| s.bg(move || theme().accent))
.child(text(&label[..1]).text_sm().text_color(move || {
if on.get() { theme().accent_foreground } else { theme().muted_foreground }
}))
};
toggle_group()
.flex_row()
.self_start()
.gap_1()
.child(item("Bold", bold))
.child(item("Italic", italic))
}import { mount, signal, Text } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
function App() {
const marks = signal<string[]>(["bold"]);
const item = (value: string, label: string) => (
<Toggle
pressed={marks().includes(value)}
accessibilityLabel={label}
onChange={({ pressed }) =>
marks.set(pressed ? [...marks(), value] : marks().filter((m) => m !== value))
}
class="group size-8 items-center justify-center rounded-(--radius) aria-pressed:bg-accent"
>
<Text class="text-sm text-muted-foreground group-aria-pressed:text-accent-foreground">{label[0]}</Text>
</Toggle>
);
return (
<ToggleGroup class="flex-row self-start gap-1">
{item("bold", "Bold")}
{item("italic", "Italic")}
</ToggleGroup>
);
}
mount(App);Toggle und ToggleGroup stammen aus nsh/base, dem nsh-base-Crate in Rust. Der Wert gehört der App: Jedem Toggle wird mitgeteilt, ob er gedrückt ist, ein Druck meldet den gewünschten Wert mit onChange, und der Toggle zeigt, was die App als pressed zurückgibt. Auch die Gruppe hält keinen Wert, daher entscheidet ebenfalls die App, wie viele Toggles gedrückt sein dürfen: beliebig viele (Fett, Kursiv und Unterstrichen) oder genau einer (eine Ausrichtung, ein View-Umschalter). Dann drückt die App den angeforderten Toggle, lässt die anderen los und hält den gedrückten gedrückt. In Rust erzeugen toggle() und toggle_group() sie, mit einem Setter pro Prop (.pressed(..)), einem Listener pro Ereignis (.on_change(..)), den typisierten Style-Methoden und .child(..) für ihren Inhalt.
#Props
#Toggle
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
pressed |
.pressed(..) |
boolean |
false |
An oder aus: Screenreader sagen, dass er gedrückt ist, und aria-pressed:-Stile greifen. Ein Druck meldet den anderen Wert mit onChange; der Toggle zeigt den Wert, den die App zurückgibt. |
disabled |
.disabled(..) |
boolean |
false |
Keine Drücke und kein Feedback beim Drücken; disabled:-Stile greifen, und Screenreader sagen, dass er abgeblendet ist. Ein Toggle in einer deaktivierten ToggleGroup ist ebenfalls deaktiviert. |
pressedStyle |
.pressed_style(|s| ..) |
StyleProp<ViewStyle> |
keine | Der Stil des Toggles im gedrückten, nicht deaktivierten Zustand, als aria-pressed:-Klassen. |
accessibilityLabel |
.accessibility_label(..) |
string |
keine | Was Screenreader ansagen: was der Toggle einschaltet, wenn seine Kinder keinen Text enthalten, der das sagt (ein Icon). |
hitSlop |
.hit_slop(..) |
number | Partial<Edges> |
keine | Wie weit außerhalb seiner Grenzen Drücke noch zählen, in Punkten: eine Zahl für alle Seiten oder die benannten Seiten. Wie hitSlop einer View. |
children |
.child(..) |
JSX.Element |
keine | Das Icon oder Label der App, das Screenreader ansagen. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keine | Der Toggle selbst: die Fläche um seinen Inhalt. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
keine | Der Stil des Toggles, während ein Druck läuft, bei Hover, bei Fokus oder wenn er deaktiviert ist, als <state>:-Klassen. |
#ToggleGroup
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
disabled |
.disabled(..) |
boolean |
false |
Jeder Toggle der Gruppe ist deaktiviert, unabhängig von seinem eigenen disabled. |
children |
.child(..) |
JSX.Element |
keine | Die Toggles der Gruppe und alles, was die App dazwischen anordnet (ein Trenner). |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keine | Layout und Aussehen der Gruppe: standardmäßig eine Spalte, flex-row für eine Zeile. |
#Ereignisse
| Ereignis | Rust | Beschreibung |
|---|---|---|
onChange (Toggle) |
.on_change(|pressed: &bool| ..) |
Der Toggle wurde gedrückt: ein Tippen, ein Klick, Leertaste oder Return bei Tastaturfokus oder die Aktivierung durch einen Screenreader. { pressed } ist der gewünschte Wert, das Gegenteil von pressed. Das Ereignis wird ausgelöst, bevor sich etwas ändert: Der Toggle zeigt, was die App als pressed zurückgibt. |
Eine Pfeiltaste in einer ToggleGroup bewegt nur den Tastaturfokus: Sie drückt nichts. Ein deaktivierter Toggle meldet nichts.
#Bestandteile
| Bestandteil | Enthalten in | Gestaltet mit | Zustände |
|---|---|---|---|
| Die Gruppe | class, style von ToggleGroup |
||
| Der Toggle | der Gruppe oder überall | class, style von Toggle |
aria-pressed:, disabled:, active:, hover:, focus: (oder pressedStyle, disabledStyle, …) |
| Das Icon oder Label | der Toggle | eigene Elemente (children) |
group-aria-pressed:, group-disabled: und die anderen group-*:-Varianten mit group auf dem class des Toggles |
aria-pressed: bedeutet, dass der Toggle an ist; active: ist ein laufender Druck, an oder aus. Zeige einen gedrückten Toggle über seine eigene Fläche: eine Füllung, eine erhabene Pille, ein kräftigeres Icon (siehe Design-Leitfäden).
#Segmentiert
Eine Gruppe, deren Toggles verbunden sind und von denen jeweils einer gedrückt ist, ist ein segmentiertes Steuerelement (ein View-Umschalter): Die App hält einen Wert und drückt den Toggle, der ihn benennt. Das verbundene Aussehen sind die Klassen der App:
<ToggleGroup class="flex-row self-start">
<Each of={["grid", "list"]}>
{(value) => (
<Toggle
pressed={view() === value()}
accessibilityLabel={`${value()} view`}
onChange={({ pressed }) => pressed && view.set(value())}
class="-ml-px h-8 px-3 border border-border first:ml-0 first:rounded-l-(--radius) last:rounded-r-(--radius) aria-pressed:bg-accent"
>
<Text class="text-sm">{value()}</Text>
</Toggle>
)}
</Each>
</ToggleGroup>
#Deaktiviert
Ein deaktivierter Toggle nimmt keine Drücke an, und Screenreader sagen ihn als abgeblendet an. Seine disabled:-Klassen haben Vorrang vor seinen aria-pressed:-Klassen, und group-disabled:-Klassen darin folgen ihm. disabled an einer ToggleGroup deaktiviert jeden Toggle darin, eine von der App gesperrte Toolbar:
<ToggleGroup disabled={readonly()} class="flex-row gap-1">
<Toggle pressed={bold()} onChange={({ pressed }) => bold.set(pressed)} class="size-8 disabled:opacity-50">
<Text>B</Text>
</Toggle>
</ToggleGroup>
#Ein kleines Ziel mit hitSlop
Ein Icon-Toggle kann kleiner sein als ein Finger. hitSlop lässt Drücke in seiner Nähe zählen, in Punkten über jede Seite hinaus, ohne sein Layout oder sein Aussehen zu ändern: eine Zahl für alle Seiten oder die benannten Seiten ({ top: 8, bottom: 8 }), so wie hitSlop einer View sie annimmt.
<Toggle
pressed={favorite()}
hitSlop={12}
accessibilityLabel="Favorite"
onChange={({ pressed }) => favorite.set(pressed)}
class="size-5 items-center justify-center"
>
<Text>★</Text>
</Toggle>
#Barrierefreiheit
- Ein Toggle ist für Screenreader ein einzelnes Element mit der Rolle
"button"und seinem gedrückten Zustand: eine Umschaltfläche. Sein Label ist der Text seiner Kinder oderaccessibilityLabel. ToggleGrouphat die Rolle"toolbar": Screenreader hören die Toggles darin als eine Toolbar, jeder weiterhin ein eigenes Element.disabledwird so angesagt, wie die Plattform es ansagt (VoiceOver, TalkBack, UI Automation, der Accessibility-Baum des Browsers).- Unter macOS und im Web ist die Gruppe ein Halt des Tastaturfokus, und die Pfeiltasten wechseln zwischen ihren Toggles (siehe unten).
Siehe Barrierefreiheit für Labels, Rollen und Ansagen im Allgemeinen.
#Tastatur
Unter macOS und im Web, wo Tab die Views erreicht, die Drücke annehmen, folgt eine ToggleGroup dem WAI-ARIA-Toolbar-Muster:
| Taste | Wirkung |
|---|---|
| Tab, Shift+Tab | Betritt die Gruppe beim ersten gedrückten Toggle (beim ersten nicht deaktivierten Toggle, falls keiner gedrückt ist) und verlässt sie in einem Schritt. |
| Pfeil rechts, Pfeil unten | Bewegt den Fokus zum nächsten Toggle, vom letzten zum ersten. |
| Pfeil links, Pfeil oben | Bewegt den Fokus zum vorherigen Toggle, vom ersten zum letzten. |
| Pos1, Ende | Bewegt den Fokus zum ersten oder zum letzten Toggle. |
| Leertaste, Return | Drückt den fokussierten Toggle. |
Die Pfeiltasten überspringen deaktivierte Toggles und drücken nichts: Ein Toggle ändert sich nur, wenn er gedrückt wird. Beide Pfeilpaare bewegen den Fokus, unabhängig vom Layout der Gruppe, das Sache der Klassen der App ist. In einem Rechts-nach-links-Layout tauschen Pfeil links und Pfeil rechts die Plätze: Pfeil links bewegt zum nächsten Toggle und Pfeil rechts zum vorherigen; Pfeil oben, Pfeil unten, Pos1 und Ende behalten ihre Bedeutung. focus:-Stile zeigen, wo der Fokus liegt. Ein Toggle
außerhalb einer ToggleGroup ist ein eigener Halt.
#Hinweise zu Plattformen
- iOS, Android und Mac Catalyst: Ein Druck ist ein Tippen, das auf dem Toggle endet, einschließlich seines Inhalts.
- Desktops (macOS, Windows, Linux, GPUI) und das Web: Ein Druck ist ein Klick. Unter macOS und im Web drücken außerdem Leertaste und Return den Toggle mit Tastaturfokus, und die Pfeiltasten, Pos1 und Ende bewegen den Fokus (Tastatur).
- Jede Plattform zeichnet, was die App gestaltet: die Toggles, die Fläche der gedrückten Toggles und die Spur der Gruppe.