{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart-sheet",
  "title": "Cart Sheet",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "The mini cart on your shadcn Sheet: a bag button with a count that turns over, and a sheet that grows out of it as a circle and folds back into it (Motion; a fade under reduced motion). Ruled header, body and footer bands — the lines are the only scroller, with soft edges instead of a scrollbar — and a footer with free-delivery progress, the subtotal and checkout. Focus is trapped and handed back to the bag button; lines are keyed by id, so a cart update never remounts one mid-undo, and the subtotal follows what the shopper sees before your cart catches up. Compound, through useCartSheet().",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "sheet",
    "button",
    "@ecomcn/cart-line-item"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/cart-sheet/cart-sheet.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ShoppingBag, X } from \"lucide-react\"\nimport { AnimatePresence, MotionConfig, animate, motion, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Sheet,\n  SheetClose,\n  SheetContent,\n  SheetDescription,\n  SheetTitle,\n  SheetTrigger,\n} from \"@/components/ui/sheet\"\nimport { CartLineItem, type CartLine } from \"@/components/ecomcn/cart-line-item\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * The mini cart: a bag button that opens a sheet of the bag's lines, with\n * the subtotal and checkout held at the bottom.\n *\n *   <CartSheet lines={lines} onQuantityChange={update} onRemove={remove} onCheckout={checkout} />\n *\n *   <CartSheet lines={lines} onRemove={remove}>                          or composed\n *     <CartSheetTrigger className=\"…\" />\n *     <CartSheetContent>\n *       <CartSheetLines />\n *       <YourUpsell />\n *       <CartSheetFooter />\n *     </CartSheetContent>\n *   </CartSheet>\n *\n * On your shadcn Sheet: it traps focus and hands it back to the bag button\n * on close. Its slide keyframes are off — the sheet grows out of the bag\n * button as a circle and folds back into it (Motion; a fade under reduced\n * motion), staying open until it has. Header, body and footer are ruled\n * bands, and the body is the only part that scrolls. Lines are keyed by id,\n * so a cart update never remounts one mid-undo; the subtotal follows what\n * the shopper sees — a quantity just pressed, a line waiting to be removed —\n * before your cart catches up.\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\n/** Scrolls, without drawing a scrollbar. Edge fades say there is more. */\nconst NO_SCROLLBAR = \"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\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\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface CartSheetContextValue {\n  lines: CartLine[]\n  /** Items in the bag, as the shopper sees them. */\n  count: number\n  /** The subtotal, as the shopper sees it. */\n  subtotal: number\n  formatMoney: (amount: number) => string\n  currency: string\n  locale?: string\n  open: boolean\n  setOpen: (open: boolean) => void\n  freeShippingThreshold?: number\n  title: React.ReactNode\n  note: React.ReactNode\n  checkoutLabel: React.ReactNode\n  onCheckout?: () => void\n  viewBagHref?: string\n  empty: React.ReactNode\n  loading: boolean\n}\n\n/** Internal: line handlers, previews and the morph's bookkeeping. */\ninterface Internals {\n  onQuantityChange?: (id: string, quantity: number) => void | Promise<void>\n  onRemove?: (id: string) => void | Promise<void>\n  onSaveForLater?: (id: string) => void | Promise<void>\n  preview: (id: string, quantity: number) => void\n  originRef: React.RefObject<HTMLElement | null>\n  closing: boolean\n  finishClose: () => void\n}\n\nconst CartSheetContext = React.createContext<CartSheetContextValue | null>(null)\nconst InternalsContext = React.createContext<Internals | null>(null)\n\nexport function useCartSheet() {\n  const context = React.useContext(CartSheetContext)\n  if (!context) {\n    throw new Error(\"useCartSheet must be used within <CartSheet>.\")\n  }\n  return context\n}\n\nfunction useInternals() {\n  const context = React.useContext(InternalsContext)\n  if (!context) throw new Error(\"Cart sheet parts must be used within <CartSheet>.\")\n  return context\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface CartSheetProps {\n  lines: CartLine[]\n  currency?: string\n  locale?: string\n  open?: boolean\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** Debounced in each line: one call once the shopper settles. Reject to put it back. */\n  onQuantityChange?: (id: string, quantity: number) => void | Promise<void>\n  /** After the line's undo window. Reject to put it back. */\n  onRemove?: (id: string) => void | Promise<void>\n  /** Shows \"Save for later\" on each line; runs after the undo window. */\n  onSaveForLater?: (id: string) => void | Promise<void>\n  /** Shows how far the bag is from free delivery. */\n  freeShippingThreshold?: number\n  title?: React.ReactNode\n  /** Under the subtotal. */\n  note?: React.ReactNode\n  checkoutLabel?: React.ReactNode\n  onCheckout?: () => void\n  /** Adds a \"View bag\" link under checkout. */\n  viewBagHref?: string\n  /** What an empty bag says — a \"Continue shopping\" link, say. */\n  empty?: React.ReactNode\n  loading?: boolean\n  /** Trigger and content parts. Omit for the default layout. */\n  children?: React.ReactNode\n}\n\nexport function CartSheet({\n  lines,\n  currency = \"USD\",\n  locale: localeProp,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  onQuantityChange,\n  onRemove,\n  onSaveForLater,\n  freeShippingThreshold,\n  title = \"Your bag\",\n  note = \"Shipping and taxes are worked out at checkout.\",\n  checkoutLabel = \"Checkout\",\n  onCheckout,\n  viewBagHref,\n  empty,\n  loading = false,\n  children,\n}: CartSheetProps) {\n  const locale = useFormatLocale(localeProp)\n  const [open, setOpen] = useControllableState(openProp, defaultOpen, onOpenChange)\n\n  // The primitive stays open until the fold has played; `open` is what the\n  // shopper asked for.\n  const [mounted, setMounted] = React.useState(open)\n  if (open && !mounted) setMounted(true)\n  const closing = mounted && !open\n  const originRef = React.useRef<HTMLElement | null>(null)\n  const finishClose = React.useCallback(() => setMounted(false), [])\n\n  // What each line shows, ahead of your cart: a new quantity, or 0 while\n  // its removal waits out the undo window.\n  const [previews, setPreviews] = React.useState<Record<string, number>>({})\n  const preview = React.useCallback((id: string, quantity: number) => {\n    setPreviews((current) => (current[id] === quantity ? current : { ...current, [id]: quantity }))\n  }, [])\n\n  const formatMoney = React.useMemo(() => {\n    const cents = new Intl.NumberFormat(locale, { style: \"currency\", currency })\n    return (amount: number) => cents.format(amount)\n  }, [locale, currency])\n\n  const shown = (line: CartLine) => previews[line.id] ?? line.quantity\n  const count = lines.reduce((n, line) => n + shown(line), 0)\n  const subtotal = lines.reduce((sum, line) => sum + line.unitPrice * shown(line), 0)\n\n  const context = React.useMemo<CartSheetContextValue>(\n    () => ({\n      lines,\n      count,\n      subtotal,\n      formatMoney,\n      currency,\n      locale,\n      open,\n      setOpen,\n      freeShippingThreshold,\n      title,\n      note,\n      checkoutLabel,\n      onCheckout,\n      viewBagHref,\n      empty: empty ?? <p className=\"text-sm text-muted-foreground\">Nothing in it yet.</p>,\n      loading,\n    }),\n    [lines, count, subtotal, formatMoney, currency, locale, open, setOpen, freeShippingThreshold, title, note, checkoutLabel, onCheckout, viewBagHref, empty, loading]\n  )\n  const internals = React.useMemo<Internals>(\n    () => ({ onQuantityChange, onRemove, onSaveForLater, preview, originRef, closing, finishClose }),\n    [onQuantityChange, onRemove, onSaveForLater, preview, closing, finishClose]\n  )\n\n  return (\n    <CartSheetContext.Provider value={context}>\n      <InternalsContext.Provider value={internals}>\n        <MotionConfig reducedMotion=\"user\">\n          <Sheet\n            open={mounted}\n            onOpenChange={(next) => {\n              // Escape twice, or the overlay mid-fold: already closing.\n              if (next !== open) setOpen(next)\n            }}\n          >\n            {children ?? (\n              <>\n                <CartSheetTrigger />\n                <CartSheetContent />\n              </>\n            )}\n          </Sheet>\n        </MotionConfig>\n      </InternalsContext.Provider>\n    </CartSheetContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\n/**\n * The bag button: an icon and the count, which turns over when it changes.\n * Style it with `className`; it is your Sheet's trigger, so focus comes\n * back to it when the sheet closes.\n */\nexport function CartSheetTrigger({ className, children }: { className?: string; children?: React.ReactNode }) {\n  const { count } = useCartSheet()\n  const { originRef } = useInternals()\n  const reduce = useReducedMotion()\n  return (\n    <SheetTrigger\n      aria-label={`Bag, ${count} ${count === 1 ? \"item\" : \"items\"}`}\n      className={cn(\n        \"inline-flex h-9 items-center gap-2 border px-3 text-sm transition-colors outline-none hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring\",\n        className\n      )}\n    >\n      {/* The morph's origin. A span, not a ref on the trigger: refs reach\n          each base's primitive differently. */}\n      <span\n        ref={(node) => {\n          originRef.current = node\n        }}\n        className=\"inline-flex items-center gap-2\"\n      >\n        {children ?? (\n          <>\n            <ShoppingBag aria-hidden className=\"size-4\" />\n            <span className=\"relative inline-grid overflow-hidden tabular-nums\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={count}\n                  initial={{ y: reduce ? 0 : \"100%\", opacity: 0 }}\n                  animate={{ y: 0, opacity: 1 }}\n                  exit={{ y: reduce ? 0 : \"-100%\", opacity: 0 }}\n                  transition={reduce ? { duration: 0.15 } : MORPH}\n                >\n                  {count}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </>\n        )}\n      </span>\n    </SheetTrigger>\n  )\n}\n\n/** Whether a scroller has more above or below. */\nfunction useOverflow() {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const [edges, setEdges] = React.useState({ start: false, end: false })\n  React.useEffect(() => {\n    const node = ref.current\n    if (!node) return\n    const update = () => {\n      const next = {\n        start: node.scrollTop > 1,\n        end: node.scrollTop + node.clientHeight < node.scrollHeight - 1,\n      }\n      setEdges((current) => (current.start === next.start && current.end === next.end ? current : next))\n    }\n    update()\n    const observer = new ResizeObserver(update)\n    observer.observe(node)\n    if (node.firstElementChild) observer.observe(node.firstElementChild)\n    node.addEventListener(\"scroll\", update, { passive: true })\n    return () => {\n      observer.disconnect()\n      node.removeEventListener(\"scroll\", update)\n    }\n  }, [])\n  return [ref, edges] as const\n}\n\n/**\n * The sheet: a header band with the title and count, the lines — the only\n * scroller — and the footer band with the subtotal and checkout.\n */\nexport function CartSheetContent({ className, children }: { className?: string; children?: React.ReactNode }) {\n  const { title, count } = useCartSheet()\n  return (\n    <SheetContent\n      side=\"right\"\n      showCloseButton={false}\n      // The primitive positions the panel and traps focus; its surface and\n      // keyframes are off, and Motion plays the open and the close.\n      className=\"w-full gap-0 border-0 bg-transparent p-0 shadow-none animate-none! transition-none! sm:max-w-md\"\n    >\n      <SheetPanel>\n        <div data-slot=\"cart-sheet-header\" className=\"flex h-15 shrink-0 items-center justify-between gap-4 border-b px-5\">\n          <SheetTitle className=\"text-xl font-normal\">\n            {title}\n            <span className=\"ml-2 text-sm text-muted-foreground tabular-nums\">{count}</span>\n          </SheetTitle>\n          <SheetDescription className=\"sr-only\">\n            The items in your bag, with the subtotal and checkout.\n          </SheetDescription>\n          <SheetClose className=\"grid size-9 place-items-center outline-none hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring\">\n            <X aria-hidden className=\"size-4\" />\n            <span className=\"sr-only\">Close</span>\n          </SheetClose>\n        </div>\n        <div className={cn(\"flex min-h-0 flex-1 flex-col\", className)}>\n          {children ?? (\n            <>\n              <CartSheetLines />\n              <CartSheetFooter />\n            </>\n          )}\n        </div>\n      </SheetPanel>\n    </SheetContent>\n  )\n}\n\n/** The lines: the only part of the sheet that scrolls, with soft edges instead of a scrollbar. */\nexport function CartSheetLines({ className }: { className?: string }) {\n  const { lines, currency, locale, empty, loading } = useCartSheet()\n  const { onQuantityChange, onRemove, onSaveForLater, preview } = useInternals()\n  const [scroller, edges] = useOverflow()\n\n  return (\n    <div className=\"relative flex min-h-0 flex-1 flex-col\">\n      <div\n        ref={scroller}\n        data-slot=\"cart-sheet-lines\"\n        className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5\", NO_SCROLLBAR, className)}\n      >\n        {loading ? (\n          <ul>\n            {Array.from({ length: 2 }, (_, i) => (\n              <CartLineItem key={i} line={{ id: String(i), name: \"\", unitPrice: 0, quantity: 1 }} density=\"compact\" loading />\n            ))}\n          </ul>\n        ) : lines.length ? (\n          <ul>\n            {lines.map((line) => (\n              <CartLineItem\n                // Keyed by id: a cart update never remounts a line mid-undo.\n                key={line.id}\n                line={line}\n                currency={currency}\n                locale={locale}\n                density=\"compact\"\n                onQuantityChange={onQuantityChange ? (q) => onQuantityChange(line.id, q) : undefined}\n                onRemove={onRemove ? () => onRemove(line.id) : undefined}\n                onSaveForLater={onSaveForLater ? () => onSaveForLater(line.id) : undefined}\n                onPreviewQuantityChange={(q) => preview(line.id, q)}\n              />\n            ))}\n          </ul>\n        ) : (\n          <div data-slot=\"cart-sheet-empty\" className=\"py-10\">\n            <p className=\"text-2xl\">Your bag is empty.</p>\n            <div className=\"mt-3\">{empty}</div>\n          </div>\n        )}\n      </div>\n      <EdgeFade side=\"top\" visible={edges.start} />\n      <EdgeFade side=\"bottom\" visible={edges.end} />\n    </div>\n  )\n}\n\nfunction EdgeFade({ side, visible }: { side: \"top\" | \"bottom\"; visible: boolean }) {\n  return (\n    <div\n      aria-hidden\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 h-8 from-background to-transparent transition-opacity duration-200\",\n        side === \"top\" ? \"top-0 bg-gradient-to-b\" : \"bottom-0 bg-gradient-to-t\",\n        visible ? \"opacity-100\" : \"opacity-0\"\n      )}\n    />\n  )\n}\n\n/**\n * The footer band: how far from free delivery, the subtotal, a note, and\n * checkout. It never scrolls away.\n */\nexport function CartSheetFooter({ className }: { className?: string }) {\n  const { lines, subtotal, formatMoney, freeShippingThreshold, note, checkoutLabel, onCheckout, viewBagHref, setOpen, loading } =\n    useCartSheet()\n  const reduce = useReducedMotion()\n  if (!lines.length && !loading) return null\n  const left = freeShippingThreshold !== undefined ? Math.max(0, freeShippingThreshold - subtotal) : undefined\n  const share = freeShippingThreshold ? Math.min(1, subtotal / freeShippingThreshold) : 0\n\n  return (\n    <div data-slot=\"cart-sheet-footer\" className={cn(\"shrink-0 border-t px-5 pt-4 pb-5\", className)}>\n      {left !== undefined ? (\n        <div className=\"mb-4\">\n          <p className=\"text-sm\" aria-live=\"polite\">\n            {left > 0 ? (\n              <>\n                <span className=\"tabular-nums\">{formatMoney(left)}</span> from free delivery\n              </>\n            ) : (\n              \"Free delivery on this order\"\n            )}\n          </p>\n          <div aria-hidden className=\"mt-2 h-1 bg-secondary\">\n            <motion.div\n              className=\"h-full origin-left bg-foreground\"\n              initial={false}\n              animate={{ scaleX: share }}\n              transition={reduce ? { duration: 0 } : MORPH}\n            />\n          </div>\n        </div>\n      ) : null}\n      <p className=\"flex items-baseline justify-between gap-4\">\n        <span className=\"text-xs font-medium tracking-[0.14em] uppercase\">Subtotal</span>\n        <span className=\"text-xl tabular-nums\" aria-live=\"polite\">\n          {loading ? \"—\" : formatMoney(subtotal)}\n        </span>\n      </p>\n      {note ? <p className=\"mt-1 text-xs text-muted-foreground\">{note}</p> : null}\n      <Button\n        type=\"button\"\n        size=\"lg\"\n        disabled={loading || subtotal === 0}\n        onClick={() => {\n          onCheckout?.()\n          setOpen(false)\n        }}\n        className=\"mt-4 h-12 w-full rounded-none text-xs font-medium tracking-[0.14em] uppercase shadow-none\"\n      >\n        {checkoutLabel}\n      </Button>\n      {viewBagHref ? (\n        <a\n          href={viewBagHref}\n          onClick={() => setOpen(false)}\n          className=\"mt-3 block text-center text-sm text-muted-foreground underline decoration-border underline-offset-4 hover:text-foreground\"\n        >\n          View bag\n        </a>\n      ) : null}\n    </div>\n  )\n}\n\n/**\n * The visible surface, and the morph: a circle growing from the bag button\n * to cover the panel, the contents fading in once it has; closing runs it\n * back into the button. A layout effect inside the portal, so the first\n * frame is already clipped.\n */\nfunction SheetPanel({ children }: { children: React.ReactNode }) {\n  const { originRef, closing, finishClose } = useInternals()\n  const reduce = useReducedMotion()\n  const surface = React.useRef<HTMLDivElement>(null)\n  const contents = React.useRef<HTMLDivElement>(null)\n\n  React.useLayoutEffect(() => {\n    const panel = surface.current\n    const inner = contents.current\n    if (!panel || !inner) return\n    const overlay = panel\n      .closest<HTMLElement>('[data-slot=\"sheet-content\"]')\n      ?.parentElement?.querySelector<HTMLElement>(':scope > [data-slot=\"sheet-overlay\"]')\n    const running: AnimationPlaybackControls[] = []\n    let cancelled = false\n\n    // The bag button's centre, in the panel's own coordinates — it may lie\n    // outside the panel, which a circle's centre is allowed to.\n    const circle = () => {\n      const box = panel.getBoundingClientRect()\n      const r = originRef.current?.getBoundingClientRect()\n      const x = r ? r.left + r.width / 2 - box.left : box.width\n      const y = r ? r.top + r.height / 2 - box.top : 0\n      const far = Math.ceil(Math.hypot(Math.max(x, box.width - x), Math.max(y, box.height - y)))\n      return { at: `${x}px ${y}px`, far }\n    }\n\n    if (!closing) {\n      if (reduce) {\n        panel.style.opacity = \"0\"\n        running.push(animate(panel, { opacity: [0, 1] }, { duration: 0.15 }))\n      } else {\n        const { at, far } = circle()\n        panel.style.clipPath = `circle(0px at ${at})`\n        inner.style.opacity = \"0\"\n        running.push(\n          animate(panel, { clipPath: [`circle(0px at ${at})`, `circle(${far}px at ${at})`] }, MORPH),\n          animate(inner, { opacity: 1 }, { duration: 0.25, delay: 0.15, ease: \"easeOut\" })\n        )\n      }\n      return () => {\n        cancelled = true\n        running.forEach((a) => a.stop())\n      }\n    }\n\n    if (reduce) {\n      running.push(animate(panel, { opacity: 0 }, { duration: 0.15 }))\n    } else {\n      const { at } = circle()\n      running.push(\n        animate(inner, { opacity: 0 }, { duration: 0.12, ease: \"easeIn\" }),\n        animate(panel, { clipPath: `circle(0px at ${at})` }, { ...MORPH, duration: 0.38, delay: 0.06 })\n      )\n    }\n    if (overlay) running.push(animate(overlay, { opacity: 0 }, { duration: 0.3, delay: 0.1 }))\n\n    const fallback = window.setTimeout(() => !cancelled && finishClose(), 800)\n    Promise.all(running.map((a) => a.finished)).then(() => {\n      window.clearTimeout(fallback)\n      if (!cancelled) finishClose()\n    })\n    return () => {\n      cancelled = true\n      window.clearTimeout(fallback)\n      running.forEach((a) => a.stop())\n    }\n    // Runs on open and on close; `reduce` is read, not watched.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [closing])\n\n  return (\n    <div ref={surface} data-slot=\"cart-sheet-panel\" className=\"flex h-dvh w-full flex-col bg-background text-foreground\">\n      <div ref={contents} className=\"flex min-h-0 flex-1 flex-col\">\n        {children}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/cart-sheet.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "cart"
  ],
  "type": "registry:block"
}