{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "number-input", "title": "Number Input", "description": "A hand-drawn number input with rough increment and decrement buttons.", "dependencies": [ "roughjs" ], "registryDependencies": [ "https://www.bydefaulthuman.fun/r/use-rough.json", "utils", "https://www.bydefaulthuman.fun/r/crumble-theme.json", "https://www.bydefaulthuman.fun/r/rough-lib.json" ], "files": [ { "path": "registry/new-york/ui/number-input.tsx", "content": "\"use client\";\n\nimport {\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n type InputHTMLAttributes,\n} from \"react\";\nimport { useRough } from \"@/hooks/use-rough\";\nimport { cn } from \"@/lib/utils\";\nimport {\n CrumbleContext,\n resolveRoughVars,\n stableSeed,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\n\nexport interface NumberInputProps\n extends Omit, \"type\" | \"onChange\">,\n CrumbleColorProps {\n className?: string;\n defaultValue?: number;\n disabled?: boolean;\n error?: string;\n id?: string;\n label?: string;\n max?: number;\n min?: number;\n onChange?: (value: number) => void;\n step?: number;\n theme?: CrumbleTheme;\n value?: number;\n}\n\nconst BTN_W = 32;\nconst HEIGHT = 40;\n\nfunction StepButton({\n disabled,\n label,\n onClick,\n side,\n theme,\n btnId,\n}: {\n disabled: boolean;\n label: \"+\" | \"−\";\n onClick: () => void;\n side: \"left\" | \"right\";\n theme: CrumbleTheme;\n btnId: string;\n}) {\n const externalSvgRef = useRef(null);\n const { drawLine, svgRef } = useRough({\n stableId: btnId,\n svgRef: externalSvgRef,\n theme,\n variant: \"interactive\",\n });\n\n const draw = useCallback(() => {\n const svg = svgRef.current;\n if (!svg) return;\n svg.replaceChildren();\n svg.setAttribute(\"width\", String(BTN_W));\n svg.setAttribute(\"height\", String(HEIGHT));\n svg.setAttribute(\"viewBox\", `0 0 ${BTN_W} ${HEIGHT}`);\n\n const stroke = disabled ? \"var(--cr-stroke-muted)\" : \"var(--cr-stroke)\";\n const edgeOptions = {\n fill: \"none\",\n stroke,\n };\n\n if (side === \"left\") {\n [drawLine(1, 1, 1, HEIGHT - 1, edgeOptions), drawLine(1, 1, BTN_W, 1, edgeOptions), drawLine(1, HEIGHT - 1, BTN_W, HEIGHT - 1, edgeOptions)]\n .forEach((line) => line && svg.appendChild(line));\n } else {\n [drawLine(0, 1, BTN_W - 1, 1, edgeOptions), drawLine(BTN_W - 1, 1, BTN_W - 1, HEIGHT - 1, edgeOptions), drawLine(0, HEIGHT - 1, BTN_W - 1, HEIGHT - 1, edgeOptions)]\n .forEach((line) => line && svg.appendChild(line));\n }\n\n const cx = BTN_W / 2;\n const cy = HEIGHT / 2;\n const symbolOptions = {\n stroke,\n strokeWidth: 1.5,\n };\n const h = drawLine(cx - 5, cy, cx + 5, cy, symbolOptions);\n if (h) svg.appendChild(h);\n if (label === \"+\") {\n const v = drawLine(cx, cy - 5, cx, cy + 5, symbolOptions);\n if (v) svg.appendChild(v);\n }\n }, [disabled, drawLine, side, svgRef]);\n\n useEffect(() => {\n const id = requestAnimationFrame(() => draw());\n return () => cancelAnimationFrame(id);\n }, [draw]);\n\n return (\n draw()}\n className={cn(\n \"relative shrink-0 outline-none\",\n disabled ? \"cursor-not-allowed opacity-40\" : \"cursor-pointer\",\n )}\n style={{ width: BTN_W, height: HEIGHT }}\n aria-label={label === \"+\" ? \"increment\" : \"decrement\"}\n >\n \n \n );\n}\n\nexport function NumberInput({\n className,\n defaultValue = 0,\n disabled = false,\n error,\n fill,\n id,\n label,\n max,\n min,\n onChange,\n step = 1,\n stroke,\n strokeMuted,\n theme: themeProp,\n value: controlledValue,\n ...props\n}: NumberInputProps) {\n const [internalValue, setInternalValue] = useState(defaultValue);\n const wrapperRef = useRef(null);\n const inputSvgRef = useRef(null);\n const [focused, setFocused] = useState(false);\n\n const value = controlledValue ?? internalValue;\n const inputId =\n id ?? `number-${label?.toLowerCase().replace(/\\s+/g, \"-\") ?? \"input\"}`;\n const { theme: contextTheme } = useContext(CrumbleContext);\n const theme = themeProp ?? contextTheme;\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n const { drawLine } = useRough({\n stableId: inputId,\n svgRef: inputSvgRef,\n theme: themeProp,\n variant: \"border\",\n });\n\n const drawBorder = useCallback(() => {\n const svg = inputSvgRef.current;\n const wrapper = wrapperRef.current;\n if (!svg || !wrapper) return;\n\n svg.replaceChildren();\n const w = wrapper.offsetWidth - BTN_W * 2;\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", String(HEIGHT));\n svg.setAttribute(\"viewBox\", `0 0 ${w} ${HEIGHT}`);\n\n const strokeColor = error\n ? \"var(--cr-stroke-error)\"\n : focused\n ? \"var(--cr-stroke)\"\n : \"var(--cr-stroke-muted)\";\n\n const top = drawLine(0, 1, w, 1, {\n seed: stableSeed(`${inputId}-top`),\n stroke: strokeColor,\n });\n const bottom = drawLine(0, HEIGHT - 1, w, HEIGHT - 1, {\n seed: stableSeed(`${inputId}-bottom`),\n stroke: strokeColor,\n });\n if (top) svg.appendChild(top);\n if (bottom) svg.appendChild(bottom);\n }, [drawLine, error, focused, inputId]);\n\n useEffect(() => {\n drawBorder();\n }, [drawBorder]);\n\n useEffect(() => {\n const wrapper = wrapperRef.current;\n if (!wrapper) return;\n const ro = new ResizeObserver(() => drawBorder());\n ro.observe(wrapper);\n return () => ro.disconnect();\n }, [drawBorder]);\n\n const clamp = (v: number) => {\n let result = v;\n if (min !== undefined) result = Math.max(min, result);\n if (max !== undefined) result = Math.min(max, result);\n return result;\n };\n\n const update = (next: number) => {\n const clamped = clamp(next);\n setInternalValue(clamped);\n onChange?.(clamped);\n };\n\n return (\n
\n {label ? (\n \n {label}\n \n ) : null}\n
\n update(value - step)}\n theme={theme}\n btnId={`${inputId}-dec`}\n />\n
\n \n setFocused(true)}\n onBlur={() => setFocused(false)}\n onChange={(e) => update(Number(e.target.value))}\n className=\"absolute inset-0 h-full w-full border-none bg-transparent text-center text-sm text-foreground outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n {...props}\n />\n
\n = max)}\n onClick={() => update(value + step)}\n theme={theme}\n btnId={`${inputId}-inc`}\n />\n
\n {error ? {error} : null}\n
\n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/number-input.tsx" } ], "type": "registry:ui" }