ui(components): Adds InputNumber component and 4d panel updates (#4874)

Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
Dan Rukasandsedghi authored and GitHub committed 2025-04-01 09:30:32 -04:00
1 parent c4045251fb
commit b7419fb6a1
15 files changed
+1620 -304

No files matched your search

@@ -5,9 +5,23 @@ import { cn } from '../../lib/utils';
import { Input } from '../Input/Input';
import { Slider } from '../Slider/Slider';
import { DoubleSlider } from '../DoubleSlider/DoubleSlider';
import { Button } from '../Button/Button';
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
// Calculate decimal places based on step
const getDecimalPlaces = (step: number): number => {
if (Number.isInteger(step)) {
return 0;
}
const stepStr = step.toString();
if (stepStr.includes('.')) {
return stepStr.split('.')[1].length;
}
return 0;
};
interface NumericMetaContextValue {
mode: 'number' | 'singleRange' | 'doubleRange';
mode: 'number' | 'singleRange' | 'doubleRange' | 'stepper';
singleValue: number;
doubleValue: [number, number];
setSingleValue: (val: number) => void;
@@ -23,7 +37,7 @@ const NumericMetaContext = createContext<NumericMetaContextValue | null>(null);
1) Container
---------------------------------------------------------------------------*/
interface NumericMetaContainerProps {
mode: 'number' | 'singleRange' | 'doubleRange';
mode: 'number' | 'singleRange' | 'doubleRange' | 'stepper';
value?: number; // for controlled single-value usage from parent
defaultValue?: number; // for uncontrolled single-value usage
values?: [number, number]; // for controlled double-range usage from parent
@@ -57,10 +71,10 @@ function NumericMetaContainer({
// Use useControllableState for both single and double values
const [internalSingleValue, setInternalSingleValue] = useControllableState({
prop: mode === 'number' || mode === 'singleRange' ? value : undefined,
prop: mode === 'number' || mode === 'singleRange' || mode === 'stepper' ? value : undefined,
defaultProp: calculatedDefaultValue,
onChange: newVal => {
if (mode === 'number' || mode === 'singleRange') {
if (mode === 'number' || mode === 'singleRange' || mode === 'stepper') {
onChange?.(newVal);
}
},
@@ -127,7 +141,7 @@ function NumericMetaLabel({ children, showValue, className }: NumericMetaLabelPr
let displayedValue = '';
let valueClasses = '';
if (mode === 'number' || mode === 'singleRange') {
if (mode === 'number' || mode === 'singleRange' || mode === 'stepper') {
displayedValue = singleValue.toString();
valueClasses = 'w-10';
} else if (mode === 'doubleRange') {
@@ -291,12 +305,177 @@ function NumberInput({ className }: NumberInputProps) {
);
}
/* -------------------------------------------------------------------------
6) NumberStepper sub-component
---------------------------------------------------------------------------*/
interface NumberStepperProps {
className?: string;
children?: React.ReactNode;
direction?: 'horizontal' | 'vertical';
}
// Modified NumberStepper component to properly position left/right controls
function NumberStepper({ className, children, direction }: NumberStepperProps) {
const ctx = useContext(NumericMetaContext);
if (!ctx) {
throw new Error('NumberStepper must be used inside <Numeric.Container>.');
}
const { mode, singleValue, setSingleValue, min, max, step } = ctx;
if (mode !== 'stepper') {
return null;
}
// Calculate decimal places based on step
const decimalPlaces = getDecimalPlaces(step);
// Format displayed value with proper decimal places
const displayValue = React.useMemo(() => {
return decimalPlaces > 0 ? singleValue.toFixed(decimalPlaces) : singleValue.toString();
}, [singleValue, decimalPlaces]);
const handleInputChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
const val = evt.target.value;
// Allow empty string, minus sign, or decimal point for flexibility
if (val === '' || val === '-' || val === '.') {
return;
}
const numValue = Number(val);
if (!isNaN(numValue)) {
setSingleValue(Math.max(min, Math.min(numValue, max)));
}
};
const handleBlur = () => {
// Ensure value is within constraints when input loses focus
const boundedValue = Math.max(min, Math.min(singleValue, max));
if (boundedValue !== singleValue) {
setSingleValue(boundedValue);
}
};
// Check if children is HorizontalControls component
const hasHorizontalControls = direction === 'horizontal';
if (hasHorizontalControls) {
// We'll handle the control positioning ourselves
return (
<div
className={cn(
'bg-background border-input flex h-[28px] items-center overflow-hidden rounded-md border',
className
)}
>
<LeftControl
min={min}
step={step}
value={singleValue}
setValue={setSingleValue}
/>
<Input
type="text"
value={displayValue}
onChange={handleInputChange}
onBlur={handleBlur}
className="h-6 flex-1 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none"
/>
{children}
<RightControl
max={max}
step={step}
value={singleValue}
setValue={setSingleValue}
/>
</div>
);
}
return (
<div
className={cn(
'bg-background border-input flex items-center overflow-hidden rounded-md border',
className
)}
>
<Input
type="text"
value={displayValue}
onChange={handleInputChange}
onBlur={handleBlur}
className={cn(
'h-6 flex-1 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none'
)}
/>
<div className="ml-1 flex flex-col">
<Button
variant="ghost"
size="icon"
onClick={() => setSingleValue(singleValue + step)}
disabled={singleValue >= max}
className="text-primary h-3 w-5 pr-px"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setSingleValue(singleValue - step)}
disabled={singleValue <= min}
className="text-primary h-3 w-5 pr-px"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
</div>
);
}
// New components for left and right controls
function LeftControl({ min, step, value, setValue }) {
const decrement = useCallback(() => {
const newValue = Math.max(value - step, min);
setValue(newValue);
}, [value, min, step, setValue]);
return (
<Button
variant="ghost"
size="icon"
onClick={decrement}
className="text-primary h-full w-4 cursor-pointer p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
);
}
function RightControl({ max, step, value, setValue }) {
const increment = useCallback(() => {
const newValue = Math.min(value + step, max);
setValue(newValue);
}, [value, max, step, setValue]);
return (
<Button
variant="ghost"
size="icon"
onClick={increment}
className="text-primary h-full w-4 cursor-pointer p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
);
}
export const Numeric = {
Container: NumericMetaContainer,
Label: NumericMetaLabel,
SingleRange,
DoubleRange,
NumberInput,
NumberStepper,
};
export default Numeric;
export default Numeric;