Элементы
Toast
Toast — это короткие сообщения, которые на некоторое время показываются поверх приложения: «Скопировано», «Черновик удалён · Отменить», «Не удалось отправить».
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хранит toast’ы по порядку вместе с их жизненным циклом: каждый toast сначала"starting", затем"present"после того, как пройдёт длительность анимации; пока он показан и не поставлен на паузу, его тайм-аут отсчитывается, а после этого он остаётся"ending"в течение длительности выхода и затем удаляется.ToastStackизмеряет свои toast’ы и раскладывает их от края, причём самый новый ближе всего к нему: в свёрнутом состоянии — перекрывающимися слоями, в развёрнутом — списком (когда на нём указатель или на нём палец); движение выполняется на пружинах.Toast— корень каждого toast’а: рольalert, егоtransitionStatusдля стилей приложения, свайп, который запрашивает закрытие toast’а, и чтение скринридером его метки в момент появления.
Внешний вид и содержимое определяются приложением, как и удобный способ показать toast из любого места: приложение строит
собственный toast(...) поверх этих частей, как это сделано в галерее (examples/gallery/toast.tsx), и как уведомления GPUI Kit
построены на toast’ах 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
Менеджер не имеет интерфейса: он ничего не отрисовывает и не запускает таймер. Приложение добавляет toast’ы, отрисовывает те,
что возвращает visible(limit), вместе с их статусом и вызывает advance по таймеру, пока смонтирован хотя бы один toast, передавая paused,
пока стек развёрнут. В Rust это ToastManager<I, T> над идентификаторами и значениями приложения, с Instant для
каждого вызова; в TS это класс над строковыми идентификаторами и любыми JSON-значениями, работающий на часах движка (включая ручные часы
теста).
push(id, value, options)добавляет самый новый toast, заменяя toast с тем же идентификатором (и возвращая его значение);options.timeout— время его показа в миллисекундах (не задано: он остаётся, пока его не закроют).dismiss(id)начинает выход toast’а;dismissAll()— выход каждого toast’а, возвращая их идентификаторы.advance(paused)продвигает жизненный цикл и сообщает, что изменилось:presented,ending(у них истёк тайм-аут),removed(вместе с их значениями) иchanged.visible(limit)— этоlimitсамых новых toast’ов, которые не завершаются, плюс завершающиеся, от самого старого;entries()(в Rustiter()) — все они;get(id),len(),isEmpty().
ToastMotion содержит токены обеих частей: duration (400 мс: крайний срок появления и отклик пружины стека),
exitDuration (200 мс), collapsedPeek (14), expandedGap (14), collapsedScaleStep (0.05) и
collapsedVisible (3) — значения Sonner (ToastMotion::sonner() в Rust; пропущенные в TS поля сохраняют их).
#ToastStack
Дочерние элементы стека — его toast’ы, от самого старого. От своего края placement (по умолчанию "topRight"; нижние
привязки раскладывают элементы вверх) свёрнутые toast’ы перекрываются на расстоянии collapsedPeek друг от друга, каждый следующий
уже на collapsedScaleStep, и показываются только collapsedVisible; в развёрнутом состоянии они образуют список с интервалом expandedGap. Toast’ы, которые
появляются или уходят, а также разворачивание перемещаются критически демпфированными пружинами. Стек развёрнут, пока на нём указатель
или на нём палец (onExpandedChange({ expanded }); в Rust ToastStackState, который читается через is_expanded()), поэтому
приложение приостанавливает тайм-ауты, пока пользователь читает toast’ы или касается их.
Стек размещает приложение: в Overlay у края, внутри безопасной области. Его class и style
задают его ширину. Пока в нём есть toast’ы, он реагирует на Escape и кнопку «Назад» в Android (onCancel; closeOnEscape={false}
оставляет их остальной части приложения): закрыть самый новый.
#Toast
Toast — это роль alert вокруг содержимого приложения. Его transitionStatus задаёт менеджер, а классы его состояний
следуют за ним через 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 и «Назад», пока содержит toast’ы. |
onExpandedChange | .on_expanded_change(..), .state(..) | (e: { expanded: boolean }) => void | нет | Взаимодействие развернуло или свернуло его. |
onCancel | .on_cancel(..) | () => void | нет | Escape или «Назад»: закрыть самый новый. |