{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-quick-view",
  "title": "Product Quick View",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Quick view with a shared-element morph: the card image grows into the dialog and shrinks back on close. Motion's layoutId drives the morph, a native <dialog> does the modal work, focus returns to the trigger, and reduced-motion users get no movement. The one ecomcn block that depends on motion — opt-in.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "@ecomcn/price-tag",
    "@ecomcn/product-card"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/product-quick-view/product-quick-view.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, MotionConfig, motion, type Transition } from \"motion/react\"\nimport { ArrowRight, Check, Eye, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { PriceTag } from \"@/components/ecomcn/price-tag\"\nimport type { ProductCardProduct } from \"@/components/ecomcn/product-card\"\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Quick view with a shared-element morph: the card's image grows into the\n * dialog's image and shrinks back on close, so the shopper never loses track\n * of which product they opened. Motion's `layoutId` does the morph; a native\n * <dialog> does the modal work (top layer, inert page, Escape).\n *\n * Opt-in on purpose — it is the only ecomcn block that depends on `motion`.\n */\n\n// Ease-out, no overshoot: an editorial morph should settle, not bounce.\nconst MORPH: Transition = { duration: 0.45, ease: [0.2, 0, 0, 1] }\n\ntype AddState = \"idle\" | \"pending\" | \"added\"\n\ninterface ActiveView {\n  product: ProductCardProduct\n  colorIndex: number\n}\n\nexport interface ProductQuickViewContextValue {\n  active: ActiveView | null\n  open: (\n    product: ProductCardProduct,\n    options?: { colorIndex?: number; trigger?: HTMLElement | null }\n  ) => void\n  close: () => void\n  /** The shared id that links a card's image to the dialog's. */\n  layoutId: (productId: string) => string\n}\n\nconst ProductQuickViewContext = React.createContext<ProductQuickViewContextValue | null>(null)\n\nexport function useProductQuickView() {\n  const context = React.useContext(ProductQuickViewContext)\n  if (!context) {\n    throw new Error(\"useProductQuickView must be used within <ProductQuickView>.\")\n  }\n  return context\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface ProductQuickViewProps {\n  /** The listing — cards containing ProductQuickViewImage and ProductQuickViewTrigger. */\n  children: React.ReactNode\n  currency?: string\n  locale?: string\n  /** Return a promise to keep the button pending until it settles. */\n  onAddToBag?: (product: ProductCardProduct, colorIndex: number) => void | Promise<void>\n  /** Extra content under the price: a description, sizes, a size-guide link. */\n  renderDetails?: (product: ProductCardProduct) => React.ReactNode\n  detailsLabel?: string\n}\n\nexport function ProductQuickView({\n  children,\n  currency = \"USD\",\n  locale,\n  onAddToBag,\n  renderDetails,\n  detailsLabel = \"View full details\",\n}: ProductQuickViewProps) {\n  const [active, setActive] = React.useState<ActiveView | null>(null)\n  const trigger = React.useRef<HTMLElement | null>(null)\n  // Namespaces the layout ids, so two quick views on one page never morph\n  // into each other's dialogs.\n  const scope = React.useId()\n\n  const open = React.useCallback<ProductQuickViewContextValue[\"open\"]>(\n    (product, options = {}) => {\n      trigger.current =\n        options.trigger ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)\n      setActive({ product, colorIndex: options.colorIndex ?? 0 })\n    },\n    []\n  )\n  const close = React.useCallback(() => setActive(null), [])\n  const layoutId = React.useCallback((id: string) => `${scope}-quick-view-${id}`, [scope])\n\n  const context = React.useMemo(\n    () => ({ active, open, close, layoutId }),\n    [active, open, close, layoutId]\n  )\n\n  return (\n    <ProductQuickViewContext.Provider value={context}>\n      {children}\n      <AnimatePresence>\n        {active ? (\n          <QuickViewDialog\n            key={active.product.id}\n            view={active}\n            returnFocus={() => {\n              if (trigger.current?.isConnected) trigger.current.focus()\n            }}\n            currency={currency}\n            locale={locale}\n            onAddToBag={onAddToBag}\n            renderDetails={renderDetails}\n            detailsLabel={detailsLabel}\n          />\n        ) : null}\n      </AnimatePresence>\n    </ProductQuickViewContext.Provider>\n  )\n}\n\n/* ─── parts used inside a card ─────────────────────────────────────────── */\n\nexport function ProductQuickViewImage({\n  product,\n  className,\n  children,\n}: {\n  product: ProductCardProduct\n  className?: string\n  /** The card's image — usually <ProductCardImage />. */\n  children: React.ReactNode\n}) {\n  const { layoutId } = useProductQuickView()\n  return (\n    // reducedMotion=\"user\": with the OS setting on, the dialog simply\n    // appears — layout and transform animations are skipped entirely.\n    <MotionConfig reducedMotion=\"user\" transition={MORPH}>\n      <motion.div\n        layoutId={layoutId(product.id)}\n        data-slot=\"product-quick-view-image\"\n        className={cn(\"absolute inset-0\", className)}\n      >\n        {children}\n      </motion.div>\n    </MotionConfig>\n  )\n}\n\nexport interface ProductQuickViewTriggerProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\"> {\n  product: ProductCardProduct\n  /** Open on this colour — pass the card's selected colour. */\n  colorIndex?: number\n}\n\nexport function ProductQuickViewTrigger({\n  product,\n  colorIndex,\n  className,\n  children,\n  ...props\n}: ProductQuickViewTriggerProps) {\n  const { open } = useProductQuickView()\n  return (\n    <button\n      type=\"button\"\n      aria-haspopup=\"dialog\"\n      data-slot=\"product-quick-view-trigger\"\n      onClick={(event) => open(product, { colorIndex, trigger: event.currentTarget })}\n      className={cn(\n        // Sits above the card's ::after link overlay. Revealed on hover and\n        // on keyboard focus; always visible where there is no hover at all.\n        \"absolute top-2 right-2 z-10 inline-flex items-center gap-1.5 bg-background/90 px-2.5 py-1.5 text-[11px] tracking-[0.14em] uppercase opacity-0 transition-opacity outline-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none [@media(hover:none)]:opacity-100\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <Eye className=\"size-3.5\" aria-hidden />\n          Quick view\n        </>\n      )}\n      <span className=\"sr-only\">: {product.name}</span>\n    </button>\n  )\n}\n\n/* ─── the dialog ───────────────────────────────────────────────────────── */\n\nfunction QuickViewDialog({\n  view,\n  returnFocus,\n  currency,\n  locale,\n  onAddToBag,\n  renderDetails,\n  detailsLabel,\n}: {\n  view: ActiveView\n  returnFocus: () => void\n  currency: string\n  locale?: string\n  onAddToBag?: ProductQuickViewProps[\"onAddToBag\"]\n  renderDetails?: ProductQuickViewProps[\"renderDetails\"]\n  detailsLabel: string\n}) {\n  const { close, layoutId } = useProductQuickView()\n  const { product } = view\n  const dialog = React.useRef<HTMLDialogElement>(null)\n  const titleId = React.useId()\n  const [colorIndex, setColorIndex] = React.useState(view.colorIndex)\n  const [addState, setAddState] = React.useState<AddState>(\"idle\")\n  const color = product.colors?.[colorIndex]\n\n  React.useEffect(() => {\n    const node = dialog.current\n    // showModal puts the dialog in the top layer and makes the page inert:\n    // focus containment and Escape come from the platform, not from us.\n    if (node && !node.open) node.showModal()\n\n    // Lock page scroll, padding by the scrollbar's width so nothing behind\n    // the overlay shifts sideways.\n    const root = document.documentElement\n    const gap = window.innerWidth - root.clientWidth\n    const before = { overflow: root.style.overflow, paddingRight: root.style.paddingRight }\n    root.style.overflow = \"hidden\"\n    if (gap > 0) root.style.paddingRight = `${gap}px`\n    return () => {\n      root.style.overflow = before.overflow\n      root.style.paddingRight = before.paddingRight\n      // This cleanup runs once the exit morph has finished and the dialog has\n      // left the DOM. Any earlier and the page is still inert, the focus call\n      // is ignored, and the shopper is dropped on <body>.\n      returnFocus()\n    }\n    // Mount and unmount only: the dialog opens once per product.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  const addToBag = async () => {\n    if (!onAddToBag || addState === \"pending\") return\n    setAddState(\"pending\")\n    try {\n      await onAddToBag(product, colorIndex)\n      setAddState(\"added\")\n    } catch {\n      setAddState(\"idle\")\n    }\n  }\n\n  return (\n    <dialog\n      ref={dialog}\n      aria-labelledby={titleId}\n      data-slot=\"product-quick-view\"\n      // Escape: play the closing morph instead of vanishing instantly.\n      onCancel={(event) => {\n        event.preventDefault()\n        close()\n      }}\n      onClose={close}\n      className=\"m-0 h-dvh max-h-none w-full max-w-none bg-transparent p-0 text-foreground backdrop:bg-transparent\"\n    >\n      <div className=\"fixed inset-0 grid place-items-center overflow-y-auto p-4 sm:p-8\">\n        <motion.div\n          aria-hidden\n          className=\"fixed inset-0 bg-black/45\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: 0.25 }}\n          onClick={close}\n        />\n\n        <div className=\"relative grid w-full max-w-4xl md:grid-cols-[1.05fr_0.95fr]\">\n          {/* The panel's surface fades on its own layer, so the morphing\n              image is never inside an element whose opacity is animating. */}\n          <motion.div\n            aria-hidden\n            className=\"absolute inset-0 bg-background shadow-2xl\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.2 }}\n          />\n\n          <button\n            type=\"button\"\n            onClick={close}\n            className=\"absolute top-3 right-3 z-20 grid size-9 place-items-center bg-background/90 outline-none hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <X className=\"size-4\" aria-hidden />\n            <span className=\"sr-only\">Close</span>\n          </button>\n\n          <MotionConfig reducedMotion=\"user\" transition={MORPH}>\n            <motion.div\n              layoutId={layoutId(product.id)}\n              data-slot=\"product-quick-view-media\"\n              className=\"relative aspect-[4/5] overflow-hidden bg-secondary\"\n            >\n              <div className=\"absolute inset-0\">{color?.image ?? product.image}</div>\n            </motion.div>\n          </MotionConfig>\n\n          <motion.div\n            className=\"relative flex flex-col p-6 sm:p-10\"\n            initial={{ opacity: 0, y: 8 }}\n            animate={{ opacity: 1, y: 0, transition: { delay: 0.18, duration: 0.3 } }}\n            exit={{ opacity: 0, transition: { duration: 0.12 } }}\n          >\n            {product.brand ? (\n              <p className=\"text-[11px] tracking-[0.18em] text-muted-foreground uppercase\">\n                {product.brand}\n              </p>\n            ) : null}\n            <h2 id={titleId} className=\"mt-1 text-3xl leading-tight\">\n              {product.name}\n            </h2>\n            <PriceTag\n              price={product.price}\n              compareAt={product.compareAt}\n              currency={currency}\n              locale={locale}\n              className=\"mt-3\"\n            />\n\n            {product.colors?.length ? (\n              <div className=\"mt-6\">\n                <p className=\"text-xs text-muted-foreground\">\n                  Colour: <span className=\"text-foreground\">{color?.name}</span>\n                </p>\n                <div role=\"group\" aria-label=\"Colour\" className=\"mt-2 flex gap-2\">\n                  {product.colors.map((c, i) => (\n                    <button\n                      key={c.name}\n                      type=\"button\"\n                      aria-label={c.name}\n                      aria-pressed={i === colorIndex}\n                      onClick={() => {\n                        setColorIndex(i)\n                        setAddState(\"idle\")\n                      }}\n                      className={cn(\n                        \"size-6 border outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n                        i === colorIndex\n                          ? \"border-foreground ring-1 ring-foreground ring-offset-2 ring-offset-background\"\n                          : \"border-foreground/20 hover:border-foreground/50\"\n                      )}\n                      style={{ background: c.hex }}\n                    />\n                  ))}\n                </div>\n              </div>\n            ) : null}\n\n            {renderDetails ? <div className=\"mt-6 text-sm\">{renderDetails(product)}</div> : null}\n\n            <div className=\"mt-auto flex flex-col gap-3 pt-8\">\n              {onAddToBag ? (\n                <Button\n                  type=\"button\"\n                  onClick={addToBag}\n                  disabled={addState === \"pending\"}\n                  className=\"h-11 w-full\"\n                >\n                  {addState === \"added\" ? (\n                    <>\n                      <Check className=\"size-4\" aria-hidden /> Added to bag\n                    </>\n                  ) : addState === \"pending\" ? (\n                    \"Adding…\"\n                  ) : (\n                    \"Add to bag\"\n                  )}\n                </Button>\n              ) : null}\n              <a\n                href={product.href}\n                className=\"inline-flex items-center gap-1.5 self-start text-sm underline-offset-4 outline-none hover:underline focus-visible:underline\"\n              >\n                {detailsLabel} <ArrowRight className=\"size-3.5\" aria-hidden />\n              </a>\n              <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n                {addState === \"added\" ? `${product.name} added to bag` : \"\"}\n              </span>\n            </div>\n          </motion.div>\n        </div>\n      </div>\n    </dialog>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/product-quick-view.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "catalog",
    "listing",
    "animation"
  ],
  "type": "registry:block"
}