Skip to content

Toaster 轻提示 ​

在视口一角弹出短暂的通知(保存成功、复制完成、操作失败),几秒后自动消失。行为照源包依赖的 sonner:最新的在最前,后面的缩小叠在下面;悬停、聚焦或页面切到后台时暂停计时并展开; 按住往边缘滑动可以关掉一条。需要用户确认的事用 Modal,页面上常驻的提示用 Alert。

挂载一次

整个应用挂一个 toaster(通常放在根组件末尾),通知由 toast() 或元素的 show() 弹出。它本身不占版面,没有通知时页面上什么也看不到。

页面上没有通知时 toaster 不可见;下面的代码就是它的全部写法。
<pt-toaster position="bottom-right" close-button></pt-toaster>

用法 ​

整个应用挂一个 <pt-toaster>,然后在任何地方调 toast():

ts
import { toast } from '@ptengine/ui/toast';

toast('已复制到剪贴板');
toast.success('保存成功', { description: '改动已同步到所有成员' });
toast.error('保存失败');
toast.warning('配额即将用完');
toast.info('有新版本可用');

const id = toast.loading('上传中…');
toast.success('上传完成', { id }); // 同一个 id:原地更新那一条
toast.dismiss(id); // 不传 id 关掉全部

三个框架都从 @ptengine/ui/toast 导入,包装包里不再导出一份。它不是组件,也不持有状态: 通知全在页面上的 <pt-toaster> 里,toast() 只是找到它(没有就在 <body> 末尾建一个)并调它的方法。 所以不挂 toaster 也能用 —— 挂了的话位置、rich-colors 这些由你挂的那个决定。

tsx
import { PtToaster } from '@ptengine/ui-react';
import { toast } from '@ptengine/ui/toast';

export function App() {
  return (
    <>
      <button onClick={() => toast.success('已保存')}>保存</button>
      <PtToaster position="top-center" richColors />
    </>
  );
}
vue
<script setup lang="ts">
import { PtToaster } from '@ptengine/ui-vue';
import { toast } from '@ptengine/ui/toast';
</script>

<template>
  <button @click="toast.success('已保存')">保存</button>
  <PtToaster position="top-center" rich-colors />
</template>
ts
import { Component } from '@angular/core';
import { PtToaster } from '@ptengine/ui-angular';
import { toast } from '@ptengine/ui/toast';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PtToaster],
  template: `
    <button (click)="save()">保存</button>
    <pt-toaster position="top-center" rich-colors></pt-toaster>
  `
})
export class AppComponent {
  save() {
    toast.success('已保存');
  }
}

Angular 里注意:action.onClick / onDismiss / onAutoClose 由 toaster 自己的点击与计时触发,包装层又让组件在 NgZone 之外渲染,这些回调因此跑在 zone 外 —— 里面改组件状态要包一层 ngZone.run(() => …),否则界面不会立刻刷新。

调用是异步交付的(下一个宏任务):首屏渲染过程中调用时,框架还来不及把 <PtToaster> 挂进 DOM, 等一拍就不会先建出一个多余的 toaster。返回的 id 是同步给的。服务端渲染(Node 里没有 document)时 所有调用都是空操作,只返回 id,可以放心在 SSR 会走到的代码里调用。

也可以不经 toast(),直接调元素的方法:show(options) 返回 id;update(id, options) 只改给出的几项; dismiss(id?)。

选项 ​

toast(message, options) 的第一个参数是标题;show(options) 里标题写在 title 上。

选项说明
id不传时自动分配递增数字;与屏幕上某条相同时改为更新那一条
typedefault / success / error / warning / info / loading(toast.success() 这些快捷方法替你填)
description标题下的说明
action{ label, onClick }:主操作按钮,点了关闭这条;onClick 里 event.preventDefault() 就不关
cancel{ label, onClick }:次要按钮,点了关闭这条
duration停留毫秒数,默认用 toaster 的 duration(4000);0 或 Infinity 不自动关闭;loading 始终不自动关
dismissible默认 true;false 时不显示关闭按钮、不能滑走。action / cancel 照常执行并关闭,dismiss() 与到时照常关闭
closeButton这一条是否显示关闭按钮,不给时跟随 toaster 的 close-button
richColors这一条是否用语义底色,不给时跟随 toaster 的 rich-colors
onDismiss被关闭按钮、滑动或 dismiss() 关掉时调用(点 action / cancel 关掉的不调)
onAutoClose计时到了自动关闭时调用

内容只收纯文本 —— 标题、说明、按钮文案,不收插槽或 HTML。

duration: 0 与 sonner 不同(sonner 把 0 当成「没给」):这里沿用 AntD message 的约定,0 表示常驻。

promise ​

ts
toast.promise(saveReport(), {
  loading: '保存中…',
  success: report => `已保存「${report.name}」`,
  error: error => `保存失败:${(error as Error).message}`
});

