Kinetic
Drag-to-scrub number fields with animated digits. Click to edit, keyboard to nudge — design-tool ergonomics for React.
Installation
pnpm dlx shadcn@latest add @kinetic/scrub-number-fieldAdd the @kinetic registry to components.json (until listed in the shadcn registry directory), plus shadcn input and input-group.
{
"registries": {
"@kinetic": "/r/{name}.json"
}
}Install dependencies
pnpm add calligraph lucide-react motionAdd shadcn components
pnpm dlx shadcn@latest add input input-groupCopy source files
/** * Scrub Number Input — self-contained copy-paste module * * Horizontal scrub + click-to-edit number field with Calligraph slot-digit animation. * * ## Display overflow * * Values stay left-aligned; overflow is clipped on the right. Hover shows the full * value via `title` when truncated; screen readers get `aria-valuetext`. Click-to-edit * Click-to-edit opens the full string. When the value overflows horizontally, * the input scrolls to keep the caret visible instead of forcing it to the end. */ "use client" import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type ComponentProps, type CSSProperties, type KeyboardEvent, type PointerEvent, type RefObject, } from "react" import { Calligraph } from "calligraph" import { GripHorizontal, GripVertical, Move, Percent, type LucideIcon, } from "lucide-react" import { useReducedMotion, animate, motion, useMotionValue } from "motion/react" import { InputGroup, InputGroupAddon } from "@/components/ui/input-group" import { Input } from "@/components/ui/input" import { useControllableState } from "@/hooks/use-controllable-state" import { useDisplayOverflowTruncated } from "@/lib/scrub-number-overflow" import { boundOverflow, clampNumber, countDraftDecimalPlaces, formatDisplayValue, getAtBound, getBoundEdge, getScrubPointerDelta, hasExceededScrubThreshold, isFineModifierPressed, applyStepDelta, consumeWheelDelta, normalizeCoarseModifier, normalizeFineModifier, normalizeFiniteNumber, normalizeNumberFieldBounds, normalizePositiveFiniteStep, normalizeScrubThreshold, normalizeWheelDelta, normalizeWheelSensitivity, quantizeNumber, resolveActiveStep, resolveFineStep, resolveQuantizeStep, resolveExclusiveModifiers, resolveScrubStepModifiers, preserveDisplayDraft, resolveDisplayDecimalPlaces, sanitizeNumericDraft, toModifierKeys, type CoarseModifier, type FineModifier, } from "@/lib/scrub-number-math" import { cn } from "@/lib/utils" import { spring } from "@/lib/springs" import { cva } from "class-variance-authority" import "./scrub-number-input.css" export { boundOverflow, clampNumber, countDraftDecimalPlaces, formatDisplayValue, formatMinimalDisplayValue, getAtBound, getBoundEdge, getDecimalPlaces, getCoarseModifierLabel, getFineModifierLabel, getScrubPointerDelta, hasExceededScrubThreshold, isCoarseModifierPressed, isFineModifierPressed, isModifierKeyPressed, applyStepDelta, consumeWheelDelta, normalizeCoarseModifier, normalizeFineModifier, normalizeFiniteNumber, normalizeNumberFieldBounds, normalizePositiveFiniteStep, normalizeScrubThreshold, normalizeWheelDelta, normalizeWheelSensitivity, quantizeNumber, resolveActiveStep, resolveCoarseModifierKey, resolveFineModifierKey, resolveFineStep, resolveQuantizeStep, resolveExclusiveModifiers, resolveScrubStepModifiers, preserveDisplayDraft, resolveDisplayDecimalPlaces, toModifierKeys, getValueDecimalPlaces, stepFromDecimalPlaces, MODIFIER_OPTIONS, type CoarseModifier, type FineModifier, type DisplayFormat, type ModifierKey, } from "@/lib/scrub-number-math" const SCRUB_NUMBER_FIELD_CLASS = "tabular-nums" const SCRUB_NUMBER_SPINNER_HIDE_CLASS = "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" const scrubFieldVariants = cva( "w-full min-w-0 rounded-[20px] border border-input bg-[var(--input-fill)] py-1 text-start text-base text-foreground transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-7 px-2 text-[0.8rem]", ) export type InputSettings = { selectOnEdit: boolean } export const DEFAULT_INPUT_SETTINGS: InputSettings = { selectOnEdit: true, } export type FormatSettings = { alwaysShowSign: boolean } export const DEFAULT_FORMAT_SETTINGS: FormatSettings = { alwaysShowSign: false, } export type BoundFeedbackMode = | "none" | "rubberBand" | "shake" | "borderPulse" export const BOUND_FEEDBACK_MODES = [ "none", "rubberBand", "shake", "borderPulse", ] as const satisfies readonly BoundFeedbackMode[] export type BoundFeedbackSource = "wheel" | "key" | "scrub" export type BoundFeedbackState = { edge: "min" | "max" overflow: number source: BoundFeedbackSource tick: number } export type ScrubSettings = { direction: "horizontal" | "vertical" shiftStep: number sensitivity: number threshold?: number wheelEnabled: boolean boundFeedback: BoundFeedbackMode /** Step used while the fine modifier is held. Defaults to step / 10. */ fineStep?: number /** * Key held for fine step. */ fineModifier?: FineModifier /** * Key held for coarse step. */ coarseModifier?: CoarseModifier /** Accumulated wheel delta (px) required before one step fires. */ wheelSensitivity?: number } export const DEFAULT_SCRUB_SETTINGS: ScrubSettings = { direction: "horizontal", shiftStep: 10, sensitivity: 1, threshold: 3, wheelEnabled: false, boundFeedback: "none", fineModifier: "alt", coarseModifier: "shift", wheelSensitivity: 20, } export type CalligraphSettings = { variant: "number" | "slots" animation: "default" | "smooth" | "snappy" | "bouncy" stagger: number autoSize: boolean } export const DEFAULT_CALLIGRAPH_SETTINGS: CalligraphSettings = { variant: "slots", animation: "snappy", stagger: 0.02, autoSize: false, } const VALUE_NUDGE_KEYS = new Set([ "ArrowUp", "ArrowDown", "PageUp", "PageDown", ]) export const LOGO_ICON_OPTIONS = [ "GripVertical", "GripHorizontal", "Move", "Percent", ] as const export type LogoIconName = (typeof LOGO_ICON_OPTIONS)[number] export type LogoSettings = { enabled: boolean icon: LogoIconName } export const DEFAULT_LOGO_SETTINGS: LogoSettings = { enabled: false, icon: "GripVertical", } const LOGO_ICONS: Record<LogoIconName, LucideIcon> = { GripVertical, GripHorizontal, Move, Percent, } export function ScrubLogoIcon({ className, name, }: { className?: string name: LogoIconName }) { const Icon = LOGO_ICONS[name] return <Icon aria-hidden className={className} /> } export type ScrubFieldSettings = { scrub: ScrubSettings calligraph: CalligraphSettings input: InputSettings format: FormatSettings logo: LogoSettings min?: number max?: number step?: number } export const DEFAULT_SCRUB_FIELD_SETTINGS: ScrubFieldSettings = { scrub: DEFAULT_SCRUB_SETTINGS, calligraph: DEFAULT_CALLIGRAPH_SETTINGS, input: DEFAULT_INPUT_SETTINGS, format: DEFAULT_FORMAT_SETTINGS, logo: DEFAULT_LOGO_SETTINGS, } export function normalizeScrubFieldSettings( settings: ScrubFieldSettings, ): ScrubFieldSettings { const { min, max } = normalizeNumberFieldBounds(settings.min, settings.max) const resolvedStep = normalizePositiveFiniteStep(settings.step) const scrub = { ...DEFAULT_SCRUB_SETTINGS, ...settings.scrub } let fineStep = scrub.fineStep if (fineStep != null && Number.isFinite(fineStep) && fineStep > 0) { if (fineStep >= resolvedStep) { fineStep = resolveFineStep(resolvedStep) } } let shiftStep = scrub.shiftStep if (!Number.isFinite(shiftStep) || shiftStep < resolvedStep) { shiftStep = Math.max(resolvedStep, DEFAULT_SCRUB_SETTINGS.shiftStep) } let threshold = scrub.threshold ?? DEFAULT_SCRUB_SETTINGS.threshold ?? 3 threshold = normalizeScrubThreshold(threshold) const fineModifier = normalizeFineModifier( scrub.fineModifier, DEFAULT_SCRUB_SETTINGS.fineModifier, ) const coarseModifier = normalizeCoarseModifier( scrub.coarseModifier, DEFAULT_SCRUB_SETTINGS.coarseModifier, ) const exclusiveModifiers = resolveExclusiveModifiers( fineModifier, coarseModifier, ) let wheelSensitivity = scrub.wheelSensitivity ?? DEFAULT_SCRUB_SETTINGS.wheelSensitivity ?? 20 wheelSensitivity = normalizeWheelSensitivity(wheelSensitivity) const inputOverrides = settings.input ?? {} const requestedLogoIcon = settings.logo?.icon const logoIcon = requestedLogoIcon && LOGO_ICON_OPTIONS.includes(requestedLogoIcon as LogoIconName) ? requestedLogoIcon : DEFAULT_LOGO_SETTINGS.icon return { scrub: { ...scrub, fineStep, fineModifier: exclusiveModifiers.fine, coarseModifier: exclusiveModifiers.coarse, shiftStep, threshold, wheelSensitivity, boundFeedback: scrub.boundFeedback === "rubberBand" ? "none" : (scrub.boundFeedback as string) === "combo" ? "shake" : scrub.boundFeedback, }, calligraph: { ...DEFAULT_CALLIGRAPH_SETTINGS, ...settings.calligraph }, input: { ...DEFAULT_INPUT_SETTINGS, ...inputOverrides, }, format: { alwaysShowSign: Boolean(settings.format?.alwaysShowSign), }, logo: { ...DEFAULT_LOGO_SETTINGS, ...settings.logo, icon: logoIcon, }, min, max, step: typeof settings.step === "number" && Number.isFinite(settings.step) ? settings.step : undefined, } } export function getScrubCursorClass( scrub: Pick<ScrubSettings, "direction">, atBound: "min" | "max" | null = null, bounds?: { min?: number; max?: number }, ) { if ( bounds?.min != null && bounds?.max != null && bounds.min === bounds.max ) { return "cursor-not-allowed" } if (scrub.direction === "vertical") { if (atBound === "min") { return "cursor-n-resize" } if (atBound === "max") { return "cursor-s-resize" } return "cursor-ns-resize" } if (atBound === "min") { return "cursor-e-resize" } if (atBound === "max") { return "cursor-w-resize" } return "cursor-ew-resize" } type EditPointerPoint = { clientX: number clientY: number } function approximateCaretFromX(input: HTMLInputElement, clientX: number) { const text = input.value const rect = input.getBoundingClientRect() const style = getComputedStyle(input) const paddingLeft = Number.parseFloat(style.paddingLeft) || 0 const paddingRight = Number.parseFloat(style.paddingRight) || 0 const contentWidth = rect.width - paddingLeft - paddingRight const canvas = document.createElement("canvas") const ctx = canvas.getContext("2d") if (!ctx) { input.setSelectionRange(text.length, text.length) scrollCaretIntoView(input) return } ctx.font = `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}` ctx.letterSpacing = style.letterSpacing const textWidth = ctx.measureText(text).width let textStart = paddingLeft + input.scrollLeft const textAlign = style.textAlign if (textAlign === "center") { textStart += Math.max(0, (contentWidth - textWidth) / 2) } else if (textAlign === "right" || textAlign === "end") { textStart += Math.max(0, contentWidth - textWidth) } const relativeX = clientX - rect.left - textStart if (relativeX <= 0) { input.setSelectionRange(0, 0) scrollCaretIntoView(input) return } if (relativeX >= textWidth) { input.setSelectionRange(text.length, text.length) scrollCaretIntoView(input) return } let offset = 0 for (let index = 1; index <= text.length; index++) { const width = ctx.measureText(text.slice(0, index)).width if (width >= relativeX) { const previousWidth = index > 1 ? ctx.measureText(text.slice(0, index - 1)).width : 0 const characterWidth = ctx.measureText(text[index - 1] ?? "").width offset = relativeX - previousWidth < characterWidth / 2 ? index - 1 : index break } offset = index } input.setSelectionRange(offset, offset) scrollCaretIntoView(input) } function placeCaretAtPoint( input: HTMLInputElement, clientX: number, clientY: number, ) { const doc = input.ownerDocument if (typeof doc.caretRangeFromPoint === "function") { const range = doc.caretRangeFromPoint(clientX, clientY) if (range && input.contains(range.startContainer)) { const offset = Math.min(range.startOffset, input.value.length) input.setSelectionRange(offset, offset) scrollCaretIntoView(input) return true } } if (typeof doc.caretPositionFromPoint === "function") { const position = doc.caretPositionFromPoint(clientX, clientY) if ( position && (input === position.offsetNode || input.contains(position.offsetNode)) ) { const offset = Math.min(position.offset, input.value.length) input.setSelectionRange(offset, offset) scrollCaretIntoView(input) return true } } approximateCaretFromX(input, clientX) return true } function inputTextOverflows(input: HTMLInputElement) { return input.scrollWidth > input.clientWidth + 1 } function scrollCaretIntoView(input: HTMLInputElement) { if (!inputTextOverflows(input)) { return } const caret = input.selectionStart ?? input.value.length const style = getComputedStyle(input) const paddingLeft = Number.parseFloat(style.paddingLeft) || 0 const paddingRight = Number.parseFloat(style.paddingRight) || 0 const contentWidth = input.clientWidth - paddingLeft - paddingRight const canvas = document.createElement("canvas") const ctx = canvas.getContext("2d") if (!ctx || contentWidth <= 0) { return } ctx.font = `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}` ctx.letterSpacing = style.letterSpacing const textBeforeCaret = input.value.slice(0, caret) const caretX = ctx.measureText(textBeforeCaret).width const maxScroll = Math.max(0, input.scrollWidth - input.clientWidth) if (caretX - input.scrollLeft < paddingLeft) { input.scrollLeft = Math.max(0, caretX - paddingLeft) return } const visibleEnd = input.scrollLeft + contentWidth - paddingRight if (caretX > visibleEnd) { input.scrollLeft = Math.min(maxScroll, caretX - contentWidth + paddingRight) } } function focusCaretAtEnd(input: HTMLInputElement) { const length = input.value.length input.setSelectionRange(length, length) scrollCaretIntoView(input) } function focusInputForEdit( input: HTMLInputElement, selectOnEdit: boolean, point?: EditPointerPoint, ) { input.focus({ preventScroll: true }) if (selectOnEdit) { input.select() scrollCaretIntoView(input) return } if (point) { placeCaretAtPoint(input, point.clientX, point.clientY) return } focusCaretAtEnd(input) } const RUBBER_BAND_MAX_OFFSET = 6 // Keep in sync with --scrub-bound-feedback-ms in scrub-number-input.css const SCRUB_BOUND_FEEDBACK_MS = 80 * 2 + 60 * 2 const SCRUB_BOUND_REVERT_HOLD_MS = 600 function restartBoundShake(targets: HTMLElement[]) { for (const element of targets) { element.classList.remove("is-shaking") void element.offsetWidth element.classList.add("is-shaking") } } function clearBoundShake(targets: HTMLElement[]) { for (const element of targets) { element.classList.remove("is-shaking") } } function ScrubBoundFeedback({ boundFeedback, children, className, direction, mode, onFeedbackComplete, }: { boundFeedback: BoundFeedbackState | null children: React.ReactNode className?: string direction: ScrubSettings["direction"] mode: BoundFeedbackMode onFeedbackComplete: () => void }) { const shouldReduceMotion = useReducedMotion() const offsetX = useMotionValue(0) const offsetY = useMotionValue(0) const wrapRef = useRef<HTMLDivElement>(null) const [boundHit, setBoundHit] = useState<"min" | "max" | null>(null) const [boundError, setBoundError] = useState(false) const scrubbingAtEdgeRef = useRef(false) useEffect(() => { if (!boundFeedback || mode === "none") { scrubbingAtEdgeRef.current = false offsetX.set(0) offsetY.set(0) setBoundError(false) return } const activeMode = mode const sign = boundFeedback.edge === "min" ? -1 : 1 const axis = direction === "vertical" ? offsetY : offsetX const otherAxis = direction === "vertical" ? offsetX : offsetY const shakeTargets = wrapRef.current ? Array.from( wrapRef.current.querySelectorAll<HTMLElement>(".scrub-bound-field"), ) : [] if (activeMode === "rubberBand") { const stretch = Math.min( RUBBER_BAND_MAX_OFFSET, 2 + Math.sqrt(boundFeedback.overflow) * 1.25, ) const offset = sign * stretch if (boundFeedback.source === "scrub") { scrubbingAtEdgeRef.current = true axis.set(shouldReduceMotion ? 0 : offset) otherAxis.set(0) return } scrubbingAtEdgeRef.current = false if (shouldReduceMotion) { axis.set(0) otherAxis.set(0) onFeedbackComplete() return } const controls = animate(axis, [offset, 0], { ...spring.fast, onComplete: onFeedbackComplete, }) return () => { controls.stop() } } scrubbingAtEdgeRef.current = false axis.set(0) otherAxis.set(0) if (activeMode === "shake") { setBoundError(true) if (!shouldReduceMotion && shakeTargets.length > 0) { restartBoundShake(shakeTargets) } const completeTimer = window.setTimeout(() => { onFeedbackComplete() }, shouldReduceMotion ? 0 : SCRUB_BOUND_FEEDBACK_MS) const revertTimer = window.setTimeout(() => { setBoundError(false) clearBoundShake(shakeTargets) }, shouldReduceMotion ? 0 : SCRUB_BOUND_FEEDBACK_MS + SCRUB_BOUND_REVERT_HOLD_MS) return () => { window.clearTimeout(completeTimer) window.clearTimeout(revertTimer) } } if (activeMode === "borderPulse") { setBoundHit(boundFeedback.edge) const timeout = window.setTimeout(() => { setBoundHit(null) onFeedbackComplete() }, shouldReduceMotion ? 0 : SCRUB_BOUND_FEEDBACK_MS) return () => { window.clearTimeout(timeout) } } }, [ boundFeedback, direction, mode, offsetX, offsetY, onFeedbackComplete, shouldReduceMotion, ]) return ( <motion.div className={className} style={{ x: offsetX, y: offsetY }}> <div ref={wrapRef} data-slot="scrub-bound-feedback" data-bound-direction={direction} data-bound-hit={boundHit ?? undefined} className={cn( "scrub-bound-wrap", boundError && "is-bound-error", )} > {children} </div> </motion.div> ) } export type UseNumberScrubOptions = { disabled?: boolean format?: FormatSettings formatValue?: (value: number) => string logo?: LogoSettings max?: number min?: number onChange: (value: number) => void onValueCommit?: (value: number) => void defaultResetValue?: number scrub?: ScrubSettings selectOnEdit?: boolean shiftStep?: number step?: number value: number } export type ScrubState = ReturnType<typeof useNumberScrub> export function useNumberScrub({ disabled = false, format = DEFAULT_FORMAT_SETTINGS, formatValue, logo = DEFAULT_LOGO_SETTINGS, max: maxProp, min: minProp, onChange, onValueCommit, defaultResetValue, scrub = DEFAULT_SCRUB_SETTINGS, selectOnEdit = true, shiftStep: shiftStepProp, step: stepProp = 1, value: valueProp, }: UseNumberScrubOptions) { const { min, max } = normalizeNumberFieldBounds(minProp, maxProp) const value = normalizeFiniteNumber(valueProp) ?? 0 const step = normalizePositiveFiniteStep(stepProp) const effectiveShiftStep = normalizePositiveFiniteStep( shiftStepProp ?? scrub.shiftStep, Math.max(step, DEFAULT_SCRUB_SETTINGS.shiftStep), ) const fineStep = resolveFineStep(step, scrub.fineStep) const wheelSensitivity = normalizeWheelSensitivity(scrub.wheelSensitivity) const fineModifier = normalizeFineModifier( scrub.fineModifier, DEFAULT_SCRUB_SETTINGS.fineModifier ?? "alt", ) const coarseModifier = normalizeCoarseModifier( scrub.coarseModifier, DEFAULT_SCRUB_SETTINGS.coarseModifier ?? "shift", ) const scrubThreshold = normalizeScrubThreshold(scrub.threshold) const logoScrollEnabled = logo.enabled const userDecimalPlacesRef = useRef<number | null>(null) const displayDecimalPlacesRef = useRef<number | null>(null) const wheelDeltaRef = useRef(0) const wheelModifierRef = useRef<string | null>(null) const formatForEdit = useCallback( (nextValue: number) => formatDisplayValue( nextValue, format, userDecimalPlacesRef.current ?? displayDecimalPlacesRef.current, ), [format], ) const formatForDisplay = useCallback( (nextValue: number) => { if (formatValue) { return formatValue(nextValue) } return formatForEdit(nextValue) }, [formatForEdit, formatValue], ) const [draft, setDraft] = useState(() => formatForEdit(value)) const draftRef = useRef(draft) draftRef.current = draft const [editing, setEditing] = useState(false) const editingRef = useRef(false) editingRef.current = editing const [invalid, setInvalid] = useState(false) const [boundFeedback, setBoundFeedback] = useState<BoundFeedbackState | null>( null, ) const boundFeedbackRef = useRef<BoundFeedbackState | null>(boundFeedback) boundFeedbackRef.current = boundFeedback const boundFeedbackTickRef = useRef(0) const boundFeedbackLatchedRef = useRef({ max: false, min: false }) const interactingRef = useRef(false) const lastCommittedValueRef = useRef(value) const lastNudgeDirectionRef = useRef<1 | -1 | 0>(0) const [interactionEpoch, setInteractionEpoch] = useState(0) const lastClickRef = useRef<{ time: number x: number y: number } | null>(null) const inputRef = useRef<HTMLInputElement>(null) const displaySurfaceRef = useRef<HTMLDivElement>(null) const surfaceRef = useRef<HTMLDivElement>(null) const scrubRef = useRef<{ captureTarget: HTMLElement | null pointerId: number scrubbing: boolean source: "input" | "label" startValue: number startX: number startY: number } | null>(null) const scrubSessionGuardRef = useRef<(() => void) | null>(null) const pendingEditKeyboardNudgeRef = useRef<{ direction: 1 | -1 modifiers: { coarse?: boolean; fine?: boolean } source: BoundFeedbackSource } | null>(null) const detachScrubSessionGuard = useCallback(() => { scrubSessionGuardRef.current?.() scrubSessionGuardRef.current = null }, []) const notifyCommit = useCallback( (committedValue: number) => { onValueCommit?.(committedValue) }, [onValueCommit], ) const resetToDefault = useCallback(() => { if (defaultResetValue == null) { return false } const bounded = clampNumber(defaultResetValue, min, max) onChange(bounded) lastCommittedValueRef.current = bounded setDraft(formatForEdit(bounded)) notifyCommit(bounded) return true }, [defaultResetValue, formatForEdit, max, min, notifyCommit, onChange]) const finishInteraction = useCallback(() => { if (!interactingRef.current) { return } interactingRef.current = false setInteractionEpoch((epoch) => epoch + 1) }, []) useEffect(() => { if (interactingRef.current) { return } const parsedDraft = Number(draftRef.current.replace(/^\+/, "")) const isExternalChange = Number.isFinite(parsedDraft) && parsedDraft !== value && lastCommittedValueRef.current !== value if (isExternalChange) { userDecimalPlacesRef.current = null displayDecimalPlacesRef.current = null } const nextDraft = preserveDisplayDraft( draftRef.current, value, formatForEdit(value), ) displayDecimalPlacesRef.current = resolveDisplayDecimalPlaces( nextDraft, userDecimalPlacesRef.current, ) draftRef.current = nextDraft setDraft(nextDraft) lastCommittedValueRef.current = value }, [formatForEdit, value]) useEffect(() => { if (min != null && value > min) { boundFeedbackLatchedRef.current.min = false } if (max != null && value < max) { boundFeedbackLatchedRef.current.max = false } }, [max, min, value]) const clearBoundFeedback = useCallback(() => { if (boundFeedbackRef.current === null) { return } setBoundFeedback(null) }, []) const triggerBoundFeedback = useCallback( ( edge: "min" | "max", source: BoundFeedbackSource, attempted: number, ) => { if (scrub.boundFeedback === "none") { return } const isContinuousScrubRubberBand = source === "scrub" && scrub.boundFeedback === "rubberBand" if (!isContinuousScrubRubberBand && boundFeedbackLatchedRef.current[edge]) { return } if (!isContinuousScrubRubberBand) { boundFeedbackLatchedRef.current[edge] = true } boundFeedbackTickRef.current += 1 setBoundFeedback({ edge, overflow: boundOverflow(attempted, edge, min, max), source, tick: boundFeedbackTickRef.current, }) }, [max, min, scrub.boundFeedback], ) const getCurrentNumericValue = useCallback(() => { const current = Number(draftRef.current.replace(/^\+/, "")) return Number.isFinite(current) ? current : value }, [value]) const resolveCommitQuantizeStep = useCallback( (currentValue: number, fine = false) => resolveQuantizeStep({ step, fineStep, fine, currentValue, userDecimalPlaces: userDecimalPlacesRef.current, }), [fineStep, step], ) const commit = useCallback( ( nextValue: number, source?: BoundFeedbackSource, quantizeStep = step, activeStep = step, direction?: 1 | -1, ) => { const current = getCurrentNumericValue() const attempted = quantizeNumber(nextValue, quantizeStep) const bounded = clampNumber(attempted, min, max) if (source) { const edge = getBoundEdge(current, attempted, min, max) if (edge) { triggerBoundFeedback(edge, source, attempted) } else if (source === "scrub" && boundFeedbackRef.current !== null) { setBoundFeedback(null) } } if (direction) { lastNudgeDirectionRef.current = direction } else if (bounded !== current) { lastNudgeDirectionRef.current = bounded > current ? 1 : -1 } onChange(bounded) const decimalPlaces = resolveDisplayDecimalPlaces( draftRef.current, userDecimalPlacesRef.current, activeStep, ) displayDecimalPlacesRef.current = decimalPlaces const nextDraft = formatForEdit(bounded) draftRef.current = nextDraft setDraft(nextDraft) lastCommittedValueRef.current = bounded return bounded }, [ formatForEdit, getCurrentNumericValue, max, min, onChange, step, triggerBoundFeedback, ], ) const getActiveStep = useCallback( (modifiers: { coarse?: boolean; fine?: boolean }) => resolveActiveStep({ step, shiftStep: effectiveShiftStep, fineStep, coarse: modifiers.coarse, fine: modifiers.fine, }), [effectiveShiftStep, fineStep, step], ) const resetWheelAccumulator = useCallback(() => { wheelDeltaRef.current = 0 wheelModifierRef.current = null }, []) const getStepModifiers = useCallback( (event: { shiftKey: boolean altKey: boolean metaKey: boolean getModifierState?: (key: string) => boolean }) => resolveScrubStepModifiers(toModifierKeys(event), { fineModifier, coarseModifier, }), [coarseModifier, fineModifier], ) const getDomStepModifiers = useCallback( (event: { shiftKey: boolean altKey: boolean metaKey: boolean getModifierState: (key: string) => boolean }) => getStepModifiers(event), [getStepModifiers], ) const applyDisplayNudge = useCallback( ( direction: 1 | -1, modifiers: { coarse?: boolean; fine?: boolean }, source: BoundFeedbackSource, count = 1, ) => { if (editingRef.current) { return false } const current = getCurrentNumericValue() const fine = modifiers.fine ?? false const activeStep = getActiveStep({ coarse: modifiers.coarse ?? false, fine, }) const attempted = applyStepDelta(current, direction * activeStep * count, { step, fineStep, fine, userDecimalPlaces: userDecimalPlacesRef.current, }) const bounded = clampNumber(attempted, min, max) if (bounded === current) { const edge = getBoundEdge(current, attempted, min, max) if (edge) { triggerBoundFeedback(edge, source, attempted) } return true } interactingRef.current = true lastNudgeDirectionRef.current = direction const decimalPlaces = resolveDisplayDecimalPlaces( draftRef.current, userDecimalPlacesRef.current, activeStep, ) displayDecimalPlacesRef.current = decimalPlaces const nextDraft = formatForEdit(bounded) draftRef.current = nextDraft setDraft(nextDraft) lastCommittedValueRef.current = bounded onChange(bounded) return true }, [ fineStep, formatForEdit, getActiveStep, getCurrentNumericValue, max, min, onChange, step, triggerBoundFeedback, ], ) const applyWheelNudge = useCallback( (event: WheelEvent) => { const modifiers = getDomStepModifiers({ shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey, getModifierState: (key) => event.getModifierState(key as "Shift"), }) const modifierKey = `${modifiers.coarse}:${modifiers.fine}` if (wheelModifierRef.current !== modifierKey) { wheelDeltaRef.current = 0 wheelModifierRef.current = modifierKey } const normalizedDelta = normalizeWheelDelta( event.deltaY, event.deltaMode, ) const { accumulated, direction, steps } = consumeWheelDelta( wheelDeltaRef.current, normalizedDelta, wheelSensitivity, ) wheelDeltaRef.current = accumulated if (steps === 0 || direction === 0) { event.preventDefault() return } if (applyDisplayNudge(direction, modifiers, "wheel", steps)) { event.preventDefault() } }, [applyDisplayNudge, getDomStepModifiers, wheelSensitivity], ) useEffect(() => { const node = surfaceRef.current if (!node || disabled || logoScrollEnabled) { return } const handleWheel = (event: WheelEvent) => { if (!scrub.wheelEnabled) { return } applyWheelNudge(event) } const handlePointerLeave = () => { resetWheelAccumulator() } node.addEventListener("wheel", handleWheel, { passive: false }) node.addEventListener("pointerleave", handlePointerLeave) return () => { node.removeEventListener("wheel", handleWheel) node.removeEventListener("pointerleave", handlePointerLeave) } }, [ applyWheelNudge, disabled, logoScrollEnabled, resetWheelAccumulator, scrub.wheelEnabled, ]) const jumpToBound = useCallback( (target: number) => { commit(target, "key") }, [commit], ) const handleKeyboardNudge = useCallback( (event: KeyboardEvent<HTMLElement>) => { if (disabled) { return false } const wasEditing = editingRef.current && VALUE_NUDGE_KEYS.has(event.key) if (wasEditing) { editingRef.current = false setEditing(false) } const modifiers = getDomStepModifiers({ shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey, getModifierState: (key) => event.getModifierState(key as "Shift"), }) const scheduleOrApplyNudge = ( direction: 1 | -1, nudgeModifiers: { coarse?: boolean; fine?: boolean }, source: BoundFeedbackSource, ) => { event.preventDefault() if (wasEditing) { pendingEditKeyboardNudgeRef.current = { direction, modifiers: nudgeModifiers, source, } return true } return applyDisplayNudge(direction, nudgeModifiers, source) } switch (event.key) { case "ArrowUp": return scheduleOrApplyNudge(1, modifiers, "key") case "ArrowDown": return scheduleOrApplyNudge(-1, modifiers, "key") case "PageUp": return scheduleOrApplyNudge(1, { coarse: true }, "key") case "PageDown": return scheduleOrApplyNudge(-1, { coarse: true }, "key") case "Home": if (min != null) { event.preventDefault() jumpToBound(min) return true } return false case "End": if (max != null) { event.preventDefault() jumpToBound(max) return true } return false default: return false } }, [applyDisplayNudge, disabled, getDomStepModifiers, jumpToBound, max, min], ) useLayoutEffect(() => { const pending = pendingEditKeyboardNudgeRef.current if (editing || !pending) { return } pendingEditKeyboardNudgeRef.current = null applyDisplayNudge(pending.direction, pending.modifiers, pending.source) }, [applyDisplayNudge, editing]) useEffect(() => { const handleKeyUp = (event: globalThis.KeyboardEvent) => { if (VALUE_NUDGE_KEYS.has(event.key)) { requestAnimationFrame(() => { finishInteraction() }) } } window.addEventListener("keyup", handleKeyUp) return () => { window.removeEventListener("keyup", handleKeyUp) } }, [finishInteraction]) const enterEditMode = useCallback( (point?: EditPointerPoint) => { if (disabled) { return } editingRef.current = true setEditing(true) interactingRef.current = true setDraft((current) => preserveDisplayDraft(current, value, formatForEdit(value)), ) requestAnimationFrame(() => { const input = inputRef.current if (!input) { return } requestAnimationFrame(() => { if (!inputRef.current) { return } focusInputForEdit(inputRef.current, selectOnEdit, point) }) }) }, [disabled, formatForEdit, selectOnEdit, value], ) const canScrub = !disabled && !editing const endScrubSession = useCallback( (event: PointerEvent<HTMLElement>, allowEditOnClick: boolean) => { const state = scrubRef.current if (!state) { return } const wasScrubbing = state.scrubbing scrubRef.current = null detachScrubSessionGuard() if (state.captureTarget) { try { state.captureTarget.releasePointerCapture(event.pointerId) } catch { // Pointer capture may already be released. } } if (wasScrubbing) { finishInteraction() if (boundFeedbackRef.current !== null) { setBoundFeedback(null) } setDraft((current) => { const nextDraft = preserveDisplayDraft( current, value, formatForEdit(value), ) draftRef.current = nextDraft displayDecimalPlacesRef.current = resolveDisplayDecimalPlaces( nextDraft, userDecimalPlacesRef.current, ) return nextDraft }) notifyCommit(value) event.preventDefault() return } if ( isFineModifierPressed( toModifierKeys({ shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey, getModifierState: (key) => event.getModifierState(key as "Shift"), }), fineModifier, ) && resetToDefault() ) { event.preventDefault() return } const now = Date.now() const lastClick = lastClickRef.current if ( lastClick && now - lastClick.time < 300 && Math.hypot(event.clientX - lastClick.x, event.clientY - lastClick.y) < 5 ) { lastClickRef.current = null if (resetToDefault()) { event.preventDefault() return } } else { lastClickRef.current = { time: now, x: event.clientX, y: event.clientY, } } if (allowEditOnClick && state.source === "input") { enterEditMode({ clientX: event.clientX, clientY: event.clientY, }) } }, [detachScrubSessionGuard, enterEditMode, fineModifier, finishInteraction, formatForEdit, notifyCommit, resetToDefault, value], ) const attachScrubSessionGuard = useCallback(() => { detachScrubSessionGuard() const handleGlobalPointerEnd = (event: globalThis.PointerEvent) => { const state = scrubRef.current if (!state || event.pointerId !== state.pointerId) { return } endScrubSession( event as unknown as PointerEvent<HTMLElement>, state.source === "input", ) } document.addEventListener("pointerup", handleGlobalPointerEnd) document.addEventListener("pointercancel", handleGlobalPointerEnd) scrubSessionGuardRef.current = () => { document.removeEventListener("pointerup", handleGlobalPointerEnd) document.removeEventListener("pointercancel", handleGlobalPointerEnd) } }, [detachScrubSessionGuard, endScrubSession]) const beginPointerCapture = useCallback( (captureTarget: HTMLElement | null, pointerId: number) => { const state = scrubRef.current if (!state || !captureTarget) { return } state.captureTarget = captureTarget captureTarget.blur() try { captureTarget.setPointerCapture(pointerId) } catch { // Pointer capture may be unavailable. } }, [], ) const activateScrubbing = useCallback( ( event: PointerEvent<HTMLElement>, captureTarget: HTMLElement | null, ) => { const state = scrubRef.current if (!state || state.scrubbing) { return } state.scrubbing = true interactingRef.current = true event.preventDefault() beginPointerCapture(captureTarget, event.pointerId) }, [beginPointerCapture], ) const applyScrubDelta = useCallback( (event: PointerEvent<HTMLElement>) => { const state = scrubRef.current if (!state) { return } if (event.pointerType === "mouse" && event.buttons === 0) { endScrubSession(event, state.source === "input") return } if ( !state.scrubbing && hasExceededScrubThreshold( event, state.startX, state.startY, scrub.direction, scrubThreshold, ) ) { activateScrubbing(event, event.currentTarget) } if (!state.scrubbing) { return } const pointerDelta = getScrubPointerDelta( event, state.startX, state.startY, scrub.direction, ) const modifiers = getDomStepModifiers({ shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey, getModifierState: (key) => event.getModifierState(key as "Shift"), }) const delta = getActiveStep(modifiers) const effectiveDelta = pointerDelta / scrub.sensitivity const attempted = applyStepDelta( state.startValue, effectiveDelta * delta, { step, fineStep, fine: modifiers.fine, userDecimalPlaces: userDecimalPlacesRef.current, }, ) const scrubDirection = effectiveDelta === 0 ? undefined : effectiveDelta > 0 ? 1 : -1 commit( attempted, "scrub", resolveCommitQuantizeStep(state.startValue, modifiers.fine), delta, scrubDirection, ) }, [ activateScrubbing, commit, endScrubSession, fineStep, getActiveStep, getDomStepModifiers, resolveCommitQuantizeStep, scrub.direction, scrub.sensitivity, scrubThreshold, step, ], ) const beginLabelScrub = useCallback( (event: PointerEvent<HTMLElement>) => { if (!canScrub) { return } if (event.pointerType === "mouse" && event.button !== 0) { return } const current = Number(draft.replace(/^\+/, "")) if (!Number.isFinite(current)) { return } scrubRef.current = { captureTarget: event.currentTarget, pointerId: event.pointerId, scrubbing: false, source: "label", startValue: current, startX: event.clientX, startY: event.clientY, } attachScrubSessionGuard() event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) }, [attachScrubSessionGuard, canScrub, draft], ) const beginInputScrub = useCallback( (event: PointerEvent<HTMLElement>) => { if (!canScrub) { return } if (event.pointerType === "mouse" && event.button !== 0) { return } const current = Number(draft.replace(/^\+/, "")) if (!Number.isFinite(current)) { return } scrubRef.current = { captureTarget: null, pointerId: event.pointerId, scrubbing: false, source: "input", startValue: current, startX: event.clientX, startY: event.clientY, } attachScrubSessionGuard() event.preventDefault() }, [attachScrubSessionGuard, canScrub, draft], ) const onInputPointerMove = applyScrubDelta const scrubSurfaceHandlers = useMemo( () => ({ onPointerCancel: (event: PointerEvent<HTMLElement>) => { endScrubSession(event, false) }, onPointerDown: beginInputScrub, onPointerMove: onInputPointerMove, onPointerUp: (event: PointerEvent<HTMLElement>) => { endScrubSession(event, true) }, }), [beginInputScrub, endScrubSession, onInputPointerMove], ) const logoScrubHandlers = useMemo( () => ({ onPointerCancel: (event: PointerEvent<HTMLElement>) => { endScrubSession(event, false) }, onPointerDown: beginLabelScrub, onPointerMove: applyScrubDelta, onPointerUp: (event: PointerEvent<HTMLElement>) => { endScrubSession(event, false) }, }), [applyScrubDelta, beginLabelScrub, endScrubSession], ) const focusDisplaySurface = useCallback(() => { requestAnimationFrame(() => { displaySurfaceRef.current?.focus() }) }, []) const onDisplayFocus = useCallback(() => { interactingRef.current = true }, []) const onDisplayBlur = useCallback(() => { finishInteraction() resetWheelAccumulator() }, [finishInteraction, resetWheelAccumulator]) const activateEdit = enterEditMode const atBound = getAtBound(value, min, max) const spinbuttonProps = { "aria-valuemax": max, "aria-valuemin": min, "aria-valuenow": value, role: "spinbutton" as const, } const inputProps = { ...spinbuttonProps, "aria-invalid": invalid || undefined, "data-slot": "scrub-number-scrubbable", inputMode: "decimal" as const, onBlur: () => { interactingRef.current = false editingRef.current = false setEditing(false) const currentDraft = draftRef.current const draftBody = currentDraft.replace(/^\+/, "").trim() if (draftBody === "" || draftBody === "-" || draftBody === "+" || draftBody === ".") { setInvalid(true) setDraft(formatForEdit(value)) window.setTimeout(() => { setInvalid(false) }, 600) return } const parsed = Number(draftBody) if (Number.isFinite(parsed)) { setInvalid(false) const decimalPlaces = countDraftDecimalPlaces(currentDraft) userDecimalPlacesRef.current = decimalPlaces > 0 ? decimalPlaces : null const bounded = commit( parsed, undefined, resolveCommitQuantizeStep(parsed), ) notifyCommit(bounded) return } setInvalid(true) setDraft(formatForEdit(value)) window.setTimeout(() => { setInvalid(false) }, 600) }, onChange: (event: ChangeEvent<HTMLInputElement>) => { setInvalid(false) const nextValue = sanitizeNumericDraft( event.currentTarget.value, draftRef.current, ) draftRef.current = nextValue setDraft(nextValue) }, onFocus: () => { interactingRef.current = true setInvalid(false) }, onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") { event.currentTarget.blur() return } if (event.key === "Escape") { setInvalid(false) const revertedDraft = formatForEdit(value) draftRef.current = revertedDraft setDraft(revertedDraft) editingRef.current = false setEditing(false) event.currentTarget.blur() return } if (handleKeyboardNudge(event)) { editingRef.current = false event.currentTarget.blur() focusDisplaySurface() return } }, ref: inputRef, type: "text" as const, value: draft, } const handleDisplayKeyDown = useCallback( (event: KeyboardEvent<HTMLElement>) => { if (disabled) { return } if (handleKeyboardNudge(event)) { return } if (event.key === "Enter" || event.key === " ") { event.preventDefault() enterEditMode() } }, [disabled, enterEditMode, handleKeyboardNudge], ) const visibleDisplayValue = (() => { if (editing) { return draft } const draftNumeric = Number(draft.replace(/^\+/, "")) if ( Number.isFinite(draftNumeric) && draftNumeric === lastCommittedValueRef.current && lastCommittedValueRef.current !== value ) { return formatForDisplay(lastCommittedValueRef.current) } return formatForDisplay(value) })() return { activateEdit, atBound, boundFeedback, canScrub, clearBoundFeedback, displaySurfaceRef, displayValue: visibleDisplayValue, editing, handleDisplayKeyDown, inputProps, inputRef, interactingRef, interactionEpoch, invalid, logoScrubHandlers, logoScrollEnabled, nudgeTrend: lastNudgeDirectionRef.current, onDisplayBlur, onDisplayFocus, scrubSurfaceHandlers, spinbuttonProps, surfaceRef, } } function splitSignedDisplayValue(value: string) { if (value.startsWith("-")) { return { body: value.slice(1), sign: "-" } } if (value.startsWith("+")) { return { body: value.slice(1), sign: "+" } } return { body: value, sign: "" } } function mirrorInputTypography(source: HTMLElement): CSSProperties { const computed = getComputedStyle(source) return { fontFamily: computed.fontFamily, fontFeatureSettings: computed.fontFeatureSettings, fontSize: computed.fontSize, fontStyle: computed.fontStyle, fontVariantNumeric: computed.fontVariantNumeric as CSSProperties["fontVariantNumeric"], fontWeight: computed.fontWeight, letterSpacing: computed.letterSpacing, lineHeight: computed.lineHeight, } } function mirrorCalligraphTypography(source: HTMLElement): CSSProperties { const computed = getComputedStyle(source) const { lineHeight: _lineHeight, ...typography } = mirrorInputTypography(source) return { ...typography, lineHeight: 1, } } function CalligraphNumber({ contentRef, layoutKey, settings = DEFAULT_CALLIGRAPH_SETTINGS, style, trend = 0, value, }: { contentRef?: RefObject<HTMLSpanElement | null> layoutKey?: string settings?: CalligraphSettings style?: CSSProperties trend?: 1 | -1 | 0 value: string }) { const shouldReduceMotion = useReducedMotion() const { body, sign } = splitSignedDisplayValue(value) const animation = settings.animation === "default" ? undefined : settings.animation if (shouldReduceMotion) { return ( <span ref={contentRef} data-slot="scrub-number-calligraph-content" style={style} > {value} </span> ) } return ( <span ref={contentRef} className="inline-flex items-center justify-start" data-slot="scrub-number-calligraph-content" style={style} > {sign ? ( <span aria-hidden="true" className="inline-block" style={style}> {sign} </span> ) : null} <Calligraph key={layoutKey} animation={animation} autoSize={settings.autoSize} className="scrub-number-calligraph inline-flex items-center justify-start leading-none" stagger={settings.stagger} style={style} trend={trend} variant={settings.variant} > {body} </Calligraph> </span> ) } function getFieldClasses(inputClassName?: string, extra?: string) { return cn( scrubFieldVariants(), SCRUB_NUMBER_FIELD_CLASS, SCRUB_NUMBER_SPINNER_HIDE_CLASS, extra, inputClassName, ) } export function ScrubNumberInput({ calligraph = DEFAULT_CALLIGRAPH_SETTINGS, className, disabled, grouped = false, inputClassName, inputSettings = DEFAULT_INPUT_SETTINGS, logo = DEFAULT_LOGO_SETTINGS, max, min, scrub, scrubSettings = DEFAULT_SCRUB_SETTINGS, ...props }: { calligraph?: CalligraphSettings className?: string disabled?: boolean grouped?: boolean inputClassName?: string inputSettings?: InputSettings logo?: LogoSettings max?: number min?: number scrub: ScrubState scrubSettings?: ScrubSettings } & Omit<ComponentProps<"input">, "onChange" | "type" | "value" | "size">) { const scrubBounds = { min, max } const fieldClass = getFieldClasses(inputClassName) const ariaLabel = props["aria-label"] const mirrorRef = useRef<HTMLInputElement>(null) const calligraphClipRef = useRef<HTMLDivElement>(null) const calligraphContentRef = useRef<HTMLSpanElement>(null) const [mirroredTypography, setMirroredTypography] = useState<CSSProperties>({}) const prevTypographyRef = useRef<string>("") const logoScrollEnabled = scrub.logoScrollEnabled const usesInputGroup = logoScrollEnabled const usesGroupedControl = grouped || logoScrollEnabled const isDisplayTruncated = useDisplayOverflowTruncated( calligraphClipRef, [scrub.displayValue, mirroredTypography, scrub.editing, scrub.interactionEpoch], mirrorRef, ) const displaySpinbuttonProps = { ...scrub.spinbuttonProps, ...(isDisplayTruncated ? { "aria-valuetext": scrub.displayValue } : {}), } const syncMirroredTypography = useCallback(() => { if (scrub.interactingRef.current) { return } const source = scrub.editing ? scrub.inputRef.current : mirrorRef.current if (!source) { return } const nextTypography = mirrorCalligraphTypography(source) const nextKey = JSON.stringify(nextTypography) const typographyChanged = nextKey !== prevTypographyRef.current prevTypographyRef.current = nextKey if (!typographyChanged) { return } setMirroredTypography(nextTypography) }, [scrub.editing, scrub.inputRef, scrub.interactingRef]) useLayoutEffect(() => { syncMirroredTypography() const source = scrub.editing ? scrub.inputRef.current : mirrorRef.current if (!source || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(syncMirroredTypography) observer.observe(source) return () => { observer.disconnect() } }, [scrub.displayValue, scrub.editing, scrub.interactionEpoch, syncMirroredTypography]) const groupControlClass = "relative z-[1] flex min-w-0 w-full flex-1 items-center justify-start overflow-hidden rounded-none border-0 bg-transparent text-foreground shadow-none dark:bg-transparent" const calligraphLayoutKey = usesGroupedControl ? "group" : "field" const scrubSurface = scrub.editing ? ( <Input {...props} {...scrub.inputProps} className={cn( fieldClass, usesGroupedControl ? "w-full rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent" : "relative z-[1] scrub-bound-field", "text-start", )} disabled={disabled} data-slot={usesGroupedControl ? "input-group-control" : undefined} /> ) : ( <div ref={scrub.displaySurfaceRef} {...(logoScrollEnabled ? {} : scrub.scrubSurfaceHandlers)} {...displaySpinbuttonProps} aria-label={typeof ariaLabel === "string" ? ariaLabel : undefined} aria-invalid={scrub.invalid || undefined} className={cn( fieldClass, usesGroupedControl ? groupControlClass : cn( "relative z-[1] flex items-center justify-start text-foreground scrub-bound-field", ), !logoScrollEnabled && scrub.canScrub && getScrubCursorClass(scrubSettings, scrub.atBound, scrubBounds), !logoScrollEnabled && scrub.canScrub && "select-none", logoScrollEnabled && "cursor-text", disabled && "cursor-not-allowed opacity-50", scrub.invalid && "is-bound-error", )} data-slot={usesGroupedControl ? "input-group-control" : "scrub-number-scrubbable"} tabIndex={disabled ? -1 : 0} title={isDisplayTruncated ? scrub.displayValue : undefined} onClick={ logoScrollEnabled && !disabled ? () => { scrub.activateEdit() } : undefined } onBlur={scrub.onDisplayBlur} onFocus={scrub.onDisplayFocus} onKeyDown={scrub.handleDisplayKeyDown} > <motion.div {...(grouped ? {} : { layoutRoot: true })} ref={calligraphClipRef} className={cn( "pointer-events-none relative flex w-full min-w-0 items-center justify-start overflow-hidden text-foreground", )} data-slot="scrub-number-calligraph-value" style={mirroredTypography} > <CalligraphNumber contentRef={calligraphContentRef} layoutKey={calligraphLayoutKey} settings={calligraph} style={mirroredTypography} trend={scrub.nudgeTrend} value={scrub.displayValue} /> </motion.div> </div> ) const fieldContent = ( <div className={cn( "relative", usesGroupedControl ? "flex min-w-0 flex-1 overflow-hidden" : "shrink-0", )} > <Input ref={mirrorRef} aria-hidden className={fieldClass} readOnly tabIndex={-1} value={scrub.displayValue} style={{ inset: 0, opacity: 0, pointerEvents: "none", position: "absolute", zIndex: 0, }} /> {scrubSurface} </div> ) const inputGroup = ( <InputGroup className={cn("h-7 scrub-bound-field w-full")} data-logo-scroll={logoScrollEnabled ? "" : undefined} > {logoScrollEnabled ? ( <InputGroupAddon align="inline-start" aria-hidden className="pointer-events-none invisible shrink-0 select-none pl-1.5" tabIndex={-1} > <ScrubLogoIcon className="size-3.5 shrink-0" name={logo.icon} /> </InputGroupAddon> ) : null} {fieldContent} {logoScrollEnabled ? ( <InputGroupAddon align="inline-end" {...scrub.logoScrubHandlers} className={cn( "shrink-0 select-none pr-1.5", scrub.canScrub && getScrubCursorClass(scrubSettings, scrub.atBound, scrubBounds), )} data-slot="scrub-number-logo-scroll" onClick={(event) => { event.preventDefault() }} > <ScrubLogoIcon className="pointer-events-none size-3.5 shrink-0 text-muted-foreground" name={logo.icon} /> </InputGroupAddon> ) : null} </InputGroup> ) return ( <div ref={scrub.surfaceRef} className={cn("relative shrink-0", className)}> <ScrubBoundFeedback boundFeedback={scrub.boundFeedback} className={usesInputGroup || grouped ? "w-full min-w-0" : undefined} direction={scrubSettings.direction} mode={scrubSettings.boundFeedback} onFeedbackComplete={scrub.clearBoundFeedback} > {usesInputGroup ? inputGroup : fieldContent} </ScrubBoundFeedback> </div> ) } export type ScrubNumberFieldProps = Omit< ComponentProps<"input">, "onChange" | "type" | "value" | "defaultValue" | "size" > & { calligraph?: CalligraphSettings format?: FormatSettings formatValue?: (value: number) => string inputSettings?: InputSettings label?: string labelClassName?: string logo?: LogoSettings onValueChange?: (value: number) => void onValueCommit?: (value: number) => void scrub?: ScrubSettings shiftStep?: number grouped?: boolean value?: number defaultValue?: number defaultResetValue?: number min?: number max?: number step?: number className?: string inputClassName?: string } export function ScrubNumberField({ calligraph, className, defaultResetValue, defaultValue, disabled, format = DEFAULT_FORMAT_SETTINGS, formatValue, grouped = false, inputSettings = DEFAULT_INPUT_SETTINGS, label, labelClassName, logo = DEFAULT_LOGO_SETTINGS, max, min, onValueChange, onValueCommit, scrub: scrubSettings = DEFAULT_SCRUB_SETTINGS, shiftStep, step, value: valueProp, inputClassName, ...props }: ScrubNumberFieldProps) { const { min: normalizedMin, max: normalizedMax } = normalizeNumberFieldBounds( min, max, ) const [value, setValue] = useControllableState({ prop: valueProp, defaultProp: defaultValue ?? 0, onChange: onValueChange, caller: "ScrubNumberField", }) const resetValue = defaultResetValue ?? defaultValue const scrub = useNumberScrub({ disabled, format, formatValue, logo, max: normalizedMax, min: normalizedMin, onChange: setValue, onValueCommit, defaultResetValue: resetValue, scrub: scrubSettings, selectOnEdit: inputSettings.selectOnEdit, shiftStep, step: typeof step === "number" ? step : undefined, value, }) const field = ( <div className="min-w-0"> <ScrubNumberInput {...props} calligraph={calligraph} className={cn( grouped ? "min-w-0 flex-1" : logo.enabled ? "w-[6.75rem]" : "w-[4.75rem]", className, )} disabled={disabled} grouped={grouped} inputClassName={inputClassName} inputSettings={inputSettings} logo={logo} max={normalizedMax} min={normalizedMin} scrub={scrub} scrubSettings={scrubSettings} /> </div> ) if (!label) { return field } return ( <div className="flex items-center gap-3"> <span className={cn( "w-16 text-sm font-medium text-muted-foreground", labelClassName, )} > {label} </span> {field} </div> ) }/* Required for Calligraph slot clipping in ScrubNumberInput. Import alongside scrub-number-input.tsx: import "./scrub-number-input.css" */ [data-slot="scrub-number-calligraph-value"], [data-slot="scrub-number-calligraph-value"] .scrub-number-calligraph { line-height: 1; overflow: hidden; } [data-slot="scrub-number-calligraph-value"] { color: inherit; display: flex; justify-content: flex-start; position: relative; } [data-slot="scrub-number-calligraph-value"] .scrub-number-calligraph, [data-slot="scrub-number-calligraph-value"] .scrub-number-calligraph * { color: inherit; } [data-slot="scrub-number-calligraph-value"] .scrub-number-calligraph { align-items: center !important; justify-content: flex-start !important; } [data-slot="scrub-number-calligraph-value"] .scrub-number-calligraph > span { align-items: center !important; display: inline-flex !important; justify-content: flex-start !important; margin: 0 !important; overflow: hidden !important; padding: 0 !important; } [data-slot="scrub-number-calligraph-value"] .scrub-number-calligraph span[style*="vertical-align: top"] { align-items: center !important; display: inline-flex !important; height: 1em !important; justify-content: flex-start !important; line-height: 1 !important; max-height: 1em !important; overflow: hidden !important; vertical-align: middle !important; } [data-slot="scrub-number-calligraph-value"] .scrub-number-calligraph span[style*="visibility: hidden"] { font: inherit !important; line-height: inherit !important; } [data-slot="scrub-number-scrubbable"], [data-slot="input-group-control"] { text-align: start; } /* Transitions.dev — bound-limit shake (adapted for scrub fields) */ :root { --scrub-bound-shake-distance: 6px; --scrub-bound-shake-overshoot: 4px; --scrub-bound-shake-dur-a: 80ms; --scrub-bound-shake-dur-b: 60ms; --scrub-bound-shake-ease: cubic-bezier(0.22, 1, 0.36, 1); --scrub-bound-feedback-ms: calc( var(--scrub-bound-shake-dur-a) * 2 + var(--scrub-bound-shake-dur-b) * 2 ); --scrub-bound-revert-hold: 600ms; --scrub-bound-revert-dur: 280ms; --scrub-bound-shake-color: var(--foreground); --scrub-number-field-padding-inline: 0.5rem; } /* Boundary feedback: border pulse on blocked attempt */ @keyframes scrub-bound-pulse { 0% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--scrub-bound-shake-color) 60%, transparent); } 100% { box-shadow: 0 0 0 3px color-mix(in oklch, var(--scrub-bound-shake-color) 0%, transparent); } } [data-slot="scrub-bound-feedback"][data-bound-hit] [data-slot="input-group"], [data-slot="scrub-bound-feedback"][data-bound-hit] [data-slot="scrub-number-scrubbable"], [data-slot="scrub-bound-feedback"][data-bound-hit] [data-slot="input-group-control"] { animation: scrub-bound-pulse var(--scrub-bound-feedback-ms) linear; } @media (prefers-reduced-motion: reduce) { [data-slot="scrub-bound-feedback"][data-bound-hit] [data-slot="input-group"], [data-slot="scrub-bound-feedback"][data-bound-hit] [data-slot="scrub-number-scrubbable"], [data-slot="scrub-bound-feedback"][data-bound-hit] [data-slot="input-group-control"] { animation: none; box-shadow: 0 0 0 2px var(--scrub-bound-shake-color); } } .scrub-bound-field, [data-slot="scrub-number-scrubbable"], [data-slot="input-group-control"], [data-slot="scrub-bound-feedback"] [data-slot="input-group"] [data-slot="input-group-control"] { padding-inline: var(--scrub-number-field-padding-inline) !important; } .scrub-bound-field { transition: border-color 150ms ease-out; will-change: transform; } .scrub-bound-wrap.is-bound-error .scrub-bound-field { border-color: var(--scrub-bound-shake-color); transition: border-color var(--scrub-bound-revert-dur) ease-out; } .scrub-bound-field.is-shaking { animation: scrub-bound-shake-x var(--scrub-bound-feedback-ms) linear; } .scrub-bound-wrap[data-bound-direction="vertical"] .scrub-bound-field.is-shaking { animation-name: scrub-bound-shake-y; } @keyframes scrub-bound-shake-x { 0% { transform: translateX(0); animation-timing-function: var(--scrub-bound-shake-ease); } 28.57% { transform: translateX(var(--scrub-bound-shake-distance)); animation-timing-function: var(--scrub-bound-shake-ease); } 57.14% { transform: translateX(calc(var(--scrub-bound-shake-distance) * -1)); animation-timing-function: var(--scrub-bound-shake-ease); } 78.57% { transform: translateX(var(--scrub-bound-shake-overshoot)); animation-timing-function: var(--scrub-bound-shake-ease); } 100% { transform: translateX(0); } } @keyframes scrub-bound-shake-y { 0% { transform: translateY(0); animation-timing-function: var(--scrub-bound-shake-ease); } 28.57% { transform: translateY(var(--scrub-bound-shake-distance)); animation-timing-function: var(--scrub-bound-shake-ease); } 57.14% { transform: translateY(calc(var(--scrub-bound-shake-distance) * -1)); animation-timing-function: var(--scrub-bound-shake-ease); } 78.57% { transform: translateY(var(--scrub-bound-shake-overshoot)); animation-timing-function: var(--scrub-bound-shake-ease); } 100% { transform: translateY(0); } } @media (prefers-reduced-motion: reduce) { .scrub-bound-field.is-shaking { animation: none !important; transform: none !important; } }export type DisplayFormat = { alwaysShowSign: boolean } export type ResolveActiveStepOptions = { step: number shiftStep: number fineStep: number coarse?: boolean fine?: boolean } export type ModifierKey = "shift" | "alt" | "meta" export type FineModifier = "shift" | "alt" | "meta" export type CoarseModifier = "shift" | "alt" | "meta" const FINE_MODIFIER_KEYS = new Set<FineModifier>(["shift", "alt", "meta"]) const COARSE_MODIFIER_KEYS = new Set<CoarseModifier>(["shift", "alt", "meta"]) export type ModifierKeys = { shiftKey: boolean altKey: boolean metaKey: boolean getModifierState?: (key: string) => boolean } const DEFAULT_SCRUB_THRESHOLD = 3 export function isMacPlatform() { if (typeof navigator === "undefined") { return false } const platform = navigator.platform ?? "" const userAgent = navigator.userAgent ?? "" return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac OS X/.test(userAgent) } export function isModifierKeyPressed(event: ModifierKeys, key: ModifierKey) { const getState = event.getModifierState?.bind(event) switch (key) { case "shift": return event.shiftKey || getState?.("Shift") === true case "alt": return event.altKey || getState?.("Alt") === true case "meta": return event.metaKey || getState?.("Meta") === true } } export function normalizeFineModifier( modifier: string | undefined, fallback: FineModifier = "alt", ): FineModifier { if (modifier != null && FINE_MODIFIER_KEYS.has(modifier as FineModifier)) { return modifier as FineModifier } if (modifier === "auto") { return isMacPlatform() ? "meta" : "alt" } return fallback } export function normalizeCoarseModifier( modifier: string | undefined, fallback: CoarseModifier = "shift", ): CoarseModifier { if (modifier != null && COARSE_MODIFIER_KEYS.has(modifier as CoarseModifier)) { return modifier as CoarseModifier } if (modifier === "auto") { return "shift" } return fallback } export const MODIFIER_OPTIONS = ["shift", "alt", "meta"] as const satisfies readonly ModifierKey[] export function pickAlternateModifier( current: ModifierKey, exclude: ModifierKey, ): ModifierKey { return MODIFIER_OPTIONS.find((option) => option !== exclude) ?? current } export function resolveExclusiveModifiers( fine: FineModifier, coarse: CoarseModifier, ): { fine: FineModifier; coarse: CoarseModifier } { if (fine !== coarse) { return { fine, coarse } } return { fine, coarse: pickAlternateModifier(coarse, fine) as CoarseModifier, } } export function resolveFineModifierKey( modifier: FineModifier = "alt", ): ModifierKey { return modifier } export function resolveCoarseModifierKey( modifier: CoarseModifier = "shift", ): ModifierKey { return modifier } export function isFineModifierPressed( event: ModifierKeys, modifier: FineModifier = "alt", ) { return isModifierKeyPressed(event, resolveFineModifierKey(modifier)) } export function isCoarseModifierPressed( event: ModifierKeys, modifier: CoarseModifier = "shift", ) { return isModifierKeyPressed(event, resolveCoarseModifierKey(modifier)) } export function getModifierLabel(key: ModifierKey) { switch (key) { case "shift": return "Shift" case "alt": return "Alt" case "meta": return "Cmd" } } export function getFineModifierLabel(modifier: FineModifier = "alt") { return getModifierLabel(resolveFineModifierKey(modifier)) } export function getCoarseModifierLabel(modifier: CoarseModifier = "shift") { return getModifierLabel(resolveCoarseModifierKey(modifier)) } export type ScrubStepModifiers = { coarse: boolean fine: boolean } export function toModifierKeys(event: { shiftKey: boolean altKey: boolean metaKey: boolean getModifierState?: (key: string) => boolean }): ModifierKeys { return { shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey, getModifierState: event.getModifierState ? (key) => event.getModifierState!(key) : undefined, } } export function resolveScrubStepModifiers( event: ModifierKeys, options: { fineModifier?: FineModifier coarseModifier?: CoarseModifier } = {}, ): ScrubStepModifiers { const coarse = isCoarseModifierPressed(event, options.coarseModifier) const fine = isFineModifierPressed(event, options.fineModifier) return { coarse, fine } } export function clampNumber(value: number, min?: number, max?: number) { let bounded = value if (min != null && Number.isFinite(min)) { bounded = Math.max(min, bounded) } if (max != null && Number.isFinite(max)) { bounded = Math.min(max, bounded) } return bounded } export function normalizeFiniteNumber( value: number | undefined, ): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined } export function normalizeNumberFieldBounds( min?: number, max?: number, ): { min?: number; max?: number } { let normalizedMin = normalizeFiniteNumber(min) let normalizedMax = normalizeFiniteNumber(max) if ( normalizedMin != null && normalizedMax != null && normalizedMin > normalizedMax ) { ;[normalizedMin, normalizedMax] = [normalizedMax, normalizedMin] } return { min: normalizedMin, max: normalizedMax } } export function normalizePositiveFiniteStep( step: number | undefined, fallback = 1, ) { if ( typeof step === "number" && Number.isFinite(step) && step > 0 ) { return step } return fallback } export function normalizeWheelSensitivity( sensitivity: number | undefined, fallback = 20, ) { return clampNumber( normalizePositiveFiniteStep(sensitivity, fallback), 1, 200, ) } export function normalizeScrubThreshold(threshold: number | undefined, fallback = 3) { const candidate = typeof threshold === "number" && Number.isFinite(threshold) ? threshold : fallback return clampNumber(candidate, 1, 20) } export function getBoundEdge( current: number, attempted: number, min?: number, max?: number, ): "min" | "max" | null { if (attempted > current && max != null && current >= max) { return "max" } if (attempted < current && min != null && current <= min) { return "min" } return null } export function getAtBound( value: number, min?: number, max?: number, ): "min" | "max" | null { if (max != null && value >= max) { return "max" } if (min != null && value <= min) { return "min" } return null } export function boundOverflow( attempted: number, edge: "min" | "max", min?: number, max?: number, ) { if (edge === "max" && max != null) { return Math.max(0, attempted - max) } if (edge === "min" && min != null) { return Math.max(0, min - attempted) } return 1 } export function quantizeNumber(value: number, step: number) { if (!Number.isFinite(step) || step <= 0) { return value } const quantized = Math.round(value / step) * step if (Number.isInteger(step)) { return quantized } const decimals = step.toString().split(".")[1]?.length ?? 0 return parseFloat(quantized.toFixed(decimals)) } export function getDecimalPlaces(step: number) { if (!Number.isFinite(step) || Number.isInteger(step)) { return 0 } return step.toString().split(".")[1]?.length ?? 0 } function toPlainNumberString(value: number) { if (!Number.isFinite(value)) { return String(value) } if (Object.is(value, -0)) { return "0" } const str = value.toString() if (!/[eE]/.test(str)) { return str } return value.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 100, }) } export function formatMinimalDisplayValue(value: number) { const normalized = Number(value.toPrecision(12)) if (Number.isInteger(normalized)) { return toPlainNumberString(Math.trunc(normalized)) } return toPlainNumberString(normalized) } export function countDraftDecimalPlaces(draft: string) { const body = draft.trim().replace(/^[+-]/, "") if (!body.includes(".")) { return 0 } return body.split(".")[1]?.length ?? 0 } export function resolveDisplayDecimalPlaces( previousDraft: string, userDecimalPlaces?: number | null, activeStep?: number, ) { if (userDecimalPlaces != null && userDecimalPlaces > 0) { return userDecimalPlaces } const fromDraft = countDraftDecimalPlaces(previousDraft) if (fromDraft > 0) { return fromDraft } const fromStep = activeStep != null ? getDecimalPlaces(activeStep) : 0 return fromStep > 0 ? fromStep : null } /** Minimal display by default; decimals only after the user typed them. */ export function formatDisplayValue( value: number, format: Pick<DisplayFormat, "alwaysShowSign">, userDecimalPlaces: number | null = null, ) { const formatted = userDecimalPlaces != null && userDecimalPlaces > 0 ? value.toFixed(userDecimalPlaces) : formatMinimalDisplayValue(value) if (format.alwaysShowSign && value > 0) { return `+${formatted}` } return formatted } export function resolveActiveStep(options: ResolveActiveStepOptions) { const { step, shiftStep, fineStep, coarse = false, fine = false } = options if (coarse) { return shiftStep } if (fine) { return fineStep } return step } const DEFAULT_WHEEL_LINE_DELTA = 16 const DEFAULT_WHEEL_PAGE_DELTA = 100 export function normalizeWheelDelta(deltaY: number, deltaMode = 0) { switch (deltaMode) { case 1: return deltaY * DEFAULT_WHEEL_LINE_DELTA case 2: return deltaY * DEFAULT_WHEEL_PAGE_DELTA default: return deltaY } } export type ConsumeWheelDeltaResult = { accumulated: number direction: 0 | 1 | -1 steps: number } export function consumeWheelDelta( accumulated: number, deltaY: number, sensitivity: number, ): ConsumeWheelDeltaResult { const next = accumulated + deltaY const abs = Math.abs(next) const threshold = Math.max(1, sensitivity) if (abs < threshold) { return { accumulated: next, steps: 0, direction: 0 } } const steps = Math.floor(abs / threshold) const sign = next < 0 ? -1 : 1 const remainder = next - sign * steps * threshold const direction = (next < 0 ? 1 : -1) as 1 | -1 return { accumulated: remainder, steps, direction } } export function getValueDecimalPlaces(value: number) { if (!Number.isFinite(value)) { return 0 } const normalized = Number(value.toPrecision(12)) if (Number.isInteger(normalized)) { return 0 } const text = normalized.toString() if (text.includes("e") || text.includes("E")) { return getValueDecimalPlaces(Number(normalized.toFixed(12))) } return text.split(".")[1]?.length ?? 0 } export function stepFromDecimalPlaces(decimals: number) { if (decimals <= 0) { return 1 } return Number((1 / 10 ** decimals).toFixed(decimals)) } export function resolveQuantizeStep(options: { step: number fineStep: number fine?: boolean currentValue: number userDecimalPlaces?: number | null }) { const activeStep = options.fine ? options.fineStep : options.step const valuePrecision = Math.max( getValueDecimalPlaces(options.currentValue), options.userDecimalPlaces ?? 0, ) const stepPrecision = getDecimalPlaces(activeStep) if (valuePrecision > stepPrecision) { return stepFromDecimalPlaces(valuePrecision) } return activeStep } export function applyStepDelta( current: number, delta: number, options: { step: number fineStep: number fine?: boolean userDecimalPlaces?: number | null }, ) { const next = current + delta if (options.userDecimalPlaces != null && options.userDecimalPlaces > 0) { return parseFloat(next.toFixed(options.userDecimalPlaces)) } const quantizeStep = resolveQuantizeStep({ step: options.step, fineStep: options.fineStep, fine: options.fine, currentValue: current, userDecimalPlaces: options.userDecimalPlaces, }) return quantizeNumber(next, quantizeStep) } /** Keeps the visible draft string when entering edit if it still matches the numeric value. */ export function preserveDisplayDraft( currentDraft: string, value: number, fallback: string, ) { const parsed = Number(currentDraft.replace(/^\+/, "")) if (currentDraft !== "" && Number.isFinite(parsed) && parsed === value) { return currentDraft } return fallback } export function getScrubPointerDelta( event: { clientX: number; clientY: number }, startX: number, startY: number, direction: "horizontal" | "vertical", ) { if (direction === "vertical") { return startY - event.clientY } return event.clientX - startX } export function hasExceededScrubThreshold( event: { clientX: number; clientY: number }, startX: number, startY: number, direction: "horizontal" | "vertical", threshold = DEFAULT_SCRUB_THRESHOLD, ) { const effectiveThreshold = Math.max(1, threshold) if (direction === "vertical") { return Math.abs(event.clientY - startY) > effectiveThreshold } return Math.abs(event.clientX - startX) > effectiveThreshold } export function resolveFineStep(step: number, fineStep?: number) { if (fineStep != null && Number.isFinite(fineStep) && fineStep > 0) { return fineStep } return quantizeNumber(step / 10, step) || step / 10 } /** Keeps only characters valid while typing a numeric draft (+, -, digits, one dot). */ export function sanitizeNumericDraft(value: string, previousValue = "") { if (value === "") { return "" } if (/^[+-]{2,}/.test(value)) { return previousValue } if (/[eE]/.test(value) || (value.match(/\./g)?.length ?? 0) > 1) { return previousValue } let sign = "" let rest = value if (rest.startsWith("+") || rest.startsWith("-")) { sign = rest[0] rest = rest.slice(1) } let hasDot = false let body = "" for (const character of rest) { if (character >= "0" && character <= "9") { body += character continue } if (character === "." && !hasDot) { hasDot = true body += character } } const sanitized = sign + body return sanitized }import { useLayoutEffect, useState, type RefObject } from "react" /** * Detects when display content overflows its container (scrollWidth > clientWidth). * Used for Figma-style right-anchored number fields: leading digits are masked, * trailing digits stay visible, and consumers can expose the full value via title/aria. */ export function measureDisplayOverflow(element: HTMLElement | null) { if (!element) { return false } return element.scrollWidth > element.clientWidth + 1 } export function measureContentOverflow( container: HTMLElement | null, content: HTMLElement | null, ) { if (!container || !content) { return false } const contentOverflows = content.scrollWidth > content.clientWidth + 1 || content.getBoundingClientRect().width > container.clientWidth + 1 if (!contentOverflows) { return false } return content.scrollWidth > container.clientWidth + 1 } export function useDisplayOverflowTruncated( containerRef: RefObject<HTMLElement | null>, deps: ReadonlyArray<unknown>, contentRef?: RefObject<HTMLElement | null>, ) { const [isTruncated, setIsTruncated] = useState(false) useLayoutEffect(() => { const container = containerRef.current const content = contentRef?.current ?? container if (!container || !content) { setIsTruncated(false) return } const update = () => { setIsTruncated(measureContentOverflow(container, content)) } update() if (typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(update) observer.observe(container) if (content !== container) { observer.observe(content) } return () => { observer.disconnect() } // eslint-disable-next-line react-hooks/exhaustive-deps -- remeasure when display content changes }, deps) return isTruncated }export const spring = { fast: { type: "spring" as const, duration: 0.08, bounce: 0, exit: { duration: 0.06 }, }, // Critically damped: same perceived speed as a bouncier tier, but lands // exactly with no overshoot — for short travel and panels/sheets that must // settle precisely (dropdowns, tabs, drawers, merged selection backgrounds). moderate: { type: "spring" as const, duration: 0.16, bounce: 0, exit: { duration: 0.12 }, }, slow: { type: "spring" as const, duration: 0.24, bounce: 0.12, exit: { duration: 0.16 }, }, } as const; // Fallback delay (ms) for deferred-unmount timers that guard an exit tween: // popups keep their portal mounted until onAnimationComplete fires, but a // throttled/background tab can stall the animation, so a timer force-unmounts // after the tier's exit duration plus a safety buffer. Deriving it here keeps // the timers in step with the tokens above. export const exitFallbackMs = (tier: { exit: { duration: number } }) => Math.round(tier.exit.duration * 1000) + 100;import * as React from "react" // use-layout-effect.tsx // https://github.com/radix-ui/primitives/blob/main/packages/react/use-layout-effect/src/use-layout-effect.tsx /** * On the server, React emits a warning when calling `useLayoutEffect`. * This is because neither `useLayoutEffect` nor `useEffect` run on the server. * We use this safe version which suppresses the warning by replacing it with a noop on the server. * * See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect */ const useLayoutEffect = globalThis?.document ? React.useLayoutEffect : () => {} // use-controllable-state.tsx // https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/use-controllable-state.tsx // Prevent bundlers from trying to optimize the import const useInsertionEffect: typeof useLayoutEffect = (React as never)[" useInsertionEffect ".trim().toString()] || useLayoutEffect type ChangeHandler<T> = (state: T) => void type SetStateFn<T> = React.Dispatch<React.SetStateAction<T>> interface UseControllableStateParams<T> { prop?: T | undefined defaultProp: T onChange?: ChangeHandler<T> caller?: string } export function useControllableState<T>({ prop, defaultProp, onChange = () => {}, caller, }: UseControllableStateParams<T>): [T, SetStateFn<T>] { const [uncontrolledProp, setUncontrolledProp, onChangeRef] = useUncontrolledState({ defaultProp, onChange, }) const isControlled = prop !== undefined const value = isControlled ? prop : uncontrolledProp // OK to disable conditionally calling hooks here because they will always run // consistently in the same environment. Bundlers should be able to remove the // code block entirely in production. /* eslint-disable react-hooks/rules-of-hooks */ if (process.env.NODE_ENV !== "production") { const isControlledRef = React.useRef(prop !== undefined) React.useEffect(() => { const wasControlled = isControlledRef.current if (wasControlled !== isControlled) { const from = wasControlled ? "controlled" : "uncontrolled" const to = isControlled ? "controlled" : "uncontrolled" console.warn( `${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.` ) } isControlledRef.current = isControlled }, [isControlled, caller]) } /* eslint-enable react-hooks/rules-of-hooks */ const setValue = React.useCallback<SetStateFn<T>>( (nextValue) => { if (isControlled) { const value = isFunction(nextValue) ? nextValue(prop) : nextValue if (value !== prop) { onChangeRef.current?.(value) } } else { setUncontrolledProp(nextValue) } }, [isControlled, prop, setUncontrolledProp, onChangeRef] ) return [value, setValue] } function useUncontrolledState<T>({ defaultProp, onChange, }: Omit<UseControllableStateParams<T>, "prop">): [ Value: T, setValue: React.Dispatch<React.SetStateAction<T>>, OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>, ] { const [value, setValue] = React.useState(defaultProp) const prevValueRef = React.useRef(value) const onChangeRef = React.useRef(onChange) useInsertionEffect(() => { onChangeRef.current = onChange }, [onChange]) React.useEffect(() => { if (prevValueRef.current !== value) { onChangeRef.current?.(value) prevValueRef.current = value } }, [value, prevValueRef]) return [value, setValue, onChangeRef] } function isFunction(value: unknown): value is (...args: never[]) => unknown { return typeof value === "function" }Update import paths
// Update @/ import paths in copied files to match your tsconfig paths aliases.