{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "variant-picker",
  "type": "registry:ui",
  "title": "Variant Picker",
  "description": "Colour swatches and size buttons as radiogroups that derive availability, stock and price deltas from your variants.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/variant-picker.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface VariantOptionValue {\n  /** Stable value stored in the selection record and in `ProductVariant.options`. */\n  value: string;\n  /** Display label (defaults to `value`). */\n  label?: string;\n  /** CSS colour for swatch options. */\n  color?: string;\n}\n\nexport interface VariantOption {\n  /** Key used in the selection record, e.g. \"colour\". */\n  name: string;\n  /** Visible group label, e.g. \"Colour\" (defaults to `name`). */\n  label?: string;\n  /** Round/square colour swatches or text buttons. */\n  kind?: \"swatch\" | \"button\";\n  values: VariantOptionValue[];\n}\n\nexport interface ProductVariant {\n  id: string;\n  /** One value per option name, e.g. { colour: \"sage\", size: \"m\" }. */\n  options: Record<string, string>;\n  price: number;\n  stock: number;\n}\n\n/** The chosen value per option name. */\nexport type VariantSelection = Record<string, string>;\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface VariantPickerProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Option groups in display order. */\n  options: VariantOption[];\n  /** Every variant that exists; combinations that are missing are treated as \"not made\". */\n  variants: ProductVariant[];\n  /** Controlled selection. */\n  value?: VariantSelection;\n  /** Initial selection when uncontrolled (defaults to the first in-stock variant). */\n  defaultValue?: VariantSelection;\n  /** Called with the new selection and the variant it resolves to (if any). */\n  onChange?: (value: VariantSelection, variant: ProductVariant | undefined) => void;\n  /** ISO 4217 currency code for prices. */\n  currency?: string;\n  /** Locale used to format prices (fixed by default so server and client agree). */\n  locale?: string;\n  /** Show \"+$4\" style deltas on values that cost more than the cheapest value in their group. */\n  showPriceDelta?: boolean;\n  /** Show the stock hint (\"Only 2 left\", \"Sold out\") in the summary row. */\n  showStock?: boolean;\n  /** Stock at or below this number reads \"Only N left\". */\n  lowStock?: number;\n  /** Swatch shape. */\n  swatchShape?: \"circle\" | \"square\";\n  /** Swatch diameter in px. */\n  swatchSize?: number;\n  /** Spring for the sliding selection ring. */\n  spring?: Spring;\n  /** Selection ring, focus ring and low-stock colour. */\n  accent?: string;\n  /** When set, a hidden input submits the resolved variant id. */\n  name?: string;\n  /** Accessible name for the whole picker. */\n  label?: string;\n}\n\ntype Availability = \"available\" | \"soldout\" | \"missing\";\n\ninterface ValueState {\n  availability: Availability;\n  variant: ProductVariant | undefined;\n}\n\nconst DEFAULT_SPRING: Spring = { stiffness: 520, damping: 36, mass: 0.8 };\n\nconst labelOf = (option: VariantOption) => option.label ?? option.name;\nconst valueLabel = (option: VariantOption, value: string | undefined) =>\n  option.values.find((v) => v.value === value)?.label ?? value ?? \"\";\n\n/**\n * A product option selector. Each option is a radiogroup (arrow keys move and select, skipping\n * unavailable values); availability is derived from `variants` for the current selection of the\n * other options, so impossible or sold-out combinations are struck through with a reason.\n */\nexport function VariantPicker({\n  options,\n  variants,\n  value,\n  defaultValue,\n  onChange,\n  currency = \"USD\",\n  locale = \"en-US\",\n  showPriceDelta = true,\n  showStock = true,\n  lowStock = 3,\n  swatchShape = \"circle\",\n  swatchSize = 32,\n  spring = DEFAULT_SPRING,\n  accent = \"#FF7448\",\n  name,\n  label = \"Product options\",\n  className = \"\",\n  style,\n  ...props\n}: VariantPickerProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n\n  const [inner, setInner] = React.useState<VariantSelection>(() => {\n    if (defaultValue) return defaultValue;\n    const first = variants.find((v) => v.stock > 0) ?? variants[0];\n    if (first) return { ...first.options };\n    return Object.fromEntries(options.map((o) => [o.name, o.values[0]?.value ?? \"\"]));\n  });\n  const selection = value ?? inner;\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const [preview, setPreview] = React.useState<{ option: string; value: string } | null>(null);\n\n  const money = new Intl.NumberFormat(locale, { style: \"currency\", currency });\n  const signed = new Intl.NumberFormat(locale, { style: \"currency\", currency, signDisplay: \"exceptZero\" });\n\n  const resolve = (sel: VariantSelection) =>\n    variants.find((v) => options.every((o) => v.options[o.name] === sel[o.name]));\n\n  const current = resolve(selection);\n\n  /** Availability of one value given the current choice of every other option. */\n  const stateOf = (option: VariantOption, v: string): ValueState => {\n    const variant = resolve({ ...selection, [option.name]: v });\n    if (!variant) return { availability: \"missing\", variant };\n    return { availability: variant.stock > 0 ? \"available\" : \"soldout\", variant };\n  };\n\n  /** \"Sage\" or \"Sage / Linen\": the other options' chosen labels, used in reason text. */\n  const othersLabel = (option: VariantOption) =>\n    options\n      .filter((o) => o.name !== option.name)\n      .map((o) => valueLabel(o, selection[o.name]))\n      .filter(Boolean)\n      .join(\" / \");\n\n  const reasonFor = (option: VariantOption, state: ValueState) => {\n    const others = othersLabel(option);\n    if (state.availability === \"missing\") return others ? `Not made in ${others}` : \"Not available\";\n    if (state.availability === \"soldout\") return others ? `Sold out in ${others}` : \"Sold out\";\n    return \"\";\n  };\n\n  const stockText = (variant: ProductVariant | undefined) => {\n    if (!variant) return \"This combination isn't made\";\n    if (variant.stock <= 0) return \"Sold out\";\n    if (variant.stock <= lowStock) return `Only ${variant.stock} left`;\n    return \"In stock\";\n  };\n\n  const select = (option: VariantOption, v: string) => {\n    if (selection[option.name] === v) return;\n    const next = { ...selection, [option.name]: v };\n    const variant = resolve(next);\n    if (value === undefined) setInner(next);\n    onChange?.(next, variant);\n    const parts = [`${labelOf(option)}: ${valueLabel(option, v)}.`];\n    if (variant) parts.push(money.format(variant.price) + \".\");\n    if (showStock) parts.push(stockText(variant) + \".\");\n    setAnnouncement(parts.join(\" \"));\n  };\n\n  const ringTransition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const radius = swatchShape === \"circle\" ? \"rounded-full\" : \"rounded-[6px]\";\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={`flex w-full flex-col gap-5 ${className}`}\n      style={{ [\"--vp-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {options.map((option) => {\n        const groupId = `${uid}-${option.name}`;\n        const states = option.values.map((v) => stateOf(option, v.value));\n        // Deltas are relative to the cheapest value in this group (others held), so they stay put while you choose.\n        const prices = states.flatMap((st) => (st.variant ? [st.variant.price] : []));\n        const groupMin = prices.length ? Math.min(...prices) : 0;\n        const unavailable = option.values\n          .map((v, i) => ({ v, state: states[i] }))\n          .filter(({ state }) => state.availability !== \"available\");\n        const shown = preview?.option === option.name ? preview.value : selection[option.name];\n        const isPreview = preview?.option === option.name && preview.value !== selection[option.name];\n\n        return (\n          <div key={option.name} className=\"flex flex-col gap-2.5\">\n            <div className=\"flex items-baseline gap-2\">\n              <span id={`${groupId}-label`} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n                {labelOf(option)}:\n              </span>\n              <span\n                aria-hidden=\"true\"\n                className={`text-[14px] font-medium tracking-tight transition-colors duration-150 ${\n                  isPreview ? \"text-zinc-400 dark:text-zinc-500\" : \"text-zinc-900 dark:text-zinc-50\"\n                }`}\n              >\n                {valueLabel(option, shown) || \"—\"}\n              </span>\n              {/* The visible value above is a hover preview; screen readers get the real choice. */}\n              <span id={`${groupId}-value`} className=\"sr-only\">\n                {valueLabel(option, selection[option.name])}\n              </span>\n            </div>\n\n            <OptionGroup\n              option={option}\n              groupId={groupId}\n              selected={selection[option.name]}\n              states={states}\n              onSelect={(v) => select(option, v)}\n              onUnavailable={(v, state) => setAnnouncement(`${valueLabel(option, v)}: ${reasonFor(option, state)}.`)}\n              onPreview={(v) => setPreview(v === null ? null : { option: option.name, value: v })}\n              reasonFor={(state) => reasonFor(option, state)}\n              deltaFor={(state) =>\n                showPriceDelta && state.variant && state.variant.price !== groupMin ? signed.format(state.variant.price - groupMin) : \"\"\n              }\n              radius={radius}\n              swatchSize={swatchSize}\n              ringTransition={ringTransition}\n            />\n\n            {unavailable.length > 0 && (\n              <p aria-hidden=\"true\" className=\"text-[12px] leading-snug text-zinc-500 dark:text-zinc-400\">\n                {othersLabel(option) ? `In ${othersLabel(option)}: ` : \"\"}\n                {unavailable.map(({ v, state }, i) => (\n                  <React.Fragment key={v.value}>\n                    {i > 0 && \", \"}\n                    <span className=\"font-medium text-zinc-700 dark:text-zinc-300\">{v.label ?? v.value}</span>{\" \"}\n                    {state.availability === \"missing\" ? \"not made\" : \"sold out\"}\n                  </React.Fragment>\n                ))}\n              </p>\n            )}\n          </div>\n        );\n      })}\n\n      <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-zinc-900/10 pt-4 dark:border-white/10\">\n        <div className=\"flex items-baseline gap-2\">\n          <span className=\"text-[20px] font-semibold tabular-nums tracking-tight text-zinc-950 dark:text-white\">\n            {current ? money.format(current.price) : \"—\"}\n          </span>\n          {current && variants.length > 0 && (\n            <PriceFromNote price={current.price} variants={variants} format={signed} />\n          )}\n        </div>\n        {showStock && (\n          <span className=\"inline-flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            <span\n              aria-hidden=\"true\"\n              className={`size-1.5 rounded-full ${\n                !current || current.stock <= 0\n                  ? \"bg-zinc-400 dark:bg-zinc-500\"\n                  : current.stock <= lowStock\n                    ? \"bg-[var(--vp-accent)]\"\n                    : \"bg-emerald-500\"\n              }`}\n            />\n            {stockText(current)}\n          </span>\n        )}\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n      {name && <input type=\"hidden\" name={name} value={current && current.stock > 0 ? current.id : \"\"} />}\n    </div>\n  );\n}\n\n/** \"+$4.00 over base\" when the chosen variant costs more than the cheapest one. */\nfunction PriceFromNote({ price, variants, format }: { price: number; variants: ProductVariant[]; format: Intl.NumberFormat }) {\n  const base = Math.min(...variants.map((v) => v.price));\n  if (price === base) return null;\n  return (\n    <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n      {format.format(price - base)} over base\n    </span>\n  );\n}\n\ninterface OptionGroupProps {\n  option: VariantOption;\n  groupId: string;\n  selected: string | undefined;\n  states: ValueState[];\n  onSelect: (value: string) => void;\n  onUnavailable: (value: string, state: ValueState) => void;\n  onPreview: (value: string | null) => void;\n  reasonFor: (state: ValueState) => string;\n  deltaFor: (state: ValueState) => string;\n  radius: string;\n  swatchSize: number;\n  ringTransition: { duration: number } | ({ type: \"spring\" } & Spring);\n}\n\n/** One radiogroup with a roving tabindex; arrow keys select the next available value. */\nfunction OptionGroup({\n  option,\n  groupId,\n  selected,\n  states,\n  onSelect,\n  onUnavailable,\n  onPreview,\n  reasonFor,\n  deltaFor,\n  radius,\n  swatchSize,\n  ringTransition,\n}: OptionGroupProps) {\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const isSwatch = option.kind === \"swatch\";\n  const available = (i: number) => states[i]?.availability === \"available\";\n  const selectedIndex = option.values.findIndex((v) => v.value === selected);\n  // The tab stop is the checked radio, or the first available one when nothing valid is checked.\n  const tabStop = selectedIndex !== -1 ? selectedIndex : Math.max(0, states.findIndex((s) => s.availability === \"available\"));\n\n  const move = (from: number, step: 1 | -1) => {\n    const n = option.values.length;\n    for (let k = 1; k <= n; k++) {\n      const i = (from + step * k + n) % n;\n      if (available(i)) return i;\n    }\n    return -1;\n  };\n\n  const focusAndSelect = (i: number) => {\n    if (i < 0) return;\n    refs.current[i]?.focus();\n    onSelect(option.values[i].value);\n  };\n\n  const handleKeyDown = (i: number, event: React.KeyboardEvent<HTMLButtonElement>) => {\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusAndSelect(move(i, 1));\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusAndSelect(move(i, -1));\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusAndSelect(move(-1, 1));\n        break;\n      case \"End\":\n        event.preventDefault();\n        focusAndSelect(move(option.values.length, -1));\n        break;\n      case \" \":\n      case \"Enter\":\n        event.preventDefault();\n        if (available(i)) onSelect(option.values[i].value);\n        else onUnavailable(option.values[i].value, states[i]);\n        break;\n    }\n  };\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-labelledby={`${groupId}-label ${groupId}-value`}\n      className={`flex flex-wrap items-center ${isSwatch ? \"gap-3 px-1 py-1\" : \"gap-2\"}`}\n      onPointerLeave={() => onPreview(null)}\n    >\n      {option.values.map((v, i) => {\n        const state = states[i];\n        const checked = v.value === selected;\n        const ok = state.availability === \"available\";\n        const reason = reasonFor(state);\n        const delta = deltaFor(state);\n        const reasonId = `${groupId}-reason-${i}`;\n        const text = v.label ?? v.value;\n\n        return (\n          <button\n            key={v.value}\n            ref={(el) => {\n              refs.current[i] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-disabled={ok ? undefined : true}\n            aria-label={isSwatch ? (delta ? `${text}, ${delta}` : text) : undefined}\n            aria-describedby={reason ? reasonId : undefined}\n            tabIndex={i === tabStop ? 0 : -1}\n            title={isSwatch ? text : undefined}\n            data-state={state.availability}\n            onClick={() => (ok ? onSelect(v.value) : onUnavailable(v.value, state))}\n            onKeyDown={(e) => handleKeyDown(i, e)}\n            onPointerEnter={(e) => {\n              if (e.pointerType === \"mouse\") onPreview(v.value);\n            }}\n            onFocus={() => onPreview(null)}\n            className={\n              isSwatch\n                ? `group/sw relative grid place-items-center ${radius} outline-hidden focus-visible:outline-2 focus-visible:outline-offset-[6px] focus-visible:outline-solid focus-visible:outline-[var(--vp-accent)] ${\n                    ok ? \"cursor-pointer\" : \"cursor-not-allowed\"\n                  }`\n                : `relative inline-flex h-10 min-w-12 items-center justify-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium tracking-tight outline-hidden transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--vp-accent)] motion-reduce:transition-none ${\n                    ok\n                      ? checked\n                        ? \"border-transparent text-zinc-950 dark:text-white\"\n                        : \"cursor-pointer border-zinc-900/12 text-zinc-800 hover:border-zinc-900/35 dark:border-white/12 dark:text-zinc-200 dark:hover:border-white/35\"\n                      : `cursor-not-allowed border-dashed text-zinc-400 dark:text-zinc-500 ${\n                          checked ? \"border-[var(--vp-accent)]/60\" : \"border-zinc-900/15 dark:border-white/15\"\n                        }`\n                  }`\n            }\n            style={isSwatch ? { width: swatchSize, height: swatchSize } : undefined}\n          >\n            {isSwatch ? (\n              <>\n                {checked && (\n                  <motion.span\n                    layoutId={`${groupId}-ring`}\n                    transition={ringTransition}\n                    aria-hidden=\"true\"\n                    className={`pointer-events-none absolute -inset-[5px] ${radius} border-[1.5px] ${\n                      ok ? \"border-[var(--vp-accent)]\" : \"border-dashed border-zinc-400 dark:border-zinc-500\"\n                    }`}\n                  />\n                )}\n                <span\n                  aria-hidden=\"true\"\n                  className={`absolute inset-0 ${radius} shadow-[inset_0_0_0_1px_rgba(0,0,0,0.14)] transition-[scale,opacity] duration-200 dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)] motion-reduce:transition-none ${\n                    ok ? \"group-hover/sw:scale-[1.06]\" : \"opacity-45\"\n                  }`}\n                  style={{ background: v.color ?? \"#a1a1aa\" }}\n                />\n                {!ok && <StrikeLine />}\n              </>\n            ) : (\n              <>\n                {checked && (\n                  <motion.span\n                    layoutId={`${groupId}-ring`}\n                    transition={ringTransition}\n                    aria-hidden=\"true\"\n                    className={`pointer-events-none absolute -inset-px rounded-[inherit] border-[1.5px] ${\n                      ok\n                        ? \"border-[var(--vp-accent)] bg-[color-mix(in_srgb,var(--vp-accent)_10%,transparent)]\"\n                        : \"border-dashed border-zinc-400 dark:border-zinc-500\"\n                    }`}\n                  />\n                )}\n                <span className={`relative ${ok ? \"\" : \"line-through decoration-1\"}`}>{text}</span>\n                {delta && (\n                  <span className=\"relative font-mono text-[10px] tabular-nums tracking-normal text-zinc-500 dark:text-zinc-400\">\n                    {delta}\n                  </span>\n                )}\n              </>\n            )}\n          </button>\n        );\n      })}\n      {/* Reasons are referenced by aria-describedby; hidden nodes still provide descriptions. */}\n      {option.values.map((v, i) => {\n        const reason = reasonFor(states[i]);\n        return reason ? (\n          <span key={v.value} id={`${groupId}-reason-${i}`} hidden>\n            {reason}\n          </span>\n        ) : null;\n      })}\n    </div>\n  );\n}\n\n/** Diagonal strike used on unavailable swatches. */\nfunction StrikeLine() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 32 32\" className=\"pointer-events-none absolute inset-0 size-full\" preserveAspectRatio=\"none\">\n      <line x1=\"5\" y1=\"27\" x2=\"27\" y2=\"5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" className=\"text-zinc-700 dark:text-zinc-200\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/variant-picker"
}