先弹一条 loading,兑现后原地换成 success,拒绝后换成 error;对应阶段没给文案时直接关掉那条 loading。 传进去的 Promise 照常 await,这里不吞它的结果。

位置、堆叠与层级 ​

position 取 top-left / top-center / top-right / bottom-left / bottom-center / bottom-right(默认,与 sonner 一致), 是物理方向,RTL 下不翻转;offset 是离视口边缘的距离(默认 24px)。窄屏(≤600px)下铺满宽度、左右各留 16px。

默认最多露出 3 条(visible-toasts),后面的每条缩小 5%、错开一个间距叠在下面;悬停或键盘聚焦时展开, 条与条之间隔 gap(默认 14px)。expand 让它始终展开。

通知区经 popover API 进顶层,不被祖先的 overflow / transform 影响。顶层按进入先后叠放: 通知出现之后才打开的弹窗会盖住它;弹窗打开期间出新通知时,通知区会重新提到最上面,按钮照样点得到,弹窗也不会被关掉。 宿主带 aria-live,模态气泡卡片把页面其余部分设为 inert 时会跳过它 —— 前提是 toaster 与弹层所在的那条祖先链是兄弟关系, 所以最好挂在应用根组件的末尾(或者干脆不挂,让 toast() 建在 <body> 末尾)。

视觉 ​

白底(--pt-bg-box)、1px 描边、shadow-lg、圆角 8px、宽 356px、内边距 16px;标题 13/20 500 strong 色,说明 13/20 standard 色。 success / error / warning / info 只有图标染色,文字仍是中性色(源包的取舍:颜色已经由图标表达,彩色文字更难读)。 rich-colors 再把整条换成对应的语义浅底与描边,文字照样不染。action 按钮是高强调黑,cancel 按钮是次要灰。 系统开了「减少动态效果」时不播入场、堆叠与退场过渡,loading 的转圈也停。

键盘与读屏 ​

  • Alt+T:焦点移到通知区并展开(同 sonner),Tab 在通知与按钮之间移动 (弹窗打开时也一样,走到头回到弹窗里); Esc 收起,焦点还给按快捷键之前的元素。弹窗打开时这次 Esc 只收起通知区,不会关弹窗。
  • 宿主是 aria-live="polite" 的播报区,每条通知 role="status",error 那条 aria-live="assertive" 立即打断播报。
  • 通知区的读屏名称默认「通知 Alt+T」(t('toast.label')),label 可以改;关闭按钮默认「关闭通知」(t('toast.close')),close-label 可以改。

API ​

属性

属性Attribute类型默认值说明
closeButtonclose-buttonbooleanfalse每条通知的起始角显示关闭按钮。每条可用 `closeButton` 单独覆盖;loading 类型不显示
closeLabelclose-labelstring | undefined—关闭按钮的无障碍名称。不设置时用内置文案(`t('toast.close')`,随 locale 切换)
durationdurationnumber4000默认停留时长(ms)。每条的 `duration` 优先;`0` 表示都不自动关闭
expandexpandbooleanfalse始终展开(不堆叠),每条之间隔 `gap`
gapgapnumber14展开时通知之间的间距(px)
labellabelstring | undefined—通知区的读屏名称,后面会接上「Alt+T」。不设置时用内置文案(`t('toast.label')`)
offsetoffsetnumber | string | undefined—离视口边缘的距离:数字按 px,也可以写任意 CSS 长度。默认 24px(窄屏 16px,不受它影响)
positionposition"bottom-center" | "bottom-left" | "bottom-right" | "top-center" | "top-left" | "top-right"'bottom-right'在视口的哪个角(或上下边的中间)
richColorsrich-colorsbooleanfalse用语义底色:success / error / warning / info 的整条通知换成对应的浅底与描边(文字仍是中性色)
visibleToastsvisible-toastsnumber3堆叠时最多露出几条,更早的淡出(仍在计时)

方法

方法说明
dismiss(id?: ToastId) => Promise<void>关闭一条通知(传 id)或全部(不传)。会调用对应通知的 `onDismiss`
show(options: ToastOptions) => Promise<ToastId>显示一条通知,返回它的 id。`options.id` 与屏幕上某条相同时改为更新那一条(等同 `update()`)。 各项含义见 `ToastOptions`;回调(按钮的 `onClick`、`onDismiss`、`onAutoClose`)只能经 JS 传。
update(id: ToastId, options: ToastOptions) => Promise<void>更新屏幕上的一条通知(只改给出的几项),并重新开始计时。那条已经关闭时什么也不做

可定制的内部元素(::part())

part说明
action主操作按钮
cancel次要按钮
close关闭按钮(`close-button` 开启时)
description说明
icon类型图标(default 类型没有)
list通知区(进顶层的那一层,带读屏名称)
title标题
toast每一条通知

CSS 变量

变量说明
--pt-toaster-radius通知圆角,默认 --pt-radius-md(8px)
--pt-toaster-width通知宽度,默认 356px(窄屏下铺满,不受它影响)

Apache-2.0 协议开源