0a8c44b5a1
- Reusable stepper with +/- buttons flanking a number input - Handles min clamping, max constraint, decimal steps - Controlled component (no internal state): value/onChange props - 44px touch targets, 16px font, aria-labels present - Rejects non-numeric input silently
90 lines
2.2 KiB
React
90 lines
2.2 KiB
React
function StepperInput({
|
||
value = '',
|
||
onChange,
|
||
step = 1,
|
||
min = 0,
|
||
max = null,
|
||
label = 'Value',
|
||
suffix = '',
|
||
disabled = false,
|
||
}) {
|
||
const numValue = parseFloat(value) || 0;
|
||
|
||
function handleInputChange(e) {
|
||
const raw = e.target.value;
|
||
if (raw === '') {
|
||
onChange('');
|
||
return;
|
||
}
|
||
const parsed = parseFloat(raw);
|
||
if (isNaN(parsed)) return;
|
||
if (parsed < min) {
|
||
onChange(String(min));
|
||
} else if (max !== null && parsed > max) {
|
||
onChange(String(max));
|
||
} else {
|
||
onChange(String(parsed));
|
||
}
|
||
}
|
||
|
||
function handleDecrement() {
|
||
if (disabled) return;
|
||
const newVal = Math.max(min, numValue - step);
|
||
onChange(String(newVal));
|
||
}
|
||
|
||
function handleIncrement() {
|
||
if (disabled) return;
|
||
const newVal = numValue + step;
|
||
if (max === null || newVal <= max) {
|
||
onChange(String(newVal));
|
||
}
|
||
}
|
||
|
||
const canDecrement = numValue > min;
|
||
const canIncrement = max === null || numValue < max;
|
||
|
||
return (
|
||
<div className="stepper-wrapper" role="group" aria-labelledby={`stepper-label-${label}`}>
|
||
<label id={`stepper-label-${label}`} className="stepper-label">{label}</label>
|
||
<div className="stepper-container">
|
||
<button
|
||
type="button"
|
||
className="stepper-btn stepper-minus"
|
||
onClick={handleDecrement}
|
||
disabled={!canDecrement || disabled}
|
||
aria-label={`Decrease ${label}`}
|
||
>
|
||
−
|
||
</button>
|
||
<div className="stepper-input-wrapper">
|
||
<input
|
||
type="number"
|
||
value={value}
|
||
onChange={handleInputChange}
|
||
min={min}
|
||
max={max ?? undefined}
|
||
step={step}
|
||
inputMode={step % 1 === 0 ? 'numeric' : 'decimal'}
|
||
className="stepper-input"
|
||
aria-label={label}
|
||
disabled={disabled}
|
||
/>
|
||
{suffix && <span className="input-suffix">{suffix}</span>}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="stepper-btn stepper-plus"
|
||
onClick={handleIncrement}
|
||
disabled={!canIncrement || disabled}
|
||
aria-label={`Increase ${label}`}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default StepperInput;
|