{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/number-field/number-field.tsx","type":"registry:component","target":"@components/arc/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; 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 {digit};\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 <>0{DIGITS.map(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 {partsOf(value, format).map(part => \n {\"digit\" in part ? : part.text}\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 {[...text].map((char, index) => {char})};\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(null);\n const width = useMotionValue(\"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 {word}\n {word}\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 <>{text}{words.map((word, index) => {index > 0 && \" \"})};\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 {text ? : null};\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(null);\n const [height, setHeight] = useState(\"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 \n \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 {edge !== 0 && \n {text}\n };\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; 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 ;\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(null);\n const minusIcon = useRef(null);\n const plusIcon = useRef(null);\n const pushedTimer = useRef(undefined);\n const underTimer = useRef(undefined);\n const strainTimer = useRef(undefined);\n const effort = useRef({ edge: 0, count: 0, at: 0 });\n const inputRef = useRef(null);\n const groupRef = useRef(null);\n const latest = useRef(current);\n const editStart = useRef(current);\n const hold = useRef(undefined);\n const drag = useRef<{ pointer: number; x: number; from: number; active: boolean } | null>(null);\n const suppressClick = useRef(false);\n const shiftFrom = useRef(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) {\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)[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) {\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) {\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) {\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
\n
\n \n \n
\n
0 ? \"max\" : \"min\") : undefined}>\n startHold(-1, step, \"button\")} onRelease={stopHold} onActivate={() => { commitDraft(); nudge(-1, 1, \"button\"); }} />\n
{ if (event.target !== inputRef.current) event.preventDefault(); }} onClick={event => { if (!disabled && event.target !== inputRef.current) { inputRef.current?.focus(); inputRef.current?.select(); } }}>\n \n \n \n \n {editing && {draft}}\n onChange(event.currentTarget.value)} onKeyDown={onKeyDown} onKeyUp={event => { if (/^(Arrow(Up|Down)|Page(Up|Down))$/.test(event.key)) stopHold(); }} onBlur={() => { stopHold(); commitDraft(); }} />\n \n \n \n
\n startHold(1, step, \"button\")} onRelease={stopHold} onActivate={() => { commitDraft(); nudge(1, 1, \"button\"); }} />\n
\n \n {announcement}\n
;\n}\n"},{"path":"registry/components/number-field/number-field.module.css","type":"registry:component","target":"@components/arc/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://uiarc.dev/components/number-field","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/number-field","markdown":"https://uiarc.dev/components/number-field/markdown","tags":["field","numeric"]}}