{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart-line-item",
  "title": "Cart Line Item",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "One line in a bag: thumbnail, name and variant, a quantity stepper, a right-aligned tabular line total, and save-for-later / remove. Removing never asks — the row folds into a \"Removed — Undo\" bar, focus moves to Undo, and onRemove runs only once the undo window has passed or the line leaves the page. Quantity changes show at once and reach onQuantityChange once, after the shopper settles. Either handler can reject: the line puts itself back and says so. Rows fold to their measured height with Motion (a fade under reduced motion). Compound, through useCartLineItem().",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/cart-line-item/cart-line-item.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Minus, Plus } from \"lucide-react\"\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * One line in a bag: thumbnail, name and variant, a quantity stepper, the\n * line total, and save-for-later / remove.\n *\n *   <CartLineItem line={line} onQuantityChange={(q) => cart.update(line.id, q)} onRemove={() => cart.remove(line.id)} />\n *\n *   <CartLineItem line={line} onRemove={remove}>                        or composed\n *     <CartLineItemMedia />\n *     <CartLineItemInfo />\n *     <CartLineItemTotal />\n *   </CartLineItem>\n *\n * Removing never asks \"are you sure?\": the row folds into a \"Removed —\n * Undo\" bar and your `onRemove` runs only once the undo window has passed\n * (or the line leaves the page). Quantity changes show at once and reach\n * your `onQuantityChange` once, after the shopper stops pressing — not once\n * per press. Either handler can reject: the line puts itself back and says\n * so. The row folds and unfolds to its measured height with Motion.\n */\n\nexport interface CartLine {\n  id: string\n  name: string\n  href?: string\n  brand?: string\n  /** \"Tan · EU 41\". */\n  variant?: string\n  /** Already rendered, so the block never assumes next/image. */\n  image?: React.ReactNode\n  unitPrice: number\n  /** Was-price per unit; struck through when higher than `unitPrice`. */\n  compareAt?: number\n  quantity: number\n  /** Stock left, or a per-order cap. */\n  maxQuantity?: number\n}\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\n\nconst subscribeNothing = () => () => {}\n\n/** Your locale, or the visitor's once hydrated — so server and client HTML agree. */\nfunction useFormatLocale(locale?: string) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  return locale ?? (hydrated ? undefined : \"en-US\")\n}\n\n/** Follows its content's height, so a row folds rather than jumps. */\nfunction useMeasuredHeight() {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const [height, setHeight] = React.useState<number | \"auto\">(\"auto\")\n  React.useLayoutEffect(() => {\n    const node = ref.current\n    if (!node) return\n    const observer = new ResizeObserver(([entry]) =>\n      setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight)\n    )\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [])\n  return [ref, height] as const\n}\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\ntype Leaving = \"removed\" | \"saved\"\n\nexport interface CartLineItemContextValue {\n  line: CartLine\n  /** The quantity on show: what the shopper last chose, before your handler has it. */\n  quantity: number\n  setQuantity: (quantity: number) => void\n  /** One more or one fewer, from the quantity last asked for — fast presses all count. */\n  step: (delta: number) => void\n  /** The cap on the stepper: `maxQuantity`, or 99. */\n  maxQuantity: number\n  /** Unit price × the quantity on show. */\n  total: number\n  formatMoney: (amount: number) => string\n  /** Folds the row into its undo bar; `onRemove` runs once the window has passed. */\n  remove: () => void\n  /** Undefined when the line was given no `onSaveForLater`. */\n  saveForLater?: () => void\n  /** Set while the row is folded, waiting out the undo window. */\n  leaving: Leaving | null\n  undo: () => void\n  /** What the line last failed to do, or null. */\n  error: string | null\n  density: \"comfortable\" | \"compact\"\n  loading: boolean\n}\n\nconst CartLineItemContext = React.createContext<CartLineItemContextValue | null>(null)\n\nexport function useCartLineItem() {\n  const context = React.useContext(CartLineItemContext)\n  if (!context) {\n    throw new Error(\"useCartLineItem must be used within <CartLineItem>.\")\n  }\n  return context\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface CartLineItemProps extends Omit<React.HTMLAttributes<HTMLLIElement>, \"children\"> {\n  line: CartLine\n  currency?: string\n  locale?: string\n  /** Called once the shopper settles on a quantity. Reject to put it back. */\n  onQuantityChange?: (quantity: number) => void | Promise<void>\n  /** How long to wait after the last press. Defaults to 500ms. */\n  debounceMs?: number\n  /** Called when the undo window passes. Reject to put the line back. */\n  onRemove?: () => void | Promise<void>\n  /** Shows \"Save for later\"; runs, like remove, after the undo window. */\n  onSaveForLater?: () => void | Promise<void>\n  /** How long \"Undo\" stays. Defaults to 5 seconds. */\n  undoMs?: number\n  /**\n   * The quantity on show whenever it changes — 0 while the line waits to be\n   * removed — so a subtotal elsewhere can follow before your cart does.\n   */\n  onPreviewQuantityChange?: (quantity: number) => void\n  /** `compact` for a mini cart: a smaller image, tighter type. */\n  density?: \"comfortable\" | \"compact\"\n  loading?: boolean\n  children?: React.ReactNode\n}\n\nexport function CartLineItem({\n  line,\n  currency = \"USD\",\n  locale: localeProp,\n  onQuantityChange,\n  debounceMs = 500,\n  onRemove,\n  onSaveForLater,\n  undoMs = 5000,\n  onPreviewQuantityChange,\n  density = \"comfortable\",\n  loading = false,\n  className,\n  children,\n  ...props\n}: CartLineItemProps) {\n  const locale = useFormatLocale(localeProp)\n  const reduce = useReducedMotion()\n  const [inner, height] = useMeasuredHeight()\n  const maxQuantity = Math.max(1, line.maxQuantity ?? 99)\n\n  // The quantity on show. It follows the prop until the shopper presses,\n  // then leads it until the cart has caught up.\n  const [draft, setDraft] = React.useState<number | null>(null)\n  const [seen, setSeen] = React.useState(line.quantity)\n  if (seen !== line.quantity) {\n    // Your cart has caught up with the stepper: let the prop lead again.\n    setSeen(line.quantity)\n    if (draft === line.quantity) setDraft(null)\n  }\n  const quantity = draft ?? line.quantity\n  const draftRef = React.useRef<number | null>(null)\n  React.useEffect(() => {\n    draftRef.current = draft\n  }, [draft])\n  const [error, setError] = React.useState<string | null>(null)\n  const [leaving, setLeaving] = React.useState<Leaving | null>(null)\n\n  // Handlers in refs: the timers fire with the latest ones, and the flush on\n  // unmount does not need them as effect dependencies.\n  const handlers = React.useRef({ onQuantityChange, onRemove, onSaveForLater })\n  React.useEffect(() => {\n    handlers.current = { onQuantityChange, onRemove, onSaveForLater }\n  })\n  const quantityTimer = React.useRef<number | undefined>(undefined)\n  const leaveTimer = React.useRef<number | undefined>(undefined)\n  const pending = React.useRef<{ quantity: number | null; leaving: Leaving | null }>({ quantity: null, leaving: null })\n  const row = React.useRef<HTMLLIElement>(null)\n\n  const commitQuantity = React.useCallback((next: number) => {\n    pending.current.quantity = null\n    Promise.resolve()\n      .then(() => handlers.current.onQuantityChange?.(next))\n      // On success the stepper keeps showing the new quantity until your\n      // line catches up; on failure it returns to what the cart holds.\n      .catch(() => {\n        setDraft(null)\n        setError(\"Couldn't change the quantity. Try again.\")\n      })\n  }, [])\n\n  const commitLeave = React.useCallback((kind: Leaving) => {\n    pending.current.leaving = null\n    const run = kind === \"saved\" ? handlers.current.onSaveForLater : handlers.current.onRemove\n    Promise.resolve()\n      .then(() => run?.())\n      .catch(() => {\n        setLeaving(null)\n        setError(kind === \"saved\" ? \"Couldn't save it for later. Try again.\" : \"Couldn't remove it. Try again.\")\n      })\n  }, [])\n\n  const setQuantity = React.useCallback(\n    (next: number) => {\n      const clamped = Math.min(Math.max(1, Math.round(next) || 1), maxQuantity)\n      setError(null)\n      setDraft(clamped)\n      window.clearTimeout(quantityTimer.current)\n      pending.current.quantity = clamped\n      quantityTimer.current = window.setTimeout(() => commitQuantity(clamped), debounceMs)\n    },\n    [maxQuantity, debounceMs, commitQuantity]\n  )\n\n  // From the quantity last asked for, not the last render: presses quicker\n  // than a render would otherwise read the same number and count as one.\n  const lineQuantity = line.quantity\n  const step = React.useCallback(\n    (delta: number) => setQuantity((pending.current.quantity ?? draftRef.current ?? lineQuantity) + delta),\n    [setQuantity, lineQuantity]\n  )\n\n  const leave = React.useCallback(\n    (kind: Leaving) => {\n      setError(null)\n      setLeaving(kind)\n      pending.current.leaving = kind\n      window.clearTimeout(leaveTimer.current)\n      leaveTimer.current = window.setTimeout(() => commitLeave(kind), undoMs)\n    },\n    [undoMs, commitLeave]\n  )\n\n  const undo = React.useCallback(() => {\n    window.clearTimeout(leaveTimer.current)\n    pending.current.leaving = null\n    setLeaving(null)\n  }, [])\n\n  // Leaving the page — a closed mini cart, a route change — flushes what is\n  // waiting: the quantity the shopper chose, the removal they did not undo.\n  React.useEffect(\n    () => () => {\n      window.clearTimeout(quantityTimer.current)\n      window.clearTimeout(leaveTimer.current)\n      const { quantity: q, leaving: l } = pending.current\n      if (l) {\n        const run = l === \"saved\" ? handlers.current.onSaveForLater : handlers.current.onRemove\n        void Promise.resolve().then(() => run?.()).catch(() => {})\n      } else if (q !== null) {\n        void Promise.resolve().then(() => handlers.current.onQuantityChange?.(q)).catch(() => {})\n      }\n    },\n    []\n  )\n\n  // Focus follows the fold: to Undo when the row goes, back to Remove when\n  // it returns. Found by query — a ref on your Button primitive would not\n  // reach the DOM on React 18.\n  const [focusAfter, setFocusAfter] = React.useState<\"undo\" | \"remove\" | null>(null)\n  React.useEffect(() => {\n    if (!focusAfter) return\n    row.current?.querySelector<HTMLElement>(`[data-action=\"${focusAfter}\"]`)?.focus()\n  }, [focusAfter, leaving])\n\n  const preview = leaving ? 0 : quantity\n  React.useEffect(() => {\n    onPreviewQuantityChange?.(preview)\n  }, [preview, onPreviewQuantityChange])\n\n  const formatMoney = React.useMemo(() => {\n    const whole = new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 })\n    const cents = new Intl.NumberFormat(locale, { style: \"currency\", currency })\n    return (amount: number) => (Number.isInteger(amount) ? whole : cents).format(amount)\n  }, [locale, currency])\n\n  const context = React.useMemo<CartLineItemContextValue>(\n    () => ({\n      line,\n      quantity,\n      setQuantity,\n      step,\n      maxQuantity,\n      total: line.unitPrice * quantity,\n      formatMoney,\n      remove: () => {\n        setFocusAfter(\"undo\")\n        leave(\"removed\")\n      },\n      saveForLater: onSaveForLater\n        ? () => {\n            setFocusAfter(\"undo\")\n            leave(\"saved\")\n          }\n        : undefined,\n      leaving,\n      undo: () => {\n        setFocusAfter(\"remove\")\n        undo()\n      },\n      error,\n      density,\n      loading,\n    }),\n    [line, quantity, setQuantity, step, maxQuantity, formatMoney, leave, onSaveForLater, leaving, undo, error, density, loading]\n  )\n\n  return (\n    <CartLineItemContext.Provider value={context}>\n      <MotionConfig reducedMotion=\"user\">\n        <li\n          ref={row}\n          data-slot=\"cart-line-item\"\n          data-state={leaving ?? \"active\"}\n          aria-busy={loading || undefined}\n          className={cn(\"list-none border-b\", className)}\n          {...props}\n        >\n          <motion.div\n            initial={false}\n            animate={{ height }}\n            transition={reduce ? { duration: 0 } : MORPH}\n            className=\"overflow-hidden\"\n          >\n            <div ref={inner}>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {leaving ? (\n                  <motion.div\n                    key=\"undo\"\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    exit={{ opacity: 0 }}\n                    transition={{ duration: 0.2 }}\n                  >\n                    <CartLineItemUndo />\n                  </motion.div>\n                ) : (\n                  <motion.div\n                    key=\"line\"\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    exit={{ opacity: 0 }}\n                    transition={{ duration: 0.2 }}\n                    className={cn(\n                      \"grid gap-x-4\",\n                      density === \"compact\"\n                        ? \"grid-cols-[4rem_minmax(0,1fr)] py-4\"\n                        : \"grid-cols-[5rem_minmax(0,1fr)] py-5 sm:grid-cols-[6rem_minmax(0,1fr)]\"\n                    )}\n                  >\n                    {children ?? (\n                      <>\n                        <CartLineItemMedia />\n                        <div className=\"flex min-w-0 flex-col gap-3\">\n                          <div className=\"flex items-start justify-between gap-4\">\n                            <CartLineItemInfo />\n                            <CartLineItemTotal />\n                          </div>\n                          <CartLineItemActions />\n                        </div>\n                      </>\n                    )}\n                    {error ? (\n                      <p role=\"alert\" className=\"col-span-full pt-3 text-sm text-[var(--sale,var(--destructive))]\">\n                        {error}\n                      </p>\n                    ) : null}\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          </motion.div>\n          {/* Always in the page, so the change is read out. */}\n          <span className=\"sr-only\" aria-live=\"polite\">\n            {leaving\n              ? `${leaving === \"saved\" ? \"Saved for later\" : \"Removed\"}: ${line.name}. Undo is available for a few seconds.`\n              : \"\"}\n          </span>\n        </li>\n      </MotionConfig>\n    </CartLineItemContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\n/** The thumbnail, linked to the product when the line has an `href`. */\nexport function CartLineItemMedia({ className }: { className?: string }) {\n  const { line, loading } = useCartLineItem()\n  const frame = cn(\"relative block aspect-[4/5] w-full overflow-hidden bg-secondary\", className)\n  if (loading) return <span aria-hidden className={cn(frame, \"animate-pulse bg-muted\")} />\n  return line.href ? (\n    // The name carries the link for assistive tech; this one is a pointer shortcut.\n    <a href={line.href} tabIndex={-1} aria-hidden data-slot=\"cart-line-item-media\" className={frame}>\n      {line.image}\n    </a>\n  ) : (\n    <span data-slot=\"cart-line-item-media\" className={frame}>\n      {line.image}\n    </span>\n  )\n}\n\n/** Brand, name and variant. */\nexport function CartLineItemInfo({ className }: { className?: string }) {\n  const { line, density, loading } = useCartLineItem()\n  if (loading) {\n    return (\n      <span aria-hidden className={cn(\"flex flex-col gap-2\", className)}>\n        <span className=\"h-3 w-16 animate-pulse bg-muted\" />\n        <span className=\"h-4 w-32 animate-pulse bg-muted\" />\n      </span>\n    )\n  }\n  return (\n    <div data-slot=\"cart-line-item-info\" className={cn(\"min-w-0\", className)}>\n      {line.brand ? (\n        <p className=\"text-[11px] tracking-[0.16em] text-muted-foreground uppercase\">{line.brand}</p>\n      ) : null}\n      <p className={cn(\"leading-tight\", density === \"compact\" ? \"text-base\" : \"text-lg\")}>\n        {line.href ? (\n          <a href={line.href} className=\"outline-none hover:underline focus-visible:underline\">\n            {line.name}\n          </a>\n        ) : (\n          line.name\n        )}\n      </p>\n      {line.variant ? <p className=\"mt-1 text-sm text-muted-foreground\">{line.variant}</p> : null}\n    </div>\n  )\n}\n\n/**\n * The line total, right-aligned in tabular figures so a column of lines\n * reads as a ledger — with the unit price under it when there is more than\n * one, and the was-price struck through.\n */\nexport function CartLineItemTotal({ className }: { className?: string }) {\n  const { line, quantity, total, formatMoney, loading } = useCartLineItem()\n  const reduce = useReducedMotion()\n  if (loading) return <span aria-hidden className={cn(\"h-4 w-14 animate-pulse bg-muted\", className)} />\n  const was = line.compareAt && line.compareAt > line.unitPrice ? line.compareAt * quantity : undefined\n  return (\n    <div data-slot=\"cart-line-item-total\" className={cn(\"shrink-0 text-right tabular-nums\", className)}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.p\n          key={total}\n          initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n          transition={reduce ? { duration: 0.15 } : MORPH}\n          className={cn(was && \"text-[var(--sale)]\")}\n        >\n          {formatMoney(total)}\n        </motion.p>\n      </AnimatePresence>\n      {was ? (\n        <p className=\"text-xs text-muted-foreground line-through\">\n          <span className=\"sr-only\">Was </span>\n          {formatMoney(was)}\n        </p>\n      ) : null}\n      {quantity > 1 ? (\n        <p className=\"text-xs text-muted-foreground\">{formatMoney(line.unitPrice)} each</p>\n      ) : null}\n    </div>\n  )\n}\n\n/** − [ 2 ] + as one ruled block; typing works too, settling on blur or Enter. */\nexport function CartLineItemQuantity({ className }: { className?: string }) {\n  const { line, quantity, setQuantity, step, maxQuantity, loading } = useCartLineItem()\n  const [typed, setTyped] = React.useState<string | null>(null)\n  const commit = () => {\n    if (typed !== null) setQuantity(Number.parseInt(typed, 10))\n    setTyped(null)\n  }\n  return (\n    <div\n      role=\"group\"\n      aria-label={`Quantity of ${line.name}`}\n      data-slot=\"cart-line-item-quantity\"\n      className={cn(\"flex h-9 w-fit items-stretch border\", className)}\n    >\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        aria-label=\"One fewer\"\n        disabled={loading || quantity <= 1}\n        onClick={() => step(-1)}\n        className=\"h-full w-9 rounded-none p-0\"\n      >\n        <Minus aria-hidden className=\"size-3.5\" />\n      </Button>\n      <Input\n        aria-label=\"Quantity\"\n        inputMode=\"numeric\"\n        pattern=\"[0-9]*\"\n        disabled={loading}\n        value={typed ?? String(quantity)}\n        onChange={(event) => setTyped(event.target.value.replace(/\\D/g, \"\").slice(0, 2))}\n        onBlur={commit}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\") commit()\n        }}\n        className=\"h-full w-9 rounded-none border-0 px-0 text-center text-sm tabular-nums shadow-none focus-visible:ring-2 focus-visible:ring-inset\"\n      />\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        aria-label=\"One more\"\n        disabled={loading || quantity >= maxQuantity}\n        onClick={() => step(1)}\n        className=\"h-full w-9 rounded-none p-0\"\n      >\n        <Plus aria-hidden className=\"size-3.5\" />\n      </Button>\n    </div>\n  )\n}\n\n/** The stepper, then save-for-later and remove as quiet text buttons. */\nexport function CartLineItemActions({ className }: { className?: string }) {\n  const { line, quantity, maxQuantity, remove, saveForLater, loading } = useCartLineItem()\n  const quiet =\n    \"h-auto rounded-none p-0 text-[13px] font-normal text-muted-foreground underline decoration-border underline-offset-4 hover:bg-transparent hover:text-foreground hover:decoration-foreground\"\n  return (\n    <div data-slot=\"cart-line-item-actions\" className={cn(\"flex flex-wrap items-center gap-x-5 gap-y-2\", className)}>\n      <CartLineItemQuantity />\n      {saveForLater ? (\n        <Button type=\"button\" variant=\"ghost\" disabled={loading} onClick={saveForLater} className={quiet}>\n          Save for later<span className=\"sr-only\">: {line.name}</span>\n        </Button>\n      ) : null}\n      <Button type=\"button\" variant=\"ghost\" data-action=\"remove\" disabled={loading} onClick={remove} className={quiet}>\n        Remove<span className=\"sr-only\">: {line.name}</span>\n      </Button>\n      {quantity >= maxQuantity && line.maxQuantity !== undefined ? (\n        <p className=\"w-full text-xs text-muted-foreground\">\n          {line.maxQuantity === 1 ? \"Only one left.\" : `Only ${line.maxQuantity} left.`}\n        </p>\n      ) : null}\n    </div>\n  )\n}\n\n/**\n * The folded row: what happened, and the way back. Focus moves to Undo, so\n * a keyboard is one key from reversing it; the row's live region reads it out.\n */\nfunction CartLineItemUndo() {\n  const { line, leaving, undo, density } = useCartLineItem()\n  return (\n    <div\n      data-slot=\"cart-line-item-undo\"\n      className={cn(\"flex items-center justify-between gap-4\", density === \"compact\" ? \"py-3\" : \"py-4\")}\n    >\n      <p className=\"min-w-0 truncate text-sm text-muted-foreground\">\n        {leaving === \"saved\" ? \"Saved for later: \" : \"Removed: \"}\n        <span className=\"text-foreground\">{line.name}</span>\n      </p>\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        data-action=\"undo\"\n        onClick={undo}\n        className=\"h-8 shrink-0 rounded-none px-3 text-xs font-medium tracking-[0.14em] uppercase\"\n      >\n        Undo\n      </Button>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/cart-line-item.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "cart"
  ],
  "type": "registry:component"
}