NumberField
A text field for amounts and quantities that reads and writes the reader's notation and hands your application exact decimal text.
Use Number wherever a person types a number that matters: an amount, a percentage, a
quantity. It shows and reads the notation of the presentation you give it: under a Dutch
presentation the reader types 1234,5, under an English one 1234.5. Your application receives
the same canonical decimal text either way: '1234.5'.
Up to two decimals.
- Value received
- '1234.5'
- Status
- valid
// apps/docs/examples/ui/number-field-notation.tsx'use client';
import {useState} from 'react';
import type {NumberInputStatus} from '@workspace/active-format';import { DescriptionItem, DescriptionList,} from '@workspace/active-ui/components/composed/description-list';import {NumberField} from '@workspace/active-ui/components/composed/number-field';import { Field, FieldDescription, FieldLabel,} from '@workspace/active-ui/components/primitives/field';import { ToggleGroup, ToggleGroupItem,} from '@workspace/active-ui/components/primitives/toggle-group';
const LANGUAGES = {en: 'English', nl: 'Dutch', de: 'German'} as const;type Language = keyof typeof LANGUAGES;
/** * One controlled field under three presentations. The field shows and reads * the notation of the presentation it receives; what travels to the * application is always canonical decimal text. Type `1234,5` under Dutch, * then switch to English: the value stays 1234.5 and the field rewrites it. */export default function NumberFieldNotation() { const [language, setLanguage] = useState<Language>('nl'); const [value, setValue] = useState('1234.5'); const [status, setStatus] = useState<NumberInputStatus>('valid'); return ( <div className="flex max-w-sm flex-col gap-4"> <ToggleGroup aria-label="Presentation" variant="outline" size="sm" spacing={0} value={[language]} onValueChange={(next: string[]) => { const chosen = next[0]; if (chosen === 'en' || chosen === 'nl' || chosen === 'de') { setLanguage(chosen); } }} > {(Object.keys(LANGUAGES) as Language[]).map((code) => ( <ToggleGroupItem key={code} value={code}> {LANGUAGES[code]} </ToggleGroupItem> ))} </ToggleGroup> <Field> <FieldLabel htmlFor="notation-amount">Annual salary</FieldLabel> <NumberField id="notation-amount" locale={language} value={value} onValueChange={setValue} onStateChange={(next) => setStatus(next)} decimals={2} /> <FieldDescription>Up to two decimals.</FieldDescription> </Field> <DescriptionList orientation="inline" columns={1}> <DescriptionItem label="Value received"> <span className="font-mono" data-number-value> {value === '' ? "'' (empty)" : `'${value}'`} </span> </DescriptionItem> <DescriptionItem label="Status"> <span className="font-mono" data-number-status> {status} </span> </DescriptionItem> </DescriptionList> </div> );}What the composition adds#
Number is the Input primitive plus the number reading of
active-format. Compared with an <input type="number"> or a
plain text input, it:
reads the notation of the presentation you pass, not the browser's language, and writes the value back in that notation when the field loses focus;
tells typing from pasting: under a Dutch presentation a pasted
1.234,56is read as one amount, while a typed thousands separator is refused, so a half-typed1.234never silently becomes 1.234 or 1234;keeps empty, incomplete and invalid apart:
''means cleared, and a half-typed or rejected text never changes the value your application holds;reports why a text is rejected, as a code your application turns into a message;
applies a domain contract: the number of decimals, whether negative values are allowed, and the currency an amount may carry when it is pasted.
Usage#
import {NumberField} from '@workspace/active-ui/components/composed/number-field';
const [value, setValue] = useState(''); // canonical decimal text, or ''
<NumberField id="amount" locale={presentation} value={value} onValueChange={setValue} decimals={2}/>;Place it in a Field with a Field, like any other control.
Validation and messages#
In euros, up to two decimals.
Value '', status empty
// apps/docs/examples/ui/number-field-validation.tsx'use client';
import {useState} from 'react';
import type { NumberInputRejection, NumberInputStatus,} from '@workspace/active-format';import {NumberField} from '@workspace/active-ui/components/composed/number-field';import { Field, FieldDescription, FieldError, FieldLabel,} from '@workspace/active-ui/components/primitives/field';
/** * The application's own words for every reason the field can report. The * component does not translate: it reports a code, the application writes * the sentence in the reader's language. */const MESSAGES: Record<NumberInputRejection, string> = { 'not-a-number': 'Enter a number.', 'cross-convention': 'This looks like another notation, such as 1.234,56. Use a point for decimals.', 'grouping-position': 'A thousands separator is in the wrong place.', 'grouping-while-typing': 'Type the number without thousands separators; pasting them is fine.', 'unsupported-negative': 'Write a negative amount with a leading minus sign.', 'unsupported-notation': 'Write the amount out in full.', 'negative-not-allowed': 'The amount cannot be negative.', 'precision-exceeded': 'Use at most two decimals.', 'currency-mismatch': 'This amount is in another currency than euros.',};
/** * A field with a domain contract: euros, two decimals, never negative. While * the reader is still typing (`incomplete`) no error shows; a rejection * shows the application's message for its reason. Try `-5`, `12.345`, * `1,234` and pasting `€ 1,234.50`. */export default function NumberFieldValidation() { const [value, setValue] = useState(''); const [state, setState] = useState<{ status: NumberInputStatus; rejection?: NumberInputRejection; }>({status: 'empty'}); const message = state.status === 'invalid' && state.rejection ? MESSAGES[state.rejection] : null; return ( <div className="flex max-w-sm flex-col gap-3"> <Field data-invalid={message ? true : undefined}> <FieldLabel htmlFor="validation-amount">Contribution (EUR)</FieldLabel> <NumberField id="validation-amount" locale="en" currency="EUR" decimals={2} allowNegative={false} value={value} onValueChange={setValue} onStateChange={(status, rejection) => setState({status, rejection})} aria-describedby={ message ? 'validation-amount-description validation-amount-error' : 'validation-amount-description' } /> <FieldDescription id="validation-amount-description"> In euros, up to two decimals. </FieldDescription> {message ? ( <FieldError id="validation-amount-error">{message}</FieldError> ) : null} </Field> <p className="text-muted-foreground text-sm"> Value <span className="font-mono">{`'${value}'`}</span>, status{' '} <span className="font-mono" data-number-status> {state.status} </span> {state.rejection ? ( <> , reason{' '} <span className="font-mono" data-number-rejection> {state.rejection} </span> </> ) : null} </p> </div> );}on reports, on every keystroke, one of four statuses:
| Status | Meaning | Show an error? |
|---|---|---|
empty | nothing typed | only if the field is required |
incomplete | a valid beginning, such as 1,; the reader is still typing | no |
invalid | cannot become a number; a rejection says why | yes |
valid | a value; on has received it | no |
The field sets aria- itself when the status is invalid. The message is yours: map each
rejection code (not-, precision-, negative-, grouping-
and the others) to a sentence in the reader's language, and show it in a Field.
What your application provides#
locale: the presentation, a language code such as'nl'or a presentation context from active-format. Which separator is a decimal one is a property of your data or your reader, not of this component.valueandon: the value as canonical decimal text. If your domain stores numbers, convert at your own boundary withValue Change numberandInput Value From Number() numberfrom active-format.From Input Value() The contract:
decimals,allow,Negative currency,allow.Percent Sign The label, the messages for each rejection, and when to show them.
Accessibility and keyboard#
A text input with
input: mobile keyboards show digits and a decimal separator.Mode="decimal" aria-follows the status; connect the error withinvalid aria-.describedby The placeholder shows the expected notation; it is a hint, never a label.
API#
NumberField#
Import from @workspace/
Props
- Type
NumberPresentation
The presentation of this field: a bare language code ('nl') or a generic presentation-context object that carries language and notation independently.
Display, placeholder and parsing all come from this one source, so by construction they cannot fall out of step. Which character is a decimal separator is the presentation of business data: in an application with a presentation setting (per administration, tenant or system) the value comes from there, not from the reader's language alone. A bare language code is right where no such setting exists.
Deliberately a prop: active- knows no administration, tenant or profile resolver. The application composes, this component receives.
- Type
string
The value as canonical exact-decimal text ('1234.56'), or '' when there is no value.
Never a `number`. Two reasons, and the second weighs most. A number cannot carry the empty state: 0 and "not filled in" would become the same. And an exact decimal string the user has just typed should not needlessly pass through a binary floating-point conversion before the domain boundary.
A form whose domain value is and stays a number uses number and number from @workspace/ at its own boundary.
- Type
boolean- Default
true
Whether a negative value is semantically allowed. Defaults to true.
- Type
boolean
Whether a trailing percent sign may be stripped on paste.
Says nothing about the scale: whether this field carries percentage points or a fraction is a domain contract of the call site, and this component never guesses it.
- Type
string
The field's currency identity, if it carries one.
Makes pasting an amount with matching decoration possible and gives conflicting decoration its own message. The field shows no symbol and never changes currency: identity keeps coming from the data.
- Type
number
The maximum number of decimals according to the domain: the schema precision, the minor units of the currency or the explicit field contract.
Never from a display preset and never from the notation. Drives both the message on too many decimals and the canonical redisplay on blur.
Events
Called by the component when something happens.
- Type
(value: string) => void
Receives the new canonical value, or ''.
`''` means only "cleared". Incomplete (1,) and unacceptable (1.234, abc) text report no value: the last valid value stays, the text stays visible, and the state travels via Number. Date follows the same rule, for the same reason: otherwise half a typo erases a stored value.
- Type
(status, rejection?) => void
Receives on every keystroke the state and, on a rejection, the reason.
The distinction between incomplete and invalid exists so a field reports no error while the user is still typing. The rejection exists so a form can show a specific message instead of one generic "invalid value"; translating is the caller's job, because active- does not translate.
Other props, such as the standard attributes of the underlying element, className and ref, are passed through.
Types
The props of Number, each described in its prop list on this page.
interface NumberFieldProps extends Omit<
React.ComponentProps<'input'>,
'value' | 'defaultValue' | 'onChange' | 'type' | 'step'
> {
value: string;
onValueChange: (value: string) => void;
onStateChange?: (
status: NumberInputStatus,
rejection?: NumberInputRejection,
) => void;
locale: NumberPresentation;
decimals?: number;
allowNegative?: boolean;
currency?: string;
allowPercentSign?: boolean;
}