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:
@@ -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;
|
||||||
Reference in New Issue
Block a user