Numeric input.
One field for quantities, cost, markup and VAT — built on a text input, never <input type="number">. Integer or decimal mode, fixed precision, comma and period both accepted, formatted to the user's locale on blur. No spinner arrows, no scroll-wheel surprises.
The rules
Every numeric field in an Aceve product follows these six. They exist because the native number input breaks all of them.
Try it
Type 1,8 in the quantity field — the comma never lands. Type 1,8 in unit cost and blur — it formats to 1,80.
Parsing rules
What goes in, what comes out. The parsed number (onValueChange) is what your data model binds to — never the display string.
Do & don't
Spec
Same anatomy as the text input — one numeric addition per row.
Props (React)
type NumericInputProps = {
mode?: "integer" | "decimal"; // default "decimal"
precision?: number; // fraction digits on blur, default 2
min?: number; // clamped on blur
max?: number; // clamped on blur
locale?: string; // blur format, default: browser locale
suffix?: string; // unit inside the field — "kr", "%", "pcs"
value: string; // display string (controlled)
onChange?: (next: string) => void;
onValueChange?: (n: number | null) => void; // parsed value, on blur
label?: string;
placeholder?: string;
help?: string;
error?: string;
disabled?: boolean;
};Copy the implementation
Self-contained reference — parseNumeric and formatNumeric included. Drop it into your product and restyle; the behaviour is the contract.
import { useId } from 'react';
// While typing: digits, optional leading minus, and (decimal mode
// only) one `,` or `.` separator. Anything else never enters the field.
const INT_RE = /^-?\d*$/;
const DEC_RE = /^-?\d*[.,]?\d*$/;
/** '1,8' | '1.8' → 1.8 — empty / partial / garbage → null. */
export function parseNumeric(raw) {
const s = String(raw ?? '').trim().replace(',', '.');
if (s === '' || s === '-' || s === '.' || s === '-.') return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
/** 1.8 → '1,80' (sv-SE, precision 2) / '1.80' (en-GB). null → ''. */
export function formatNumeric(n, { mode = 'decimal', precision = 2, locale } = {}) {
if (n == null) return '';
const digits = mode === 'integer' ? 0 : precision;
return new Intl.NumberFormat(locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
}).format(n);
}
export default function NumericInput({
label, value, onChange, onValueChange,
mode = 'decimal', precision = 2, min, max, locale,
suffix, placeholder, help, error, disabled, id,
}) {
const reactId = useId();
const inputId = id || reactId;
const describedById = error ? `${inputId}-err` : help ? `${inputId}-help` : undefined;
const allowMinus = min === undefined || min < 0;
const handleChange = (raw) => {
const re = mode === 'integer' ? INT_RE : DEC_RE;
if (!re.test(raw)) return; // reject the keystroke
if (!allowMinus && raw.startsWith('-')) return;
onChange?.(raw);
};
const handleBlur = () => {
let n = parseNumeric(value);
if (n != null) {
if (min !== undefined) n = Math.max(min, n);
if (max !== undefined) n = Math.min(max, n);
if (mode === 'integer') n = Math.round(n);
}
onChange?.(formatNumeric(n, { mode, precision, locale }));
onValueChange?.(n);
};
return (
<div className="field">
{label && <label htmlFor={inputId}>{label}</label>}
<div className="numeric-wrap">
<input
id={inputId}
type="text"
inputMode={mode === 'integer' ? 'numeric' : 'decimal'}
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={describedById}
/>
{suffix && <span className="suffix" aria-hidden>{suffix}</span>}
</div>
{error && <div id={`${inputId}-err`} className="error">{error}</div>}
{help && !error && <div id={`${inputId}-help`} className="help">{help}</div>}
</div>
);
}Using Tailwind? Same logic, utility classes instead of the CSS:
{/* Same logic — Tailwind classes instead of the .field CSS.
parseNumeric / formatNumeric / handleChange / handleBlur are
identical to the React snippet above. */}
<div className="flex min-w-60 flex-col gap-1">
<label htmlFor={inputId} className="text-xs text-neutral-600">
{label}
</label>
<div className="relative flex">
<input
id={inputId}
type="text"
inputMode={mode === 'integer' ? 'numeric' : 'decimal'}
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
aria-invalid={error ? true : undefined}
className={`h-8 flex-1 rounded border bg-white px-2.5 text-right
text-sm tabular-nums outline-none
focus:border-[#447952] focus:ring-1 focus:ring-[#447952]
disabled:opacity-50
${suffix ? 'pr-9' : ''}
${error ? 'border-red-600' : 'border-neutral-300'}`}
/>
{suffix && (
<span aria-hidden className="pointer-events-none absolute inset-y-0
right-2.5 flex items-center text-xs text-neutral-400">
{suffix}
</span>
)}
</div>
{error && <div className="text-xs text-red-600">{error}</div>}
{help && !error && <div className="text-xs text-neutral-400">{help}</div>}
</div>