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