{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "unit-input",
  "title": "Unit Input",
  "description": "A number field with a unit picker that stores the value in a base unit, with byte presets and typed units like 2gb.",
  "dependencies": [],
  "registryDependencies": [
    "utils",
    "@drift-ui/use-controllable-state",
    "@drift-ui/shape"
  ],
  "files": [
    {
      "path": "registry/default/ui/unit-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  BYTE_UNITS,\n  bestUnit,\n  clampValue,\n  formatAmount,\n  fromBase,\n  parseUnitText,\n  toBase,\n  type Unit,\n} from \"@/registry/default/lib/unit-input-utils\"\nimport { cn } from \"@/registry/default/lib/utils\"\n\nconst LARGE_STEP_MULTIPLIER = 10\n\ninterface UnitInputProps extends Omit<\n  React.ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"type\" | \"min\" | \"max\" | \"step\" | \"onChange\"\n> {\n  value?: number | null\n  defaultValue?: number | null\n  onValueChange?: (value: number | null) => void\n  units?: Unit[]\n  unit?: string\n  defaultUnit?: string\n  onUnitChange?: (unit: string) => void\n  min?: number\n  max?: number\n  step?: number\n}\n\nfunction UnitInput({\n  value,\n  defaultValue = null,\n  onValueChange,\n  units = BYTE_UNITS,\n  unit,\n  defaultUnit,\n  onUnitChange,\n  min,\n  max,\n  step = 1,\n  name,\n  disabled,\n  className,\n  onBlur,\n  onKeyDown,\n  ...props\n}: UnitInputProps) {\n  const { value: currentValue, setValue } = useControllableState<number | null>(\n    { value, defaultValue, onChange: onValueChange }\n  )\n  const { value: unitId, setValue: setUnitId } = useControllableState<string>({\n    value: unit,\n    defaultValue: defaultUnit ?? bestUnit(value ?? defaultValue, units).id,\n    onChange: onUnitChange,\n  })\n  const [draft, setDraft] = React.useState<string | null>(null)\n\n  const activeUnit =\n    units.find((candidate) => candidate.id === unitId) ??\n    bestUnit(currentValue, units)\n  const amount =\n    currentValue === null ? null : fromBase(currentValue, activeUnit)\n  const displayedText = draft ?? (amount === null ? \"\" : formatAmount(amount))\n\n  function commit(next: number | null) {\n    setDraft(null)\n    setValue(next === null ? null : clampValue(next, min, max))\n  }\n\n  function commitDraft() {\n    if (draft === null) return\n    if (draft.trim() === \"\") return commit(null)\n\n    const parsed = parseUnitText(draft, units)\n    if (!parsed) return setDraft(null)\n\n    const targetUnit = parsed.unit ?? activeUnit\n    if (parsed.unit) setUnitId(parsed.unit.id)\n    commit(toBase(parsed.amount, targetUnit))\n  }\n\n  function stepBy(delta: number) {\n    const parsed = draft === null ? null : parseUnitText(draft, units)\n    const targetUnit = parsed?.unit ?? activeUnit\n    if (parsed?.unit) setUnitId(parsed.unit.id)\n    const base =\n      parsed?.amount ??\n      amount ??\n      (min === undefined ? 0 : fromBase(min, activeUnit))\n    commit(toBase(base + delta, targetUnit))\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n    onKeyDown?.(event)\n    if (event.defaultPrevented || props.readOnly) return\n\n    const largeStep = step * LARGE_STEP_MULTIPLIER\n    const stepAmount = event.shiftKey ? largeStep : step\n\n    switch (event.key) {\n      case \"ArrowUp\":\n        stepBy(stepAmount)\n        break\n      case \"ArrowDown\":\n        stepBy(-stepAmount)\n        break\n      case \"PageUp\":\n        stepBy(largeStep)\n        break\n      case \"PageDown\":\n        stepBy(-largeStep)\n        break\n      case \"Home\":\n        if (min === undefined) return\n        commit(min)\n        break\n      case \"End\":\n        if (max === undefined) return\n        commit(max)\n        break\n      case \"Enter\":\n        commitDraft()\n        return\n      default:\n        return\n    }\n    event.preventDefault()\n  }\n\n  return (\n    <div\n      data-slot=\"unit-input\"\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        \"flex h-control w-full min-w-0 items-stretch overflow-hidden rounded-control border border-input bg-transparent shadow-control transition-[color,box-shadow] dark:bg-input/30\",\n        \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n        \"has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20\",\n        \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        className\n      )}\n    >\n      <input\n        type=\"text\"\n        inputMode=\"decimal\"\n        autoComplete=\"off\"\n        role=\"spinbutton\"\n        aria-valuenow={amount ?? undefined}\n        aria-valuetext={\n          amount === null\n            ? undefined\n            : `${formatAmount(amount)} ${activeUnit.label}`\n        }\n        aria-valuemin={\n          min === undefined ? undefined : fromBase(min, activeUnit)\n        }\n        aria-valuemax={\n          max === undefined ? undefined : fromBase(max, activeUnit)\n        }\n        disabled={disabled}\n        value={displayedText}\n        onChange={(event) => setDraft(event.target.value)}\n        onBlur={(event) => {\n          onBlur?.(event)\n          commitDraft()\n        }}\n        onKeyDown={handleKeyDown}\n        className=\"w-full min-w-0 flex-1 bg-transparent px-3 text-base tabular-nums outline-none placeholder:text-muted-foreground md:text-sm\"\n        {...props}\n      />\n      <select\n        aria-label=\"Unit\"\n        data-slot=\"unit-input-select\"\n        disabled={disabled}\n        value={activeUnit.id}\n        onChange={(event) => setUnitId(event.target.value)}\n        className=\"shrink-0 cursor-pointer border-l border-input bg-transparent px-2 text-sm text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground [&>option]:bg-popover [&>option]:text-popover-foreground\"\n      >\n        {units.map((option) => (\n          <option key={option.id} value={option.id}>\n            {option.label}\n          </option>\n        ))}\n      </select>\n      {name && (\n        <input\n          type=\"hidden\"\n          name={name}\n          disabled={disabled}\n          value={currentValue ?? \"\"}\n        />\n      )}\n    </div>\n  )\n}\n\nexport { UnitInput, type UnitInputProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/unit-input-utils.ts",
      "content": "export type Unit = {\n  id: string\n  label: string\n  factor: number\n}\n\nexport const BYTE_UNITS: Unit[] = [\n  { id: \"B\", label: \"B\", factor: 1 },\n  { id: \"KB\", label: \"KB\", factor: 1e3 },\n  { id: \"MB\", label: \"MB\", factor: 1e6 },\n  { id: \"GB\", label: \"GB\", factor: 1e9 },\n  { id: \"TB\", label: \"TB\", factor: 1e12 },\n]\n\nexport const BINARY_BYTE_UNITS: Unit[] = [\n  { id: \"B\", label: \"B\", factor: 1 },\n  { id: \"KiB\", label: \"KiB\", factor: 1024 },\n  { id: \"MiB\", label: \"MiB\", factor: 1024 ** 2 },\n  { id: \"GiB\", label: \"GiB\", factor: 1024 ** 3 },\n  { id: \"TiB\", label: \"TiB\", factor: 1024 ** 4 },\n]\n\n// Trims float noise such as 0.1 * 3 = 0.30000000000000004, leaving exact integers alone.\nfunction clean(value: number) {\n  return Number.isSafeInteger(value) ? value : Number(value.toPrecision(15))\n}\n\nexport function toBase(amount: number, unit: Unit) {\n  return clean(amount * unit.factor)\n}\n\nexport function fromBase(value: number, unit: Unit) {\n  return clean(value / unit.factor)\n}\n\nexport function clampValue(value: number, min?: number, max?: number) {\n  return Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value))\n}\n\nexport function formatAmount(amount: number) {\n  return new Intl.NumberFormat(\"en-US\", {\n    maximumFractionDigits: 6,\n    maximumSignificantDigits: 6,\n    roundingPriority: \"morePrecision\",\n    useGrouping: false,\n  }).format(amount)\n}\n\n// Exact id or label first, so \"Mb\" and \"MB\" can coexist, then case-insensitive.\nexport function findUnit(units: Unit[], text: string) {\n  const exact = units.find((unit) => unit.id === text || unit.label === text)\n  if (exact) return exact\n  const lower = text.toLowerCase()\n  return units.find(\n    (unit) =>\n      unit.id.toLowerCase() === lower || unit.label.toLowerCase() === lower\n  )\n}\n\nexport function parseUnitText(\n  text: string,\n  units: Unit[]\n): { amount: number; unit?: Unit } | null {\n  const match =\n    /^\\s*(-?(?:\\d{1,3}(?:,\\d{3})+(?:\\.\\d*)?|\\d+\\.?\\d*|\\.\\d+))\\s*([^\\d\\s.,-][^\\s]*)?\\s*$/.exec(\n      text\n    )\n  if (!match) return null\n\n  const amount = Number(match[1].replace(/,/g, \"\"))\n  if (!Number.isFinite(amount) || Math.abs(amount) > Number.MAX_SAFE_INTEGER)\n    return null\n  if (!match[2]) return { amount }\n\n  const unit = findUnit(units, match[2])\n  return unit ? { amount, unit } : null\n}\n\n// Largest unit that keeps the amount at 1 or more, for when no unit is given.\nexport function bestUnit(value: number | null | undefined, units: Unit[]) {\n  const sorted = [...units].sort((a, b) => a.factor - b.factor)\n  if (value === null || value === undefined || value === 0) return sorted[0]\n  const magnitude = Math.abs(value)\n  return sorted.filter((unit) => magnitude >= unit.factor).at(-1) ?? sorted[0]\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}