"use client"; import { X } from "lucide-react"; import { AnimatePresence, motion, PresenceContext, useAnimate, useReducedMotion, type Variants, } from "motion/react"; import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useContext, useEffect, useRef, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type BadgeVariant = | "default" | "secondary" | "outline" | "destructive" | "success" | "warning"; export type BadgeSize = "sm" | "md" | "lg"; /** Soft tinted surface or a filled one. Only the status variants differ. */ export type BadgeTone = "soft" | "solid"; const BASE: Record<"default" | "secondary" | "outline", string> = { default: "border-transparent bg-primary text-primary-foreground", secondary: "border-transparent bg-foreground/[0.07] text-foreground", outline: "border-border-strong bg-transparent text-foreground", }; // Status tints mix the token toward --foreground/--background (as `alert` // does), so the text keeps its contrast in both themes and every design style. const SOFT: Record<"destructive" | "success" | "warning", string> = { destructive: "border-[color-mix(in_oklab,var(--destructive)_30%,transparent)] bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] text-[color-mix(in_oklab,var(--destructive)_78%,var(--foreground))]", success: "border-[color-mix(in_oklab,var(--success)_32%,transparent)] bg-[color-mix(in_oklab,var(--success)_11%,var(--background))] text-[color-mix(in_oklab,var(--success)_60%,var(--foreground))]", warning: "border-[color-mix(in_oklab,var(--warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--warning)_13%,var(--background))] text-[color-mix(in_oklab,var(--warning)_52%,var(--foreground))]", }; // Filled status badges. Red reads best with light text in both themes; // green and amber are light hues, so they always get dark text. const SOLID: Record<"destructive" | "success" | "warning", string> = { destructive: "border-transparent bg-destructive text-background dark:text-foreground", success: "border-transparent bg-success text-[color-mix(in_oklab,var(--success)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--success)_22%,var(--background))]", warning: "border-transparent bg-warning text-[color-mix(in_oklab,var(--warning)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--warning)_22%,var(--background))]", }; const SIZE: Record = { sm: "h-5 gap-1 px-1.5 text-[11px] [&_svg]:size-3", md: "h-6 gap-1.5 px-2 text-xs [&_svg]:size-3.5", lg: "h-7 gap-1.5 px-2.5 text-[13px] [&_svg]:size-3.5", }; // Count badges are circles at one digit and pills beyond. const COUNT_SIZE: Record = { sm: "h-4 min-w-4 px-1 text-[10px]", md: "h-5 min-w-5 px-1.5 text-[11px]", lg: "h-6 min-w-6 px-1.5 text-xs", }; function variantClass(variant: BadgeVariant, tone: BadgeTone) { if (variant === "default" || variant === "secondary" || variant === "outline") { return BASE[variant]; } return tone === "solid" ? SOLID[variant] : SOFT[variant]; } export interface BadgeProps { children?: ReactNode; variant?: BadgeVariant; /** Status variants only (destructive/success/warning). Default "soft". */ tone?: BadgeTone; size?: BadgeSize; /** Leading status dot in the text color; `"pulse"` adds a radiating ring. */ dot?: boolean | "pulse"; /** Leading icon (sized to the badge). Ignored when `dot` is set. */ icon?: ReactNode; /** * Makes the badge removable: an × button plays the exit, then calls this. * Focus moves to the neighbouring chip's × when the removal came from it. */ onRemove?: () => void; /** Accessible name of the × button. Default `Remove `. */ removeLabel?: string; /** * Count mode: renders a compact number that rolls up or down as it changes * and bumps when it rises. Hidden at 0 unless `showZero`. */ count?: number; /** Counts above this read "max+". Default 99. */ max?: number; showZero?: boolean; /** Spoken label for count mode, e.g. `(n) => \`${n} unread\``. */ countLabel?: (count: number) => string; className?: string; } /** * General-purpose badge: six variants, three sizes, an optional (pulsing) dot * or icon, removable chips with an exit animation, and a count mode with * rolling digits capped at "99+". For animated status transitions * (loading → success) use `AnimatedBadge` instead. */ export function Badge(props: BadgeProps) { if (props.count !== undefined) return ; return ; } /* -------------------------------------------------------------------------- */ /* Label badge */ /* -------------------------------------------------------------------------- */ function LabelBadge({ children, variant = "default", tone = "soft", size = "md", dot = false, icon, onRemove, removeLabel, className, }: BadgeProps) { const reduce = useReducedMotion() ?? false; // Inside an AnimatePresence the badge pops in when added; anywhere else it // renders as-is so server HTML is visible before hydration. const inPresence = useContext(PresenceContext) !== null; const rootRef = useRef(null); const [leaving, setLeaving] = useState(false); const refocus = useRef(null); const removable = Boolean(onRemove); const hidden = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }; const startRemove = (fromKeyboardFocus: boolean) => { if (leaving) return; if (fromKeyboardFocus) refocus.current = neighbourRemoveButton(rootRef.current); setLeaving(true); }; const finishRemove = () => { if (!leaving) return; const target = refocus.current; refocus.current = null; onRemove?.(); if (target) requestAnimationFrame(() => target.focus({ preventScroll: true })); }; const text = typeof children === "string" ? children : undefined; return ( ); } /** The × of the next chip in the row, else the previous one. */ function neighbourRemoveButton(node: HTMLElement | null) { if (!node) return null; const pick = (el: Element | null) => el?.querySelector("[data-badge-remove]") ?? null; let next = node.nextElementSibling; while (next && !pick(next)) next = next.nextElementSibling; if (next) return pick(next); let prev = node.previousElementSibling; while (prev && !pick(prev)) prev = prev.previousElementSibling; return prev ? pick(prev) : null; } // One pulse cycle of the dot's radiating ring, in seconds. Slow on purpose: // an ambient "live" signal, not feedback, so it sits well outside UI timing. const PULSE_DURATION = 1.6; function Dot({ pulse }: { pulse: boolean }) { return (