frontend: FE-032 操作反馈与微交互,轻量toast接管动作通知+日志滑入+按钮按压与勾选动画
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-150 outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px active:not-aria-[haspopup]:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -19,7 +19,7 @@ function Checkbox({
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
className="animate-in zoom-in-50 fade-in grid place-content-center text-current duration-150 [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// 轻量 toast:success / error 自动消失,loading 常驻直到被同 id 更新或手动关闭。
|
||||
export type ToastKind = "success" | "error" | "loading"
|
||||
|
||||
export interface ToastItem {
|
||||
id: string
|
||||
kind: ToastKind
|
||||
text: string
|
||||
}
|
||||
|
||||
type Listener = (toasts: ToastItem[]) => void
|
||||
|
||||
// 模块级单例状态,让任意调用方无需 context 即可弹出 toast。
|
||||
let currentToasts: ToastItem[] = []
|
||||
const listeners = new Set<Listener>()
|
||||
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const AUTO_DISMISS_MS = 3200
|
||||
const MAX_VISIBLE = 4
|
||||
|
||||
/**
|
||||
* 通知所有挂载中的 Toaster 重新渲染。
|
||||
*/
|
||||
function emit() {
|
||||
for (const listener of listeners) {
|
||||
listener(currentToasts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定 toast。
|
||||
*/
|
||||
export function dismissToast(id: string) {
|
||||
const timer = dismissTimers.get(id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
dismissTimers.delete(id)
|
||||
}
|
||||
currentToasts = currentToasts.filter((item) => item.id !== id)
|
||||
emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示或更新一条 toast:同 id 复用同一条目并重置自动消失计时;
|
||||
* loading 类型不自动消失,等待后续同 id 的 success / error 替换。
|
||||
*/
|
||||
export function showToast(input: { id?: string; kind: ToastKind; text: string }): string {
|
||||
const id = input.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const existing = currentToasts.find((item) => item.id === id)
|
||||
const next: ToastItem = { id, kind: input.kind, text: input.text }
|
||||
currentToasts = existing
|
||||
? currentToasts.map((item) => (item.id === id ? next : item))
|
||||
: [...currentToasts.slice(-(MAX_VISIBLE - 1)), next]
|
||||
const timer = dismissTimers.get(id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
dismissTimers.delete(id)
|
||||
}
|
||||
if (input.kind !== "loading") {
|
||||
dismissTimers.set(
|
||||
id,
|
||||
setTimeout(() => dismissToast(id), AUTO_DISMISS_MS),
|
||||
)
|
||||
}
|
||||
emit()
|
||||
return id
|
||||
}
|
||||
|
||||
// toast 类型对应的图标与配色。
|
||||
const KIND_VISUALS: Record<ToastKind, { icon: typeof CheckCircle2; className: string }> = {
|
||||
success: { icon: CheckCircle2, className: "text-emerald-600 dark:text-emerald-400" },
|
||||
error: { icon: AlertCircle, className: "text-destructive" },
|
||||
loading: { icon: Loader2, className: "animate-spin text-primary" },
|
||||
}
|
||||
|
||||
/**
|
||||
* toast 挂载点:固定在窗口右下角,新条目滑入,点击可关闭。
|
||||
*/
|
||||
export function Toaster() {
|
||||
const [items, setItems] = useState<ToastItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const listener: Listener = (toasts) => setItems([...toasts])
|
||||
listeners.add(listener)
|
||||
setItems([...currentToasts])
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 bottom-4 z-[60] flex w-80 flex-col gap-2">
|
||||
{items.map((item) => {
|
||||
const visual = KIND_VISUALS[item.kind]
|
||||
const Icon = visual.icon
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
className="animate-in fade-in slide-in-from-bottom-2 ease-out-quart pointer-events-auto flex items-start gap-2.5 rounded-lg border bg-popover p-3 text-left text-sm shadow-overlay duration-200"
|
||||
>
|
||||
<Icon className={cn("mt-0.5 size-4 shrink-0", visual.className)} />
|
||||
<span className="min-w-0 break-words">{item.text}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user