"use client"; import { motion, useReducedMotion, type Variants } from "motion/react"; import type { ReactNode } from "react"; import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type EmptyStateSize = "sm" | "md" | "lg"; export type EmptyStateIllustrationVariant = "documents" | "search"; /* -------------------------------------------------------------------------- */ /* Illustration */ /* -------------------------------------------------------------------------- */ export interface EmptyStateIllustrationProps { variant?: EmptyStateIllustrationVariant; /** Width in px; height follows the 5:4 viewBox. */ size?: number; className?: string; } const draw = (delay: number, reduce: boolean) => ({ initial: reduce ? { pathLength: 1, opacity: 0 } : { pathLength: 0, opacity: 0 }, whileInView: { pathLength: 1, opacity: 1 }, viewport: { once: true }, transition: reduce ? { duration: 0.2 } : { pathLength: { duration: 0.5, ease: EASE_OUT, delay }, opacity: { duration: 0.1, delay }, }, }); // Idle float: translate only (no rotate/scale), so no SVG transform-origin is // involved. Rotations in the art are static `transform` attributes on an // inner that motion never touches. const float = (reduce: boolean, amplitude: number, duration: number, delay: number) => reduce ? {} : { animate: { y: [0, -amplitude, 0] }, transition: { duration, ease: EASE_IN_OUT, repeat: Infinity, delay }, }; /** Small default empty-state art: a floating document stack or a search lens. */ export function EmptyStateIllustration({ variant = "documents", size = 120, className, }: EmptyStateIllustrationProps) { const reduce = useReducedMotion() ?? false; const lineClass = "stroke-border-strong"; return ( ); } /* -------------------------------------------------------------------------- */ /* EmptyState */ /* -------------------------------------------------------------------------- */ export interface EmptyStateProps { title: ReactNode; description?: ReactNode; /** * Icon or illustration slot. Defaults to ``; * pass `null` for none. */ icon?: ReactNode; /** Primary call to action (e.g. a library `Button`). */ action?: ReactNode; /** Secondary call to action, rendered after the primary. */ secondaryAction?: ReactNode; size?: EmptyStateSize; /** Dashed border container, for empty regions inside a page. */ bordered?: boolean; /** Heading element for the title. Default `h3`. */ titleAs?: "h2" | "h3" | "h4" | "p"; /** Extra content under the actions (links, hints). */ children?: ReactNode; className?: string; } const SIZE: Record< EmptyStateSize, { root: string; title: string; description: string; art: number } > = { sm: { root: "gap-3 px-4 py-6", title: "text-sm", description: "text-xs max-w-[32ch]", art: 88 }, md: { root: "gap-4 px-6 py-10", title: "text-base", description: "text-sm max-w-[38ch]", art: 120 }, lg: { root: "gap-5 px-8 py-14", title: "text-lg", description: "text-base max-w-[44ch]", art: 152 }, }; /** * Empty-state block: illustration, title, description and actions that * stagger in when scrolled into view (opacity + small rise; opacity only under * reduced motion). */ export function EmptyState({ title, description, icon, action, secondaryAction, size = "md", bordered = false, titleAs: Title = "h3", children, className, }: EmptyStateProps) { const reduce = useReducedMotion(); const s = SIZE[size]; const container: Variants = { hidden: {}, show: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } }, }; const item: Variants = { hidden: { opacity: 0, y: reduce ? 0 : 8 }, show: { opacity: 1, y: 0, transition: { duration: 0.32, ease: EASE_OUT } }, }; const art = icon === undefined ? : icon; return ( {art != null ? ( {art} ) : null}
{title} {description != null ? ( {description} ) : null}
{action != null || secondaryAction != null ? ( {action} {secondaryAction} ) : null} {children != null ? {children} : null}
); }