{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "size-guide-dialog",
  "title": "Size Guide Dialog",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "A measurement table in your shadcn Dialog that morphs out of a quiet link beside the size label, and back into it (Motion; a fade under reduced motion). Centimetres and inches with tape-honest rounding, the shopper's selected size marked and announced, ranges and size equivalents (EU 38) in the same table, and a size column that stays put while the measurements scroll sideways on narrow screens. Composed from parts that share the unit through context.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "dialog",
    "table"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/size-guide-dialog/size-guide-dialog.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Ruler, X } from \"lucide-react\"\nimport { animate, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\"\n\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\"\nimport {\n  Table,\n  TableBody,\n  TableCaption,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\"\nimport { cn } from \"@/lib/utils\"\n\nconst subscribeNothing = () => () => {}\n\n/**\n * Your locale, or the visitor's once hydrated. The server can't know the\n * visitor's locale, and its HTML must match the first client render — a\n * server in en-IN prints \"1,24,000\" where an en-US browser prints\n * \"124,000\" — so until hydration it formats in en-US.\n */\nfunction useFormatLocale(locale?: string) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  return locale ?? (hydrated ? undefined : \"en-US\")\n}\n\n/*\n * A size guide, opened from the size label:\n *\n *   <SizeGuideDialog columns={[\"Chest\", \"Waist\"]} rows={rows} selectedSize=\"M\" />\n *\n *   <SizeGuideDialog columns={columns} rows={rows}>          or composed\n *     <SizeGuideTrigger>Size & fit</SizeGuideTrigger>\n *     <SizeGuideContent>\n *       <SizeGuideUnitToggle />\n *       <SizeGuideTable />\n *       <HowToMeasure />                                     your own part\n *     </SizeGuideContent>\n *   </SizeGuideDialog>\n *\n * Built on your shadcn/ui Dialog and Table. The panel morphs out of the link\n * that opened it and back into it on close, driven by Motion; with reduced\n * motion it simply fades. The parts share the unit and the selected size\n * through context; `useSizeGuide()` reads them.\n */\n\nexport type SizeGuideUnit = \"cm\" | \"in\"\n\n/**\n * One cell: a measurement in `baseUnit`, a `[min, max]` range, or text shown\n * as-is — \"EU 38\", \"UK 10\" — which is never converted.\n */\nexport type SizeGuideValue = number | [min: number, max: number] | string\n\nexport interface SizeGuideRow {\n  size: string\n  /** One per column, in column order. */\n  values: SizeGuideValue[]\n}\n\nconst CM_PER_INCH = 2.54\n\n/** Inches to the nearest half, centimetres to the whole — how tapes are read. */\nfunction convert(value: number, from: SizeGuideUnit, to: SizeGuideUnit) {\n  if (from === to) return value\n  return to === \"in\"\n    ? Math.round((value / CM_PER_INCH) * 2) / 2\n    : Math.round(value * CM_PER_INCH)\n}\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\n\n/** Scrolls, without drawing a scrollbar. Edge fades say there is more. */\nconst NO_SCROLLBAR = \"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface SizeGuideContextValue {\n  columns: string[]\n  rows: SizeGuideRow[]\n  unit: SizeGuideUnit\n  setUnit: (unit: SizeGuideUnit) => void\n  /** The unit `rows` are written in. */\n  baseUnit: SizeGuideUnit\n  selectedSize?: string\n  /** A cell, converted to the current unit and formatted for the locale. */\n  format: (value: SizeGuideValue) => string\n  title: React.ReactNode\n  description: React.ReactNode\n}\n\nconst SizeGuideContext = React.createContext<SizeGuideContextValue | null>(null)\n\nexport function useSizeGuide() {\n  const context = React.useContext(SizeGuideContext)\n  if (!context) {\n    throw new Error(\"useSizeGuide must be used within <SizeGuideDialog>.\")\n  }\n  return context\n}\n\n/** The morph's bookkeeping. Internal: nothing here is part of the API. */\ninterface MorphContextValue {\n  /** The element the panel grows out of, and shrinks back into. */\n  anchorRef: React.RefObject<HTMLElement | null>\n  /** Closed as far as the shopper is concerned, still on screen animating out. */\n  closing: boolean\n  /** Called once the exit has played: the dialog really closes. */\n  finishClose: () => void\n}\n\nconst MorphContext = React.createContext<MorphContextValue | null>(null)\n\nfunction useMorph() {\n  const context = React.useContext(MorphContext)\n  if (!context) {\n    throw new Error(\"Size guide parts must be used within <SizeGuideDialog>.\")\n  }\n  return context\n}\n\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/** Whether a scroller has more to show past its start or end edge. */\nfunction useOverflow(axis: \"x\" | \"y\") {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const [edges, setEdges] = React.useState({ start: false, end: false })\n\n  React.useEffect(() => {\n    const node = ref.current\n    if (!node) return\n    const update = () => {\n      const offset = axis === \"x\" ? node.scrollLeft : node.scrollTop\n      const size = axis === \"x\" ? node.clientWidth : node.clientHeight\n      const total = axis === \"x\" ? node.scrollWidth : node.scrollHeight\n      const next = { start: offset > 1, end: offset + size < total - 1 }\n      setEdges((current) =>\n        current.start === next.start && current.end === next.end ? current : next\n      )\n    }\n    update()\n    const observer = new ResizeObserver(update)\n    observer.observe(node)\n    if (node.firstElementChild) observer.observe(node.firstElementChild)\n    node.addEventListener(\"scroll\", update, { passive: true })\n    return () => {\n      observer.disconnect()\n      node.removeEventListener(\"scroll\", update)\n    }\n  }, [axis])\n\n  return [ref, edges] as const\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface SizeGuideDialogProps {\n  /** Measurement names, left to right: \"Chest\", \"Waist\", \"Hip\". */\n  columns: string[]\n  rows: SizeGuideRow[]\n  /** The unit `rows` are written in. Defaults to centimetres. */\n  baseUnit?: SizeGuideUnit\n  unit?: SizeGuideUnit\n  /** Defaults to `baseUnit`. */\n  defaultUnit?: SizeGuideUnit\n  onUnitChange?: (unit: SizeGuideUnit) => void\n  /** The size picked in the buy box. Its row is marked, and announced. */\n  selectedSize?: string\n  locale?: string\n  title?: React.ReactNode\n  description?: React.ReactNode\n  /** \"Runs small — size up.\" Shown under the table in the default layout. */\n  fitNote?: React.ReactNode\n  open?: boolean\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** Trigger and content parts. Omit for the default layout. */\n  children?: React.ReactNode\n}\n\nexport function SizeGuideDialog({\n  columns,\n  rows,\n  baseUnit = \"cm\",\n  unit: unitProp,\n  defaultUnit,\n  onUnitChange,\n  selectedSize,\n  locale: localeProp,\n  title = \"Size guide\",\n  description = \"Body measurements. Measure over light clothing, with the tape level and snug.\",\n  fitNote,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  children,\n}: SizeGuideDialogProps) {\n  const locale = useFormatLocale(localeProp)\n  const [unit, setUnit] = useControllableState(\n    unitProp,\n    defaultUnit ?? baseUnit,\n    onUnitChange\n  )\n  const [open, setOpen] = useControllableState(openProp, defaultOpen, onOpenChange)\n\n  // The primitive stays open until the exit morph has played; `open` is what\n  // the shopper asked for. Derived during render, so a controlled `open`\n  // that flips from outside animates the same way.\n  const [mounted, setMounted] = React.useState(open)\n  if (open && !mounted) setMounted(true)\n  const closing = mounted && !open\n\n  const anchorRef = React.useRef<HTMLElement | null>(null)\n  const finishClose = React.useCallback(() => setMounted(false), [])\n\n  const onPrimitiveOpenChange = (next: boolean) => {\n    // Escape pressed twice, or the overlay clicked mid-exit: already closing.\n    if (next === open) return\n    if (next && !anchorRef.current) {\n      // A trigger of your own: grow from whatever opened the dialog.\n      const active = document.activeElement\n      anchorRef.current = active instanceof HTMLElement && active !== document.body ? active : null\n    }\n    setOpen(next)\n  }\n\n  const context = React.useMemo<SizeGuideContextValue>(() => {\n    const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 })\n    const one = (n: number) => number.format(convert(n, baseUnit, unit))\n    return {\n      columns,\n      rows,\n      unit,\n      setUnit,\n      baseUnit,\n      selectedSize,\n      format: (value) =>\n        typeof value === \"string\"\n          ? value\n          : Array.isArray(value)\n            ? `${one(value[0])}–${one(value[1])}`\n            : one(value),\n      title,\n      description,\n    }\n  }, [columns, rows, unit, setUnit, baseUnit, selectedSize, locale, title, description])\n\n  const morph = React.useMemo<MorphContextValue>(\n    () => ({ anchorRef, closing, finishClose }),\n    [closing, finishClose]\n  )\n\n  return (\n    <SizeGuideContext.Provider value={context}>\n      <MorphContext.Provider value={morph}>\n        <Dialog open={mounted} onOpenChange={onPrimitiveOpenChange}>\n          {children ?? (\n            <>\n              <SizeGuideTrigger />\n              <SizeGuideContent>\n                <SizeGuideUnitToggle />\n                <SizeGuideTable />\n                {fitNote ? <SizeGuideNote>{fitNote}</SizeGuideNote> : null}\n              </SizeGuideContent>\n            </>\n          )}\n        </Dialog>\n      </MorphContext.Provider>\n    </SizeGuideContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\n/** A quiet text link, sized to sit beside the \"Size\" label. */\nexport function SizeGuideTrigger({\n  className,\n  children = \"Size guide\",\n}: {\n  className?: string\n  children?: React.ReactNode\n}) {\n  const { anchorRef } = useMorph()\n  return (\n    <DialogTrigger\n      className={cn(\n        \"inline-flex items-center rounded-sm text-sm text-muted-foreground underline decoration-border underline-offset-4 transition-colors outline-none hover:text-foreground hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        className\n      )}\n    >\n      {/* The morph's origin. A span, not a ref on the trigger: the trigger\n          is your primitive's, and refs reach it differently on each base. */}\n      <span\n        ref={(node) => {\n          anchorRef.current = node\n        }}\n        className=\"inline-flex items-center gap-1.5\"\n      >\n        <Ruler className=\"size-4\" aria-hidden />\n        {children}\n      </span>\n    </DialogTrigger>\n  )\n}\n\n/**\n * The panel: title and description, then your parts in a body that scrolls\n * without a scrollbar. It grows out of the trigger, and shrinks back into it.\n */\nexport function SizeGuideContent({\n  className,\n  children,\n}: {\n  className?: string\n  children?: React.ReactNode\n}) {\n  const { title, description } = useSizeGuide()\n  const [body, edges] = useOverflow(\"y\")\n\n  return (\n    <DialogContent\n      showCloseButton={false}\n      // The primitive only positions and traps focus. Its surface, padding\n      // and CSS keyframes are switched off: the surface is ours, and Motion\n      // plays the open and the close.\n      className=\"animate-none! gap-0 rounded-none border-0 bg-transparent p-0 shadow-none ring-0 transition-none! sm:max-w-[min(48rem,calc(100%-2rem))]\"\n    >\n      <MorphPanel>\n        <DialogHeader className=\"shrink-0 gap-2 border-b px-5 pt-6 pr-16 pb-5 text-left sm:px-8 sm:pt-8\">\n          <DialogTitle className=\"text-xl font-medium tracking-tight\">{title}</DialogTitle>\n          <DialogDescription className=\"max-w-prose text-sm leading-relaxed\">\n            {description}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"relative flex min-h-0 flex-1 flex-col\">\n          <div\n            ref={body}\n            data-slot=\"size-guide-body\"\n            className={cn(\n              \"flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain px-5 py-6 sm:px-8 sm:pb-8\",\n              NO_SCROLLBAR,\n              className\n            )}\n          >\n            {children}\n          </div>\n          <EdgeFade side=\"bottom\" visible={edges.end} />\n        </div>\n\n        <DialogClose className=\"absolute top-3 right-3 grid size-9 place-items-center text-muted-foreground transition-colors outline-none hover:bg-secondary hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring sm:top-5 sm:right-5\">\n          <X className=\"size-4\" aria-hidden />\n          <span className=\"sr-only\">Close</span>\n        </DialogClose>\n      </MorphPanel>\n    </DialogContent>\n  )\n}\n\n/**\n * The visible surface, and the morph. A container transform: the surface\n * starts as the trigger's box, scales out to its real size, and only then do\n * its contents fade in — they are never seen squashed. Closing runs it back.\n */\nfunction MorphPanel({ children }: { children: React.ReactNode }) {\n  const { anchorRef, closing, finishClose } = useMorph()\n  const reduce = useReducedMotion()\n  const surface = React.useRef<HTMLDivElement>(null)\n  const contents = React.useRef<HTMLDivElement>(null)\n  const entered = React.useRef(false)\n\n  // A layout effect, inside the portal: this runs once the panel is in the\n  // document and before the browser paints it, so it never flashes full size.\n  React.useLayoutEffect(() => {\n    const panel = surface.current\n    const inner = contents.current\n    if (!panel || !inner) return\n    const overlay = findOverlay(panel)\n    const running: AnimationPlaybackControls[] = []\n    let cancelled = false\n\n    if (!closing) {\n      const from = reduce ? null : deltaTo(panel, anchorRef.current)\n      if (!entered.current) {\n        entered.current = true\n        // Paint the first frame now; Motion takes over on the next one.\n        if (from) {\n          panel.style.transform = `translate(${from.x}px, ${from.y}px) scale(${from.scaleX}, ${from.scaleY})`\n        } else {\n          panel.style.opacity = \"0\"\n        }\n        inner.style.opacity = \"0\"\n        running.push(\n          from\n            ? animate(panel, { x: [from.x, 0], y: [from.y, 0], scaleX: [from.scaleX, 1], scaleY: [from.scaleY, 1] }, MORPH)\n            : animate(panel, reduce ? { opacity: [0, 1] } : { opacity: [0, 1], y: [8, 0] }, { duration: reduce ? 0.15 : 0.3, ease: MORPH.ease })\n        )\n      } else {\n        // Reopened while still closing: turn around from wherever it is.\n        running.push(animate(panel, { x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 1 }, MORPH))\n        if (overlay) running.push(animate(overlay, { opacity: 1 }, { duration: 0.2 }))\n      }\n      running.push(\n        animate(inner, { opacity: 1 }, { duration: 0.25, delay: from ? 0.2 : 0, ease: \"easeOut\" })\n      )\n    } else {\n      const to = reduce ? null : deltaTo(panel, anchorRef.current)\n      running.push(animate(inner, { opacity: 0 }, { duration: 0.12, ease: \"easeIn\" }))\n      if (to) {\n        running.push(\n          animate(panel, { x: to.x, y: to.y, scaleX: to.scaleX, scaleY: to.scaleY }, { ...MORPH, duration: 0.38, delay: 0.06 }),\n          // The last of it fades, so the box hands over to the link.\n          animate(panel, { opacity: 0 }, { duration: 0.14, delay: 0.3 })\n        )\n      } else {\n        running.push(animate(panel, reduce ? { opacity: 0 } : { opacity: 0, y: 8 }, { duration: reduce ? 0.15 : 0.2 }))\n      }\n      if (overlay) running.push(animate(overlay, { opacity: 0 }, { duration: 0.3, delay: 0.1 }))\n\n      // A fallback, should a frame never come (a background tab, say).\n      const fallback = window.setTimeout(() => !cancelled && finishClose(), 800)\n      Promise.all(running.map((animation) => animation.finished)).then(() => {\n        window.clearTimeout(fallback)\n        if (!cancelled) finishClose()\n      })\n      return () => {\n        cancelled = true\n        window.clearTimeout(fallback)\n        running.forEach((animation) => animation.stop())\n      }\n    }\n\n    return () => {\n      cancelled = true\n      running.forEach((animation) => animation.stop())\n    }\n    // Runs on open and on close; `reduce` is read, not watched.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [closing])\n\n  return (\n    <div\n      ref={surface}\n      data-slot=\"size-guide-panel\"\n      // min-w-0: the primitive's content box may be a grid, and a grid item\n      // grows to its widest content — the table — past the screen's edge.\n      className=\"relative w-full min-w-0 origin-center border bg-background text-foreground shadow-2xl\"\n    >\n      <div\n        ref={contents}\n        className=\"flex max-h-[min(calc(100dvh-2rem),56rem)] flex-col\"\n      >\n        {children}\n      </div>\n    </div>\n  )\n}\n\n/**\n * The move and scale that lay the panel over `anchor`. Null when there is no\n * anchor on screen — then the panel rises and fades instead.\n */\nfunction deltaTo(panel: HTMLElement, anchor: HTMLElement | null) {\n  if (!anchor?.isConnected) return null\n  const a = anchor.getBoundingClientRect()\n  if (a.width === 0 || a.height === 0) return null\n  // Untransformed size: the panel may be mid-morph when this runs.\n  const width = panel.offsetWidth\n  const height = panel.offsetHeight\n  const box = panel.parentElement?.getBoundingClientRect()\n  if (!box || width === 0 || height === 0) return null\n  // The panel fills its positioned parent, which is never transformed by us.\n  const centerX = box.left + box.width / 2\n  const centerY = box.top + box.height / 2\n  return {\n    x: a.left + a.width / 2 - centerX,\n    y: a.top + a.height / 2 - centerY,\n    scaleX: a.width / width,\n    scaleY: a.height / height,\n  }\n}\n\n/** The dialog's overlay: a sibling of the content, somewhere up the portal. */\nfunction findOverlay(panel: HTMLElement) {\n  const content = panel.closest<HTMLElement>('[data-slot=\"dialog-content\"]')\n  let node = content?.parentElement ?? null\n  while (node && node !== document.body) {\n    const overlay = node.querySelector<HTMLElement>(':scope > [data-slot=\"dialog-overlay\"]')\n    if (overlay) return overlay\n    node = node.parentElement\n  }\n  return null\n}\n\n/** A soft edge over scrolled-away content, in place of a scrollbar. */\nfunction EdgeFade({ side, visible }: { side: \"bottom\" | \"right\"; visible: boolean }) {\n  return (\n    <div\n      aria-hidden\n      data-slot=\"size-guide-fade\"\n      data-side={side}\n      className={cn(\n        \"pointer-events-none absolute from-background to-transparent transition-opacity duration-200\",\n        side === \"bottom\" ? \"inset-x-0 bottom-0 h-10 bg-gradient-to-t\" : \"inset-y-0 right-0 w-12 bg-gradient-to-l\",\n        visible ? \"opacity-100\" : \"opacity-0\"\n      )}\n    />\n  )\n}\n\n/** cm / in, as a pressed-button pair: two options do not need a menu. */\nexport function SizeGuideUnitToggle({ className }: { className?: string }) {\n  const { unit, setUnit } = useSizeGuide()\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Units\"\n      data-slot=\"size-guide-units\"\n      className={cn(\"flex w-fit items-center border\", className)}\n    >\n      {(\n        [\n          [\"cm\", \"Centimetres\"],\n          [\"in\", \"Inches\"],\n        ] as const\n      ).map(([key, name]) => (\n        <button\n          key={key}\n          type=\"button\"\n          aria-pressed={unit === key}\n          aria-label={name}\n          onClick={() => setUnit(key)}\n          className={cn(\n            \"h-8 min-w-11 px-3 text-xs font-medium tracking-[0.14em] uppercase transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n            unit === key\n              ? \"bg-foreground text-background\"\n              : \"text-muted-foreground hover:text-foreground\"\n          )}\n        >\n          {key}\n        </button>\n      ))}\n    </div>\n  )\n}\n\n/**\n * Ruled, not striped. The size column stays put while the measurements\n * scroll sideways inside the table — never the page — on a narrow screen,\n * with a soft edge, not a scrollbar, saying there is more.\n */\nexport function SizeGuideTable({ className }: { className?: string }) {\n  const { columns, rows, unit, selectedSize, format } = useSizeGuide()\n  const [scroller, edges] = useOverflow(\"x\")\n  const unitName = unit === \"cm\" ? \"centimetres\" : \"inches\"\n\n  return (\n    <div\n      data-slot=\"size-guide-table\"\n      // min-w-0: inside a flex or grid parent an item grows to its content —\n      // without it the whole panel scrolls sideways instead of the table.\n      className={cn(\"relative min-w-0 border-y\", className)}\n    >\n      <div\n        ref={scroller}\n        role=\"region\"\n        aria-label={`Size chart, in ${unitName}`}\n        // Focusable, so the chart scrolls from the keyboard too.\n        tabIndex={0}\n        className={cn(\n          \"overflow-x-auto overscroll-x-contain outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n          // The Table's own wrapper would scroll too; this region does it.\n          \"[&_[data-slot=table-container]]:overflow-visible\",\n          NO_SCROLLBAR\n        )}\n      >\n        {/* Separate borders, drawn on the cells: with collapsed borders the\n            pinned size column paints over the row rules as it scrolls. */}\n        <Table className=\"border-separate border-spacing-0 whitespace-nowrap [&_tbody_tr:last-child>*]:border-b-0 [&_th]:border-b [&_td]:border-b\">\n          <TableCaption className=\"sr-only\">Body measurements in {unitName}</TableCaption>\n          <TableHeader>\n            <TableRow className=\"hover:bg-transparent\">\n              <TableHead\n                scope=\"col\"\n                className=\"sticky left-0 z-10 bg-background pr-6 pl-0 text-xs font-medium tracking-[0.14em] text-foreground uppercase\"\n              >\n                Size\n              </TableHead>\n              {columns.map((column) => (\n                <TableHead\n                  key={column}\n                  scope=\"col\"\n                  className=\"text-right text-xs font-medium tracking-[0.14em] text-muted-foreground uppercase last:pr-0\"\n                >\n                  {column}\n                </TableHead>\n              ))}\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {rows.map((row) => {\n              const selected = row.size === selectedSize\n              return (\n                <TableRow\n                  key={row.size}\n                  data-state={selected ? \"selected\" : undefined}\n                  aria-current={selected ? \"true\" : undefined}\n                  className=\"group hover:bg-transparent data-[state=selected]:bg-muted\"\n                >\n                  <TableHead\n                    scope=\"row\"\n                    className=\"sticky left-0 z-10 bg-background pr-6 pl-0 font-medium text-foreground group-data-[state=selected]:bg-muted\"\n                  >\n                    <span className=\"flex items-center gap-2\">\n                      {row.size}\n                      {selected ? (\n                        <span className=\"text-[11px] font-normal tracking-[0.14em] text-muted-foreground uppercase\">\n                          Selected\n                        </span>\n                      ) : null}\n                    </span>\n                  </TableHead>\n                  {columns.map((column, i) => (\n                    <TableCell\n                      key={column}\n                      className=\"text-right tabular-nums last:pr-0\"\n                    >\n                      {row.values[i] === undefined ? \"—\" : format(row.values[i])}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              )\n            })}\n          </TableBody>\n        </Table>\n      </div>\n      <EdgeFade side=\"right\" visible={edges.end} />\n    </div>\n  )\n}\n\n/** The fit note: one line of judgment under the numbers. */\nexport function SizeGuideNote({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLParagraphElement>) {\n  return (\n    <p\n      data-slot=\"size-guide-note\"\n      className={cn(\"text-sm leading-relaxed text-foreground\", className)}\n      {...props}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/size-guide-dialog.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "product",
    "pdp",
    "sizing"
  ],
  "type": "registry:component"
}