{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"unit-field","type":"registry:ui","title":"Unit field","description":"A number field with its unit beside it. Switching the unit converts the amount and the figure counts across to the new value rather than jumping, so it is clear the quantity is the same.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/segmented-control.tsx","type":"registry:ui","target":"components/ui/segmented-control.tsx","content":"\"use client\";\n\nimport {\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./shared/segmented-plate.css\";\n\n/** With an icon, the option shows only the icon and `label` becomes its accessible name and tooltip. */\nexport type SegmentedOption = { value: string; label: string; icon?: ReactNode };\n\nexport function SegmentedControl({\n  label,\n  value,\n  onValueChange,\n  options,\n  className,\n}: {\n  label: string;\n  value: string;\n  onValueChange: (value: string) => void;\n  options: readonly SegmentedOption[];\n  className?: string;\n}) {\n  const group = useRef<HTMLDivElement>(null);\n  const [initialValue] = useState(value);\n  const [indicator, setIndicator] = useState<{\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  } | null>(null);\n\n  useLayoutEffect(() => {\n    const element = group.current;\n    if (!element) return;\n    const measure = () => {\n      const active = element.querySelector<HTMLElement>('[aria-checked=\"true\"]');\n      setIndicator(\n        active\n          ? {\n              left: active.offsetLeft,\n              top: active.offsetTop,\n              width: active.offsetWidth,\n              height: active.offsetHeight,\n            }\n          : null,\n      );\n    };\n    measure();\n    const resize = new ResizeObserver(measure);\n    resize.observe(element);\n    element\n      .querySelectorAll('[role=\"radio\"]')\n      .forEach((item) => resize.observe(item));\n    return () => resize.disconnect();\n  }, [value, options]);\n\n  function move(event: KeyboardEvent<HTMLDivElement>) {\n    const index = options.findIndex((item) => item.value === value);\n    const next =\n      event.key === \"ArrowRight\" || event.key === \"ArrowDown\"\n        ? (index + 1) % options.length\n        : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\"\n          ? (index - 1 + options.length) % options.length\n          : -1;\n    if (next < 0) return;\n    event.preventDefault();\n    onValueChange(options[next].value);\n    group.current\n      ?.querySelectorAll<HTMLButtonElement>('[role=\"radio\"]')\n      [next]?.focus();\n  }\n\n  return (\n    <div\n      ref={group}\n      role=\"radiogroup\"\n      aria-label={label}\n      data-slot=\"segmented\"\n      onKeyDown={move}\n      className={cn(\n        // Too many options for a narrow screen scroll sideways instead of spilling out.\n        \"relative inline-flex max-w-full overflow-x-auto rounded-touch bg-sunken p-[3px] shadow-sunken [scrollbar-width:none]\",\n        className,\n      )}\n    >\n      {indicator && (\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"segmented-indicator\"\n          style={{\n            width: indicator.width,\n            height: indicator.height,\n            transform: `translate(${indicator.left}px, ${indicator.top}px)`,\n          }}\n        >\n          <span\n            key={value}\n            data-slot=\"segmented-plate\"\n            data-moved={value !== initialValue}\n          />\n        </span>\n      )}\n      {options.map((option) => {\n        const selected = option.value === value;\n        return (\n          <button\n            key={option.value}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={selected}\n            tabIndex={selected ? 0 : -1}\n            aria-label={option.icon ? option.label : undefined}\n            title={option.icon ? option.label : undefined}\n            onClick={() => onValueChange(option.value)}\n            className=\"relative z-10 inline-flex shrink-0 h-[calc(var(--control-height)-6px)] min-w-10 items-center justify-center rounded-[calc(var(--radius-touch)-3px)] px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-ring aria-checked:text-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0\"\n          >\n            {option.icon ?? option.label}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n"},{"path":"ui/unit-field.tsx","type":"registry:ui","target":"components/ui/unit-field.tsx","content":"\"use client\";\nimport { useEffect, useId, useRef, useState, type ComponentProps } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport { SegmentedControl } from \"./segmented-control\";\nimport \"./unit-field.css\";\n\n/** A unit as a straight line from a base unit: base = value × factor + offset. */\nexport type FieldUnit = { value: string; label: string; factor: number; offset?: number; decimals?: number };\n\nexport const toBase = (value: number, unit: FieldUnit) => value * unit.factor + (unit.offset ?? 0);\nexport const fromBase = (base: number, unit: FieldUnit) => (base - (unit.offset ?? 0)) / unit.factor;\n\nexport type UnitFieldProps = Omit<ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  label: string;\n  units: FieldUnit[];\n  /** The amount in the base unit (the unit whose factor is 1). */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (base: number) => void;\n  defaultUnit?: string;\n};\n\n/**\n * A number field with its unit beside it. Switching the unit converts the amount, and the figure counts across to\n * the new value rather than jumping, so it is clear the quantity is the same. The value is kept in a base unit, so\n * switching back and forth never drifts. Under reduced motion the figure changes at once.\n */\nexport function UnitField({ label, units, value, defaultValue = 0, onValueChange, defaultUnit, className, ...props }: UnitFieldProps) {\n  const id = useId();\n  const [own, setOwn] = useState(defaultValue);\n  const base = value ?? own;\n  const [unitValue, setUnitValue] = useState(defaultUnit ?? units[0].value);\n  const unit = units.find((u) => u.value === unitValue) ?? units[0];\n  const round = (n: number, u = unit) => Number(n.toFixed(u.decimals ?? 1));\n  // What is typed, or the figure mid-count; otherwise the field shows the value, so outside changes appear.\n  const [draft, setDraft] = useState<string | null>(null);\n  const text = draft ?? String(round(fromBase(base, unit)));\n  const tween = useRef(0);\n  useEffect(() => () => cancelAnimationFrame(tween.current), []);\n\n  const switchTo = (next: string) => {\n    const to = units.find((u) => u.value === next)!;\n    const from = Number(text) || 0;\n    const target = round(fromBase(base, to), to);\n    setUnitValue(next);\n    cancelAnimationFrame(tween.current);\n    if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return setDraft(null);\n    let start = -1;\n    const step = (now: number) => {\n      if (start < 0) start = now;\n      const t = Math.min(1, (now - start) / 420);\n      const eased = 1 - Math.pow(1 - t, 3);\n      setDraft(String(Number((from + (target - from) * eased).toFixed(to.decimals ?? 1))));\n      if (t < 1) tween.current = requestAnimationFrame(step);\n      else {\n        tween.current = 0;\n        setDraft(null);\n      }\n    };\n    tween.current = requestAnimationFrame(step);\n  };\n\n  const type = (next: string) => {\n    setDraft(next);\n    const number = Number(next);\n    if (next.trim() === \"\" || !Number.isFinite(number)) return;\n    const nextBase = toBase(number, unit);\n    if (value === undefined) setOwn(nextBase);\n    onValueChange?.(nextBase);\n  };\n\n  return (\n    <div data-slot=\"unit-field\" className={cn(\"grid gap-2\", className)} {...props}>\n      <label htmlFor={id} className=\"text-sm font-medium\">\n        {label}\n      </label>\n      <div className=\"flex items-center gap-2\">\n        <div data-slot=\"uf-box\">\n          <input\n            id={id}\n            inputMode=\"decimal\"\n            data-slot=\"uf-input\"\n            value={text}\n            aria-describedby={`${id}-unit`}\n            onBlur={() => setDraft(null)}\n            onChange={(event) => type(event.target.value)}\n          />\n          <span id={`${id}-unit`} data-slot=\"uf-suffix\">\n            {unit.label}\n          </span>\n        </div>\n        <SegmentedControl label={`${label} unit`} value={unit.value} onValueChange={switchTo} options={units.map((u) => ({ value: u.value, label: u.label }))} />\n      </div>\n    </div>\n  );\n}\n"},{"path":"ui/shared/segmented-plate.css","type":"registry:file","target":"components/ui/shared/segmented-plate.css","content":"/* Shared by billing-toggle.tsx, segmented-control.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Segmented control: one raised plate travels to the choice on the settle\n     curve and stretches 7% toward it mid-flight, so selection reads as one\n     object moving. Labels and the value change at once. */\n  [data-slot=\"segmented-indicator\"] {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 0;\n    pointer-events: none;\n    transition:\n      transform 320ms var(--ease-settle),\n      width 320ms var(--ease-settle);\n  }\n  [data-slot=\"segmented-plate\"] {\n    display: block;\n    width: 100%;\n    height: 100%;\n    /* Concentric with the track: its radius less the 3px inset. */\n    border-radius: calc(var(--radius-touch) - 3px);\n    background: var(--accent);\n    box-shadow: var(--shadow-plate);\n  }\n  [data-slot=\"segmented-plate\"][data-moved=\"true\"] {\n    animation: caliper-plate-stretch 320ms var(--ease-out);\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"segmented-indicator\"],\n    [data-slot=\"segmented-plate\"] {\n      transition: none;\n    }\n    [data-slot=\"segmented-plate\"][data-moved=\"true\"] {\n      animation: none;\n    }\n  }\n}\n"},{"path":"ui/unit-field.css","type":"registry:file","target":"components/ui/unit-field.css","content":"/* unit-field.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Unit field: the figure and its unit read as one field; the unit switch sits beside it. */\n  [data-slot=\"unit-field\"] [data-slot=\"uf-box\"] { display: flex; flex: 1; min-width: 0; align-items: center; height: var(--control-height); padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--background); transition: border-color var(--duration-fast) var(--ease-out); }\n  [data-slot=\"unit-field\"] [data-slot=\"uf-box\"]:focus-within { border-color: var(--ring); box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 30%, transparent); }\n  [data-slot=\"unit-field\"] [data-slot=\"uf-input\"] { flex: 1; min-width: 0; border: 0; background: none; color: var(--foreground); font: inherit; font-size: 15px; font-variant-numeric: tabular-nums; outline: none; }\n  [data-slot=\"unit-field\"] [data-slot=\"uf-suffix\"] { flex: none; color: var(--muted-foreground); font-size: 13px; }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"changing-values","docs":"/docs/components/unit-field"}}