Elementi
Toast
I toast sono brevi messaggi mostrati per qualche istante sopra l’app: “Copiato”, “Bozza eliminata · Annulla”, “Invio non riuscito”.
nsh/base ne fornisce il comportamento in tre parti senza stile, come il gpui-base di 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);ToastManagermantiene i toast in ordine con il loro ciclo di vita: ognuno è"starting", poi"present"una volta trascorsa la durata del movimento, conta alla rovescia il proprio timeout finché è presente e non in pausa, e resta"ending"per la durata dell’uscita prima di essere rimosso.ToastStackmisura i propri toast e li dispone a partire da un bordo, con il più recente più vicino a esso: livelli sovrapposti quando è compresso, un elenco quando è espanso (con il puntatore sopra, o un dito appoggiato sopra), mossi con molle.Toastè la radice di ogni toast: il ruoloalert, il suotransitionStatusper gli stili dell’app, uno swipe che chiede di chiuderlo, e lo screen reader che legge la sua etichetta quando compare.
L’aspetto e il contenuto spettano all’app, così come la comodità di mostrare un toast da qualsiasi punto: un’app costruisce il
proprio toast(...) su questi elementi, come fa la gallery (examples/gallery/toast.tsx) e come le notifiche di GPUI Kit sono
costruite sui toast di 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 provengono da nsh/base. In Rust sono
toast, toast_stack e ToastManager di nsh_base.
#ToastManager
Il manager è headless: non renderizza nulla e non esegue alcun timer. L’app aggiunge i toast, renderizza quelli
restituiti da visible(limit) con il loro stato e chiama advance a intervalli finché ce n’è almeno uno montato, passando paused
mentre lo stack è espanso. In Rust è ToastManager<I, T> sugli id e sui valori dell’app, con un Instant per
ogni chiamata; in TS è una classe su id di tipo stringa e qualsiasi valore JSON, basata sull’orologio del motore (compreso
l’orologio manuale di un test).
push(id, value, options)aggiunge il toast più recente, sostituendo quello con lo stesso id (di cui restituisce il valore);options.timeoutè il suo tempo di permanenza in millisecondi (assente: resta finché non viene chiuso).dismiss(id)avvia l’uscita di un toast;dismissAll()quella di tutti, restituendone gli id.advance(paused)fa avanzare il ciclo di vita e riporta cosa è cambiato:presented,ending(il loro timeout è scaduto),removed(con i loro valori) echanged.visible(limit)sono ilimittoast più recenti che non stanno terminando più quelli in uscita, dal più vecchio;entries()(in Rustiter()) tutti;get(id),len(),isEmpty().
ToastMotion contiene i token di entrambi: duration (400 ms: la scadenza dell’ingresso e la risposta della molla dello stack),
exitDuration (200 ms), collapsedPeek (14), expandedGap (14), collapsedScaleStep (0.05) e
collapsedVisible (3), quelli di Sonner (ToastMotion::sonner() in Rust; i campi omessi in TS mantengono questi valori).
#ToastStack
I figli dello stack sono i suoi toast, dal più vecchio. A partire dal suo bordo placement ("topRight" per impostazione predefinita; gli
ancoraggi in basso dispongono verso l’alto), i toast compressi si sovrappongono a distanza di collapsedPeek, ciascuno successivo più stretto di
collapsedScaleStep, e ne compaiono solo collapsedVisible; da espansi, formano un elenco con expandedGap di distanza. I toast che
arrivano o se ne vanno, e l’espansione, li muovono con molle a smorzamento critico. È espanso mentre il puntatore è sopra di esso
o un dito è appoggiato sopra (onExpandedChange({ expanded }); in Rust ToastStackState, che is_expanded() legge), così
l’app mette in pausa i timeout mentre l’utente legge o tocca i toast.
È l’app a posizionare lo stack: in un Overlay sul bordo, all’interno della safe area. Il suo class e il suo style
ne determinano la larghezza. Finché contiene toast, ascolta Escape e il pulsante Indietro di Android (onCancel; closeOnEscape={false}
li lascia al resto dell’app): chiude il più recente.
#Toast
Un toast è il ruolo alert attorno al contenuto dell’app. Il suo transitionStatus è quello del manager, e le sue classi di stato
lo seguono tramite class: startingClass nel suo primo frame mentre è "starting", così le sue classi transition si animano
a partire da esso (starting: di Tailwind, @starting-style di CSS); endingClass mentre è "ending", per la durata dell’uscita;
swipingClass mentre un puntatore lo trascina con uno swipe (transition-none lo mantiene sotto il dito).
Un dito o il mouse trascinati verso swipeDirection ("right" per impostazione predefinita; null disattiva lo swipe) lo spostano, una volta
che il puntatore si è mosso, così i suoi pulsanti restano pulsanti; se rilasciato oltre swipeThreshold punti (50) o con un flick, esce
del tutto e chiede di essere chiuso (onDismissRequest), altrimenti torna indietro. Lo screen reader legge la sua
accessibilityLabel quando compare.
#Prop di Toast
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
transitionStatus | .transition_status(..) | "starting" | "present" | "ending" | "starting" | La sua fase del ciclo di vita. |
startingClass, endingClass, swipingClass | .starting_class(..), … | string | nessuno | Le sue classi nel primo frame, durante l’uscita, durante lo swipe. |
swipeDirection | .swipe_direction(..) | "right" | "left" | "up" | "down" | null | "right" | La direzione in cui viene trascinato via con lo swipe. |
swipeThreshold | .swipe_threshold(..) | number | 50 | Quanto deve andare avanti uno swipe prima che un rilascio ne chieda la chiusura. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che lo screen reader legge quando compare. |
onDismissRequest | .on_dismiss_request(..) | () => void | nessuno | L’utente lo ha rimosso con uno swipe. |
#Prop di ToastStack
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
motion | .motion(..) | ToastMotion | Quelli di Sonner | I suoi token di movimento. |
placement | .placement(..) | "topLeft" | "topCenter" | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | "leftCenter" | "rightCenter" | "topRight" | Il bordo da cui dispone i toast. |
closeOnEscape | .close_on_escape(..) | boolean | true | Ascolta Escape e Indietro finché contiene toast. |
onExpandedChange | .on_expanded_change(..), .state(..) | (e: { expanded: boolean }) => void | nessuno | L’interazione lo ha espanso o compresso. |
onCancel | .on_cancel(..) | () => void | nessuno | Escape o Indietro: chiude il più recente. |