feat(01-input-ux-01): create StepperInput controlled component

- 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
This commit is contained in:
2026-02-16 08:03:04 +01:00
parent c88729dfed
commit ff83340c92
+89
View File
@@ -0,0 +1,89 @@
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;