Elementos
Toggle
Um toggle é um botão que fica ligado ou desligado: negrito em uma barra de ferramentas de texto, uma estrela de favorito, uma opção de visualização. Toggle cuida do comportamento de um toggle: pressionamentos, ativação pelo teclado, o estado desabilitado e o papel de botão com seu estado pressionado para leitores de tela, que o anunciam como botão de alternância. Um ToggleGroup em volta de vários toggles é uma barra de ferramentas: leitores de tela a anunciam como tal, e o teclado a alcança em uma única parada e se move entre seus toggles com as setas. A aparência é do app: um toggle não tem cores, tamanhos, ícone ou rótulo próprios. Seus filhos são o ícone ou o rótulo do app, e as classes aria-pressed: no toggle, ou as classes group-aria-pressed: no que está dentro dele, acompanham seu estado.
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 e ToggleGroup vêm de nsh/base, o crate nsh-base em Rust. O valor é do app: cada toggle recebe a informação de se está pressionado, um pressionamento informa o valor que pede com onChange, e o toggle mostra o pressed que o app devolve. O grupo também não guarda valor, então quantos toggles podem ficar pressionados também é decisão do app: qualquer quantidade (negrito, itálico e sublinhado) ou exatamente um (um alinhamento, um alternador de visualização), caso em que o app pressiona o toggle solicitado, solta os outros e mantém o pressionado pressionado. Em Rust, toggle() e toggle_group() os criam, com um setter por prop (.pressed(..)), um listener por evento (.on_change(..)), os métodos de estilo tipados e .child(..) para o conteúdo.
#Props
#Toggle
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
pressed |
.pressed(..) |
boolean |
false |
Ligado ou desligado: leitores de tela dizem que está pressionado, e os estilos aria-pressed: se aplicam. Um pressionamento informa o outro valor com onChange; o toggle mostra o valor que o app devolve. |
disabled |
.disabled(..) |
boolean |
false |
Sem pressionamentos e sem feedback de pressão; os estilos disabled: se aplicam, e leitores de tela dizem que está esmaecido. Um toggle em um ToggleGroup desabilitado também fica desabilitado. |
pressedStyle |
.pressed_style(|s| ..) |
StyleProp<ViewStyle> |
nenhum | O estilo do toggle enquanto está pressionado e não desabilitado, como classes aria-pressed:. |
accessibilityLabel |
.accessibility_label(..) |
string |
nenhum | O que leitores de tela dizem: o que o toggle liga, quando seus filhos não têm texto que diga isso (um ícone). |
hitSlop |
.hit_slop(..) |
number | Partial<Edges> |
nenhum | Até onde, fora de seus limites, os pressionamentos ainda contam, em pontos: um número para todos os lados, ou os lados nomeados. Como o hitSlop de uma view. |
children |
.child(..) |
JSX.Element |
nenhum | O ícone ou rótulo do app, que os leitores de tela leem. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
nenhum | O próprio toggle: a superfície em volta do conteúdo. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
nenhum | O estilo do toggle enquanto um pressionamento está em andamento, com o ponteiro sobre ele, com foco ou desabilitado, como as classes <state>:. |
#ToggleGroup
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
disabled |
.disabled(..) |
boolean |
false |
Todos os toggles do grupo ficam desabilitados, independentemente de seu próprio disabled. |
children |
.child(..) |
JSX.Element |
nenhum | Os toggles do grupo e o que o app dispuser entre eles (um separador). |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
nenhum | O layout e a aparência do grupo: uma coluna por padrão, flex-row para uma linha. |
#Eventos
| Evento | Rust | Descrição |
|---|---|---|
onChange (Toggle) |
.on_change(|pressed: &bool| ..) |
O toggle foi pressionado: um toque, um clique, Espaço ou Return enquanto ele tem o foco do teclado, ou a ativação por um leitor de tela. { pressed } é o valor que ele pede, o oposto de pressed. Roda antes de qualquer mudança: o toggle mostra o pressed que o app devolve. |
Uma tecla de seta de um ToggleGroup apenas move o foco do teclado: não pressiona nada. Um toggle desabilitado não informa nada.
#Partes
| Parte | Dentro de | Estilizada com | Estados |
|---|---|---|---|
| O grupo | class, style de ToggleGroup |
||
| O toggle | o grupo, ou qualquer lugar | class, style de Toggle |
aria-pressed:, disabled:, active:, hover:, focus: (ou pressedStyle, disabledStyle, …) |
| O ícone ou rótulo | o toggle | seus próprios elementos (children) |
group-aria-pressed:, group-disabled: e as outras variantes group-*: com group no class do toggle |
aria-pressed: é o toggle estar ligado; active: é um pressionamento em andamento, ligado ou desligado. Mostre um toggle pressionado pela própria superfície dele: um preenchimento, uma pílula elevada, um ícone mais forte (veja Guias de design).
#Segmentado
Um grupo cujos toggles são unidos, com um pressionado por vez, é um controle segmentado (um alternador de visualização): o app mantém um valor e pressiona o toggle que o nomeia. A aparência unida são as classes do 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>
#Desabilitado
Um toggle desabilitado não aceita pressionamentos, e leitores de tela o anunciam como esmaecido. Suas classes disabled: se aplicam sobre as aria-pressed:, e as classes group-disabled: dentro dele as acompanham. disabled em um ToggleGroup desabilita todos os toggles dentro dele, uma barra de ferramentas que o app trava:
<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>
#Um alvo pequeno com hitSlop
Um toggle de ícone pode ser menor que um dedo. hitSlop faz com que pressionamentos que caem perto dele contem, em pontos além de cada lado, sem mudar seu layout nem sua aparência: um número para todos os lados, ou os lados nomeados ({ top: 8, bottom: 8 }), como o hitSlop de uma view os recebe.
<Toggle
pressed={favorite()}
hitSlop={12}
accessibilityLabel="Favorite"
onChange={({ pressed }) => favorite.set(pressed)}
class="size-5 items-center justify-center"
>
<Text>★</Text>
</Toggle>
#Acessibilidade
- Um toggle é um único elemento para leitores de tela, com o papel
"button"e seu estado pressionado: um botão de alternância. Seu rótulo é o texto de seus filhos, ouaccessibilityLabel. ToggleGrouptem o papel"toolbar": leitores de tela ouvem os toggles dentro dele como uma única barra de ferramentas, cada um ainda como um elemento próprio.disabledé anunciado como a plataforma o anuncia (VoiceOver, TalkBack, UI Automation, a árvore de acessibilidade do navegador).- No macOS e na web, o grupo é uma única parada do foco do teclado, e as setas se movem entre seus toggles (abaixo).
Veja Acessibilidade para rótulos, papéis e anúncios em geral.
#Teclado
No macOS e na web, onde Tab alcança as views que aceitam pressionamentos, um ToggleGroup segue o padrão de barra de ferramentas do WAI-ARIA:
| Tecla | O que faz |
|---|---|
| Tab, Shift+Tab | Entra no grupo pelo primeiro toggle pressionado (se nenhum estiver pressionado, pelo primeiro toggle que não esteja desabilitado) e sai dele em um único passo. |
| Direita, Baixo | Move o foco para o próximo toggle, do último para o primeiro. |
| Esquerda, Cima | Move o foco para o toggle anterior, do primeiro para o último. |
| Home, End | Move o foco para o primeiro ou para o último toggle. |
| Espaço, Return | Pressiona o toggle com foco. |
As setas ignoram toggles desabilitados e não pressionam nada: um toggle só muda quando é pressionado. Os dois pares de setas movem o foco, qualquer que seja o layout do grupo, que é uma classe do app. Em um layout da direita para a esquerda, Esquerda e Direita trocam de lugar: Esquerda move para o próximo toggle e Direita para o anterior; Cima, Baixo, Home e End mantêm o seu comportamento. Os estilos focus: mostram onde está o foco. Um Toggle fora de um ToggleGroup é uma parada própria.
#Notas de plataforma
- iOS, Android e Mac Catalyst: um pressionamento é um toque que termina sobre o toggle, incluindo seu conteúdo.
- Desktops (macOS, Windows, Linux, GPUI) e web: um pressionamento é um clique. No macOS e na web, Espaço e Return também pressionam o toggle que tem o foco do teclado, e as setas, Home e End o movem (Teclado).
- Todas as plataformas desenham o que o app estiliza: os toggles, a superfície dos pressionados e a trilha do grupo.