{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stat-tile",
  "type": "registry:ui",
  "title": "StatTile and KpiGroup",
  "description": "KPI tile with a compact headline value, DeltaBadge, comparison, caption and sparkline trend, plus loading, link and interactive states, and a responsive KpiGroup with separate or joined layouts.",
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/chart-core.json",
    "https://components.dethink.co.uk/r/delta-badge.json",
    "https://components.dethink.co.uk/r/sparkline.json",
    "https://components.dethink.co.uk/r/skeleton.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/stat-tile/stat-tile.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/stat-tile/stat-tile.tsx",
      "content": "import {\n  createElement,\n  forwardRef,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useState,\n  type AnchorHTMLAttributes,\n  type HTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport { useChartSize } from \"../chart/use-chart-size\";\nimport {\n  formatChartValue,\n  isFiniteValue,\n  type ChartColor,\n  type ChartValue,\n  type FormatChartValueOptions,\n} from \"../chart/chart-core\";\nimport { DeltaBadge, type DeltaBadgeProps } from \"../delta-badge\";\nimport { Skeleton } from \"../skeleton\";\nimport { Sparkline, type SparklineVariant } from \"../sparkline\";\n\nexport type StatTileSize = \"sm\" | \"md\" | \"lg\";\nexport type StatTileTrendPlacement = \"bottom\" | \"end\";\n\nexport interface StatTileProps extends Omit<\n  HTMLAttributes<HTMLElement>,\n  \"children\"\n> {\n  /** Sentence-case metric name, without a trailing colon. */\n  label: ReactNode;\n  /** Numbers are formatted compactly; strings render as given; null shows a dash. */\n  value: number | string | null | undefined;\n  formatValue?: (value: number) => string;\n  /** Options for the default compact formatter, such as currency or percent. */\n  formatOptions?: FormatChartValueOptions;\n  /** A signed change, full DeltaBadge props, or null when the change is unknown. */\n  delta?: number | null | Omit<DeltaBadgeProps, \"size\">;\n  /** Comparison period shown beside the delta, for example \"vs last month\". */\n  comparison?: string;\n  /** Recent values drawn as a sparkline under (or beside) the value. */\n  trend?: readonly ChartValue[];\n  trendColor?: ChartColor;\n  trendVariant?: SparklineVariant;\n  trendPlacement?: StatTileTrendPlacement;\n  /** Accessible name for the trend. Defaults to the label when it is a string. */\n  trendLabel?: string;\n  /** Small leading icon or visual beside the label. */\n  icon?: ReactNode;\n  /** Secondary text under the value, such as a target or data freshness. */\n  caption?: ReactNode;\n  /** Render the layout with skeletons while data loads. */\n  loading?: boolean;\n  /** Render the tile as a link. */\n  href?: string;\n  target?: AnchorHTMLAttributes<HTMLAnchorElement>[\"target\"];\n  rel?: string;\n  /** Hover and focus treatment for tiles wrapped in your own link or button. */\n  interactive?: boolean;\n  size?: StatTileSize;\n}\n\nconst rootClasses =\n  \"group/stat-tile relative flex min-w-0 flex-col gap-[var(--dt-space-3)] rounded-xl border border-border bg-background p-[var(--dt-space-5)] text-foreground outline-hidden [--dt-chart-surface:var(--dt-color-background)] \" +\n  // Inside a joined KpiGroup the group owns the frame. Each tile draws a\n  // hairline on its start and top edges and is pulled 1px under the frame, so\n  // outer edges clip away and only the dividers between tiles remain.\n  \"group-data-[variant=joined]/kpi:-ms-px group-data-[variant=joined]/kpi:-mt-px group-data-[variant=joined]/kpi:rounded-none group-data-[variant=joined]/kpi:border-0 group-data-[variant=joined]/kpi:shadow-[inset_1px_1px_0_var(--dt-color-border)] rtl:group-data-[variant=joined]/kpi:shadow-[inset_-1px_1px_0_var(--dt-color-border)]\";\n\nconst interactiveClasses =\n  \"cursor-pointer motion-safe:transition-[border-color,box-shadow,background-color] motion-safe:duration-200 hover:border-foreground/25 hover:bg-muted/40 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-ring group-data-[variant=joined]/kpi:focus-visible:-outline-offset-2\";\n\nconst valueSizeClasses: Record<StatTileSize, string> = {\n  sm: \"text-2xl\",\n  md: \"text-3xl\",\n  lg: \"text-4xl\",\n};\n\nconst trendSizeClasses: Record<StatTileSize, string> = {\n  sm: \"h-8\",\n  md: \"h-10\",\n  lg: \"h-12\",\n};\n\nfunction formatTileValue(\n  value: StatTileProps[\"value\"],\n  formatValue: StatTileProps[\"formatValue\"],\n  formatOptions: StatTileProps[\"formatOptions\"],\n) {\n  if (typeof value === \"string\") return value;\n  if (!isFiniteValue(value)) return \"—\";\n  return formatValue\n    ? formatValue(value)\n    : formatChartValue(value, formatOptions);\n}\n\nexport const StatTile = forwardRef<HTMLElement, StatTileProps>(\n  (\n    {\n      caption,\n      className,\n      comparison,\n      delta,\n      formatOptions,\n      formatValue,\n      href,\n      icon,\n      interactive,\n      label,\n      loading = false,\n      rel,\n      size = \"md\",\n      target,\n      trend,\n      trendColor = \"chart-1\",\n      trendLabel,\n      trendPlacement = \"bottom\",\n      trendVariant = \"area\",\n      value,\n      ...props\n    },\n    ref,\n  ) => {\n    const labelId = useId();\n    const isLink = href !== undefined;\n    const deltaProps =\n      delta === undefined\n        ? undefined\n        : typeof delta === \"number\" || delta === null\n          ? { value: delta }\n          : delta;\n    const hasTrend = trend !== undefined && trend.length > 0;\n    const resolvedTrendLabel =\n      trendLabel ?? (typeof label === \"string\" ? `${label} trend` : \"Trend\");\n\n    const trendNode = hasTrend ? (\n      loading ? (\n        <Skeleton\n          radius=\"sm\"\n          className={cn(\n            trendSizeClasses[size],\n            trendPlacement === \"end\" ? \"w-24\" : \"w-full\",\n          )}\n        />\n      ) : (\n        <Sparkline\n          data={trend}\n          color={trendColor}\n          variant={trendVariant}\n          label={resolvedTrendLabel}\n          className={cn(\n            trendSizeClasses[size],\n            trendPlacement === \"end\" ? \"w-24 shrink-0 sm:w-28\" : \"w-full\",\n          )}\n        />\n      )\n    ) : null;\n\n    const valueNode = loading ? (\n      <Skeleton\n        radius=\"sm\"\n        className={cn(\"h-[1em] w-28\", valueSizeClasses[size])}\n      />\n    ) : (\n      <span\n        data-slot=\"stat-tile-value\"\n        // Formatted numbers read left-to-right; free text follows its script.\n        dir={typeof value === \"string\" ? \"auto\" : \"ltr\"}\n        className={cn(\n          \"max-w-full min-w-0 justify-self-start truncate font-semibold tracking-tight [font-variant-numeric:proportional-nums]\",\n          valueSizeClasses[size],\n        )}\n      >\n        {formatTileValue(value, formatValue, formatOptions)}\n      </span>\n    );\n\n    const deltaRow =\n      deltaProps || comparison ? (\n        <div\n          data-slot=\"stat-tile-delta\"\n          className=\"flex min-w-0 flex-wrap items-center gap-x-[var(--dt-space-2)] gap-y-1\"\n        >\n          {loading ? (\n            <Skeleton radius=\"full\" className=\"h-5 w-24\" />\n          ) : (\n            <>\n              {deltaProps ? (\n                <DeltaBadge\n                  comparison={comparison}\n                  {...deltaProps}\n                  size={size === \"lg\" ? \"md\" : \"sm\"}\n                />\n              ) : null}\n              {comparison ? (\n                <span\n                  aria-hidden={deltaProps ? true : undefined}\n                  className=\"text-muted-foreground truncate text-xs\"\n                >\n                  {comparison}\n                </span>\n              ) : null}\n            </>\n          )}\n        </div>\n      ) : null;\n\n    return createElement(\n      isLink ? \"a\" : \"div\",\n      {\n        ...props,\n        ref,\n        href,\n        target,\n        rel: rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined),\n        role: isLink ? undefined : \"group\",\n        \"aria-labelledby\": isLink ? undefined : labelId,\n        \"aria-busy\": loading || undefined,\n        \"data-slot\": \"stat-tile\",\n        \"data-size\": size,\n        \"data-loading\": loading || undefined,\n        \"data-interactive\": isLink || interactive || undefined,\n        className: cn(\n          rootClasses,\n          (isLink || interactive) && interactiveClasses,\n          className,\n        ),\n      },\n      <div className=\"flex min-w-0 items-center gap-[var(--dt-space-2)]\">\n        {icon ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"text-muted-foreground inline-flex size-4 shrink-0 items-center justify-center [&>svg]:size-4\"\n          >\n            {icon}\n          </span>\n        ) : null}\n        <span\n          id={labelId}\n          data-slot=\"stat-tile-label\"\n          className=\"text-muted-foreground min-w-0 truncate text-sm font-medium\"\n        >\n          {label}\n        </span>\n      </div>,\n      trendPlacement === \"end\" ? (\n        <div className=\"flex min-w-0 items-end justify-between gap-[var(--dt-space-4)]\">\n          <div className=\"grid min-w-0 gap-[var(--dt-space-2)]\">\n            {valueNode}\n            {deltaRow}\n          </div>\n          {trendNode}\n        </div>\n      ) : (\n        <div className=\"grid min-w-0 gap-[var(--dt-space-2)]\">\n          {valueNode}\n          {deltaRow}\n        </div>\n      ),\n      caption ? (\n        <p\n          data-slot=\"stat-tile-caption\"\n          className=\"text-muted-foreground text-xs leading-relaxed\"\n        >\n          {caption}\n        </p>\n      ) : null,\n      trendPlacement === \"bottom\" && trendNode ? (\n        <div className=\"mt-auto pt-[var(--dt-space-1)]\">{trendNode}</div>\n      ) : null,\n    );\n  },\n);\n\nStatTile.displayName = \"StatTile\";\n\nexport type KpiGroupVariant = \"separate\" | \"joined\";\n\nexport interface KpiGroupProps extends HTMLAttributes<HTMLDivElement> {\n  /** \"joined\" frames the tiles as one panel with hairline dividers. */\n  variant?: KpiGroupVariant;\n  /** Narrowest a tile may get before the row wraps, in px or rem. */\n  minTileWidth?: string;\n  /**\n   * Even out wrapped rows: four tiles that fit three-across become 2 × 2\n   * instead of 3 + 1. Falls back to plain auto-fit wrapping when off.\n   */\n  balanced?: boolean;\n}\n\n/**\n * The fewest columns that still need the same number of rows, so wrapped rows\n * stay as even as possible (4 tiles fitting 3-across → 2 columns).\n */\nexport function balancedColumns(count: number, fit: number) {\n  if (count <= 0) return 1;\n  const columns = Math.max(1, Math.min(Math.floor(fit), count));\n  const rows = Math.ceil(count / columns);\n  return Math.ceil(count / rows);\n}\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nfunction lengthToPixels(length: string, element: HTMLElement) {\n  // Separate integer-led and leading-dot forms so digit runs cannot overlap.\n  const match = /^(\\d+(?:\\.\\d+)?|\\.\\d+)(px|rem)$/.exec(length.trim());\n  if (!match) return undefined;\n  const value = Number(match[1]);\n  if (match[2] === \"px\") return value;\n  const rootSize = parseFloat(\n    getComputedStyle(element.ownerDocument.documentElement).fontSize,\n  );\n  return value * (Number.isFinite(rootSize) ? rootSize : 16);\n}\n\nexport const KpiGroup = forwardRef<HTMLDivElement, KpiGroupProps>(\n  (\n    {\n      balanced = true,\n      children,\n      className,\n      minTileWidth = \"13rem\",\n      style,\n      variant = \"separate\",\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const [measureRef, size] = useChartSize<HTMLDivElement>();\n    const [columns, setColumns] = useState<number>();\n\n    // Runs after every commit: count the grid items actually rendered, so\n    // fragments, mapped arrays, conditionals and wrapper components that\n    // render several tiles are all counted correctly.\n    useIsomorphicLayoutEffect(() => {\n      const element = measureRef.current;\n      let next: number | undefined;\n\n      if (balanced && element && size && size.width > 0) {\n        const minimum = lengthToPixels(minTileWidth, element);\n        if (minimum !== undefined && minimum > 0) {\n          const gap = parseFloat(getComputedStyle(element).columnGap) || 0;\n          next = balancedColumns(\n            element.childElementCount,\n            (size.width + gap) / (minimum + gap),\n          );\n        }\n      }\n\n      setColumns((previous) => (previous === next ? previous : next));\n    });\n\n    const setRef = (node: HTMLDivElement | null) => {\n      measureRef.current = node;\n      if (typeof forwardedRef === \"function\") forwardedRef(node);\n      else if (forwardedRef) forwardedRef.current = node;\n    };\n\n    return (\n      <div\n        {...props}\n        ref={setRef}\n        role={\n          props[\"aria-label\"] || props[\"aria-labelledby\"] ? \"group\" : props.role\n        }\n        data-slot=\"kpi-group\"\n        data-variant={variant}\n        data-columns={columns}\n        className={cn(\n          // Auto-fit is the server and no-JS layout; balancing refines it.\n          \"group/kpi grid min-w-0 [grid-template-columns:repeat(auto-fit,minmax(min(100%,var(--kpi-min-tile)),1fr))]\",\n          variant === \"joined\"\n            ? \"border-border bg-background overflow-hidden rounded-xl border\"\n            : \"gap-[var(--dt-space-4)]\",\n          className,\n        )}\n        style={{\n          ...style,\n          [\"--kpi-min-tile\" as string]: minTileWidth,\n          ...(columns\n            ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }\n            : null),\n        }}\n      >\n        {children}\n      </div>\n    );\n  },\n);\n\nKpiGroup.displayName = \"KpiGroup\";\n"
    },
    {
      "path": "packages/components/src/components/stat-tile/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/stat-tile/index.ts",
      "content": "export {\n  KpiGroup,\n  balancedColumns,\n  StatTile,\n  type KpiGroupProps,\n  type KpiGroupVariant,\n  type StatTileProps,\n  type StatTileSize,\n  type StatTileTrendPlacement,\n} from \"./stat-tile\";\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
