{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart-drawer",
  "type": "registry:component",
  "title": "Cart Drawer",
  "description": "A slide-over bag with quantity steppers, line totals and subtotal that roll digit by digit, a free-shipping bar that fills as you add, and a friendly empty state.",
  "categories": [
    "component",
    "Commerce"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/cart-drawer.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { MinusIcon, PlusIcon, ShoppingBagIcon, Trash2Icon, TruckIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CartLine {\n  id: string;\n  name: string;\n  variant?: string;\n  price: number;\n  qty: number;\n  art?: ReactNode;\n  max?: number;\n}\n\nexport interface CartDrawerProps {\n  items?: CartLine[];\n  defaultItems?: CartLine[];\n  onItemsChange?: (items: CartLine[]) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  freeShippingAt?: number;\n  currency?: string;\n  locale?: string;\n  onCheckout?: (items: CartLine[], subtotal: number) => void;\n  children?: (cartButton: ReactNode) => ReactNode;\n  className?: string;\n}\n\nexport function CartDrawer({\n  items: itemsProp,\n  defaultItems = [],\n  onItemsChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  freeShippingAt,\n  currency = \"USD\",\n  locale = \"en-US\",\n  onCheckout,\n  children,\n  className,\n}: CartDrawerProps) {\n  const reduce = useReducedMotion();\n  const [innerItems, setInnerItems] = useState(defaultItems);\n  const [innerOpen, setInnerOpen] = useState(defaultOpen);\n  const items = itemsProp ?? innerItems;\n  const open = openProp ?? innerOpen;\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const opened = useRef(false);\n\n  const setItems = (next: CartLine[]) => {\n    if (itemsProp === undefined) setInnerItems(next);\n    onItemsChange?.(next);\n  };\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setInnerOpen(next);\n    onOpenChange?.(next);\n    opened.current = next;\n    if (!next) requestAnimationFrame(() => triggerRef.current?.focus());\n  };\n  useEffect(() => {\n    if (open && opened.current) panelRef.current?.focus();\n  }, [open]);\n\n  const fmt = (n: number) => new Intl.NumberFormat(locale, { style: \"currency\", currency }).format(n);\n  const count = items.reduce((n, l) => n + l.qty, 0);\n  const subtotal = items.reduce((n, l) => n + l.qty * l.price, 0);\n  const remaining = freeShippingAt ? Math.max(0, freeShippingAt - subtotal) : 0;\n  const unlocked = !!freeShippingAt && remaining === 0;\n\n  const setQty = (id: string, qty: number) => setItems(qty <= 0 ? items.filter((l) => l.id !== id) : items.map((l) => (l.id === id ? { ...l, qty } : l)));\n\n  const onKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\" && open) {\n      event.stopPropagation();\n      setOpen(false);\n      return;\n    }\n    const panel = panelRef.current;\n    if (event.key !== \"Tab\" || !open || !panel) return;\n    const items = [...panel.querySelectorAll<HTMLElement>(\"button:not(:disabled)\")];\n    const first = items[0];\n    const last = items[items.length - 1];\n    const at = document.activeElement;\n    if (event.shiftKey && (at === first || at === panel)) {\n      event.preventDefault();\n      last?.focus();\n    } else if (!event.shiftKey && at === last) {\n      event.preventDefault();\n      first?.focus();\n    }\n  };\n\n  const ease = [0.32, 0.72, 0, 1] as const;\n\n  const cartButton = (\n    <button\n      aria-expanded={open}\n      aria-label={`Bag, ${count} item${count === 1 ? \"\" : \"s\"}`}\n      className=\"relative flex size-9 cursor-pointer items-center justify-center rounded-full text-foreground outline-none transition-[background-color,scale] duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n      onClick={() => setOpen(!open)}\n      ref={triggerRef}\n      type=\"button\"\n    >\n      <ShoppingBagIcon className=\"size-4.5\" />\n      <AnimatePresence initial={false}>\n        {count > 0 && (\n          <motion.span\n            animate={{ scale: 1, opacity: 1 }}\n            className=\"absolute top-0.5 right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-semibold text-[10px] text-primary-foreground tabular-nums ring-2 ring-card\"\n            exit={{ scale: 0.6, opacity: 0 }}\n            initial={{ scale: 0.6, opacity: 0 }}\n            key=\"badge\"\n            transition={{ duration: 0.18, ease: \"easeOut\" }}\n          >\n            {count}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </button>\n  );\n\n  return (\n    <div className={cn(\"relative isolate overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)} onKeyDown={onKey}>\n      <div aria-hidden={open || undefined} className=\"h-full\" inert={open || undefined}>\n        {children?.(cartButton) ?? <div className=\"flex justify-end p-3\">{cartButton}</div>}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <>\n            <motion.div\n              animate={{ opacity: 1 }}\n              className=\"absolute inset-0 z-10 bg-black/24 dark:bg-black/48\"\n              exit={{ opacity: 0 }}\n              initial={{ opacity: 0 }}\n              key=\"scrim\"\n              onClick={() => setOpen(false)}\n              transition={{ duration: 0.2 }}\n            />\n            <motion.div\n              animate={{ x: 0 }}\n              aria-label=\"Shopping bag\"\n              aria-modal=\"true\"\n              className=\"absolute inset-y-0 right-0 z-20 flex w-[min(360px,90%)] flex-col border-s bg-popover text-popover-foreground shadow-xl/10 outline-none\"\n              exit={reduce ? { opacity: 0 } : { x: \"100%\", transition: { duration: 0.22, ease } }}\n              initial={reduce ? { opacity: 0 } : { x: \"100%\" }}\n              key=\"panel\"\n              ref={panelRef}\n              role=\"dialog\"\n              tabIndex={-1}\n              transition={{ duration: 0.28, ease }}\n            >\n              <div className=\"flex items-center justify-between gap-2 px-4 pt-4 pb-3\">\n                <h2 className=\"font-semibold text-base\">\n                  Your bag <span className=\"font-normal text-muted-foreground tabular-nums\">({count})</span>\n                </h2>\n                <Button aria-label=\"Close bag\" className=\"-me-1.5\" onClick={() => setOpen(false)} size=\"icon-sm\" variant=\"ghost\">\n                  <XIcon />\n                </Button>\n              </div>\n\n              {freeShippingAt && items.length > 0 && (\n                <div className=\"mx-4 mb-2 rounded-xl bg-muted/70 px-3 py-2.5\">\n                  <div className=\"relative h-4 overflow-hidden text-xs\">\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      <motion.p\n                        animate={{ opacity: 1, y: 0 }}\n                        className={cn(\"flex items-center gap-1.5\", unlocked ? \"font-medium text-success-foreground\" : \"text-muted-foreground\")}\n                        exit={{ opacity: 0, y: -10 }}\n                        initial={reduce ? false : { opacity: 0, y: 10 }}\n                        key={unlocked ? \"free\" : \"left\"}\n                        transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n                      >\n                        <TruckIcon className=\"size-3.5\" />\n                        {unlocked ? (\n                          \"Free shipping unlocked\"\n                        ) : (\n                          <span>\n                            <span className=\"font-medium text-foreground tabular-nums\">{fmt(remaining)}</span> away from free shipping\n                          </span>\n                        )}\n                      </motion.p>\n                    </AnimatePresence>\n                  </div>\n                  <div className=\"mt-2 h-1.5 overflow-hidden rounded-full bg-background dark:bg-input/40\">\n                    <motion.div\n                      animate={{ scaleX: Math.min(1, subtotal / freeShippingAt) }}\n                      className={cn(\"h-full origin-left rounded-full transition-colors duration-300\", unlocked ? \"bg-success\" : \"bg-primary/80\")}\n                      initial={false}\n                      transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 32 }}\n                    />\n                  </div>\n                </div>\n              )}\n\n              <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4\">\n                {items.length === 0 ? (\n                  <motion.div\n                    animate={{ opacity: 1, y: 0 }}\n                    className=\"flex h-full flex-col items-center justify-center gap-1 pb-8 text-center\"\n                    initial={reduce ? false : { opacity: 0, y: 8 }}\n                    transition={{ duration: 0.24, ease: [0.23, 1, 0.32, 1], delay: 0.1 }}\n                  >\n                    <span className=\"mb-3 flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground\">\n                      <ShoppingBagIcon className=\"size-6\" />\n                    </span>\n                    <p className=\"font-medium text-sm\">Your bag is empty</p>\n                    <p className=\"max-w-52 text-pretty text-muted-foreground text-xs\">Things you add show up here, ready when you are.</p>\n                    <Button className=\"mt-4\" onClick={() => setOpen(false)} size=\"sm\" variant=\"outline\">\n                      Keep shopping\n                    </Button>\n                  </motion.div>\n                ) : (\n                  <ul>\n                    <AnimatePresence initial={false}>\n                      {items.map((line) => (\n                        <motion.li\n                          animate={{ opacity: 1, height: \"auto\" }}\n                          className=\"overflow-hidden\"\n                          exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }}\n                          initial={reduce ? false : { opacity: 0, height: 0 }}\n                          key={line.id}\n                          transition={{ duration: 0.24, ease: [0.23, 1, 0.32, 1] }}\n                        >\n                          <div className=\"flex gap-3 border-b py-3\">\n                            <span className=\"size-14 shrink-0 overflow-hidden rounded-xl bg-muted\">{line.art}</span>\n                            <div className=\"flex min-w-0 flex-1 flex-col\">\n                              <div className=\"flex items-start justify-between gap-2\">\n                                <div className=\"min-w-0\">\n                                  <p className=\"truncate font-medium text-sm\">{line.name}</p>\n                                  {line.variant && <p className=\"truncate text-muted-foreground text-xs\">{line.variant}</p>}\n                                </div>\n                                <RollingNumber className=\"font-medium text-sm\" text={fmt(line.qty * line.price)} />\n                              </div>\n                              <div className=\"mt-auto flex items-center justify-between pt-2\">\n                                <Stepper\n                                  label={line.name}\n                                  max={line.max ?? 9}\n                                  onChange={(q) => setQty(line.id, q)}\n                                  value={line.qty}\n                                />\n                                <button\n                                  aria-label={`Remove ${line.name}`}\n                                  className=\"relative flex size-7 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none transition-colors before:absolute before:-inset-1 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                                  onClick={() => setQty(line.id, 0)}\n                                  type=\"button\"\n                                >\n                                  <Trash2Icon className=\"size-3.5\" />\n                                </button>\n                              </div>\n                            </div>\n                          </div>\n                        </motion.li>\n                      ))}\n                    </AnimatePresence>\n                  </ul>\n                )}\n              </div>\n\n              {items.length > 0 && (\n                <div className=\"border-t px-4 pt-3 pb-4\">\n                  <div className=\"flex items-center justify-between text-sm\">\n                    <span className=\"text-muted-foreground\">Subtotal</span>\n                    <RollingNumber className=\"font-medium\" text={fmt(subtotal)} />\n                  </div>\n                  <div className=\"mt-1 flex items-center justify-between text-xs\">\n                    <span className=\"text-muted-foreground\">Shipping</span>\n                    <span className={unlocked ? \"font-medium text-success-foreground\" : \"text-muted-foreground\"}>{unlocked ? \"Free\" : \"Calculated at checkout\"}</span>\n                  </div>\n                  <Button className=\"mt-3 w-full\" onClick={() => onCheckout?.(items, subtotal)} size=\"lg\">\n                    Checkout\n                  </Button>\n                </div>\n              )}\n            </motion.div>\n          </>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Stepper({ value, onChange, max, label }: { value: number; onChange: (v: number) => void; max: number; label: string }) {\n  return (\n    <div aria-label={`Quantity of ${label}`} className=\"flex h-7 items-center rounded-full border bg-background shadow-xs/5 dark:bg-input/32\" role=\"group\">\n      <button\n        aria-label={value === 1 ? `Remove ${label}` : `One less ${label}`}\n        className=\"relative flex h-full w-7 cursor-pointer items-center justify-center rounded-s-full before:absolute before:-inset-y-1 before:inset-x-0 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-95\"\n        onClick={() => onChange(value - 1)}\n        type=\"button\"\n      >\n        <MinusIcon className=\"size-3\" />\n      </button>\n      <RollingNumber className=\"w-4 justify-center font-medium text-xs\" text={String(value)} />\n      <button\n        aria-label={`One more ${label}`}\n        className=\"relative flex h-full w-7 cursor-pointer items-center justify-center rounded-e-full before:absolute before:-inset-y-1 before:inset-x-0 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-95 disabled:cursor-not-allowed disabled:opacity-40\"\n        disabled={value >= max}\n        onClick={() => onChange(value + 1)}\n        type=\"button\"\n      >\n        <PlusIcon className=\"size-3\" />\n      </button>\n    </div>\n  );\n}\n\nfunction RollingNumber({ text, className }: { text: string; className?: string }) {\n  const reduce = useReducedMotion();\n  const chars = text.split(\"\");\n  return (\n    <span aria-label={text} className={cn(\"inline-flex tabular-nums leading-5\", className)} role=\"img\">\n      {chars.map((c, i) => {\n        const key = chars.length - i;\n        if (!/\\d/.test(c)) {\n          return (\n            <span aria-hidden key={`s${key}`}>\n              {c}\n            </span>\n          );\n        }\n        return (\n          <span aria-hidden className=\"relative inline-block h-5 overflow-hidden [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)]\" key={`d${key}`}>\n            <motion.span\n              animate={{ y: `${-Number(c) * 10}%` }}\n              className=\"flex flex-col\"\n              initial={false}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 280, damping: 30 }}\n            >\n              {Array.from({ length: 10 }, (_, d) => (\n                <span className=\"h-5\" key={d}>\n                  {d}\n                </span>\n              ))}\n            </motion.span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n"
    }
  ]
}