تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

Toast

الـ Toasts رسائل قصيرة تظهر لفترة وجيزة فوق التطبيق: “تم النسخ”، “تم حذف المسودة · تراجع”، “تعذّر الإرسال”. nsh/base يوفّر سلوكها في ثلاث قطع غير مصمَّمة الشكل، تماماً مثل gpui-base في GPUI Kit:

el-toast.tsx
el-toast.tsxLive
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))
            },
        ))
}

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() (في Rust iter()) كلها؛ ثم 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(..)number50المسافة التي تقطعها السحبة قبل أن يطلب الإفلات إغلاقه.
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(..)booleantrueيستمع إلى Escape والرجوع ما دام يحتوي على toasts.
onExpandedChange.on_expanded_change(..)، .state(..)(e: { expanded: boolean }) => voidلا شيءالتفاعل وسّعه أو طواه.
onCancel.on_cancel(..)() => voidلا شيءEscape أو الرجوع: إغلاق الأحدث.