एलिमेंट
Toast
Toasts छोटे संदेश होते हैं जो ऐप के ऊपर कुछ समय के लिए दिखाए जाते हैं: “Copied”, “Draft deleted · Undo”, “Could not send”.
nsh/base इनका व्यवहार तीन बिना स्टाइल वाले हिस्सों में देता है, ठीक GPUI Kit के gpui-base की तरह:
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);ToastManagertoasts को उनके जीवनचक्र के साथ क्रम में रखता है: हर toast पहले"starting"होता है, फिर motion की अवधि बीतने पर"present", मौजूद रहने और रोका न जाने के दौरान अपना timeout घटाता है, और हटाए जाने से पहले exit की अवधि तक"ending"रहता है।ToastStackअपने toasts को नापता है और उन्हें किसी किनारे से सजाता है, सबसे नया किनारे के सबसे पास: collapsed होने पर परतें एक-दूसरे पर चढ़ी होती हैं, expanded होने पर सूची (जब pointer उस पर हो, या उस पर उँगली रखी हो), और गति springs से होती है।Toastहर toast का root है:alertrole, ऐप की styles के लिए उसकाtransitionStatus, उसे dismiss करने का अनुरोध करने वाला swipe, और screen reader जो toast दिखते ही उसका label पढ़ता है।
रूप और सामग्री ऐप की होती है, और किसी भी जगह से toast दिखाने की सुविधा भी: ऐप इन्हीं पर अपना toast(...) बनाता है, जैसा gallery करती है (examples/gallery/toast.tsx) और जैसे GPUI Kit के notifications gpui-base के toasts पर बने हैं।
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 और ToastManager nsh/base से आते हैं। Rust में ये nsh_base के toast, toast_stack और ToastManager हैं।
#ToastManager
Manager headless है: यह कुछ render नहीं करता और कोई timer नहीं चलाता। ऐप toasts push करती है, visible(limit) जो toasts उनकी status के साथ लौटाता है उन्हें render करती है, और कोई भी toast mounted रहने तक timer पर advance को कॉल करती है, stack के expanded रहने पर paused पास करते हुए। Rust में यह ऐप के ids और values पर ToastManager<I, T> है, और हर कॉल के लिए एक Instant है; TS में यह string ids और किसी भी JSON value पर एक class है, जो engine की घड़ी पर चलती है (test की manual clock सहित)।
push(id, value, options)सबसे नया toast जोड़ता है, और उसी id वाले toast को बदल देता है (जिसकी value वह लौटाता है);options.timeoutउसका present समय milliseconds में है (none: वह dismiss होने तक रहता है)।dismiss(id)किसी toast का exit शुरू करता है;dismissAll()हर toast का, और उनके ids लौटाता है।advance(paused)जीवनचक्र को आगे बढ़ाता है और बताता है कि क्या बदला:presented,ending(जिनका timeout समाप्त हो गया),removed(उनकी values के साथ), औरchanged।visible(limit)वे सबसे नएlimittoasts हैं जो खत्म नहीं हो रहे, साथ में खत्म हो रहे toasts, सबसे पुराना पहले;entries()(Rust मेंiter()) सभी toasts;get(id),len(),isEmpty()।
ToastMotion में दोनों के tokens हैं: duration (400 ms: enter की deadline और stack के spring की response), exitDuration (200 ms), collapsedPeek (14), expandedGap (14), collapsedScaleStep (0.05) और collapsedVisible (3), जो Sonner के हैं (Rust में ToastMotion::sonner(); TS में छोड़े गए fields यही रखते हैं)।
#ToastStack
Stack के children उसके toasts हैं, सबसे पुराना पहले। अपने placement किनारे से (डिफ़ॉल्ट रूप से "topRight"; नीचे वाले anchors ऊपर की ओर सजाते हैं), collapsed toasts एक-दूसरे से collapsedPeek की दूरी पर overlap करते हैं, हर अगला toast collapsedScaleStep से संकरा होता है, और केवल collapsedVisible दिखते हैं; expanded होने पर वे expandedGap की दूरी वाली सूची बन जाते हैं। आने या जाने वाले toasts, और expansion, critically damped springs से गति करते हैं। यह तब expanded होता है जब pointer उस पर हो या उस पर उँगली रखी हो (onExpandedChange({ expanded }); Rust में ToastStackState, जिसे is_expanded() पढ़ता है), ताकि उपयोगकर्ता के toasts पढ़ने या छूने के दौरान ऐप timeouts को रोक सके।
Stack को ऐप रखती है: किनारे पर एक Overlay में, safe area के अंदर। इसके class और style इसकी चौड़ाई तय करते हैं। जब तक इसमें toasts हैं, यह Escape और Android के back button को सुनता है (onCancel; closeOnEscape={false} उन्हें ऐप के बाकी हिस्से पर छोड़ देता है): सबसे नए toast को dismiss करें।
#Toast
Toast ऐप की सामग्री के चारों ओर alert role है। इसका transitionStatus manager का है, और इसकी state classes class पर उसी का अनुसरण करती हैं: startingClass अपने पहले frame में जब "starting" हो, ताकि इसकी transition classes उससे animate हों (Tailwind का starting:, CSS का @starting-style); endingClass जब "ending" हो, exit की अवधि तक; swipingClass जब कोई pointer इसे swipe कर रहा हो (transition-none इसे उँगली के नीचे बनाए रखता है)।
उँगली या mouse को swipeDirection की ओर खींचने पर (डिफ़ॉल्ट "right"; null swipe बंद कर देता है) यह खिसकता है, pointer के हिलने के बाद ही, ताकि इसके buttons buttons ही रहें; swipeThreshold points (50) से आगे छोड़ने पर या झटके से, यह आगे बाहर चला जाता है और dismiss होने का अनुरोध करता है (onDismissRequest), उससे पहले छोड़ने पर यह वापस लौट जाता है। Screen reader इसका accessibilityLabel तब पढ़ता है जब यह दिखता है।
#Toast props
| Prop | Rust | टाइप | डिफ़ॉल्ट | विवरण |
|---|---|---|---|---|
transitionStatus | .transition_status(..) | "starting" | "present" | "ending" | "starting" | इसका जीवनचक्र चरण। |
startingClass, endingClass, swipingClass | .starting_class(..), … | string | कोई नहीं | इसके पहले frame में, खत्म होते समय, और swipe के दौरान की classes। |
swipeDirection | .swipe_direction(..) | "right" | "left" | "up" | "down" | null | "right" | वह दिशा जिसमें इसे swipe करके हटाया जाता है। |
swipeThreshold | .swipe_threshold(..) | number | 50 | छोड़ने पर dismiss का अनुरोध होने से पहले swipe कितनी दूर जाता है। |
accessibilityLabel | .accessibility_label(..) | string | कोई नहीं | Screen reader इसे दिखते समय क्या पढ़ता है। |
onDismissRequest | .on_dismiss_request(..) | () => void | कोई नहीं | उपयोगकर्ता ने इसे swipe करके हटा दिया। |
#ToastStack props
| Prop | Rust | टाइप | डिफ़ॉल्ट | विवरण |
|---|---|---|---|---|
motion | .motion(..) | ToastMotion | Sonner के | इसके motion tokens। |
placement | .placement(..) | "topLeft" | "topCenter" | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | "leftCenter" | "rightCenter" | "topRight" | वह किनारा जहाँ से यह सजाता है। |
closeOnEscape | .close_on_escape(..) | boolean | true | toasts होने तक यह Escape और back सुनता है। |
onExpandedChange | .on_expanded_change(..), .state(..) | (e: { expanded: boolean }) => void | कोई नहीं | interaction ने इसे expanded या collapsed किया। |
onCancel | .on_cancel(..) | () => void | कोई नहीं | Escape या back: सबसे नए को dismiss करें। |