العناصر
Toast
الـ Toasts رسائل قصيرة تظهر لفترة وجيزة فوق التطبيق: “تم النسخ”، “تم حذف المسودة · تراجع”، “تعذّر الإرسال”.
nsh/base يوفّر سلوكها في ثلاث قطع غير مصمَّمة الشكل، تماماً مثل gpui-base في 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);ToastManagerيحافظ على ترتيب الـ toasts مع دورة حياتها: كل واحد منها"starting"، ثم"present"بعد انقضاء مدة الحركة، ويعدّ مهلته تنازلياً ما دام ظاهراً وغير متوقف مؤقتاً، ويبقى"ending"طوال مدة الخروج قبل أن تتم إزالته.ToastStackيقيس الـ toasts ويرتّبها انطلاقاً من إحدى الحواف، بحيث يكون الأحدث أقربها إليها: طبقات متراكبة في حالة الطي، وقائمة في حالة التوسيع (عندما يكون المؤشر فوقها أو إصبع مضغوط عليها)، وتتحرك بنوابض.Toastهو الجذر لكل toast: الدورalert، وtransitionStatusالخاص به لأنماط التطبيق، وسحبة (swipe) تطلب إغلاقه، وقارئ الشاشة الذي يقرأ تسميته عند ظهوره.
الشكل والمحتوى من شأن التطبيق، وكذلك سهولة عرض toast من أي مكان: يبني التطبيق
toast(...) الخاص به فوق هذه القطع، كما يفعل المعرض (examples/gallery/toast.tsx)، وكما بُنيت إشعارات GPUI Kit
فوق toasts الخاصة بـ 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 وToastManager تأتي من nsh/base. في Rust هي
toast وtoast_stack وToastManager من nsh_base.
#ToastManager
المدير بلا واجهة (headless): لا يعرض شيئاً ولا يشغّل أي مؤقّت. يدفع التطبيق الـ toasts إليه، ويعرض تلك التي
يعيدها visible(limit) مع حالتها، ويستدعي advance على مؤقّت ما دام أي منها مثبّتاً، مع تمرير paused
أثناء توسيع المكدّس. في Rust هو ToastManager<I, T> فوق معرّفات التطبيق وقيمه، مع Instant لكل
استدعاء؛ وفي TS هو صنف (class) فوق معرّفات نصية وأي قيمة JSON، على ساعة المحرك (بما في ذلك الساعة اليدوية
في الاختبارات).
push(id, value, options)يضيف أحدث toast، ويستبدل أي toast يحمل المعرّف نفسه (ويعيد قيمته)؛ وoptions.timeoutهي مدة بقائه بالمللي ثانية (بدونها: يبقى حتى يُغلق).dismiss(id)يبدأ خروج toast؛ وdismissAll()يبدأ خروج كل الـ toasts، ويعيد معرّفاتها.advance(paused)يقدّم دورة الحياة ويبلغ بما تغيّر:presented، وending(انتهت مهلتها)، وremoved(مع قيمها)، وchanged.visible(limit)هي أحدثlimitمن الـ toasts التي لا تخرج حالياً، إضافة إلى الخارجة منها، الأقدم أولاً؛ وentries()(في Rustiter()) كلها؛ ثمget(id)وlen()وisEmpty().
ToastMotion يحمل قيم (tokens) الاثنين معاً: duration (400 مللي ثانية: الموعد النهائي للدخول واستجابة نابض المكدّس)،
exitDuration (200 مللي ثانية)، collapsedPeek (14)، expandedGap (14)، collapsedScaleStep (0.05)، و
collapsedVisible (3)، وهي قيم Sonner (ToastMotion::sonner() في Rust؛ الحقول المحذوفة في TS تحتفظ بها).
#ToastStack
أبناء المكدّس هم الـ toasts الخاصة به، الأقدم أولاً. انطلاقاً من حافته placement ("topRight" افتراضياً؛ وتُرتَّب
الحواف السفلية صعوداً)، تتراكب الـ toasts المطوية بمسافة collapsedPeek بينها، وكل toast إضافي أضيق
بمقدار collapsedScaleStep، ولا يظهر إلا collapsedVisible؛ وعند التوسيع تصبح قائمة بمسافة expandedGap بينها. الـ toasts التي
تصل أو تغادر، وكذلك التوسيع، تتحرك بنوابض مخمَّدة حرجياً. يكون المكدّس موسَّعاً ما دام المؤشر فوقه
أو إصبع مضغوط عليه (onExpandedChange({ expanded })؛ في Rust ToastStackState، الذي تقرؤه is_expanded())، وبذلك
يوقف التطبيق المهل مؤقتاً أثناء قراءة المستخدم للـ toasts أو لمسها.
التطبيق هو من يضع المكدّس: في Overlay عند الحافة، داخل المنطقة الآمنة. class وstyle الخاصان به
يحددان عرضه. وما دام يحتوي على toasts فإنه يستمع إلى Escape وزر الرجوع في Android (onCancel؛ وcloseOnEscape={false}
يتركهما لبقية التطبيق): فيغلق الأحدث.
#Toast
الـ toast هو الدور alert حول محتوى التطبيق. transitionStatus الخاص به يأتي من المدير، وتتبعه فئات الحالة (state classes)
عبر class: startingClass في إطاره الأول أثناء "starting"، بحيث تتحرك فئات transition الخاصة به
منها (starting: في Tailwind، و@starting-style في CSS)؛ وendingClass أثناء "ending"، طوال مدة الخروج؛
وswipingClass بينما يسحبه مؤشر (transition-none يبقيه تحت الإصبع).
سحب الإصبع أو الفأرة نحو swipeDirection ("right" افتراضياً؛ وnull يعطّل السحب) يحرّكه، بعد
أن يتحرك المؤشر فعلاً، وبذلك تبقى أزراره أزراراً؛ وعند الإفلات بعد تجاوز swipeThreshold نقطة (50) أو بحركة خاطفة، يتابع
خروجه ويطلب إغلاقه (onDismissRequest)، وإن كان دون ذلك يعود إلى مكانه. ويقرأ قارئ الشاشة
accessibilityLabel الخاص به عند ظهوره.
#خصائص Toast
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
transitionStatus | .transition_status(..) | "starting" | "present" | "ending" | "starting" | مرحلة دورة حياته. |
startingClass، endingClass، swipingClass | .starting_class(..)، … | string | لا شيء | فئاته في إطاره الأول، وأثناء الخروج، وأثناء السحب. |
swipeDirection | .swipe_direction(..) | "right" | "left" | "up" | "down" | null | "right" | الاتجاه الذي يُسحب نحوه لإبعاده. |
swipeThreshold | .swipe_threshold(..) | number | 50 | المسافة التي تقطعها السحبة قبل أن يطلب الإفلات إغلاقه. |
accessibilityLabel | .accessibility_label(..) | string | لا شيء | ما يقرؤه قارئ الشاشة عند ظهوره. |
onDismissRequest | .on_dismiss_request(..) | () => void | لا شيء | سحبه المستخدم بعيداً. |
#خصائص ToastStack
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
motion | .motion(..) | ToastMotion | قيم Sonner | قيم الحركة الخاصة به. |
placement | .placement(..) | "topLeft" | "topCenter" | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | "leftCenter" | "rightCenter" | "topRight" | الحافة التي يرتّب منها. |
closeOnEscape | .close_on_escape(..) | boolean | true | يستمع إلى Escape والرجوع ما دام يحتوي على toasts. |
onExpandedChange | .on_expanded_change(..)، .state(..) | (e: { expanded: boolean }) => void | لا شيء | التفاعل وسّعه أو طواه. |
onCancel | .on_cancel(..) | () => void | لا شيء | Escape أو الرجوع: إغلاق الأحدث. |