{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart",
  "type": "registry:ui",
  "title": "Cart",
  "description": "The cart as a sheet from the right (a bottom sheet on a phone): lines with a quantity and Remove undone from a toast, and a summary with a free-shipping meter, a rolling subtotal and Check out. Plus the header's cart button, Add to cart with its Added state, and useCartState, a cart in memory.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/chart.json",
    "https://littmd.vercel.app/r/dialog.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/media.json",
    "https://littmd.vercel.app/r/number-field.json",
    "https://littmd.vercel.app/r/price.json",
    "https://littmd.vercel.app/r/product-card.json",
    "https://littmd.vercel.app/r/rolling-number.json",
    "https://littmd.vercel.app/r/states.json",
    "https://littmd.vercel.app/r/toast.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/cart.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/cart 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Cart: the lines someone is about to buy, in a sheet, and the controls around it (components.md, \"Cart\").\n *\n * - The cart is plain data (CartItem) behind one small interface (CartState: items, count, subtotal, add, update,\n *   remove, restore), so the parts do not care where it lives. useCartState keeps one in memory, for a demo or a store\n *   without a backend; the `shopify` item's useShopifyCart keeps one in Shopify, with the same shape.\n * - Cart: the Dialog at side=\"right\", so it is a bottom sheet on compact screens that sits above the keyboard and\n *   drags down to close. The lines scroll in the body; the summary is the footer, so it never scrolls away.\n * - CartLine: a 64px picture at 1/1 and the control radius, the title at the body size and the title weight in --litt-fg,\n *   the picked options under it at the small size in --litt-fg-subtle, and the line's price at the end (\"€90 each\" under it\n *   when there are several). Under them, a Number field at sm for the quantity, its label hidden (\"Quantity of Open\n *   water\" for screen readers), and Remove as an sm ghost button named \"Remove Open water\". Lines are a ul split by\n *   --litt-line hairlines, 16px above and below.\n * - Flat: no hairline between lines, the space parts them. The sheet is the Dialog's (--litt-surface-1, the float shadow).\n * - Remove: a line goes at once, with Undo in a toast (\"Removed Open water\"). No confirm: it can be undone. Focus moves\n *   to the next line's Remove, or the previous one, or the empty state's action, never to the page behind. The toast\n *   needs a Toaster mounted once near the root.\n * - CartSummary: an optional free-shipping Meter in --litt-fg, never a hue (\"€12 to free shipping\", then \"Free shipping\"),\n *   the subtotal as a labelled figure at the end of its row, its figure a Rolling number, \"Taxes and shipping at\n *   checkout.\" at the small size in --litt-fg-subtle, and Check out: a primary md button the width of the sheet, the open\n *   cart's one accent fill (R6). It goes to checkoutUrl (Shopify's hosted checkout), or runs onCheckout.\n * - Empty: the Empty state, \"Your cart is empty\", and one secondary action back to the products. No Check out.\n * - CartButton: the header's way in, a ghost button with the bag icon and the count in tabular figures, named \"Cart, 3 items\".\n *   The count is a Rolling number, so an add is seen where the cart lives.\n * - AddToCart: the product's primary action. While the add is in flight it shows the Button's spinner; then it says\n *   \"Added\" with a check for 2s, colors and the label only (the box never changes size, both labels share one cell).\n *   Sold out, it is a disabled \"Sold out\". The page fires its own toast (\"Added Open water\", with View cart); the cart\n *   never opens by itself unless the page opens it.\n * - Motion: the sheet's own, and the Rolling numbers. Lines leave at once, quantities change at once. Nothing flies to\n *   the cart: motion confirms, it never performs.\n */\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { Button, type ButtonSize } from \"./button\";\nimport { Meter } from \"./chart\";\nimport { Dialog, DialogContent } from \"./dialog\";\nimport { BagIcon, CheckIcon } from \"./icons\";\nimport { Media } from \"./media\";\nimport { NumberField } from \"./number-field\";\nimport { formatMoney, multiplyMoney, sumMoney, Price, type Money } from \"./price\";\nimport type { ProductImage } from \"./product-card\";\nimport { RollingNumber } from \"./rolling-number\";\nimport { Empty } from \"./states\";\nimport { toast } from \"./toast\";\n\n/** One line of a cart: a variant and how many. */\nexport type CartItem = {\n  /** The line's own id. The local cart makes one; Shopify gives its cart line's id. */\n  id: string;\n  /** The variant the line buys. Adding the same variant again adds to its line. */\n  variantId: string;\n  /** The product's name. */\n  title: string;\n  /** The picks in words: \"A2 · Natural oak\". */\n  variantTitle?: string;\n  quantity: number;\n  /** The price of one. */\n  price: Money;\n  /** What one cost before a sale. */\n  compareAt?: Money | null;\n  image?: ProductImage;\n  /** The most anyone may buy, such as the stock left. */\n  maxQuantity?: number;\n};\n\n/** What the cart parts need from a cart, wherever it lives. useCartState and useShopifyCart both return it. */\nexport type CartState = {\n  items: CartItem[];\n  /** How many things, adding up the quantities. */\n  count: number;\n  subtotal: Money;\n  /** The store's checkout page, when it has one (Shopify's cart.checkoutUrl). */\n  checkoutUrl?: string;\n  /** True while a change is on its way to the store. */\n  pending?: boolean;\n  /** Adds a variant, or adds to its line if it is already in the cart. */\n  add: (item: Omit<CartItem, \"id\" | \"quantity\"> & { quantity?: number }) => void | Promise<void>;\n  /** Sets a line's quantity. */\n  update: (id: string, quantity: number) => void | Promise<void>;\n  /** Takes a line out. */\n  remove: (id: string) => void | Promise<void>;\n  /** Puts a removed line back where it was, for Undo. */\n  restore: (item: CartItem, index: number) => void | Promise<void>;\n};\n\n/* ----------------------------------------------------------------- a cart in memory */\n\n/** A cart kept in memory: for a demo, a prototype or a store without a backend. Same shape as useShopifyCart. */\nexport function useCartState(initial: CartItem[] = [], currencyCode = initial[0]?.price.currencyCode ?? \"EUR\"): CartState {\n  const [items, setItems] = useState<CartItem[]>(initial);\n  const next = useRef(0);\n  return {\n    items,\n    count: items.reduce((sum, i) => sum + i.quantity, 0),\n    subtotal: sumMoney(items.map((i) => multiplyMoney(i.price, i.quantity)), currencyCode),\n    add: ({ quantity = 1, ...item }) =>\n      setItems((list) => {\n        const found = list.find((i) => i.variantId === item.variantId);\n        if (found) return list.map((i) => (i === found ? { ...i, quantity: Math.min(i.quantity + quantity, i.maxQuantity ?? Infinity) } : i));\n        next.current += 1;\n        return [...list, { ...item, id: `line-${item.variantId}-${next.current}`, quantity }];\n      }),\n    update: (id, quantity) => setItems((list) => list.map((i) => (i.id === id ? { ...i, quantity } : i))),\n    remove: (id) => setItems((list) => list.filter((i) => i.id !== id)),\n    restore: (item, index) =>\n      setItems((list) => (list.some((i) => i.id === item.id) ? list : [...list.slice(0, index), item, ...list.slice(index)])),\n  };\n}\n\n/* ----------------------------------------------------------------- a line */\n\nexport type CartLineProps = {\n  item: CartItem;\n  /** Called with the new quantity, by a stepper, a key or a typed number. */\n  onQuantityChange: (quantity: number) => void;\n  /** Called when Remove is pressed. */\n  onRemove: () => void;\n  /** The locale for the prices. Leave it out for the page's. */\n  locale?: string;\n  className?: string;\n};\n\n/** One line of a cart: picture, name, picks, quantity, Remove and the line's price. Render it in a ul. */\nexport function CartLine({ item, onQuantityChange, onRemove, locale, className = \"\" }: CartLineProps) {\n  const several = item.quantity > 1;\n  return (\n    <li className={\"flex gap-4 border-t border-litt-line in-[[data-material=flat]]:border-t-0 py-4 first:border-t-0 first:pt-0 last:pb-0 \" + className}>\n      <Media ratio=\"1/1\" radius=\"control\" alt=\"\" src={item.image?.src} srcSet={item.image?.srcSet} sizes=\"64px\" className=\"w-16 shrink-0 in-[[data-material=flat]]:[&>span[aria-hidden=true].pointer-events-none]:shadow-none\" />\n      <div className=\"grid min-w-0 flex-1 content-start gap-3\">\n        <div className=\"flex items-start justify-between gap-4\">\n          <div className=\"grid min-w-0 gap-1\">\n            <p className=\"text-litt-body font-litt-title text-litt-fg\">{item.title}</p>\n            {item.variantTitle && <p className=\"text-litt-small text-litt-fg-subtle\">{item.variantTitle}</p>}\n          </div>\n          <div className=\"grid shrink-0 justify-items-end gap-1 text-litt-body\">\n            <Price\n              price={multiplyMoney(item.price, item.quantity)}\n              compareAt={item.compareAt ? multiplyMoney(item.compareAt, item.quantity) : undefined}\n              locale={locale}\n            />\n            {several && <span className=\"text-litt-caption font-normal text-litt-fg-subtle tabular-nums\">{formatMoney(item.price, locale)} each</span>}\n          </div>\n        </div>\n        <div className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2\">\n          <NumberField\n            size=\"sm\"\n            hideLabel\n            label={`Quantity of ${item.title}`}\n            min={1}\n            max={item.maxQuantity}\n            required\n            value={item.quantity}\n            onValueChange={(q) => q !== null && onQuantityChange(q)}\n            className=\"w-28\"\n          />\n          <Button size=\"sm\" variant=\"ghost\" data-cart-remove=\"\" aria-label={`Remove ${item.title}`} onClick={onRemove}>\n            Remove\n          </Button>\n        </div>\n      </div>\n    </li>\n  );\n}\n\n/* ----------------------------------------------------------------- the summary */\n\n/** Currency options for a figure that may or may not have cents, as formatMoney writes them. */\nconst moneyFormat = (money: Money): Intl.NumberFormatOptions => {\n  const whole = Number.isInteger(typeof money.amount === \"number\" ? money.amount : Number.parseFloat(money.amount));\n  return { style: \"currency\", currency: money.currencyCode, minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 };\n};\nconst amount = (m: Money) => (typeof m.amount === \"number\" ? m.amount : Number.parseFloat(m.amount));\n\nexport type CartSummaryProps = {\n  subtotal: Money;\n  /** The subtotal that ships free. Shows a Meter of how far there is to go. */\n  freeShippingAt?: Money;\n  /** The words under the subtotal. */\n  note?: ReactNode;\n  /** Where Check out goes: the store's checkout page. */\n  checkoutUrl?: string;\n  /** Runs on Check out instead of following checkoutUrl. */\n  onCheckout?: () => void;\n  /** The button's words. */\n  checkoutLabel?: string;\n  /** Disables Check out, such as for an empty cart or while a change is on its way. */\n  disabled?: boolean;\n  /** Shows the Button's spinner on Check out. */\n  loading?: boolean;\n  locale?: string;\n  className?: string;\n};\n\n/** The cart's foot: how far to free shipping, the subtotal, what is still to come, and Check out. */\nexport function CartSummary({\n  subtotal,\n  freeShippingAt,\n  note = \"Taxes and shipping at checkout.\",\n  checkoutUrl,\n  onCheckout,\n  checkoutLabel = \"Check out\",\n  disabled = false,\n  loading = false,\n  locale,\n  className = \"\",\n}: CartSummaryProps) {\n  const left = freeShippingAt ? Math.max(0, amount(freeShippingAt) - amount(subtotal)) : 0;\n  return (\n    <div className={\"grid w-full gap-4 \" + className}>\n      {freeShippingAt && (\n        <Meter\n          label={left > 0 ? `${formatMoney({ amount: left, currencyCode: subtotal.currencyCode }, locale)} to free shipping` : \"Free shipping\"}\n          value={Math.min(amount(subtotal), amount(freeShippingAt))}\n          max={amount(freeShippingAt)}\n          valueText={left > 0 ? `${formatMoney(subtotal, locale)} of ${formatMoney(freeShippingAt, locale)}` : \"Included\"}\n        />\n      )}\n      <div className=\"grid gap-1\">\n        <dl className=\"flex items-baseline justify-between gap-4\">\n          <dt className=\"text-litt-body text-litt-fg-muted\">Subtotal</dt>\n          <dd className=\"text-litt-body font-litt-title text-litt-fg\">\n            <RollingNumber value={amount(subtotal)} format={moneyFormat(subtotal)} locale={locale} />\n          </dd>\n        </dl>\n        {note && <p className=\"text-litt-small text-litt-fg-subtle\">{note}</p>}\n      </div>\n      <Button\n        variant=\"primary\"\n        block\n        disabled={disabled}\n        loading={loading}\n        onClick={() => {\n          if (onCheckout) onCheckout();\n          else if (checkoutUrl) window.location.assign(checkoutUrl);\n        }}\n      >\n        {checkoutLabel}\n      </Button>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- the sheet */\n\nexport type CartProps = {\n  /** Whether the sheet is open. Pair it with onOpenChange. */\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  /** The cart: useCartState() or useShopifyCart(). */\n  cart: CartState;\n  /** The subtotal that ships free, for the summary's Meter. */\n  freeShippingAt?: Money;\n  /** Runs on Check out instead of following the cart's checkoutUrl. */\n  onCheckout?: () => void;\n  /** The empty cart's action. The default is \"Keep browsing\", which closes the sheet. */\n  emptyAction?: ReactNode;\n  /** The sheet's title. */\n  title?: string;\n  /** The locale for the prices. Leave it out for the page's. */\n  locale?: string;\n};\n\n/** The cart as a sheet from the right (a bottom sheet on a phone): the lines, and the summary pinned at its foot. */\nexport function Cart({ open, onOpenChange, cart, freeShippingAt, onCheckout, emptyAction, title = \"Cart\", locale }: CartProps) {\n  const body = useRef<HTMLDivElement>(null);\n  /* After a removal, where focus goes: the line now at that index, else the one before, else the empty state's action. */\n  const [focusAt, setFocusAt] = useState<number | null>(null);\n  useEffect(() => {\n    if (focusAt === null) return;\n    const root = body.current;\n    const removes = root?.querySelectorAll<HTMLElement>(\"[data-cart-remove]\");\n    const target = removes?.length ? removes[Math.min(focusAt, removes.length - 1)] : root?.querySelector<HTMLElement>(\"button\");\n    target?.focus();\n    setFocusAt(null);\n  }, [focusAt, cart.items.length]);\n\n  const empty = cart.items.length === 0;\n  const remove = (item: CartItem, index: number) => {\n    void cart.remove(item.id);\n    setFocusAt(index);\n    toast({ title: `Removed ${item.title}`, action: { label: \"Undo\", onClick: () => void cart.restore(item, index) } });\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent\n        side=\"right\"\n        title={title}\n        description={empty ? undefined : `${cart.count} ${cart.count === 1 ? \"item\" : \"items\"}`}\n        footer={\n          empty ? undefined : (\n            <CartSummary\n              subtotal={cart.subtotal}\n              freeShippingAt={freeShippingAt}\n              checkoutUrl={cart.checkoutUrl}\n              onCheckout={onCheckout}\n              disabled={cart.pending}\n              locale={locale}\n            />\n          )\n        }\n      >\n        <div ref={body}>\n          {empty ? (\n            <Empty bare title=\"Your cart is empty\" action={emptyAction ?? <Button onClick={() => onOpenChange(false)}>Keep browsing</Button>}>\n              Anything you add shows here.\n            </Empty>\n          ) : (\n            <ul aria-label=\"Items in your cart\">\n              {cart.items.map((item, i) => (\n                <CartLine\n                  key={item.id}\n                  item={item}\n                  locale={locale}\n                  onQuantityChange={(q) => void cart.update(item.id, q)}\n                  onRemove={() => remove(item, i)}\n                />\n              ))}\n            </ul>\n          )}\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\n/* ----------------------------------------------------------------- the ways in */\n\nexport type CartButtonProps = {\n  /** How many things are in the cart. */\n  count: number;\n  /** Opens the cart. */\n  onClick: () => void;\n  /** sm in a header (the default), md beside md controls. */\n  size?: Exclude<ButtonSize, \"lg\">;\n  className?: string;\n};\n\n/** The header's way into the cart: the bag and the count, which rolls when it changes. */\nexport function CartButton({ count, onClick, size = \"sm\", className = \"\" }: CartButtonProps) {\n  return (\n    <Button\n      variant=\"ghost\"\n      size={size}\n      icon={<BagIcon />}\n      aria-label={`Cart, ${count} ${count === 1 ? \"item\" : \"items\"}`}\n      aria-haspopup=\"dialog\"\n      onClick={onClick}\n      className={\"pointer-coarse:h-litt-control-md \" + className}\n    >\n      <RollingNumber value={count} className=\"font-normal\" />\n    </Button>\n  );\n}\n\nexport type AddToCartProps = {\n  /** Adds the picked variant. It may return a promise: the button shows a spinner until it settles. */\n  onAdd: () => void | Promise<void>;\n  /** The picked variant is out of stock: the button is a disabled \"Sold out\". */\n  soldOut?: boolean;\n  /** Nothing is picked yet, or the pick has no variant. */\n  disabled?: boolean;\n  /** The words. */\n  label?: string;\n  /** md on a product page (the default), lg only for a hero. */\n  size?: ButtonSize;\n  /** Fills the width of its column. */\n  block?: boolean;\n  className?: string;\n};\n\n/** A product's primary action: Add to cart, a spinner while it adds, then \"Added\" with a check for 2s. */\nexport function AddToCart({ onAdd, soldOut = false, disabled = false, label = \"Add to cart\", size = \"md\", block = true, className = \"\" }: AddToCartProps) {\n  const [state, setState] = useState<\"idle\" | \"adding\" | \"added\">(\"idle\");\n  const timer = useRef<number | undefined>(undefined);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n  const added = state === \"added\";\n  const press = async () => {\n    setState(\"adding\");\n    try {\n      await onAdd();\n      setState(\"added\");\n      window.clearTimeout(timer.current);\n      timer.current = window.setTimeout(() => setState(\"idle\"), 2000);\n    } catch {\n      setState(\"idle\");\n    }\n  };\n  if (soldOut) {\n    return (\n      <Button variant=\"primary\" size={size} block={block} disabled className={className}>\n        Sold out\n      </Button>\n    );\n  }\n  return (\n    <Button variant=\"primary\" size={size} block={block} disabled={disabled} loading={state === \"adding\"} onClick={press} className={className}>\n      {/* Both labels share one cell, so the box keeps its size when the words change. */}\n      <span className=\"grid\">\n        <span className={\"col-start-1 row-start-1 text-center \" + (added ? \"invisible\" : \"\")}>{label}</span>\n        <span aria-hidden={!added} className={\"col-start-1 row-start-1 inline-flex items-center justify-center gap-litt-control \" + (added ? \"\" : \"invisible\")}>\n          <CheckIcon />\n          Added\n        </span>\n      </span>\n    </Button>\n  );\n}\n",
      "target": "components/litt/cart.tsx"
    }
  ],
  "docs": "Import Cart, CartButton, AddToCart and useCartState from components/litt/cart. Mount the Toaster once near your root for Remove's Undo. useShopifyCart from the shopify item returns the same CartState, so the parts work with either.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
