35bcb63300
Componentes React accesibles basados en Radix UI con soporte de temas via CSS variables. Incluye barrel export en index.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
5.5 KiB
TypeScript
171 lines
5.5 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
import { XIcon, CheckCircle2Icon, AlertCircleIcon, AlertTriangleIcon, InfoIcon } from "lucide-react"
|
|
import { cn } from "../core/cn"
|
|
|
|
const toastVariants = cva(
|
|
"group/toast pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-xl border p-4 pr-8 shadow-lg transition-all",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: "bg-background text-foreground border-border",
|
|
success: "bg-background border-l-4 border-l-green-500 border-border text-foreground",
|
|
error: "bg-background border-l-4 border-l-destructive border-border text-foreground",
|
|
warning: "bg-background border-l-4 border-l-yellow-500 border-border text-foreground",
|
|
info: "bg-background border-l-4 border-l-blue-500 border-border text-foreground",
|
|
},
|
|
},
|
|
defaultVariants: { variant: "default" },
|
|
}
|
|
)
|
|
|
|
const variantIcons: Record<string, React.ReactNode> = {
|
|
success: <CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-green-500" />,
|
|
error: <AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />,
|
|
warning: <AlertTriangleIcon className="mt-0.5 size-4 shrink-0 text-yellow-500" />,
|
|
info: <InfoIcon className="mt-0.5 size-4 shrink-0 text-blue-500" />,
|
|
}
|
|
|
|
interface ToastProps
|
|
extends React.ComponentPropsWithoutRef<"div">,
|
|
VariantProps<typeof toastVariants> {
|
|
title?: string
|
|
description?: string
|
|
action?: React.ReactNode
|
|
onClose?: () => void
|
|
}
|
|
|
|
const Toast = React.forwardRef<HTMLDivElement, ToastProps>(
|
|
({ className, variant = "default", title, description, action, onClose, children, ...props }, ref) => {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-slot="toast"
|
|
data-variant={variant}
|
|
role="alert"
|
|
aria-live="polite"
|
|
className={cn(toastVariants({ variant }), className)}
|
|
{...props}
|
|
>
|
|
{variant && variant !== "default" && variantIcons[variant]}
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
{title && (
|
|
<div data-slot="toast-title" className="text-sm font-semibold leading-none">
|
|
{title}
|
|
</div>
|
|
)}
|
|
{description && (
|
|
<div data-slot="toast-description" className="text-sm text-muted-foreground">
|
|
{description}
|
|
</div>
|
|
)}
|
|
{children}
|
|
{action && <div data-slot="toast-action" className="mt-2">{action}</div>}
|
|
</div>
|
|
{onClose && (
|
|
<button
|
|
data-slot="toast-close"
|
|
onClick={onClose}
|
|
className="absolute top-2 right-2 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label="Close"
|
|
>
|
|
<XIcon className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
Toast.displayName = "Toast"
|
|
|
|
interface ToastViewportProps extends React.ComponentPropsWithoutRef<"div"> {
|
|
position?: "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"
|
|
}
|
|
|
|
const positionClasses: Record<NonNullable<ToastViewportProps["position"]>, string> = {
|
|
"top-left": "top-4 left-4",
|
|
"top-center": "top-4 left-1/2 -translate-x-1/2",
|
|
"top-right": "top-4 right-4",
|
|
"bottom-left": "bottom-4 left-4",
|
|
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
|
|
"bottom-right": "bottom-4 right-4",
|
|
}
|
|
|
|
function ToastViewport({ className, position = "bottom-right", ...props }: ToastViewportProps) {
|
|
return (
|
|
<div
|
|
data-slot="toast-viewport"
|
|
className={cn(
|
|
"fixed z-[100] flex max-h-screen w-full max-w-sm flex-col gap-2 p-4",
|
|
positionClasses[position],
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
type ToastEntry = ToastProps & {
|
|
id: string
|
|
duration?: number
|
|
}
|
|
|
|
interface ToastProviderContextValue {
|
|
toasts: ToastEntry[]
|
|
toast: (props: Omit<ToastEntry, "id">) => string
|
|
dismiss: (id: string) => void
|
|
dismissAll: () => void
|
|
}
|
|
|
|
const ToastContext = React.createContext<ToastProviderContextValue | null>(null)
|
|
|
|
function ToastProvider({ children, position = "bottom-right" }: { children: React.ReactNode; position?: ToastViewportProps["position"] }) {
|
|
const [toasts, setToasts] = React.useState<ToastEntry[]>([])
|
|
|
|
const dismiss = React.useCallback((id: string) => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
}, [])
|
|
|
|
const dismissAll = React.useCallback(() => {
|
|
setToasts([])
|
|
}, [])
|
|
|
|
const toast = React.useCallback(
|
|
(props: Omit<ToastEntry, "id">) => {
|
|
const id = Math.random().toString(36).slice(2)
|
|
const duration = props.duration ?? 5000
|
|
setToasts((prev) => [...prev, { ...props, id }])
|
|
if (duration > 0) {
|
|
setTimeout(() => dismiss(id), duration)
|
|
}
|
|
return id
|
|
},
|
|
[dismiss]
|
|
)
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ toasts, toast, dismiss, dismissAll }}>
|
|
{children}
|
|
<ToastViewport position={position}>
|
|
{toasts.map((t) => {
|
|
const { id, duration: _duration, ...rest } = t
|
|
return (
|
|
<Toast key={id} {...rest} onClose={() => dismiss(id)} />
|
|
)
|
|
})}
|
|
</ToastViewport>
|
|
</ToastContext.Provider>
|
|
)
|
|
}
|
|
|
|
function useToast() {
|
|
const ctx = React.useContext(ToastContext)
|
|
if (!ctx) throw new Error("useToast must be used within ToastProvider")
|
|
return ctx
|
|
}
|
|
|
|
export { Toast, ToastProvider, ToastViewport, toastVariants, useToast }
|
|
export type { ToastEntry, ToastProps, ToastViewportProps }
|