feat(ui): migrate shared/numerical_input from @tremor NumberInput to shadcn Input

- @tremor NumberInput \u2192 shadcn Input type=number.
- onChange contract preserved: numeric value, null, or undefined (tremor
  convention). Empty string or NaN parses as null.
- value / defaultValue accepted as number | string | null | undefined.
- 'precision' prop accepted for back-compat but not enforced (HTML number
  input doesn't clamp decimal precision; callers round before persisting).
- onWheel \u2192 blur to disable accidental scroll-to-change.
- All existing callers keep working \u2014 this component is used from
  organization_view, AccessGroupBaseForm, edit_user, tag_info,
  CreateTagModal, cache_settings, and others; all are already passing
  (number | null | undefined) to onChange.

Gates: TS \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 10:12:52 +00:00
parent 372fdedf5b
commit 6e3107d68c
No known key found for this signature in database

View file

@ -1,5 +1,6 @@
import React from "react";
import { NumberInput } from "@tremor/react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface NumericalInputProps {
step?: number;
@ -7,20 +8,33 @@ interface NumericalInputProps {
placeholder?: string;
min?: number;
max?: number;
onChange?: any; // Using any to avoid type conflicts with Tremor's NumberInput
// Callers pass either an onChange(numberOrNull) signature (the @tremor
// contract this component used to expose) or a React.ChangeEventHandler.
// We accept both at the signature boundary and bridge to number|null|undefined
// inside the component.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange?: any;
value?: number | string | null | undefined;
defaultValue?: number | string | null | undefined;
name?: string;
className?: string;
disabled?: boolean;
precision?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
/**
* A reusable numerical input component
* @param {Object} props - Component props
* @param {number} [props.step=0.01] - Step increment for the input
* @param {Object} [props.style] - Custom styles to apply
* @param {string} [props.placeholder="Enter a numerical value"] - Placeholder text
* @param {number} [props.min] - Minimum value
* @param {number} [props.max] - Maximum value
* @param {Function} [props.onChange] - On change handler
* @param {any} props.rest - Additional props passed to NumberInput
* Numerical input. Phase-1 shadcn-migrated replacement for the former
* @tremor NumberInput. Accepts the same prop surface so existing callers
* continue to work:
* - onChange({number | null | undefined}) \u2014 tremor-style
* - value / defaultValue as number | string | null | undefined
* - step / min / max / precision / placeholder / style / className / disabled
*
* `precision` is accepted for API-compat but not enforced (HTML input
* type=number does not clamp decimal precision; caller code typically
* rounds before persisting).
*/
const NumericalInput: React.FC<NumericalInputProps> = ({
step = 0.01,
@ -29,17 +43,50 @@ const NumericalInput: React.FC<NumericalInputProps> = ({
min,
max,
onChange,
value,
defaultValue,
className,
disabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
precision,
...rest
}) => {
const toValue = (v: number | string | null | undefined): string => {
if (v === null || v === undefined) return "";
return String(v);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!onChange) return;
const raw = e.target.value;
if (raw === "") {
onChange(null);
return;
}
const num = Number(raw);
if (Number.isNaN(num)) {
onChange(null);
return;
}
onChange(num);
};
return (
<NumberInput
onWheel={(event) => event.currentTarget.blur()}
<Input
type="number"
onWheel={(event) => (event.currentTarget as HTMLInputElement).blur()}
step={step}
style={style}
placeholder={placeholder}
min={min}
max={max}
onChange={onChange}
value={value !== undefined ? toValue(value) : undefined}
defaultValue={
defaultValue !== undefined ? toValue(defaultValue) : undefined
}
onChange={handleChange}
disabled={disabled}
className={cn(className)}
{...rest}
/>
);