{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/number-field/number-field.tsx",
      "type": "registry:component",
      "target": "@components/xzero/number-field/number-field.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue, type Variants } from \"motion/react\";\nimport { Minus, Plus } from \"lucide-react\";\nimport { Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./number-field.module.css\";\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\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, HOLD_DELAY = 400, 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, LIMIT_HINT_MS = 1500, 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, PUSH_GAIN = .22, 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 = (() => { const root = (2 * Math.PI) / (spring.morph.visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - spring.morph.bounce) * root }; })();\n\nconst decimalsOf = (value: number) => (String(value).split(\".\")[1] ?? \"\").length;\nconst escape = (text: string) => text.replace(/[.*+?^${}()|[\\]\\\\-]/g, \"\\\\$&\");\nconst affixText = (affix: NumberFieldAffix | undefined, value: number) => typeof affix === \"function\" ? affix(value) : affix ?? \"\";\n/** iOS style resistance: travel past a limit gives less and less, and never more than `limit` pixels. */\nconst rubber = (distance: number, limit = 10) => Math.sign(distance) * (1 - 1 / (Math.abs(distance) * .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\") return [...part.value].map(char => ({ key: `i${--place}`, digit: Number(char) }));\n    if (part.type === \"fraction\") return [...part.value].map(char => ({ key: `f${fraction++}`, digit: Number(char) }));\n    return [{ key: part.type === \"group\" ? `g${place}` : part.type === \"decimal\" ? \"d\" : part.type === \"minusSign\" ? \"m\" : `${part.type}${index}`, text: part.value }];\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) => ({ width: 0, scale: .6, opacity: 0, y: `${direction * .3}em`, filter: `blur(${blur.soft}px)` }),\n  center: { width: \"auto\", scale: 1, opacity: 1, y: 0, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" }, transition: { width: spring.morph, scale: spring.morph, opacity: spring.morph, y: spring.snappy, filter: { duration: .3, ease: exitEase } } },\n  exit: (direction: number) => ({ width: 0, scale: .6, opacity: 0, y: `${direction * -.3}em`, filter: `blur(${blur.subtle}px)`, transition: { width: spring.smooth, scale: spring.smooth, y: { duration: duration.fast, ease: exitEase }, opacity: { duration: duration.instant }, filter: { duration: duration.instant } } }),\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 = { enter: rest(0), center: { ...rest(1), transition: { duration: duration.instant } }, exit: { width: 0, opacity: 0, transition: { duration: 0 } } };\nconst cut: Variants = { enter: rest(1), center: { ...rest(1), transition: { duration: 0 } }, exit: { width: 0, opacity: 0, transition: { duration: 0 } } };\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 => { const distance = Math.abs(offset(current)); return distance < .02 || distance >= 1 ? \"none\" : `blur(${(distance * blur.soft * .75).toFixed(2)}px)`; });\n  return <motion.span className={styles.glyph} style={{ y, opacity, filter, visibility }}>{digit}</motion.span>;\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 <><span className={styles.sizer}>0</span>{DIGITS.map(item => <Glyph key={item} position={position} digit={item} />)}</>;\n}\n\nfunction Digits({ value, format, direction, instant }: { value: number; format: Intl.NumberFormat; direction: number; instant: boolean }) {\n  const reduced = useReducedMotion();\n  const variants = instant ? cut : reduced ? still : slot;\n  return <AnimatePresence initial={false} custom={direction}>{partsOf(value, format).map(part => <motion.span key={part.key} className={\"digit\" in part ? styles.column : styles.symbol} custom={direction} variants={variants} initial=\"enter\" animate=\"center\" exit=\"exit\">\n    {\"digit\" in part ? <Wheel digit={part.digit} direction={direction} instant={instant} /> : part.text}\n  </motion.span>)}</AnimatePresence>;\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 <span className={className}><AnimatePresence initial={false} custom={direction}>{[...text].map((char, index) => <motion.span key={`${index}:${char}`} className={styles.char} custom={direction} variants={reduced ? still : slot} initial=\"enter\" animate=\"center\" exit=\"exit\">{char}</motion.span>)}</AnimatePresence></span>;\n}\n\nconst numberIn = (word?: string) => { const digits = word?.replace(/[^\\d.-]/g, \"\"); return digits && /\\d/.test(digits) && Number.isFinite(Number(digits)) ? Number(digits) : null; };\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) => { const before = from.split(\" \"); return to.split(\" \").map((word, index) => { const a = numberIn(before[index]), b = numberIn(word); return a !== null && b !== null && b < a ? -1 : 1; }); };\n\nconst wordRise: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * .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: { opacity: 1, y: 0, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" }, transition: { duration: .22, ease: enterEase, opacity: spring.morph } },\n  exit: (direction: number) => ({ opacity: 0, y: `${direction * -.3}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .14, ease: exitEase } }),\n};\nconst wordFade: Variants = { enter: { opacity: 0, y: 0, filter: \"none\" }, center: { opacity: 1, y: 0, filter: \"none\", transition: { duration: duration.instant } }, exit: { opacity: 0, transition: { duration: 0 } } };\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 <motion.span className={styles.wordSlot} style={{ width }}>\n    <span ref={sizerRef} className={styles.wordSizer}>{word}</span>\n    <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}><motion.span key={word} className={styles.word} custom={direction} variants={reduced ? wordFade : wordRise} initial=\"enter\" animate=\"center\" exit=\"exit\">{word}</motion.span></AnimatePresence>\n  </motion.span>;\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 <><span className={styles.srOnly}>{text}</span><span className={styles.words} aria-hidden=\"true\">{words.map((word, index) => <Fragment key={index}>{index > 0 && \" \"}<WordSlot word={word} direction={directions[index] ?? 1} /></Fragment>)}</span></>;\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 <AnimatePresence initial={false}>{text ? <MessageRow key=\"message\" id={id} text={text} className={className} /> : null}</AnimatePresence>;\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]) => setHeight(entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return <motion.span className={styles.messageSlot} initial={{ height: 0, opacity: 0 }} animate={{ height, opacity: 1 }} exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: spring.smooth, opacity: { duration: duration.instant } } }} transition={reduced ? { duration: 0 } : { height: spring.smooth, opacity: { duration: duration.fast } }}>\n    <motion.span ref={copyRef} id={id} className={className} initial={reduced ? false : { y: \"0.35em\", filter: `blur(${blur.soft}px)` }} animate={{ y: 0, filter: \"blur(0px)\" }} transition={{ duration: reduced ? 0 : duration.standard, ease: enterEase }}><MotionText text={text} /></motion.span>\n  </motion.span>;\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 <AnimatePresence initial={false}>{edge !== 0 && <motion.span key=\"limit\" id={id} className={styles.limit}\n    initial={reduced ? { opacity: 0 } : { opacity: 0, y: `${edge * .45}em`, filter: `blur(${blur.subtle}px)` }}\n    animate={{ opacity: 1, y: 0, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" } }}\n    exit={{ opacity: 0, transition: { duration: reduced ? duration.instant : duration.standard, ease: exitEase } }}\n    transition={reduced ? { duration: duration.instant } : { y: spring.snappy, opacity: { duration: duration.fast }, filter: { duration: duration.fast } }}>\n    {text}\n  </motion.span>}</AnimatePresence>;\n}\n\n/** Pointer presses hold to repeat; keyboard and assistive clicks (detail 0) take one step. */\nfunction StepButton({ toward, label, disabled, controls, limit, pressed, iconRef, onPress, onRelease, onActivate }: { toward: 1 | -1; label: string; disabled?: boolean; controls: string; limit: boolean; pressed: boolean; iconRef: Ref<HTMLSpanElement>; onPress: () => void; onRelease: () => void; onActivate: () => void }) {\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  return <button type=\"button\" className={styles.step} disabled={disabled} aria-controls={controls} aria-label={`${toward > 0 ? \"Increase\" : \"Decrease\"} ${label}`} aria-disabled={limit || undefined} data-pressed={pressed || undefined}\n    onPointerDown={event => { if (event.button === 0) onPress(); }} onPointerUp={onRelease} onPointerLeave={onRelease} onPointerCancel={onRelease}\n    onMouseDown={event => event.preventDefault()} onClick={event => { if (event.detail === 0) onActivate(); }} onContextMenu={event => event.preventDefault()}>\n    <span ref={iconRef} className={styles.icon}><Icon size={16} strokeWidth={1.75} aria-hidden=\"true\" /></span>\n  </button>;\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({ label, value, defaultValue = 0, onValueChange, min = 0, max = Number.MAX_SAFE_INTEGER, step: stepProp = 1, largeStep, description, disabled, id, prefix, suffix, scrub = false, locale = \"en-US\", formatOptions, size = \"md\", limitHint = true }: 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(() => new Intl.NumberFormat(locale, { minimumFractionDigits: minFraction, maximumFractionDigits: maxFraction, useGrouping: grouping, numberingSystem: \"latn\" }), [locale, minFraction, maxFraction, grouping]);\n  const symbols = useMemo(() => { const parts = new Intl.NumberFormat(locale).formatToParts(-1234.5); return { group: parts.find(part => part.type === \"group\")?.value ?? \",\", decimal: parts.find(part => part.type === \"decimal\")?.value ?? \".\" }; }, [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) => { const index = (from - base) / step; return round(base + ((steps > 0 ? Math.floor(index + 1e-7) : Math.ceil(index - 1e-7)) + steps) * step); };\n  const spoken = (next: number) => `${affixText(prefix, next)}${format.format(next)}${affixText(suffix, next)}`.trim();\n  function parse(text: string) {\n    const normalized = text.split(symbols.group).join(\"\").replace(symbols.decimal, \".\").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(() => () => { window.clearTimeout(hold.current); window.clearTimeout(pushedTimer.current); window.clearTimeout(underTimer.current); window.clearTimeout(strainTimer.current); }, []);\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(), 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(() => { delete control.dataset.strain; }, 420);\n    }\n    if (!reduced) {\n      animate(bumpY, 0, { ...kick, velocity: -edge * BUMP_VELOCITY * (1 + Math.min(push.count - 1, PUSH_CAP) * PUSH_GAIN) });\n      const icon = (edge > 0 ? plusIcon : minusIcon).current;\n      if (icon && (source === \"button\" || source === \"key\")) animate(icon, { x: [0, -2.5, 2.5, -1.5, 1, 0] }, { duration: .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\") { strain(limit > 0 ? 1 : -1, source); 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) => commitValue(stepFrom(latest.current, toward * steps), source);\n  useLayoutEffect(() => { latest.current = current; live.current = nudge; });\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) shiftFrom.current = group.getBoundingClientRect().left - scrubX.get() - shiftX.get();\n  }\n  useLayoutEffect(() => {\n    const from = shiftFrom.current, 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) { selectNext.current = false; const input = inputRef.current; if (input && document.activeElement === input) { const end = input.value.length; input.setSelectionRange(end, end); } }\n    if (from === null || !group) return;\n    const delta = from - (group.getBoundingClientRect().left - scrubX.get() - shiftX.get());\n    if (Math.abs(delta) < .5) return;\n    shiftX.set(shiftX.get() + delta);\n    if (reduced) shiftX.jump(0); 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 * .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 = ({ ArrowUp: [1, event.shiftKey ? large : step], ArrowDown: [-1, event.shiftKey ? large : step], PageUp: [1, large], PageDown: [-1, large] } as Record<string, [1 | -1, number]>)[event.key];\n    if (move) { event.preventDefault(); if (!event.repeat) startHold(move[0], move[1], \"key\"); return; }\n    // Home and End reach the limits; while a draft is open they move the caret as in any text field.\n    if (!editing && ((event.key === \"Home\" && Number.isFinite(min)) || (event.key === \"End\" && max < Number.MAX_SAFE_INTEGER))) { event.preventDefault(); commitValue(event.key === \"Home\" ? min : max, \"key\"); return; }\n    if (event.key === \"Enter\") { event.preventDefault(); if (editing) { selectNext.current = true; commitDraft(); } else event.currentTarget.select(); }\n    if (event.key === \"Escape\" && editing) { event.preventDefault(); captureShift(); setEditing(false); clearUnder(); selectNext.current = true; commitValue(editStart.current, \"type\"); }\n  }\n  function onChange(text: string) {\n    const allowed = new RegExp(`[^0-9${escape(symbols.group)}${decimals || maxFraction ? escape(symbols.decimal) : \"\"}${min < 0 ? \"\\\\-\" : \"\"}]`, \"g\");\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) 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) { if (Math.abs(dx) < 3) return; state.active = true; setScrubbing(true); }\n    const travel = dx / SCRUB_PX, 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) => ({ toward, label, disabled, controls: inputId, limit: toward > 0 ? shown >= max : shown <= min, pressed: pressed === toward });\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 ? \"\" : typeof limitHint === \"function\" ? limitHint(noteEdge > 0 ? \"max\" : \"min\", noteLimit) : `${noteEdge > 0 ? \"Max\" : \"Min\"} ${spoken(noteLimit)}`;\n  const describedBy = [hintId, outside && limitHint ? limitId : undefined].filter(Boolean).join(\" \") || undefined;\n\n  return <div className={styles.field} data-size={size}>\n    <div className={styles.head}>\n      <label htmlFor={inputId} className={styles.label} data-scrub={(scrub && !disabled) || undefined} onPointerDown={onScrubStart} onPointerMove={onScrubMove} onPointerUp={onScrubEnd} onPointerCancel={onScrubEnd} onClick={event => { if (suppressClick.current) { event.preventDefault(); suppressClick.current = false; } }}>{label}</label>\n      <LimitNote id={limitId} edge={noteEdge} text={noteText} />\n    </div>\n    <div ref={controlRef} className={styles.control} data-scrubbing={scrubbing || undefined} data-disabled={disabled || undefined} data-warn={outside ? (outside > 0 ? \"max\" : \"min\") : undefined}>\n      <StepButton {...stepper(-1)} iconRef={minusIcon} onPress={() => startHold(-1, step, \"button\")} onRelease={stopHold} onActivate={() => { commitDraft(); nudge(-1, 1, \"button\"); }} />\n      <div className={styles.valueWrap} onMouseDown={event => { if (event.target !== inputRef.current) event.preventDefault(); }} onClick={event => { if (!disabled && event.target !== inputRef.current) { inputRef.current?.focus(); inputRef.current?.select(); } }}>\n        <motion.span ref={groupRef} className={styles.value} style={{ x, y: bumpY }}>\n          <AffixText text={affixText(prefix, shown)} direction={direction} className={styles.affix} />\n          <span className={styles.number}>\n            <span className={styles.display} data-hidden={editing || undefined} aria-hidden=\"true\"><Digits value={shown} format={format} direction={direction} instant={editing} /></span>\n            {editing && <span className={styles.mirror} aria-hidden=\"true\">{draft}</span>}\n            <input ref={inputRef} id={inputId} className={styles.input} type=\"text\" role=\"spinbutton\" inputMode={min < 0 ? \"text\" : decimals || maxFraction ? \"decimal\" : \"numeric\"} autoComplete=\"off\" spellCheck={false}\n              value={editing ? draft : format.format(current)} disabled={disabled} aria-describedby={describedBy} aria-invalid={outside ? true : undefined} aria-valuenow={current} aria-valuetext={spoken(current)}\n              aria-valuemin={Number.isFinite(min) ? min : undefined} aria-valuemax={max < Number.MAX_SAFE_INTEGER ? max : undefined}\n              onChange={event => onChange(event.currentTarget.value)} onKeyDown={onKeyDown} onKeyUp={event => { if (/^(Arrow(Up|Down)|Page(Up|Down))$/.test(event.key)) stopHold(); }} onBlur={() => { stopHold(); commitDraft(); }} />\n          </span>\n          <AffixText text={affixText(suffix, shown)} direction={direction} className={styles.affix} />\n        </motion.span>\n      </div>\n      <StepButton {...stepper(1)} iconRef={plusIcon} onPress={() => startHold(1, step, \"button\")} onRelease={stopHold} onActivate={() => { commitDraft(); nudge(1, 1, \"button\"); }} />\n    </div>\n    <FieldMessage id={hintId} text={description} className={styles.hint} />\n    <span className={styles.srOnly} aria-live=\"polite\" aria-atomic=\"true\">{announcement}</span>\n  </div>;\n}\n"
    },
    {
      "path": "registry/components/number-field/number-field.module.css",
      "type": "registry:component",
      "target": "@components/xzero/number-field/number-field.module.css",
      "content": "/* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */\n.field { --nf-height: var(--control-height-md); --nf-step: 34px; --nf-inset: 3px; --nf-text: var(--text-base); --nf-width: var(--number-field-width, 196px); --nf-limit: color-mix(in oklab, var(--warning) 72%, var(--foreground)); display: grid; min-width: 0; }\n.field[data-size=\"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.field[data-size=\"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:root[data-theme=\"dark\"] .field { --nf-limit: color-mix(in oklab, var(--warning) 88%, var(--foreground)); }\n/* The label row matches the control's width, so the limit note sits over the stepper it answers. */\n.head { display: flex; width: min(100%, var(--nf-width)); align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-2); }\n.label { min-width: 0; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); }\n.field[data-size=\"sm\"] .label { font-size: var(--text-xs); }\n.limit { flex: none; color: var(--nf-limit); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); font-variant-numeric: tabular-nums; white-space: nowrap; }\n/* A scrubbable label says so with its cursor. Vertical pans still scroll the page on touch. */\n.label[data-scrub] { cursor: ew-resize; touch-action: pan-y; user-select: none; -webkit-user-select: none; }\n/* A hairline shell. Focus darkens the border only (the library draws no rings); a typed value past a limit tints it with the warning\n   role instead, calm rather than alarming. Set --number-field-width to resize the shell. */\n.control { display: flex; align-items: center; width: min(100%, var(--nf-width)); min-height: var(--nf-height); gap: 2px; border: 1px solid var(--border); border-radius: var(--radius-control); padding: var(--nf-inset); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .control:hover, .field:has(.label[data-scrub]:hover) .control { border-color: var(--border-strong); } }\n.control[data-scrubbing] { border-color: var(--foreground); }\n.control:has(input:focus) { border-color: var(--foreground); }\n.control[data-warn], .control[data-warn]:hover { border-color: color-mix(in oklab, var(--warning) 55%, var(--border)); background: color-mix(in oklab, var(--warning) 5%, var(--surface)); }\n.control[data-warn]:has(input:focus) { border-color: color-mix(in oklab, var(--warning) 80%, var(--border)); }\n.control[data-disabled] { opacity: .5; background: var(--surface-muted); }\n/* Radius steps in by the shell's inset, so the pressed fill sits concentric with the shell. */\n.step { display: grid; width: var(--nf-step); height: var(--nf-step); flex: 0 0 var(--nf-step); place-items: center; border: 0; border-radius: calc(var(--radius-control) - var(--nf-inset) - 1px); padding: 0; background: transparent; color: var(--text-secondary); cursor: pointer; touch-action: manipulation; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-standard) var(--ease-standard); }\n.icon { display: grid; place-items: center; }\n.icon svg { transition: transform var(--duration-spring) var(--ease-spring); }\n@media (hover: hover) and (pointer: fine) { .step:hover:not(:disabled, [aria-disabled=\"true\"]) { background: var(--surface-muted); color: var(--foreground); } }\n/* Quick press, spring release: the fill deepens and the glyph sinks. A held button stays pressed for as long as it repeats. */\n.step:is(:active, [data-pressed]):not(:disabled, [aria-disabled=\"true\"]) { background: color-mix(in oklab, var(--foreground) 10%, var(--surface)); color: var(--foreground); transition: background-color 60ms linear, color 60ms linear; }\n.step:is(:active, [data-pressed]):not(:disabled, [aria-disabled=\"true\"]) svg { transform: scale(.82); transition: transform 90ms var(--ease-standard); }\n.step:focus-visible { outline: none; background: var(--surface-muted); color: var(--foreground); }\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.step[aria-disabled=\"true\"] { opacity: .32; cursor: not-allowed; }\n.step:disabled { opacity: .35; cursor: not-allowed; }\n.control[data-strain=\"max\"] .step[aria-disabled=\"true\"]:last-child, .control[data-strain=\"min\"] .step[aria-disabled=\"true\"]:first-child { opacity: .6; color: var(--nf-limit); transition: opacity 60ms linear, color 60ms linear; }\n.valueWrap { position: relative; display: flex; min-width: 0; flex: 1; align-self: stretch; align-items: center; justify-content: center; cursor: text; }\n.control[data-disabled] .valueWrap { cursor: not-allowed; }\n/* The number, its prefix, and its suffix travel as one centered group. Tabular digits keep every column the same width. */\n.value { display: inline-flex; align-items: center; white-space: nowrap; color: var(--foreground); font-size: var(--nf-text); transition: color var(--duration-standard) var(--ease-standard); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); font-variant-numeric: tabular-nums; }\n.affix { display: inline-flex; color: var(--text-secondary); font-weight: 400; }\n.char { display: inline-block; overflow-x: clip; white-space: pre; }\n/* The rolling digits and the real input share one cell. The input only draws the caret and selection; its text is always painted by a span\n   (the digits, or the draft mirror while typing), so the value keeps one weight and position whether it is rolling or being edited. */\n.number { position: relative; display: inline-grid; align-items: center; }\n.display { display: inline-flex; grid-area: 1 / 1; }\n.display[data-hidden] { position: absolute; top: 0; left: 0; visibility: hidden; }\n.mirror { grid-area: 1 / 1; white-space: pre; }\n.input { position: absolute; z-index: 1; top: 0; left: 0; width: calc(100% + 1em); height: 100%; margin: 0; border: 0; padding: 0; background: transparent; color: transparent; caret-color: var(--foreground); font: inherit; font-variant-numeric: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; outline: none; }\n.input:focus-visible { outline: none; }\n.input::selection { background: color-mix(in oklab, var(--foreground) 16%, transparent); color: transparent; }\n.input:disabled { cursor: not-allowed; }\n/* Each digit is a wheel whose window reaches a little past the line box and feathers out, so a turning digit fades at the edge instead of being cut.\n   The window clips vertically only: a column opening its width shows its whole digit fading in, never a sliver. */\n.column { --feather: .2em; position: relative; display: inline-block; overflow-x: visible; overflow-y: clip; margin-block: calc(var(--feather) * -1); padding-block: var(--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.sizer { visibility: hidden; }\n.glyph { position: absolute; inset: var(--feather) 0; text-align: center; }\n.symbol { display: inline-block; overflow-x: clip; white-space: pre; }\n/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */\n.messageSlot { display: block; overflow: hidden; }\n.hint { display: block; padding-top: var(--space-2); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); letter-spacing: var(--tracking-body); font-variant-numeric: tabular-nums; }\n/* Words are measured against their slot when they pop out to leave, so the slot must be the positioned parent. */\n.words { position: relative; display: block; }\n.wordSlot { position: relative; display: inline-block; vertical-align: top; white-space: pre; }\n.wordSizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: pre; }\n.word { display: inline-block; white-space: pre; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n/* Reduced motion: no travel. The value changes at once and a press at a limit answers with a brief warning tint instead of a strain. */\n@media (prefers-reduced-motion: reduce) {\n  .control, .step { transition: opacity var(--duration-instant) linear, border-color var(--duration-instant) linear, background-color var(--duration-instant) linear; }\n  .step:is(:active, [data-pressed]):not(:disabled, [aria-disabled=\"true\"]) svg, .icon svg { transform: none; transition: none; }\n  .control[data-strain] .value { color: var(--nf-limit); transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/number-field",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "field",
      "numeric"
    ],
    "docs": "https://ui.xzero.co.uk/components/number-field",
    "markdown": "https://ui.xzero.co.uk/components/number-field/markdown"
  }
}