Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

Toast

Toast — это короткие сообщения, которые на некоторое время показываются поверх приложения: «Скопировано», «Черновик удалён · Отменить», «Не удалось отправить». 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 хранит 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))
            },
        ))
}

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() (в Rust iter()) — все они; 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(..)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 и «Назад», пока содержит toast’ы.
onExpandedChange.on_expanded_change(..), .state(..)(e: { expanded: boolean }) => voidнетВзаимодействие развернуло или свернуло его.
onCancel.on_cancel(..)() => voidнетEscape или «Назад»: закрыть самый новый.