{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-swatch-more",
  "type": "registry:component",
  "title": "Swatches plus more",
  "description": "Featured swatches with a +N more panel grid grouped by family. A pick from the long tail is promoted into view.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "pdp",
    "beauty",
    "apparel"
  ],
  "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-swatch-more.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { X } from \"lucide-react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Featured swatches plus \"+N more\". The first few shades stay on the page;\n   the long tail opens in a panel grid, grouped by family, directly under\n   the row (in flow, so no parent overflow can clip it). Picking a shade\n   from the tail promotes it into the visible row so the choice stays in view.\n   Escape closes, Tab is trapped inside the panel, focus returns to the\n   trigger. `color` is any CSS color string carried on inline style. */\n\ntype Shade = { id: string; name: string; color: string; family?: string; soldOut?: boolean }\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\n\nconst DEFAULT_SHADES: Shade[] = [\n  [\"Porcelain\", \"248 230 216\", \"Fair\"], [\"Ivory\", \"242 222 200\", \"Fair\"], [\"Shell\", \"238 214 192\", \"Fair\"], [\"Linen\", \"232 206 180\", \"Fair\"],\n  [\"Sand\", \"226 196 166\", \"Light\"], [\"Beige\", \"218 186 152\", \"Light\"], [\"Wheat\", \"212 178 140\", \"Light\"], [\"Buff\", \"204 168 130\", \"Light\"],\n  [\"Honey\", \"196 154 112\", \"Medium\"], [\"Caramel\", \"184 140 98\", \"Medium\"], [\"Amber\", \"172 126 86\", \"Medium\"], [\"Toffee\", \"160 114 78\", \"Medium\"],\n  [\"Chestnut\", \"138 96 66\", \"Deep\"], [\"Cocoa\", \"118 80 56\", \"Deep\"], [\"Mocha\", \"98 66 46\", \"Deep\"], [\"Espresso\", \"76 50 36\", \"Deep\"],\n  [\"Ebony\", \"58 38 28\", \"Deep\"], [\"Onyx\", \"44 30 24\", \"Deep\"],\n].map(([name, rgb, family], i) => ({ id: name.toLowerCase(), name, color: `rgb(${rgb})`, family, soldOut: i === 10 }))\n\nfunction Dot({ s, selected, onPick, size = \"md\", refCb, tabIndex, onKeyDown, layoutGroup }: {\n  s: Shade\n  selected: boolean\n  onPick: () => void\n  size?: \"md\" | \"sm\"\n  refCb?: (el: HTMLButtonElement | null) => void\n  tabIndex?: number\n  onKeyDown?: (e: React.KeyboardEvent) => void\n  layoutGroup?: string\n}) {\n  const reduce = useReducedMotion()\n  return (\n    <button\n      ref={refCb}\n      type=\"button\"\n      role=\"radio\"\n      aria-checked={selected}\n      aria-label={s.soldOut ? `${s.name}, sold out` : s.name}\n      title={s.name}\n      tabIndex={tabIndex}\n      onClick={onPick}\n      onKeyDown={onKeyDown}\n      className={cn(\"group/sw relative grid h-11 w-11 shrink-0 place-items-center rounded-sk-chip transition-transform duration-200 ease-sk-out active:scale-[0.97]\", RING)}\n    >\n      {selected ? (\n        layoutGroup ? (\n          <motion.span layoutId={layoutGroup} aria-hidden transition={{ duration: reduce ? 0 : 0.35, ease: EASE }} className=\"absolute inset-0 rounded-sk-chip border-2 border-sk-ink\" />\n        ) : (\n          <span aria-hidden className=\"absolute inset-0 rounded-sk-chip border-2 border-sk-ink\" />\n        )\n      ) : (\n        <span aria-hidden className=\"absolute inset-0 rounded-sk-chip border border-transparent transition-colors duration-200 group-hover/sw:border-sk-line\" />\n      )}\n      <span aria-hidden className={cn(\"relative overflow-hidden rounded-sk-chip ring-1 ring-inset ring-sk-ink/15\", size === \"md\" ? \"h-8 w-8\" : \"h-7 w-7\")} style={{ background: s.color }}>\n        {s.soldOut ? <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  )\n}\n\nexport function PdpSwatchMore({\n  shades = DEFAULT_SHADES,\n  featured = 6,\n  label = \"Shade\",\n  defaultValue,\n  onChange,\n  className,\n}: {\n  shades?: Shade[]\n  /** How many shades stay visible before the \"+N more\" trigger. */\n  featured?: number\n  label?: string\n  defaultValue?: string\n  onChange?: (id: string) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const uid = useId()\n  const sk = useSkBlock(\"pdp\", \"pdp-swatch-more\")\n  const [value, setValue] = useState(defaultValue ?? shades.find((s) => !s.soldOut)?.id ?? shades[0]?.id)\n  const [open, setOpen] = useState(false)\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const panelRef = useRef<HTMLDivElement>(null)\n  const rowRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  const selected = shades.find((s) => s.id === value) ?? shades[0]\n  const head = shades.slice(0, featured)\n  const visible = head.some((s) => s.id === value) || !selected ? head : [...head.slice(0, featured - 1), selected]\n  const rest = shades.length - visible.length\n  const vi = Math.max(0, visible.findIndex((s) => s.id === value))\n\n  const pick = (id: string) => {\n    if (id !== value) sk.emit(\"interact\", \"swatch\", undefined)\n    setValue(id)\n    onChange?.(id)\n  }\n\n  const close = () => {\n    setOpen(false)\n    triggerRef.current?.focus()\n  }\n\n  useEffect(() => {\n    if (!open) return\n    const panel = panelRef.current\n    const first = panel?.querySelector<HTMLElement>('[aria-checked=\"true\"]') ?? panel?.querySelector<HTMLElement>(\"button\")\n    first?.focus()\n    const onDown = (e: MouseEvent) => {\n      if (panel && !panel.contains(e.target as Node) && !triggerRef.current?.contains(e.target as Node)) setOpen(false)\n    }\n    document.addEventListener(\"mousedown\", onDown)\n    return () => document.removeEventListener(\"mousedown\", onDown)\n  }, [open])\n\n  const onPanelKeys = (e: React.KeyboardEvent) => {\n    if (e.key === \"Escape\") {\n      e.preventDefault()\n      return close()\n    }\n    if (e.key !== \"Tab\" || !panelRef.current) return\n    const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>(\"button\"))\n    const i = items.indexOf(document.activeElement as HTMLElement)\n    if (e.shiftKey && i <= 0) {\n      e.preventDefault()\n      items[items.length - 1]?.focus()\n    } else if (!e.shiftKey && i === items.length - 1) {\n      e.preventDefault()\n      items[0]?.focus()\n    }\n  }\n\n  const onRowKeys = (e: React.KeyboardEvent) => {\n    const n = visible.length\n    let next = -1\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (vi + 1) % n\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (vi - 1 + n) % n\n    if (next < 0) return\n    e.preventDefault()\n    pick(visible[next].id)\n    rowRefs.current[next]?.focus()\n  }\n\n  const families = Array.from(new Set(shades.map((s) => s.family ?? \"\"))).map((f) => ({ family: f, items: shades.filter((s) => (s.family ?? \"\") === f) }))\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"relative flex w-full flex-col gap-3\", className)}>\n      <div className=\"flex items-baseline gap-2 text-sm\">\n        <span id={`${uid}-label`} className=\"font-medium text-sk-ink\">\n          {label}\n        </span>\n        <span className=\"truncate text-sk-muted\" aria-live=\"polite\">\n          {selected?.name}\n          {selected?.soldOut ? \", sold out\" : \"\"}\n        </span>\n        <span className=\"ml-auto shrink-0 font-sk-mono text-[11px] tabular-nums text-sk-muted\">{shades.length} shades</span>\n      </div>\n      <div className=\"flex flex-wrap items-center gap-1.5\">\n        <div role=\"radiogroup\" aria-labelledby={`${uid}-label`} className=\"flex flex-wrap gap-1.5\">\n          {visible.map((s, i) => (\n            <Dot\n              key={s.id}\n              s={s}\n              selected={s.id === value}\n              onPick={() => pick(s.id)}\n              tabIndex={i === vi ? 0 : -1}\n              onKeyDown={onRowKeys}\n              refCb={(el) => {\n                rowRefs.current[i] = el\n              }}\n              layoutGroup={`${uid}-ring`}\n            />\n          ))}\n        </div>\n        {rest > 0 ? (\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-haspopup=\"dialog\"\n            aria-expanded={open}\n            aria-controls={`${uid}-panel`}\n            onClick={() => {\n              if (!open) sk.emit(\"interact\", \"more_open\", undefined)\n              setOpen((o) => !o)\n            }}\n            className={cn(\n              \"h-11 rounded-sk-chip border border-sk-line px-3.5 text-[13px] font-medium tabular-nums text-sk-ink transition-[border-color,background-color,transform] duration-200 ease-sk-out hover:border-sk-ink/40 active:scale-[0.97]\",\n              open && \"border-sk-ink/40 bg-sk-ink/[0.04]\",\n              RING\n            )}\n          >\n            +{rest} more\n          </button>\n        ) : null}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {open ? (\n          <motion.div\n            ref={panelRef}\n            id={`${uid}-panel`}\n            role=\"dialog\"\n            aria-modal=\"false\"\n            aria-label={`All ${label.toLowerCase()}s`}\n            onKeyDown={onPanelKeys}\n            initial={{ opacity: 0, y: reduce ? 0 : 8, scale: reduce ? 1 : 0.98 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, y: reduce ? 0 : 4, scale: reduce ? 1 : 0.98 }}\n            transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}\n            className=\"origin-top rounded-sk-card border border-sk-line bg-sk-card p-4 shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)] sm:p-5\"\n          >\n            <div className=\"mb-3 flex items-center justify-between gap-3\">\n              <span className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">All {shades.length} shades</span>\n              <button\n                type=\"button\"\n                aria-label=\"Close\"\n                onClick={close}\n                className={cn(\"-mr-2 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-muted transition-[color,transform] duration-200 hover:text-sk-ink active:scale-[0.97]\", RING)}\n              >\n                <X size={16} />\n              </button>\n            </div>\n            <div className=\"flex max-h-[260px] flex-col gap-3 overflow-y-auto\">\n              {families.map((g) => (\n                <div key={g.family} role=\"radiogroup\" aria-label={g.family || label} className=\"flex flex-col gap-1.5\">\n                  {g.family ? <span className=\"text-[13px] text-sk-muted\">{g.family}</span> : null}\n                  <div className=\"flex flex-wrap gap-1\">\n                    {g.items.map((s) => (\n                      <Dot\n                        key={s.id}\n                        s={s}\n                        size=\"sm\"\n                        selected={s.id === value}\n                        onPick={() => {\n                          pick(s.id)\n                          close()\n                        }}\n                      />\n                    ))}\n                  </div>\n                </div>\n              ))}\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-swatch-more.tsx"
    }
  ],
  "meta": {
    "section": "pdp",
    "variant": "swatch",
    "niche": [
      "beauty",
      "apparel"
    ],
    "commerce": [
      "variants"
    ],
    "previewHeight": 560
  }
}