{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-option-matrix",
  "type": "registry:component",
  "title": "Option matrix",
  "description": "Dependent color x size selectors. Combos that are not made or sold out are disabled with the reason in a tooltip.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "pdp",
    "apparel",
    "beauty"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "pdp/pdp-option-matrix.tsx",
      "content": "\"use client\"\n\nimport { useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Dependent color x size selectors. Each axis gates the other: a size that\n   does not exist or is sold out in the chosen color is disabled with the\n   reason in a tooltip, and vice versa. Picking a color that kills the current\n   size clears the size and asks for one, instead of silently swapping it.\n   `stock` maps \"colorId:sizeId\" to units. A missing key means not made. */\n\ntype Color = { id: string; name: string; color: string }\ntype Size = { id: string; label: string; price: number }\n\nconst RING = \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\nconst EASE = [0.16, 1, 0.3, 1] as const\nconst TIP =\n  \"pointer-events-none absolute -top-9 left-1/2 z-10 -translate-x-1/2 origin-bottom scale-95 whitespace-nowrap rounded-sk-chip bg-sk-ink px-2 py-1 text-[11px] font-medium text-sk-paper opacity-0 transition-[opacity,transform] duration-150 ease-sk-out group-hover/opt:scale-100 group-hover/opt:opacity-100 group-focus-within/opt:scale-100 group-focus-within/opt:opacity-100 motion-reduce:transition-none\"\n\nconst DEFAULT_COLORS: Color[] = [\n  { id: \"sand\", name: \"Sand\", color: \"rgb(226 196 166)\" },\n  { id: \"clay\", name: \"Clay\", color: \"rgb(184 120 92)\" },\n  { id: \"cocoa\", name: \"Cocoa\", color: \"rgb(112 74 52)\" },\n]\nconst DEFAULT_SIZES: Size[] = [\n  { id: \"15\", label: \"15ml\", price: 3200 },\n  { id: \"30\", label: \"30ml\", price: 4900 },\n  { id: \"50\", label: \"50ml\", price: 6900 },\n]\nconst DEFAULT_STOCK: Record<string, number> = {\n  \"sand:15\": 40, \"sand:30\": 22, \"sand:50\": 9,\n  \"clay:15\": 12, \"clay:30\": 0, \"clay:50\": 6,\n  \"cocoa:15\": 18, \"cocoa:30\": 14,\n}\n\nfunction roving(e: React.KeyboardEvent, n: number, current: number, ok: (i: number) => boolean, pick: (i: number) => void) {\n  const fwd = e.key === \"ArrowRight\" || e.key === \"ArrowDown\"\n  const back = e.key === \"ArrowLeft\" || e.key === \"ArrowUp\"\n  if (!fwd && !back) return\n  e.preventDefault()\n  for (let s = 1; s <= n; s++) {\n    const i = (current + (fwd ? s : -s) + n * 2) % n\n    if (ok(i)) return pick(i)\n  }\n}\n\nexport function PdpOptionMatrix({\n  colors = DEFAULT_COLORS,\n  sizes = DEFAULT_SIZES,\n  stock = DEFAULT_STOCK,\n  defaultColor,\n  defaultSize,\n  onChange,\n  className,\n}: {\n  colors?: Color[]\n  sizes?: Size[]\n  stock?: Record<string, number>\n  defaultColor?: string\n  defaultSize?: string\n  onChange?: (sel: { colorId: string; sizeId: string | null; available: boolean }) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const uid = useId()\n  const sk = useSkBlock(\"pdp\", \"pdp-option-matrix\")\n  const [colorId, setColorId] = useState(defaultColor ?? colors[0]?.id)\n  const [sizeId, setSizeId] = useState<string | null>(defaultSize ?? null)\n  const colorRefs = useRef<(HTMLButtonElement | null)[]>([])\n  const sizeRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  const units = (c: string, s: string) => stock[`${c}:${s}`]\n  const sizeReason = (s: Size) => {\n    const u = units(colorId, s.id)\n    const cName = colors.find((c) => c.id === colorId)?.name ?? \"\"\n    if (u === undefined) return `Not made in ${cName}`\n    if (u <= 0) return `Sold out in ${cName}`\n    return null\n  }\n  const colorReason = (c: Color) => {\n    if (!sizeId) return sizes.some((s) => (units(c.id, s.id) ?? 0) > 0) ? null : \"Sold out in every size\"\n    const u = units(c.id, sizeId)\n    const sLabel = sizes.find((s) => s.id === sizeId)?.label ?? \"\"\n    if (u === undefined) return `Not made in ${sLabel}`\n    if (u <= 0) return `Sold out in ${sLabel}`\n    return null\n  }\n\n  const color = colors.find((c) => c.id === colorId) ?? colors[0]\n  const size = sizes.find((s) => s.id === sizeId) ?? null\n  const ci = Math.max(0, colors.findIndex((c) => c.id === colorId))\n  const si = sizes.findIndex((s) => s.id === sizeId)\n\n  const pickColor = (i: number, focus = false) => {\n    const c = colors[i]\n    if (!c) return\n    const keep = sizeId && (units(c.id, sizeId) ?? 0) > 0 ? sizeId : null\n    if (c.id !== colorId) sk.emit(\"interact\", \"color\", undefined)\n    setColorId(c.id)\n    setSizeId(keep)\n    onChange?.({ colorId: c.id, sizeId: keep, available: !!keep })\n    if (focus) colorRefs.current[i]?.focus()\n  }\n  const pickSize = (i: number, focus = false) => {\n    const s = sizes[i]\n    if (!s || sizeReason(s)) return\n    if (s.id !== sizeId) sk.emit(\"interact\", \"size\", undefined)\n    setSizeId(s.id)\n    onChange?.({ colorId, sizeId: s.id, available: true })\n    if (focus) sizeRefs.current[i]?.focus()\n  }\n\n  const summary = size ? `${color.name}, ${size.label}` : `${color.name}, choose a size`\n  const left = size ? units(colorId, size.id) ?? 0 : 0\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"flex w-full flex-col gap-5\", className)}>\n      <fieldset className=\"flex flex-col gap-2.5\">\n        <legend className=\"mb-2.5 flex w-full items-baseline gap-2 text-sm\">\n          <span className=\"font-medium text-sk-ink\">Shade</span>\n          <span className=\"text-sk-muted\">{color.name}</span>\n        </legend>\n        <div role=\"radiogroup\" aria-label=\"Shade\" className=\"flex flex-wrap gap-1.5\">\n          {colors.map((c, i) => {\n            const isSel = c.id === colorId\n            const reason = colorReason(c)\n            return (\n              <span key={c.id} className=\"group/opt relative\">\n                <button\n                  ref={(el) => {\n                    colorRefs.current[i] = el\n                  }}\n                  type=\"button\"\n                  role=\"radio\"\n                  aria-checked={isSel}\n                  aria-label={reason ? `${c.name}, ${reason.toLowerCase()}` : c.name}\n                  aria-describedby={reason ? `${uid}-c-${i}` : undefined}\n                  tabIndex={isSel ? 0 : -1}\n                  onClick={() => pickColor(i)}\n                  onKeyDown={(e) => roving(e, colors.length, ci, () => true, (j) => pickColor(j, true))}\n                  className={cn(\"relative grid h-11 w-11 place-items-center rounded-sk-chip transition-transform duration-200 ease-sk-out active:scale-[0.97]\", RING)}\n                >\n                  {isSel ? (\n                    <motion.span layoutId={`${uid}-cring`} aria-hidden transition={{ duration: reduce ? 0 : 0.35, ease: EASE }} className=\"absolute inset-0 rounded-sk-chip border-2 border-sk-ink\" />\n                  ) : null}\n                  <span aria-hidden className=\"relative h-8 w-8 overflow-hidden rounded-sk-chip ring-1 ring-inset ring-sk-ink/15\" style={{ background: c.color }}>\n                    {reason ? <span className=\"absolute left-1/2 top-1/2 h-[1.5px] w-[140%] -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-sk-paper shadow-[0_0_0_0.5px_rgb(var(--sk-ink)/0.5)]\" /> : null}\n                  </span>\n                </button>\n                {reason ? (\n                  <span id={`${uid}-c-${i}`} role=\"tooltip\" className={TIP}>\n                    {reason}\n                  </span>\n                ) : null}\n              </span>\n            )\n          })}\n        </div>\n      </fieldset>\n\n      <fieldset className=\"flex flex-col gap-2.5\">\n        <legend className=\"mb-2.5 flex w-full items-baseline gap-2 text-sm\">\n          <span className=\"font-medium text-sk-ink\">Size</span>\n          <span className={cn(size ? \"text-sk-muted\" : \"text-sk-ink\")}>{size ? size.label : \"Choose one\"}</span>\n        </legend>\n        <div role=\"radiogroup\" aria-label=\"Size\" className=\"flex flex-wrap gap-2\">\n          {sizes.map((s, i) => {\n            const isSel = s.id === sizeId\n            const reason = sizeReason(s)\n            const focusable = si >= 0 ? isSel : i === sizes.findIndex((x) => !sizeReason(x))\n            return (\n              <span key={s.id} className=\"group/opt relative\">\n                <button\n                  ref={(el) => {\n                    sizeRefs.current[i] = el\n                  }}\n                  type=\"button\"\n                  role=\"radio\"\n                  aria-checked={isSel}\n                  aria-disabled={reason ? true : undefined}\n                  aria-describedby={reason ? `${uid}-s-${i}` : undefined}\n                  tabIndex={focusable ? 0 : -1}\n                  onClick={() => pickSize(i)}\n                  onKeyDown={(e) => roving(e, sizes.length, si >= 0 ? si : i, (j) => !sizeReason(sizes[j]), (j) => pickSize(j, true))}\n                  className={cn(\n                    \"h-11 min-w-[72px] rounded-sk-btn border px-4 text-sm font-medium tabular-nums transition-[border-color,background-color,color,transform] duration-200 ease-sk-out active:scale-[0.97]\",\n                    isSel ? \"border-sk-accent bg-sk-accent/[0.08] text-sk-ink\" : \"border-sk-line text-sk-ink hover:border-sk-ink/40\",\n                    reason && \"cursor-not-allowed border-dashed text-sk-muted line-through decoration-sk-muted/70 hover:border-sk-line active:scale-100\",\n                    RING\n                  )}\n                >\n                  {s.label}\n                </button>\n                {reason ? (\n                  <span id={`${uid}-s-${i}`} role=\"tooltip\" className={TIP}>\n                    {reason}\n                  </span>\n                ) : null}\n              </span>\n            )\n          })}\n        </div>\n      </fieldset>\n\n      <div className=\"flex min-h-[48px] items-center justify-between gap-4 rounded-sk-card bg-sk-surface px-4 py-3\" aria-live=\"polite\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={summary}\n            initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n            transition={{ duration: reduce ? 0 : 0.2, ease: EASE }}\n            className=\"flex min-w-0 flex-col\"\n          >\n            <span className=\"truncate text-sm font-medium text-sk-ink\">{summary}</span>\n            <span className=\"text-[13px] tabular-nums text-sk-muted\">{size ? `${left} in stock` : \"Pick a size to see stock\"}</span>\n          </motion.span>\n        </AnimatePresence>\n        <span className=\"shrink-0 text-sm font-semibold tabular-nums text-sk-price\">{size ? money(size.price) : `From ${money(Math.min(...sizes.map((s) => s.price)))}`}</span>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-option-matrix.tsx"
    }
  ],
  "docs": "`stock` maps \"colorId:sizeId\" to units. A missing key means the combo is not made, 0 means sold out.",
  "meta": {
    "section": "pdp",
    "variant": "matrix",
    "niche": [
      "apparel",
      "beauty"
    ],
    "commerce": [
      "variants",
      "inventory"
    ],
    "previewHeight": 440
  }
}