{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-field",
  "type": "registry:ui",
  "title": "Number field",
  "description": "Enter a bounded number with clear increment controls.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/number-field/number-field.tsx",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n  type Variants,\n} from \"motion/react\";\nimport { Minus, Plus } from \"lucide-react\";\nimport {\n  Fragment,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type PointerEvent,\n  type Ref,\n} from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n\n/** Text shown beside the number. A function receives the value, so a unit can follow it: `n => n === 1 ? \" seat\" : \" seats\"`. */\nexport type NumberFieldAffix = string | ((value: number) => string);\n\nexport interface NumberFieldProps {\n  label: string;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** PageUp, PageDown, and Shift with an arrow move this far. Defaults to ten steps. */\n  largeStep?: number;\n  description?: string;\n  disabled?: boolean;\n  id?: string;\n  /** Text before the number, such as \"$\". */\n  prefix?: NumberFieldAffix;\n  /** Text after the number, such as \" seats\". */\n  suffix?: NumberFieldAffix;\n  /** Drag the label sideways to scrub the value, one step every few pixels. */\n  scrub?: boolean;\n  /** Formatting locale. Fixed by default so server and client render the same digits. */\n  locale?: string;\n  /** Fraction digits and grouping. Fraction digits follow the precision of `step` by default. */\n  formatOptions?: { minimumFractionDigits?: number; maximumFractionDigits?: number; useGrouping?: boolean };\n  /** Control height, type size, and default width. */\n  size?: NumberFieldSize;\n  /** A short note beside the label when a press meets a limit or a typed value passes one. `false` hides it; a function writes the copy. */\n  limitHint?: boolean | ((edge: \"min\" | \"max\", limit: number) => string);\n}\n\nexport type NumberFieldSize = \"sm\" | \"md\" | \"lg\";\n\n/** Per size: control height, stepper button size, shell inset, type size, and default width. Set --number-field-width to resize the shell. */\nconst SIZES: Record<NumberFieldSize, string> = {\n  sm: \"[--nf-height:var(--control-height-sm)] [--nf-step:28px] [--nf-inset:3px] [--nf-text:var(--text-sm)] [--nf-width:var(--number-field-width,164px)]\",\n  md: \"[--nf-height:var(--control-height-md)] [--nf-step:34px] [--nf-inset:3px] [--nf-text:var(--text-base)] [--nf-width:var(--number-field-width,196px)]\",\n  lg: \"[--nf-height:var(--control-height-lg)] [--nf-step:40px] [--nf-inset:4px] [--nf-text:var(--text-lg)] [--nf-width:var(--number-field-width,228px)]\",\n};\n\ntype Source = \"button\" | \"key\" | \"scrub\" | \"type\";\ntype Part = { key: string; digit: number } | { key: string; text: string };\n\nconst { spring, duration, blur } = motionTokens;\nconst enterEase = [...motionTokens.ease.enter] as [number, number, number, number];\nconst exitEase = [...motionTokens.ease.standard] as [number, number, number, number];\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n/** Pixels of label drag per step, the pause before a held control repeats, and the fastest repeat. */\nconst SCRUB_PX = 6,\n  HOLD_DELAY = 400,\n  HOLD_FASTEST = 40;\n/** A held press keeps leaning on a limit at this cadence, how long the limit note stays, and how long an under-minimum draft waits\n * before it warns (a short draft is often on its way to a larger number). */\nconst LIMIT_PUSH = 240,\n  LIMIT_HINT_MS = 1500,\n  UNDER_WARN_MS = 700;\n/** Pushes that arrive within this window count as one continued effort, so each strains a little further, up to the cap. */\nconst PUSH_WINDOW = 700,\n  PUSH_GAIN = 0.22,\n  PUSH_CAP = 4;\n/** A velocity kick on the value at a limit: it strains a few pixels toward the press and springs home. Motion drops velocity on\n * time-defined springs, so the kick runs the morph spring expressed as stiffness and damping. */\nconst BUMP_VELOCITY = 130;\nconst kick = (() => {\n  const root = (2 * Math.PI) / (spring.morph.visualDuration * 1.2);\n  return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - spring.morph.bounce) * root };\n})();\n\nconst decimalsOf = (value: number) => (String(value).split(\".\")[1] ?? \"\").length;\nconst escape = (text: string) => text.replace(/[.*+?^${}()|[\\]\\\\-]/g, \"\\\\$&\");\nconst affixText = (affix: NumberFieldAffix | undefined, value: number) =>\n  typeof affix === \"function\" ? affix(value) : (affix ?? \"\");\n/** Rubber-band resistance: travel past a limit gives less and less, and never more than `limit` pixels. */\nconst rubber = (distance: number, limit = 10) =>\n  Math.sign(distance) * (1 - 1 / ((Math.abs(distance) * 0.55) / limit + 1)) * limit;\n\n/** Split a formatted number into columns keyed by place value, so 9 → 10 keeps the ones column the ones column. */\nfunction partsOf(value: number, format: Intl.NumberFormat): Part[] {\n  const parts = format.formatToParts(value);\n  let place = parts.reduce((count, part) => count + (part.type === \"integer\" ? part.value.length : 0), 0);\n  let fraction = 0;\n  return parts.flatMap((part, index): Part[] => {\n    if (part.type === \"integer\")\n      return Array.from(part.value).map((char) => ({ key: `i${--place}`, digit: Number(char) }));\n    if (part.type === \"fraction\")\n      return Array.from(part.value).map((char) => ({ key: `f${fraction++}`, digit: Number(char) }));\n    return [\n      {\n        key:\n          part.type === \"group\"\n            ? `g${place}`\n            : part.type === \"decimal\"\n              ? \"d\"\n              : part.type === \"minusSign\"\n                ? \"m\"\n                : `${part.type}${index}`,\n        text: part.value,\n      },\n    ];\n  });\n}\n\n/* A column or character opens its width while it rises in the direction of change, and closes on a critically damped spring so it never passes zero.\n   Scale and opacity ride the same spring as width, so a digit grows with its slot instead of landing on its neighbours. */\nconst slot: Variants = {\n  enter: (direction: number) => ({\n    width: 0,\n    scale: 0.6,\n    opacity: 0,\n    y: `${direction * 0.3}em`,\n    filter: `blur(${blur.soft}px)`,\n  }),\n  center: {\n    width: \"auto\",\n    scale: 1,\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transitionEnd: { filter: \"none\" },\n    transition: {\n      width: spring.morph,\n      scale: spring.morph,\n      opacity: spring.morph,\n      y: spring.snappy,\n      filter: { duration: 0.3, ease: exitEase },\n    },\n  },\n  exit: (direction: number) => ({\n    width: 0,\n    scale: 0.6,\n    opacity: 0,\n    y: `${direction * -0.3}em`,\n    filter: `blur(${blur.subtle}px)`,\n    transition: {\n      width: spring.smooth,\n      scale: spring.smooth,\n      y: { duration: duration.fast, ease: exitEase },\n      opacity: { duration: duration.instant },\n      filter: { duration: duration.instant },\n    },\n  }),\n};\n/* Reduced motion keeps a short fade and no travel. While typing, columns follow the draft at once so they never lag the caret.\n   Every resting state matches `slot`, so a page hydrated with reduced motion renders the same styles as the server. */\nconst rest = (opacity: number) => ({ width: \"auto\", scale: 1, opacity, y: 0, filter: \"none\" });\nconst still: Variants = {\n  enter: rest(0),\n  center: { ...rest(1), transition: { duration: duration.instant } },\n  exit: { width: 0, opacity: 0, transition: { duration: 0 } },\n};\nconst cut: Variants = {\n  enter: rest(1),\n  center: { ...rest(1), transition: { duration: 0 } },\n  exit: { width: 0, opacity: 0, transition: { duration: 0 } },\n};\n\n/** One digit on the wheel. Its distance from the wheel position sets where it sits, how clear it is, and whether it shows. */\nfunction Glyph({ position, digit }: { position: MotionValue<number>; digit: number }) {\n  // Every style reads the wheel directly: a chained transform can update a frame late and leave the wheel blank on a jump.\n  const offset = (current: number) => ((((digit - current) % 10) + 15) % 10) - 5;\n  const y = useTransform(position, (current) => `${offset(current) * 1.05}em`);\n  // An eased falloff keeps a turning digit legible through the middle of the roll instead of washing out.\n  const opacity = useTransform(position, (current) => Math.max(0, 1 - Math.abs(offset(current)) ** 1.5 * 1.1));\n  const visibility = useTransform(position, (current) => (Math.abs(offset(current)) >= 1 ? \"hidden\" : \"visible\"));\n  const filter = useTransform(position, (current) => {\n    const distance = Math.abs(offset(current));\n    return distance < 0.02 || distance >= 1 ? \"none\" : `blur(${(distance * blur.soft * 0.75).toFixed(2)}px)`;\n  });\n  return (\n    <motion.span\n      className={\"absolute inset-x-0 inset-y-(--feather) text-center\"}\n      style={{ y, opacity, filter, visibility }}\n    >\n      {digit}\n    </motion.span>\n  );\n}\n\n/** An odometer wheel. It turns the way the whole number moved, wraps 9 → 0, and retargets mid spin when steps arrive quickly. */\nfunction Wheel({ digit, direction, instant }: { digit: number; direction: number; instant: boolean }) {\n  const reduced = useReducedMotion();\n  const position = useMotionValue(digit);\n  const wheel = useRef({ digit, target: digit });\n  useLayoutEffect(() => {\n    const state = wheel.current;\n    if (state.digit === digit) return;\n    // Turn the way the number moved, unless that is more than half a turn; then take the short way, so a big clamp never spins.\n    let delta = direction > 0 ? (digit - state.digit + 10) % 10 : -((state.digit - digit + 10) % 10);\n    if (Math.abs(delta) > 5) delta -= Math.sign(delta) * 10;\n    state.target += delta;\n    state.digit = digit;\n    if (instant || reduced) position.jump(state.target);\n    else animate(position, state.target, spring.snappy);\n  }, [digit, direction, instant, position, reduced]);\n  return (\n    <>\n      <span className={\"invisible\"}>0</span>\n      {DIGITS.map((item) => (\n        <Glyph key={item} position={position} digit={item} />\n      ))}\n    </>\n  );\n}\n\nfunction Digits({\n  value,\n  format,\n  direction,\n  instant,\n}: {\n  value: number;\n  format: Intl.NumberFormat;\n  direction: number;\n  instant: boolean;\n}) {\n  const reduced = useReducedMotion();\n  const variants = instant ? cut : reduced ? still : slot;\n  return (\n    <AnimatePresence initial={false} custom={direction}>\n      {partsOf(value, format).map((part) => (\n        <motion.span\n          key={part.key}\n          className={\n            \"digit\" in part\n              ? \"relative inline-block overflow-x-visible overflow-y-clip [--feather:.2em] my-[calc(var(--feather)*-1)] py-(--feather) [mask-image:linear-gradient(to_bottom,transparent,#000_calc(var(--feather)*1.5),#000_calc(100%_-_var(--feather)*1.5),transparent)] [mask-repeat:repeat-x] [mask-clip:no-clip]\"\n              : \"inline-block overflow-x-clip whitespace-pre\"\n          }\n          custom={direction}\n          variants={variants}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n        >\n          {\"digit\" in part ? <Wheel digit={part.digit} direction={direction} instant={instant} /> : part.text}\n        </motion.span>\n      ))}\n    </AnimatePresence>\n  );\n}\n\n/** Prefix and suffix characters are keyed by position, so \"seat\" → \"seats\" only opens the new \"s\" and the rest holds still. */\nfunction AffixText({ text, direction, className }: { text: string; direction: number; className?: string }) {\n  const reduced = useReducedMotion();\n  return (\n    <span className={className}>\n      <AnimatePresence initial={false} custom={direction}>\n        {Array.from(text).map((char, index) => (\n          <motion.span\n            key={`${index}:${char}`}\n            className=\"inline-block overflow-x-clip whitespace-pre\"\n            custom={direction}\n            variants={reduced ? still : slot}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n          >\n            {char}\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst numberIn = (word?: string) => {\n  const digits = word?.replace(/[^\\d.-]/g, \"\");\n  return digits && /\\d/.test(digits) && Number.isFinite(Number(digits)) ? Number(digits) : null;\n};\n/** A number word rolls down when it shrank and up when it grew; any other changed word rises. */\nconst directionsBetween = (from: string, to: string) => {\n  const before = from.split(\" \");\n  return to.split(\" \").map((word, index) => {\n    const a = numberIn(before[index]),\n      b = numberIn(word);\n    return a !== null && b !== null && b < a ? -1 : 1;\n  });\n};\n\nconst wordRise: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.3}em`, filter: `blur(${blur.soft}px)` }),\n  // Opacity follows the slot's width spring, so a longer word never shows before its room has opened.\n  center: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transitionEnd: { filter: \"none\" },\n    transition: { duration: 0.22, ease: enterEase, opacity: spring.morph },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * -0.3}em`,\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: 0.14, ease: exitEase },\n  }),\n};\nconst wordFade: Variants = {\n  enter: { opacity: 0, y: 0, filter: \"none\" },\n  center: { opacity: 1, y: 0, filter: \"none\", transition: { duration: duration.instant } },\n  exit: { opacity: 0, transition: { duration: 0 } },\n};\n\n/** A word's width follows its new copy on a spring, so a price gaining a digit eases the rest of the line along instead of shoving it. */\nfunction WordSlot({ word, direction }: { word: string; direction: number }) {\n  const reduced = useReducedMotion();\n  const sizerRef = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const measured = useRef(false);\n  useLayoutEffect(() => {\n    const node = sizerRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      if (!measured.current || reduced) width.jump(node.offsetWidth);\n      else animate(width, node.offsetWidth, spring.morph);\n      measured.current = true;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  return (\n    <motion.span className={\"relative inline-block align-top whitespace-pre\"} style={{ width }}>\n      <span ref={sizerRef} className={\"absolute top-0 left-0 invisible whitespace-pre\"}>\n        {word}\n      </span>\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n        <motion.span\n          key={word}\n          className={\"inline-block whitespace-pre\"}\n          custom={direction}\n          variants={reduced ? wordFade : wordRise}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n        >\n          {word}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\n/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */\nfunction MotionText({ text }: { text: string }) {\n  const [trail, setTrail] = useState({ text, directions: [] as number[] });\n  if (trail.text !== text) setTrail({ text, directions: directionsBetween(trail.text, text) });\n  const directions = trail.text === text ? trail.directions : directionsBetween(trail.text, text);\n  const words = text.split(\" \");\n  // Spaces live between the slots, so a word that is still opening its width never runs into the next one.\n  return (\n    <>\n      <span className={\"sr-only\"}>{text}</span>\n      <span className={\"relative block\"} aria-hidden=\"true\">\n        {words.map((word, index) => (\n          <Fragment key={index}>\n            {index > 0 && \" \"}\n            <WordSlot word={word} direction={directions[index] ?? 1} />\n          </Fragment>\n        ))}\n      </span>\n    </>\n  );\n}\n\n/** Helper copy: the row opens its height on a spring, then the words settle in. */\nfunction FieldMessage({ id, text, className }: { id?: string; text?: string; className?: string }) {\n  return (\n    <AnimatePresence initial={false}>\n      {text ? <MessageRow key=\"message\" id={id} text={text} className={className} /> : null}\n    </AnimatePresence>\n  );\n}\n\n/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */\nfunction MessageRow({ id, text, className }: { id?: string; text: string; className?: string }) {\n  const reduced = useReducedMotion();\n  const copyRef = useRef<HTMLSpanElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const node = copyRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) =>\n      setHeight(entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight),\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return (\n    <motion.span\n      className={\"block overflow-hidden\"}\n      initial={{ height: 0, opacity: 0 }}\n      animate={{ height, opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced ? { duration: 0 } : { height: spring.smooth, opacity: { duration: duration.instant } },\n      }}\n      transition={reduced ? { duration: 0 } : { height: spring.smooth, opacity: { duration: duration.fast } }}\n    >\n      <motion.span\n        ref={copyRef}\n        id={id}\n        className={className}\n        initial={reduced ? false : { y: \"0.35em\", filter: `blur(${blur.soft}px)` }}\n        animate={{ y: 0, filter: \"blur(0px)\" }}\n        transition={{ duration: reduced ? 0 : duration.standard, ease: enterEase }}\n      >\n        <MotionText text={text} />\n      </motion.span>\n    </motion.span>\n  );\n}\n\n/** The limit note rises in from the side it guards (up for a maximum, down for a minimum) and fades out in place. */\nfunction LimitNote({ id, edge, text }: { id: string; edge: 1 | -1 | 0; text: string }) {\n  const reduced = useReducedMotion();\n  return (\n    <AnimatePresence initial={false}>\n      {edge !== 0 && (\n        <motion.span\n          key=\"limit\"\n          id={id}\n          className={\n            \"flex-none text-(color:--nf-limit) text-xs font-medium leading-body tracking-body tabular-nums whitespace-nowrap\"\n          }\n          initial={reduced ? { opacity: 0 } : { opacity: 0, y: `${edge * 0.45}em`, filter: `blur(${blur.subtle}px)` }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" } }}\n          exit={{\n            opacity: 0,\n            transition: { duration: reduced ? duration.instant : duration.standard, ease: exitEase },\n          }}\n          transition={\n            reduced\n              ? { duration: duration.instant }\n              : { y: spring.snappy, opacity: { duration: duration.fast }, filter: { duration: duration.fast } }\n          }\n        >\n          {text}\n        </motion.span>\n      )}\n    </AnimatePresence>\n  );\n}\n\n/** Pointer presses hold to repeat; keyboard and assistive clicks (detail 0) take one step. */\nfunction StepButton({\n  toward,\n  label,\n  disabled,\n  controls,\n  limit,\n  pressed,\n  iconRef,\n  onPress,\n  onRelease,\n  onActivate,\n}: {\n  toward: 1 | -1;\n  label: string;\n  disabled?: boolean;\n  controls: string;\n  limit: boolean;\n  pressed: boolean;\n  iconRef: Ref<HTMLSpanElement>;\n  onPress: () => void;\n  onRelease: () => void;\n  onActivate: () => void;\n}) {\n  const Icon = toward > 0 ? Plus : Minus;\n  // A limit dims the button but keeps it focusable, so a press there strains the value instead of dropping focus.\n  // Mouse presses keep focus where it was: an open draft commits with the step, and the stepper never steals the ring.\n  const enabled = !disabled && !limit;\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        // A pressable step is a circle inside the field's shell; the shell keeps the control radius.\n        \"grid size-(--nf-step) flex-[0_0_var(--nf-step)] cursor-pointer touch-manipulation place-items-center rounded-button border-0 bg-transparent p-0 text-ink-2 select-none [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),opacity_var(--duration-standard)_var(--ease-standard)] motion-reduce:[transition:opacity_var(--duration-instant)_linear,border-color_var(--duration-instant)_linear,background-color_var(--duration-instant)_linear]\",\n        enabled && !pressed && \"hover:bg-hover hover:text-ink\",\n        !pressed && \"focus-visible:bg-hover focus-visible:text-ink focus-visible:outline-none\",\n        // Quick press, spring release: the fill deepens and the glyph sinks. A held button stays pressed for as long as it repeats.\n        enabled &&\n          \"active:bg-[color-mix(in_oklab,var(--ink)_10%,var(--surface))] active:text-ink active:[transition:background-color_60ms_linear,color_60ms_linear] motion-safe:active:[&_svg]:[transform:scale(.82)] motion-safe:active:[&_svg]:[transition:transform_90ms_var(--ease-standard)]\",\n        enabled &&\n          pressed &&\n          \"bg-[color-mix(in_oklab,var(--ink)_10%,var(--surface))] text-ink [transition:background-color_60ms_linear,color_60ms_linear] motion-safe:[&_svg]:[transform:scale(.82)] motion-safe:[&_svg]:[transition:transform_90ms_var(--ease-standard)]\",\n        // At a limit the button dims and refuses, but stays in the tab order; pressing it strains the value toward the limit and it shakes once.\n        limit && \"cursor-not-allowed opacity-[.32]\",\n        limit &&\n          (toward > 0\n            ? \"group-data-[strain=max]/control:text-(color:--nf-limit) group-data-[strain=max]/control:opacity-60 group-data-[strain=max]/control:[transition:opacity_60ms_linear,color_60ms_linear]\"\n            : \"group-data-[strain=min]/control:text-(color:--nf-limit) group-data-[strain=min]/control:opacity-60 group-data-[strain=min]/control:[transition:opacity_60ms_linear,color_60ms_linear]\"),\n        \"disabled:cursor-not-allowed disabled:opacity-35\",\n      )}\n      disabled={disabled}\n      aria-controls={controls}\n      aria-label={`${toward > 0 ? \"Increase\" : \"Decrease\"} ${label}`}\n      aria-disabled={limit || undefined}\n      data-pressed={pressed || undefined}\n      onPointerDown={(event) => {\n        if (event.button === 0) onPress();\n      }}\n      onPointerUp={onRelease}\n      onPointerLeave={onRelease}\n      onPointerCancel={onRelease}\n      onMouseDown={(event) => event.preventDefault()}\n      onClick={(event) => {\n        if (event.detail === 0) onActivate();\n      }}\n      onContextMenu={(event) => event.preventDefault()}\n    >\n      <span\n        ref={iconRef}\n        className=\"grid place-items-center [&_svg]:[transition:transform_var(--duration-spring)_var(--ease-spring)] motion-reduce:[&_svg]:[transition:none]\"\n      >\n        <Icon size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n      </span>\n    </button>\n  );\n}\n\n/**\n * A bounded number with odometer digits. Buttons and arrow keys repeat and speed up while held, PageUp and PageDown take\n * large steps, Home and End jump to the limits, and `scrub` lets the label be dragged. Typing edits a plain draft that\n * applies live while valid; the rolling digits return once it commits on Enter, blur, or the next step.\n */\nexport function NumberField({\n  label,\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = Number.MAX_SAFE_INTEGER,\n  step: stepProp = 1,\n  largeStep,\n  description,\n  disabled,\n  id,\n  prefix,\n  suffix,\n  scrub = false,\n  locale = \"en-US\",\n  formatOptions,\n  size = \"md\",\n  limitHint = true,\n}: NumberFieldProps) {\n  const reduced = useReducedMotion();\n  const generatedId = useId();\n  const inputId = id ?? generatedId;\n  const hintId = description ? `${inputId}-description` : undefined;\n  const limitId = `${inputId}-limit`;\n  const step = stepProp > 0 ? stepProp : 1;\n  const [internal, setInternal] = useState(defaultValue);\n  const current = value ?? internal;\n  const [draft, setDraft] = useState(\"\");\n  const [editing, setEditing] = useState(false);\n  const [pressed, setPressed] = useState(0);\n  const [scrubbing, setScrubbing] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  /** The limit a press just met, shown for a moment; and whether a draft under the minimum has sat long enough to warn. */\n  const [pushed, setPushed] = useState<1 | -1 | 0>(0);\n  const [underWarn, setUnderWarn] = useState(false);\n  const controlRef = useRef<HTMLDivElement>(null);\n  const minusIcon = useRef<HTMLSpanElement>(null);\n  const plusIcon = useRef<HTMLSpanElement>(null);\n  const pushedTimer = useRef<number | undefined>(undefined);\n  const underTimer = useRef<number | undefined>(undefined);\n  const strainTimer = useRef<number | undefined>(undefined);\n  const effort = useRef({ edge: 0, count: 0, at: 0 });\n  const inputRef = useRef<HTMLInputElement>(null);\n  const groupRef = useRef<HTMLSpanElement>(null);\n  const latest = useRef(current);\n  const editStart = useRef(current);\n  const hold = useRef<number | undefined>(undefined);\n  const drag = useRef<{ pointer: number; x: number; from: number; active: boolean } | null>(null);\n  const suppressClick = useRef(false);\n  const shiftFrom = useRef<number | null>(null);\n  const selectNext = useRef(false);\n  const live = useRef<(direction: 1 | -1, steps: number, source: Source) => boolean>(() => false);\n  const bumpY = useMotionValue(0);\n  const scrubX = useMotionValue(0);\n  const shiftX = useMotionValue(0);\n  const x = useTransform(() => scrubX.get() + shiftX.get());\n\n  const base = Number.isFinite(min) ? min : 0;\n  const decimals = Math.max(decimalsOf(step), decimalsOf(base));\n  const minFraction = formatOptions?.minimumFractionDigits ?? decimals;\n  const maxFraction = Math.max(minFraction, formatOptions?.maximumFractionDigits ?? decimals);\n  const grouping = formatOptions?.useGrouping ?? true;\n  const format = useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        minimumFractionDigits: minFraction,\n        maximumFractionDigits: maxFraction,\n        useGrouping: grouping,\n        numberingSystem: \"latn\",\n      }),\n    [locale, minFraction, maxFraction, grouping],\n  );\n  const symbols = useMemo(() => {\n    const parts = new Intl.NumberFormat(locale).formatToParts(-1234.5);\n    return {\n      group: parts.find((part) => part.type === \"group\")?.value ?? \",\",\n      decimal: parts.find((part) => part.type === \"decimal\")?.value ?? \".\",\n    };\n  }, [locale]);\n  const round = (next: number) => Number(next.toFixed(decimals)) || 0;\n  const clamp = (next: number) => Math.min(max, Math.max(min, next));\n  const snap = (next: number) => round(base + Math.round((next - base) / step) * step);\n  /** Off-grid values move to the next grid line in the direction of travel, then whole steps from there. */\n  const stepFrom = (from: number, steps: number) => {\n    const index = (from - base) / step;\n    return round(base + ((steps > 0 ? Math.floor(index + 1e-7) : Math.ceil(index - 1e-7)) + steps) * step);\n  };\n  const spoken = (next: number) => `${affixText(prefix, next)}${format.format(next)}${affixText(suffix, next)}`.trim();\n  function parse(text: string) {\n    const normalized = text\n      .split(symbols.group)\n      .join(\"\")\n      .replace(symbols.decimal, \".\")\n      .replace(/[^\\d.-]/g, \"\");\n    if (!/\\d/.test(normalized)) return null;\n    const parsed = Number(normalized);\n    return Number.isFinite(parsed) ? parsed : null;\n  }\n  const typed = editing ? parse(draft) : null;\n  const shown = typed ?? current;\n  const [trail, setTrail] = useState({ value: shown, direction: 1 as 1 | -1 });\n  if (trail.value !== shown) setTrail({ value: shown, direction: shown > trail.value ? 1 : -1 });\n  const direction = trail.value === shown ? trail.direction : shown > trail.value ? 1 : -1;\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(hold.current);\n      window.clearTimeout(pushedTimer.current);\n      window.clearTimeout(underTimer.current);\n      window.clearTimeout(strainTimer.current);\n    },\n    [],\n  );\n\n  /** A press past a limit. The value strains toward it and springs home, a little further with each push in a row (capped, like\n   * overscroll); the refused button shakes once; the note names the limit. Reduced motion keeps only a brief color change. */\n  function strain(edge: 1 | -1, source: Source) {\n    const now = performance.now(),\n      push = effort.current;\n    push.count = push.edge === edge && now - push.at < PUSH_WINDOW ? push.count + 1 : 1;\n    push.edge = edge;\n    push.at = now;\n    const control = controlRef.current;\n    if (control) {\n      control.dataset.strain = edge > 0 ? \"max\" : \"min\";\n      window.clearTimeout(strainTimer.current);\n      strainTimer.current = window.setTimeout(() => {\n        delete control.dataset.strain;\n      }, 420);\n    }\n    if (!reduced) {\n      animate(bumpY, 0, {\n        ...kick,\n        velocity: -edge * BUMP_VELOCITY * (1 + Math.min(push.count - 1, PUSH_CAP) * PUSH_GAIN),\n      });\n      const icon = (edge > 0 ? plusIcon : minusIcon).current;\n      if (icon && (source === \"button\" || source === \"key\"))\n        animate(icon, { x: [0, -2.5, 2.5, -1.5, 1, 0] }, { duration: 0.32, ease: \"easeOut\" });\n    }\n    if (limitHint) {\n      setPushed(edge);\n      window.clearTimeout(pushedTimer.current);\n      pushedTimer.current = window.setTimeout(() => setPushed(0), LIMIT_HINT_MS);\n    }\n  }\n  /** Every change lands here. A value past a limit clamps, strains toward it, and says which limit it met. */\n  function commitValue(next: number, source: Source) {\n    if (!Number.isFinite(next)) return false;\n    const clamped = clamp(next);\n    const limit = Math.sign(next - clamped);\n    if (limit && source !== \"scrub\") {\n      strain(limit > 0 ? 1 : -1, source);\n      setAnnouncement(`${spoken(clamped)}, ${limit > 0 ? \"maximum\" : \"minimum\"}`);\n    } else if (source === \"button\" && clamped !== latest.current) setAnnouncement(spoken(clamped));\n    if (clamped === latest.current) return false;\n    latest.current = clamped;\n    if (value === undefined) setInternal(clamped);\n    onValueChange?.(clamped);\n    return true;\n  }\n  const nudge = (toward: 1 | -1, steps: number, source: Source) =>\n    commitValue(stepFrom(latest.current, toward * steps), source);\n  useLayoutEffect(() => {\n    latest.current = current;\n    live.current = nudge;\n  });\n\n  /** The group glides back to center when typing changes its width, instead of hopping half a character per key. */\n  function captureShift() {\n    const group = groupRef.current;\n    if (group && shiftFrom.current === null)\n      shiftFrom.current = group.getBoundingClientRect().left - scrubX.get() - shiftX.get();\n  }\n  useLayoutEffect(() => {\n    const from = shiftFrom.current,\n      group = groupRef.current;\n    shiftFrom.current = null;\n    // After a commit the caret collapses to the end, so no selection box sits over the rolling digits.\n    if (selectNext.current) {\n      selectNext.current = false;\n      const input = inputRef.current;\n      if (input && document.activeElement === input) {\n        const end = input.value.length;\n        input.setSelectionRange(end, end);\n      }\n    }\n    if (from === null || !group) return;\n    const delta = from - (group.getBoundingClientRect().left - scrubX.get() - shiftX.get());\n    if (Math.abs(delta) < 0.5) return;\n    shiftX.set(shiftX.get() + delta);\n    if (reduced) shiftX.jump(0);\n    else animate(shiftX, 0, spring.morph);\n  });\n\n  function clearUnder() {\n    window.clearTimeout(underTimer.current);\n    setUnderWarn(false);\n  }\n  function commitDraft() {\n    if (!editing) return;\n    captureShift();\n    setEditing(false);\n    clearUnder();\n    const parsed = parse(draft);\n    if (parsed !== null) commitValue(snap(parsed), \"type\");\n  }\n  function stopHold() {\n    window.clearTimeout(hold.current);\n    hold.current = undefined;\n    setPressed(0);\n  }\n  /** One step now; after a pause, repeats that speed up gently until release. Held into a limit, it keeps leaning on it at a\n   * steady cadence, each push straining a little further, until the press lets go. */\n  function startHold(toward: 1 | -1, amount: number, source: Source) {\n    stopHold();\n    commitDraft();\n    const steps = Math.max(1, Math.round(amount / step));\n    if (source === \"button\") setPressed(toward);\n    let count = 0;\n    const tick = () => {\n      const moved = live.current(toward, steps, source);\n      hold.current = window.setTimeout(tick, moved ? Math.max(HOLD_FASTEST, 150 * 0.86 ** ++count) : LIMIT_PUSH);\n    };\n    hold.current = window.setTimeout(tick, nudge(toward, steps, source) ? HOLD_DELAY : LIMIT_PUSH + 120);\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    const large = largeStep ?? step * 10;\n    const move = (\n      {\n        ArrowUp: [1, event.shiftKey ? large : step],\n        ArrowDown: [-1, event.shiftKey ? large : step],\n        PageUp: [1, large],\n        PageDown: [-1, large],\n      } as Record<string, [1 | -1, number]>\n    )[event.key];\n    if (move) {\n      event.preventDefault();\n      if (!event.repeat) startHold(move[0], move[1], \"key\");\n      return;\n    }\n    // Home and End reach the limits; while a draft is open they move the caret as in any text field.\n    if (\n      !editing &&\n      ((event.key === \"Home\" && Number.isFinite(min)) || (event.key === \"End\" && max < Number.MAX_SAFE_INTEGER))\n    ) {\n      event.preventDefault();\n      commitValue(event.key === \"Home\" ? min : max, \"key\");\n      return;\n    }\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      if (editing) {\n        selectNext.current = true;\n        commitDraft();\n      } else event.currentTarget.select();\n    }\n    if (event.key === \"Escape\" && editing) {\n      event.preventDefault();\n      captureShift();\n      setEditing(false);\n      clearUnder();\n      selectNext.current = true;\n      commitValue(editStart.current, \"type\");\n    }\n  }\n  function onChange(text: string) {\n    const allowed = new RegExp(\n      `[^0-9${escape(symbols.group)}${decimals || maxFraction ? escape(symbols.decimal) : \"\"}${min < 0 ? \"\\\\-\" : \"\"}]`,\n      \"g\",\n    );\n    const next = text.replace(allowed, \"\");\n    if (!editing) editStart.current = current;\n    captureShift();\n    setDraft(next);\n    setEditing(true);\n    // Valid drafts apply as they are typed, so anything that depends on the value follows along.\n    const parsed = parse(next);\n    if (parsed !== null && parsed >= min && parsed <= max && snap(parsed) === parsed) commitValue(parsed, \"type\");\n    // Past the maximum warns at once; under the minimum waits, since \"1\" is often the start of \"12\".\n    clearUnder();\n    if (parsed !== null && parsed < min)\n      underTimer.current = window.setTimeout(() => setUnderWarn(true), UNDER_WARN_MS);\n  }\n\n  function onScrubStart(event: PointerEvent<HTMLLabelElement>) {\n    suppressClick.current = false;\n    if (!scrub || disabled || event.button !== 0) return;\n    commitDraft();\n    drag.current = { pointer: event.pointerId, x: event.clientX, from: latest.current, active: false };\n    event.currentTarget.setPointerCapture(event.pointerId);\n  }\n  function onScrubMove(event: PointerEvent<HTMLLabelElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    const dx = event.clientX - state.x;\n    if (!state.active) {\n      if (Math.abs(dx) < 3) return;\n      state.active = true;\n      setScrubbing(true);\n    }\n    const travel = dx / SCRUB_PX,\n      steps = Math.trunc(travel);\n    commitValue(steps ? stepFrom(state.from, steps) : state.from, \"scrub\");\n    // Past a limit the value follows the pointer with resistance, then springs back on release.\n    const raw = state.from + travel * step;\n    const over = raw > max ? ((raw - max) / step) * SCRUB_PX : raw < min ? ((raw - min) / step) * SCRUB_PX : 0;\n    scrubX.set(reduced ? 0 : rubber(over));\n  }\n  function onScrubEnd(event: PointerEvent<HTMLLabelElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    drag.current = null;\n    if (!state.active) return;\n    suppressClick.current = true;\n    setScrubbing(false);\n    animate(scrubX, 0, reduced ? { duration: 0 } : spring.snappy);\n    setAnnouncement(spoken(latest.current));\n  }\n\n  const stepper = (toward: 1 | -1) => ({\n    toward,\n    label,\n    disabled,\n    controls: inputId,\n    limit: toward > 0 ? shown >= max : shown <= min,\n    pressed: pressed === toward,\n  });\n  /** A typed value past a limit holds a calm warning until it commits and springs back. */\n  const outside: 1 | -1 | 0 = typed === null ? 0 : typed > max ? 1 : typed < min && underWarn ? -1 : 0;\n  const noteEdge = limitHint ? outside || pushed : 0;\n  const noteLimit = noteEdge > 0 ? max : min;\n  const noteText = !noteEdge\n    ? \"\"\n    : typeof limitHint === \"function\"\n      ? limitHint(noteEdge > 0 ? \"max\" : \"min\", noteLimit)\n      : `${noteEdge > 0 ? \"Max\" : \"Min\"} ${spoken(noteLimit)}`;\n  const describedBy = [hintId, outside && limitHint ? limitId : undefined].filter(Boolean).join(\" \") || undefined;\n\n  return (\n    <div\n      className={cn(\n        \"group/field grid min-w-0 [--nf-limit:color-mix(in_oklab,var(--orange)_72%,var(--ink))] dark:[--nf-limit:color-mix(in_oklab,var(--orange)_88%,var(--ink))]\",\n        SIZES[size],\n      )}\n      data-size={size}\n    >\n      {/* The label row matches the control's width, so the limit note sits over the stepper it answers. */}\n      <div className=\"mb-2 flex w-[min(100%,var(--nf-width))] items-baseline justify-between gap-3\">\n        {/* The label doubles as the scrub handle (a drag sideways changes the value); keyboard users step with the arrows on the spinbutton. */}\n        {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- the click handler only swallows the click that ends a drag */}\n        <label\n          htmlFor={inputId}\n          className={cn(\n            \"min-w-0\",\n            size === \"sm\" ? \"text-xs\" : \"text-sm\",\n            \"leading-body font-medium tracking-body text-ink data-scrub:cursor-ew-resize data-scrub:touch-pan-y data-scrub:select-none\",\n          )}\n          data-scrub={(scrub && !disabled) || undefined}\n          onPointerDown={onScrubStart}\n          onPointerMove={onScrubMove}\n          onPointerUp={onScrubEnd}\n          onPointerCancel={onScrubEnd}\n          onClick={(event) => {\n            if (suppressClick.current) {\n              event.preventDefault();\n              suppressClick.current = false;\n            }\n          }}\n        >\n          {label}\n        </label>\n        <LimitNote id={limitId} edge={noteEdge} text={noteText} />\n      </div>\n      <div\n        ref={controlRef}\n        className={cn(\n          // A hairline shell. Focus darkens the border only; a typed value past a limit tints it with the warning role instead, calm rather than alarming.\n          \"group/control flex min-h-(--nf-height) w-[min(100%,var(--nf-width))] items-center gap-0.5 rounded-control border border-line bg-surface p-(--nf-inset) [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-standard)_var(--ease-standard),opacity_var(--duration-fast)_var(--ease-standard)] motion-reduce:[transition:opacity_var(--duration-instant)_linear,border-color_var(--duration-instant)_linear,background-color_var(--duration-instant)_linear]\",\n          outside\n            ? \"border-[color-mix(in_oklab,var(--orange)_55%,var(--line))] bg-[color-mix(in_oklab,var(--orange)_5%,var(--surface))] has-[input:focus]:border-[color-mix(in_oklab,var(--orange)_80%,var(--line))]\"\n            : cn(\n                \"hover:border-line-strong group-has-[[data-scrub]:hover]/field:border-line-strong has-[input:focus]:border-ink\",\n                scrubbing && \"border-ink\",\n              ),\n          disabled && \"bg-hover opacity-50\",\n        )}\n        data-scrubbing={scrubbing || undefined}\n        data-disabled={disabled || undefined}\n        data-warn={outside ? (outside > 0 ? \"max\" : \"min\") : undefined}\n      >\n        <StepButton\n          {...stepper(-1)}\n          iconRef={minusIcon}\n          onPress={() => startHold(-1, step, \"button\")}\n          onRelease={stopHold}\n          onActivate={() => {\n            commitDraft();\n            nudge(-1, 1, \"button\");\n          }}\n        />\n        {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- widens the input's pointer target; the input itself takes focus and keys */}\n        <div\n          className={cn(\n            \"relative flex min-w-0 flex-1 cursor-text items-center justify-center self-stretch\",\n            disabled && \"cursor-not-allowed\",\n          )}\n          onMouseDown={(event) => {\n            if (event.target !== inputRef.current) event.preventDefault();\n          }}\n          onClick={(event) => {\n            if (!disabled && event.target !== inputRef.current) {\n              inputRef.current?.focus();\n              inputRef.current?.select();\n            }\n          }}\n        >\n          <motion.span\n            ref={groupRef}\n            className=\"inline-flex items-center text-(length:--nf-text) leading-body font-medium tracking-body whitespace-nowrap text-ink tabular-nums [transition:color_var(--duration-standard)_var(--ease-standard)] motion-reduce:group-data-strain/control:text-(color:--nf-limit) motion-reduce:group-data-strain/control:[transition:none]\"\n            style={{ x, y: bumpY }}\n          >\n            <AffixText\n              text={affixText(prefix, shown)}\n              direction={direction}\n              className={\"inline-flex font-normal text-ink-2\"}\n            />\n            <span className={\"relative inline-grid items-center\"}>\n              <span\n                className={\n                  \"inline-flex [grid-area:1/1] data-hidden:absolute data-hidden:top-0 data-hidden:left-0 data-hidden:invisible\"\n                }\n                data-hidden={editing || undefined}\n                aria-hidden=\"true\"\n              >\n                <Digits value={shown} format={format} direction={direction} instant={editing} />\n              </span>\n              {editing && (\n                <span className={\"[grid-area:1/1] whitespace-pre\"} aria-hidden=\"true\">\n                  {draft}\n                </span>\n              )}\n              <input\n                ref={inputRef}\n                id={inputId}\n                className={\n                  \"absolute top-0 left-0 z-1 m-0 h-full w-[calc(100%+1em)] border-0 bg-transparent p-0 text-left [font:inherit] leading-[inherit] tracking-[inherit] [font-variant-numeric:inherit] text-transparent caret-ink outline-none selection:bg-[color-mix(in_oklab,var(--ink)_16%,transparent)] selection:text-transparent focus-visible:outline-none disabled:cursor-not-allowed\"\n                }\n                type=\"text\"\n                role=\"spinbutton\"\n                inputMode={min < 0 ? \"text\" : decimals || maxFraction ? \"decimal\" : \"numeric\"}\n                autoComplete=\"off\"\n                spellCheck={false}\n                value={editing ? draft : format.format(current)}\n                disabled={disabled}\n                aria-describedby={describedBy}\n                aria-invalid={outside ? true : undefined}\n                aria-valuenow={current}\n                aria-valuetext={spoken(current)}\n                aria-valuemin={Number.isFinite(min) ? min : undefined}\n                aria-valuemax={max < Number.MAX_SAFE_INTEGER ? max : undefined}\n                onChange={(event) => onChange(event.currentTarget.value)}\n                onKeyDown={onKeyDown}\n                onKeyUp={(event) => {\n                  if (/^(Arrow(Up|Down)|Page(Up|Down))$/.test(event.key)) stopHold();\n                }}\n                onBlur={() => {\n                  stopHold();\n                  commitDraft();\n                }}\n              />\n            </span>\n            <AffixText\n              text={affixText(suffix, shown)}\n              direction={direction}\n              className={\"inline-flex font-normal text-ink-2\"}\n            />\n          </motion.span>\n        </div>\n        <StepButton\n          {...stepper(1)}\n          iconRef={plusIcon}\n          onPress={() => startHold(1, step, \"button\")}\n          onRelease={stopHold}\n          onActivate={() => {\n            commitDraft();\n            nudge(1, 1, \"button\");\n          }}\n        />\n      </div>\n      <FieldMessage\n        id={hintId}\n        text={description}\n        className={\"block pt-2 text-xs leading-body tracking-body text-ink-3 tabular-nums\"}\n      />\n      <span className={\"sr-only\"} aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/number-field/number-field.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/number-field",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "NumberField",
    "tags": [
      "field",
      "numeric"
    ],
    "docs": "https://irisui.dev/components/number-field"
  }
}
