{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "storage-meter",
  "type": "registry:ui",
  "title": "Storage Meter",
  "description": "A stacked usage bar with animated segments, a legend with percentages, tooltips and threshold status.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/storage-meter.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface StorageSegment {\n  /** Stable identity, used to animate a segment from its previous width. */\n  key: string;\n  label: string;\n  /** Amount in the same unit as `capacity` (bytes by default). */\n  value: number;\n  /** Segment colour (any CSS colour). Falls back to a built-in palette. */\n  color?: string;\n}\n\nexport interface StorageMeterProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  segments: StorageSegment[];\n  /** Total capacity, in the same unit as the segment values. */\n  capacity: number;\n  /** Accessible and visible name of the meter. */\n  label?: string;\n  /** Formats amounts. Defaults to decimal bytes via Intl (kB, MB, GB, TB). */\n  format?: (value: number) => string;\n  /** Locale for the default formatter and percentages (fixed, so server and client agree). */\n  locale?: string;\n  /** Fraction of capacity (0–1) at which the status turns to a warning. */\n  warningAt?: number;\n  /** Fraction of capacity (0–1) at which the status turns critical. */\n  criticalAt?: number;\n  showLegend?: boolean;\n  /** Bar thickness in px. */\n  thickness?: number;\n  /** Gap between segments in px. */\n  gap?: number;\n  /** Width transition duration in ms. */\n  duration?: number;\n}\n\nconst PALETTE = [\"#FF7448\", \"#B6F3D1\", \"#7DB7F0\", \"#E8C07A\", \"#A1A1AA\", \"#F2A6B8\"];\nconst UNITS = [\"byte\", \"kilobyte\", \"megabyte\", \"gigabyte\", \"terabyte\", \"petabyte\"] as const;\n\n/** Decimal byte formatting through Intl's unit style (e.g. \"45.2 GB\"). */\nexport function formatBytes(bytes: number, locale = \"en-US\") {\n  let i = 0;\n  let n = Math.max(0, bytes);\n  while (n >= 1000 && i < UNITS.length - 1) {\n    n /= 1000;\n    i++;\n  }\n  return new Intl.NumberFormat(locale, {\n    style: \"unit\",\n    unit: UNITS[i],\n    unitDisplay: \"short\",\n    maximumFractionDigits: n < 10 && i > 0 ? 1 : 0,\n  }).format(n);\n}\n\n/**\n * A stacked usage bar. Segment widths transition from their previous values when the data\n * changes, hovering or tapping a segment (or focusing its legend entry) highlights it with a\n * tooltip, and the total is exposed as role=\"meter\" with a text status for the warning and\n * critical thresholds. The legend doubles as the accessible breakdown list.\n */\nexport function StorageMeter({\n  segments,\n  capacity,\n  label = \"Storage\",\n  format,\n  locale = \"en-US\",\n  warningAt = 0.75,\n  criticalAt = 0.9,\n  showLegend = true,\n  thickness = 12,\n  gap = 2,\n  duration = 600,\n  className = \"\",\n  style,\n  ...props\n}: StorageMeterProps) {\n  const uid = React.useId();\n  const labelId = `sm-${uid}-label`;\n  const statusId = `sm-${uid}-status`;\n  const [active, setActive] = React.useState<string | null>(null);\n\n  const fmt = format ?? ((v: number) => formatBytes(v, locale));\n  const pctFmt = new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 });\n\n  const used = segments.reduce((sum, s) => sum + Math.max(0, s.value), 0);\n  const cap = Math.max(capacity, Number.EPSILON);\n  const ratio = used / cap;\n  // Over capacity: widths are scaled to fit the track, labels keep the real numbers.\n  const scale = ratio > 1 ? 1 / ratio : 1;\n  const status =\n    ratio > 1 ? \"over\" : ratio >= criticalAt ? \"critical\" : ratio >= warningAt ? \"warning\" : \"ok\";\n  const statusText = {\n    ok: \"Plenty of space\",\n    warning: \"Running low\",\n    critical: \"Almost full\",\n    over: \"Over capacity\",\n  }[status];\n\n  const widths = segments.map((s) => (Math.max(0, s.value) / cap) * scale * 100);\n  const layout = segments.map((s, i) => ({\n    ...s,\n    color: s.color ?? PALETTE[i % PALETTE.length],\n    left: widths.slice(0, i).reduce((a, b) => a + b, 0),\n    width: widths[i],\n  }));\n  const activeEntry = layout.find((s) => s.key === active);\n\n  return (\n    <div\n      className={`w-full ${className}`}\n      style={{ [\"--sm-duration\" as string]: `${duration}ms`, ...style }}\n      onPointerLeave={(e) => {\n        if (e.pointerType === \"mouse\") setActive(null);\n      }}\n      {...props}\n    >\n      <div className=\"mb-3 flex items-end justify-between gap-4\">\n        <div className=\"min-w-0\">\n          <p id={labelId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            {label}\n          </p>\n          <p className=\"mt-1 text-[22px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-zinc-950 dark:text-white\">\n            {fmt(used)}\n            <span className=\"ml-1.5 text-[13px] font-normal tracking-normal text-zinc-500 dark:text-zinc-400\">of {fmt(capacity)}</span>\n          </p>\n        </div>\n        <p\n          id={statusId}\n          className={`flex shrink-0 items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] ${\n            status === \"ok\"\n              ? \"text-zinc-500 dark:text-zinc-400\"\n              : status === \"warning\"\n                ? \"text-amber-600 dark:text-amber-400\"\n                : \"text-rose-600 dark:text-rose-400\"\n          }`}\n        >\n          <span\n            aria-hidden=\"true\"\n            className={`size-1.5 rounded-full ${status === \"ok\" ? \"bg-emerald-500\" : status === \"warning\" ? \"bg-amber-500\" : \"bg-rose-500\"}`}\n          />\n          {statusText}\n        </p>\n      </div>\n\n      <div className=\"relative\">\n        <div\n          role=\"meter\"\n          aria-labelledby={labelId}\n          aria-describedby={statusId}\n          aria-valuemin={0}\n          aria-valuemax={capacity}\n          aria-valuenow={Math.min(used, capacity)}\n          aria-valuetext={`${fmt(used)} of ${fmt(capacity)} used (${pctFmt.format(ratio)}). ${statusText}.`}\n          className=\"relative flex w-full overflow-hidden rounded-full bg-zinc-900/[0.07] dark:bg-white/[0.08]\"\n          style={{ height: thickness }}\n        >\n          {layout.map((s) => (\n            <div\n              key={s.key}\n              aria-hidden=\"true\"\n              onPointerEnter={(e) => {\n                if (e.pointerType === \"mouse\") setActive(s.key);\n              }}\n              onClick={() => setActive((a) => (a === s.key ? null : s.key))}\n              className=\"relative h-full shrink-0 cursor-default transition-[flex-basis,opacity] duration-[var(--sm-duration)] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none\"\n              style={{ flexBasis: `${s.width.toFixed(3)}%`, opacity: active && active !== s.key ? 0.3 : 1 }}\n            >\n              {/* The gap is cut from the segment itself, so an empty segment collapses to nothing. */}\n              <span className=\"absolute inset-y-0 left-0\" style={{ right: gap, background: s.color }} />\n            </div>\n          ))}\n          {/* Threshold ticks. */}\n          {[warningAt, criticalAt].map((t) =>\n            t > 0 && t < 1 ? (\n              <span\n                key={t}\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-y-0 w-px bg-zinc-950/25 mix-blend-multiply dark:bg-white/40 dark:mix-blend-normal\"\n                style={{ left: `${(t * 100).toFixed(2)}%` }}\n              />\n            ) : null,\n          )}\n        </div>\n\n        {/* Tooltip for the highlighted segment. */}\n        {activeEntry && activeEntry.width > 0 && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute bottom-full z-10 mb-2.5 -translate-x-1/2 whitespace-nowrap rounded-lg border border-zinc-900/10 bg-white px-2.5 py-1.5 text-[12px] shadow-[0_10px_24px_-12px_rgba(0,0,0,0.35)] transition-[left] duration-200 dark:border-white/10 dark:bg-zinc-900 motion-reduce:transition-none\"\n            style={{ left: `clamp(4.5rem, ${(activeEntry.left + activeEntry.width / 2).toFixed(3)}%, calc(100% - 4.5rem))` }}\n          >\n            <span className=\"flex items-center gap-1.5 font-medium text-zinc-900 dark:text-zinc-100\">\n              <span className=\"size-2 rounded-sm\" style={{ background: activeEntry.color }} />\n              {activeEntry.label}\n            </span>\n            <span className=\"tabular-nums text-zinc-500 dark:text-zinc-400\">\n              {fmt(activeEntry.value)} · {pctFmt.format(activeEntry.value / cap)}\n            </span>\n          </div>\n        )}\n      </div>\n\n      <p className=\"mt-2 flex justify-between font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-400 dark:text-zinc-500\">\n        <span>{pctFmt.format(Math.min(ratio, 9.99))} used</span>\n        <span>{ratio > 1 ? `${fmt(used - capacity)} over` : `${fmt(capacity - used)} free`}</span>\n      </p>\n\n      {showLegend && (\n        <ul aria-label={`${label} breakdown`} className=\"mt-4 grid grid-cols-1 gap-1 sm:grid-cols-2\">\n          {layout.map((s) => (\n            <li key={s.key} className=\"min-w-0\">\n              <button\n                type=\"button\"\n                aria-pressed={active === s.key}\n                onFocus={() => setActive(s.key)}\n                onBlur={() => setActive(null)}\n                onPointerEnter={(e) => {\n                  if (e.pointerType === \"mouse\") setActive(s.key);\n                }}\n                onClick={() => setActive((a) => (a === s.key ? null : s.key))}\n                className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] outline-hidden transition-[background-color,opacity] duration-150 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-zinc-900 dark:focus-visible:outline-white motion-reduce:transition-none ${\n                  active === s.key ? \"bg-zinc-900/[0.05] dark:bg-white/[0.07]\" : \"\"\n                } ${active && active !== s.key ? \"opacity-50\" : \"\"}`}\n              >\n                <span aria-hidden=\"true\" className=\"size-2.5 shrink-0 rounded-[3px]\" style={{ background: s.color }} />\n                <span className=\"min-w-0 flex-1 truncate text-zinc-800 dark:text-zinc-200\">{s.label}</span>\n                <span className=\"shrink-0 tabular-nums text-zinc-500 dark:text-zinc-400\">\n                  {fmt(s.value)}\n                  <span className=\"ml-1.5 inline-block w-[3.25rem] text-right font-mono text-[11px] text-zinc-400 dark:text-zinc-500\">\n                    {pctFmt.format(s.value / cap)}\n                  </span>\n                </span>\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/storage-meter"
}
