Elementos
Toast
Toasts são mensagens curtas exibidas por um tempo sobre o app: “Copiado”, “Rascunho excluído · Desfazer”, “Não foi possível enviar”.
nsh/base fornece o comportamento deles em três peças sem estilo, assim como o gpui-base do GPUI Kit:
import { mount, Overlay, signal, Text, View } from "nsh";
import { Toast, type ToastEntry, ToastManager, ToastStack } from "nsh/base";
import { safeArea } from "nsh";
import { button, buttonText, desktop, Log, size, ui, wide } from "./palette";
/** What the demo's toasts show. */
type Content = { title: string; description?: string; undo?: boolean };
function App() {
const manager = new ToastManager();
const shown = signal<ToastEntry[]>([]);
const expanded = signal(false);
const events = signal<string[]>([]);
const log = (e: string) => events.set([e, ...events()].slice(0, 6));
const sync = () => shown.set(manager.visible(3));
// The lifecycle advances on the app's timer, paused while the stack is expanded.
setInterval(() => {
const a = manager.advance(expanded());
for (const id of a.presented) log(`presented · ${id}`);
for (const id of a.ending) log(`ending · ${id}`);
for (const r of a.removed) log(`removed · ${r.id}`);
if (a.changed) sync();
}, 50);
let count = 0;
const push = (content: Content) => {
const id = `toast-${++count}`;
manager.push(id, content, { timeout: 5000 });
log(`push · ${id}`);
sync();
};
const dismiss = (id: string) => {
manager.dismiss(id);
sync();
};
/** A button of the demo. */
const action = (label: string, onPress: () => void) => (
<View onPress={onPress} {...button()}>
<Text style={buttonText()}>{label}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toast</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Show a few; rest the pointer on the stack to expand it and stop their time" : "Show a few; touch the stack to expand it, swipe one right to dismiss it"}
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{action("Copy the link", () => push({ title: "Copied", description: "The link is on the clipboard" }))}
{action("Delete the draft", () => push({ title: "Draft deleted", undo: true }))}
{action("Dismiss all", () => {
manager.dismissAll();
sync();
})}
</View>
<Log title="ToastManager.advance · onExpandedChange" lines={events()} minHeight={130} />
<Overlay style={{ pointerEvents: "box-none", left: 0, right: 0, bottom: 0, padding: 16, paddingBottom: safeArea().bottom + 16, alignItems: wide() ? "flex-end" : "center" }}>
<ToastStack
placement={wide() ? "bottomRight" : "bottomCenter"}
style={{ width: 340, maxWidth: "100%" }}
onExpandedChange={({ expanded: open }) => {
expanded.set(open);
log(`onExpandedChange · ${open}`);
}}
onCancel={() => {
const newest = shown().filter((t) => t.status !== "ending").at(-1);
if (newest) dismiss(newest.id);
}}
>
<Each of={shown()} key={(t: ToastEntry) => t.id}>
{(t) => {
const content = () => t().value as Content;
return (
<Toast
transitionStatus={t().status}
accessibilityLabel={content().title}
onDismissRequest={() => dismiss(t().id)}
class="transition-all duration-200 ease-out"
startingClass="opacity-0 translate-y-4"
endingClass="opacity-0 scale-95"
swipingClass="transition-none"
style={{ ...ui().card, flexDirection: "row", alignItems: "center", gap: 12, padding: 14, borderRadius: 12 }}
>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: 14, fontWeight: "600", color: ui().text }}>{content().title}</Text>
<If when={content().description}>
<Text style={{ fontSize: 13, color: ui().muted }}>{content().description}</Text>
</If>
</View>
<If when={content().undo}>
<View onPress={() => dismiss(t().id)} {...button("primary", { size: "sm" })}>
<Text style={buttonText("primary")}>Undo</Text>
</View>
</If>
</Toast>
);
}}
</Each>
</ToastStack>
</Overlay>
</View>
);
}
mount(App);ToastManagermantém os toasts em ordem com seu ciclo de vida: cada um está"starting", depois"present"assim que a duração do movimento passa, faz a contagem regressiva do seu timeout enquanto está presente e não pausado, e permanece"ending"pela duração da saída antes de ser removido.ToastStackmede seus toasts e os organiza a partir de uma borda, com o mais novo mais próximo dela: camadas sobrepostas quando recolhida, uma lista quando expandida (com o ponteiro sobre ela, ou um dedo pressionado sobre ela), movidos com springs.Toasté a raiz de cada toast: o papelalert, seutransitionStatuspara os estilos do app, um gesto de deslizar que pede para dispensá-lo, e o leitor de tela lendo seu rótulo quando ele aparece.
A aparência e o conteúdo são do app, e também a conveniência de mostrar um toast de qualquer lugar: um app cria seu próprio toast(...) sobre essas peças, como faz a galeria (examples/gallery/toast.tsx) e como as notificações do GPUI Kit são criadas sobre os toasts de gpui-base.
use std::time::Duration;
use nsh_core::{time::now, prelude::*};
use nsh_base::{
Anchor, ToastManager, ToastMotion, ToastOptions, ToastStackState, theme, toast, toast_stack,
};
fn toasts() -> impl IntoElement {
let manager = stored(ToastManager::<u64, SharedString>::new(ToastMotion::sonner()));
let shown = signal(Vec::new());
let state = ToastStackState::new();
let sync = move || {
shown.set(manager.with(|m| {
m.visible(3)
.map(|(id, t, s)| (*id, t.clone(), s))
.collect::<Vec<_>>()
}))
};
// Advance the lifecycle while toasts are mounted, paused while expanded.
set_interval(Duration::from_millis(50), move || {
if manager.with(|m| m.advance(now(), state.is_expanded()).changed) {
sync();
}
});
manager.with(|m| {
m.push(
1,
"Copied".into(),
ToastOptions::timeout(Some(Duration::from_secs(5))),
now(),
)
});
sync();
toast_stack()
.placement(Anchor::BottomCenter)
.state(state)
.w(px(340.0))
.child(each(
shown,
|(id, _, _)| *id,
move |item, _| {
toast()
.transition_status(move || item.get().2)
.accessibility_label(move || Some(item.get().1))
.on_dismiss_request(move || {
manager.with(|m| m.dismiss(&item.get().0, now()));
})
.rounded_xl()
.px_4()
.py_3()
.bg(move || theme().card)
.shadow_lg()
.transition_all()
.duration(200.0)
.starting_class("opacity-0 translate-y-4")
.ending_class("opacity-0 scale-95")
.child(text(move || item.get().1))
},
))
}import { Overlay, signal, Text, safeArea } from "nsh";
import { Toast, type ToastEntry, ToastManager, ToastStack } from "nsh/base";
const manager = new ToastManager();
const shown = signal<ToastEntry[]>([]);
const expanded = signal(false);
const sync = () => shown.set(manager.visible(3));
// Advance the lifecycle on a timer, paused while the stack is expanded.
setInterval(() => {
if (manager.advance(expanded()).changed) sync();
}, 50);
export function toast(title: string) {
manager.push(crypto.randomUUID(), { title }, { timeout: 5000 });
sync();
}
export function Toaster() {
return (
<Overlay class="inset-x-0 bottom-0 items-center p-4" style={{ paddingBottom: safeArea().bottom + 16 }}>
<ToastStack placement="bottomCenter" class="w-80" onExpandedChange={(e) => expanded.set(e.expanded)} onCancel={() => {
const newest = shown().filter((t) => t.status !== "ending").at(-1);
if (newest) { manager.dismiss(newest.id); sync(); }
}}>
<Each of={shown()} key={(t: ToastEntry) => t.id}>
{(t) => (
<Toast
transitionStatus={t().status}
accessibilityLabel={(t().value as { title: string }).title}
onDismissRequest={() => { manager.dismiss(t().id); sync(); }}
class="rounded-xl bg-card px-4 py-3 shadow-lg transition-all duration-200"
startingClass="opacity-0 translate-y-4"
endingClass="opacity-0 scale-95"
>
<Text class="font-semibold text-card-foreground">{(t().value as { title: string }).title}</Text>
</Toast>
)}
</Each>
</ToastStack>
</Overlay>
);
}Toast, ToastStack e ToastManager vêm de nsh/base. Em Rust, são toast, toast_stack e ToastManager de nsh_base.
#ToastManager
O manager é headless: não renderiza nada e não executa nenhum timer. O app adiciona toasts, renderiza os que visible(limit) retorna com seu status e chama advance em um timer enquanto houver algum montado, passando paused enquanto a pilha está expandida. Em Rust é ToastManager<I, T> sobre os ids e valores do app, com um Instant para cada chamada; em TS é uma classe sobre ids string e qualquer valor JSON, no relógio do engine (incluindo o relógio manual de um teste).
push(id, value, options)adiciona o toast mais novo, substituindo um com o mesmo id (cujo valor ele retorna);options.timeouté seu tempo de exibição em milissegundos (nenhum: ele permanece até ser dispensado).dismiss(id)inicia a saída de um toast;dismissAll()a de todos, retornando seus ids.advance(paused)avança o ciclo de vida e informa o que mudou:presented,ending(seu timeout se esgotou),removed(com seus valores) echanged.visible(limit)são oslimittoasts mais novos que não estão encerrando mais os que estão encerrando, do mais antigo ao mais novo;entries()(Rustiter()) todos eles;get(id),len(),isEmpty().
ToastMotion guarda os tokens de ambos: duration (400 ms: o prazo da entrada e a resposta do spring da pilha), exitDuration (200 ms), collapsedPeek (14), expandedGap (14), collapsedScaleStep (0.05) e collapsedVisible (3), os do Sonner (ToastMotion::sonner() em Rust; campos omitidos em TS mantêm esses valores).
#ToastStack
Os filhos da pilha são seus toasts, do mais antigo ao mais novo. A partir da sua borda placement ("topRight" por padrão; as âncoras inferiores organizam para cima), os toasts recolhidos se sobrepõem com collapsedPeek de distância, cada um adicional mais estreito em collapsedScaleStep, e apenas collapsedVisible aparecem; expandidos, formam uma lista com expandedGap de espaçamento. Toasts que chegam ou saem, e a expansão, são movidos com springs criticamente amortecidos. Ela fica expandida enquanto o ponteiro está sobre ela ou um dedo está pressionado sobre ela (onExpandedChange({ expanded }); Rust ToastStackState, que is_expanded() lê), de modo que o app pausa os timeouts enquanto o usuário lê ou toca nos toasts.
O app posiciona a pilha: em um Overlay na borda, dentro da safe area. Seus class e style definem sua largura. Enquanto contém toasts, ela escuta Escape e o botão voltar do Android (onCancel; closeOnEscape={false} os deixa para o restante do app): dispensar o mais novo.
#Toast
Um toast é o papel alert ao redor do conteúdo do app. Seu transitionStatus é o do manager, e suas classes de estado o acompanham por meio de class: startingClass em seu primeiro frame enquanto "starting", para que suas classes transition animem a partir dele (starting: do Tailwind, @starting-style do CSS); endingClass enquanto "ending", pela duração da saída; swipingClass enquanto um ponteiro o desliza (transition-none o mantém sob o dedo).
Um dedo ou o mouse arrastado em direção a swipeDirection ("right" por padrão; null desativa o deslizar) o move, depois que o ponteiro se moveu, para que seus botões continuem sendo botões; ao soltar além de swipeThreshold pontos (50) ou com um arremesso, ele sai por completo e pede para ser dispensado (onDismissRequest); antes disso, ele volta. O leitor de tela lê seu accessibilityLabel quando ele aparece.
#Props de Toast
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
transitionStatus |
.transition_status(..) |
"starting" | "present" | "ending" |
"starting" |
Sua fase do ciclo de vida. |
startingClass, endingClass, swipingClass |
.starting_class(..), … |
string |
nenhum | Suas classes no primeiro frame, durante o encerramento e durante o deslize. |
swipeDirection |
.swipe_direction(..) |
"right" | "left" | "up" | "down" | null |
"right" |
A direção em que ele é deslizado para fora. |
swipeThreshold |
.swipe_threshold(..) |
number |
50 |
Quanto um deslize vai antes que soltar peça para dispensá-lo. |
accessibilityLabel |
.accessibility_label(..) |
string |
nenhum | O que o leitor de tela lê quando ele aparece. |
onDismissRequest |
.on_dismiss_request(..) |
() => void |
nenhum | O usuário o deslizou para fora. |
#Props de ToastStack
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
motion |
.motion(..) |
ToastMotion |
os do Sonner | Seus tokens de movimento. |
placement |
.placement(..) |
"topLeft" | "topCenter" | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | "leftCenter" | "rightCenter" |
"topRight" |
A borda a partir da qual ela organiza. |
closeOnEscape |
.close_on_escape(..) |
boolean |
true |
Ela escuta Escape e voltar enquanto contém toasts. |
onExpandedChange |
.on_expanded_change(..), .state(..) |
(e: { expanded: boolean }) => void |
nenhum | A interação a expandiu ou recolheu. |
onCancel |
.on_cancel(..) |
() => void |
nenhum | Escape ou voltar: dispensar o mais novo. |