{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-dropdown-stack",
  "type": "registry:component",
  "title": "Dropdown stack",
  "description": "Numbered native selects with label and value always visible, sequential unlock, price deltas and a running total.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "pdp",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "pdp/pdp-dropdown-stack.tsx",
      "content": "\"use client\"\n\nimport { useId, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, ChevronDown } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Structured dropdown stack for long option sets. Each step is a numbered\n   field whose label and current value are always visible; the native select\n   sits on top, invisible, so every platform gets its own picker and full\n   keyboard support. Later steps unlock once the earlier ones have a value.\n   Price deltas are integer cents and roll into the running total. */\n\ntype Choice = { value: string; label: string; delta?: number; disabled?: boolean }\ntype Field = { id: string; label: string; options: Choice[] }\n\nconst EASE = [0.16, 1, 0.3, 1] as const\n\nconst DEFAULT_FIELDS: Field[] = [\n  {\n    id: \"flavor\",\n    label: \"Flavor\",\n    options: [\n      { value: \"original\", label: \"Original\" },\n      { value: \"pineapple\", label: \"Pineapple mint\" },\n      { value: \"berry\", label: \"Wild berry\", delta: 300 },\n    ],\n  },\n  {\n    id: \"count\",\n    label: \"Size\",\n    options: [\n      { value: \"1\", label: \"30 servings\" },\n      { value: \"2\", label: \"60 servings\", delta: 4000 },\n      { value: \"3\", label: \"3-pouch bundle\", delta: 8000 },\n    ],\n  },\n  {\n    id: \"shaker\",\n    label: \"Add a shaker\",\n    options: [\n      { value: \"none\", label: \"None\" },\n      { value: \"glass\", label: \"Glass shaker\", delta: 1200 },\n      { value: \"steel\", label: \"Steel shaker\", delta: 1800, disabled: true },\n    ],\n  },\n]\n\nexport function PdpDropdownStack({\n  fields = DEFAULT_FIELDS,\n  basePrice = 5900,\n  defaultValues = {},\n  onChange,\n  className,\n}: {\n  fields?: Field[]\n  /** Price before any option deltas, integer cents. */\n  basePrice?: number\n  defaultValues?: Record<string, string>\n  onChange?: (values: Record<string, string>, total: number) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const uid = useId()\n  const sk = useSkBlock(\"pdp\", \"pdp-dropdown-stack\")\n  const [values, setValues] = useState<Record<string, string>>(defaultValues)\n\n  const total = fields.reduce((sum, f) => sum + (f.options.find((o) => o.value === values[f.id])?.delta ?? 0), basePrice)\n  const done = fields.every((f) => values[f.id])\n\n  const set = (id: string, v: string) => {\n    const next = { ...values, [id]: v }\n    setValues(next)\n    sk.emit(\"interact\", id, undefined)\n    onChange?.(next, fields.reduce((s, f) => s + (f.options.find((o) => o.value === next[f.id])?.delta ?? 0), basePrice))\n  }\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"flex w-full flex-col gap-3\", className)}>\n      <ol className=\"flex flex-col gap-2.5\">\n        {fields.map((f, i) => {\n          const locked = fields.slice(0, i).some((p) => !values[p.id])\n          const chosen = f.options.find((o) => o.value === values[f.id])\n          const id = `${uid}-${f.id}`\n          return (\n            <li key={f.id}>\n              <label\n                htmlFor={id}\n                className={cn(\n                  \"group/dd relative flex min-h-[60px] items-center gap-3 rounded-sk-card border bg-sk-card px-4 py-2.5 transition-[border-color,background-color,opacity] duration-200 ease-sk-out\",\n                  \"focus-within:border-sk-accent focus-within:ring-[3px] focus-within:ring-sk-accent/15\",\n                  locked ? \"border-sk-line opacity-50\" : chosen ? \"border-sk-line hover:border-sk-ink/30\" : \"border-sk-ink/30 hover:border-sk-ink/50\"\n                )}\n              >\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"grid h-6 w-6 shrink-0 place-items-center rounded-full border font-sk-mono text-[11px] tabular-nums transition-colors duration-200\",\n                    chosen ? \"border-sk-accent bg-sk-accent text-sk-accent-ink\" : \"border-sk-line text-sk-muted\"\n                  )}\n                >\n                  {chosen ? <Check size={12} strokeWidth={3} /> : i + 1}\n                </span>\n                <span className=\"flex w-0 min-w-0 flex-1 flex-col\">\n                  <span className=\"truncate font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">{f.label}</span>\n                  <span className={cn(\"truncate text-sm\", chosen ? \"font-medium text-sk-ink\" : \"text-sk-muted\")}>\n                    {chosen ? chosen.label : locked ? \"Choose the step above first\" : `Select ${f.label.toLowerCase()}`}\n                  </span>\n                </span>\n                {chosen?.delta ? <span className=\"shrink-0 text-[13px] tabular-nums text-sk-muted\">+{money(chosen.delta)}</span> : null}\n                <ChevronDown size={16} aria-hidden className=\"shrink-0 text-sk-muted transition-transform duration-200 group-hover/dd:translate-y-0.5\" />\n                <select\n                  id={id}\n                  value={values[f.id] ?? \"\"}\n                  disabled={locked}\n                  onChange={(e) => set(f.id, e.target.value)}\n                  className=\"absolute inset-0 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed\"\n                >\n                  <option value=\"\" disabled>\n                    {`Select ${f.label.toLowerCase()}`}\n                  </option>\n                  {f.options.map((o) => (\n                    <option key={o.value} value={o.value} disabled={o.disabled}>\n                      {o.label}\n                      {o.delta ? ` (+${money(o.delta)})` : \"\"}\n                      {o.disabled ? \" (sold out)\" : \"\"}\n                    </option>\n                  ))}\n                </select>\n              </label>\n            </li>\n          )\n        })}\n      </ol>\n      <div className=\"flex items-center justify-between gap-4 border-t border-sk-line pt-3 text-sm\">\n        <span className=\"text-sk-muted\">{done ? \"Your configuration\" : `${fields.filter((f) => values[f.id]).length} of ${fields.length} chosen`}</span>\n        <span className=\"relative inline-flex h-6 overflow-hidden\" aria-live=\"polite\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={total}\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduce ? 0 : -8 }}\n              transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}\n              className=\"font-semibold tabular-nums text-sk-price\"\n            >\n              {money(total)}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-dropdown-stack.tsx"
    }
  ],
  "meta": {
    "section": "pdp",
    "variant": "dropdown",
    "niche": [
      "any"
    ],
    "commerce": [
      "variants"
    ],
    "previewHeight": 420
  }
}