Skip to content
Download ZIP

Blue accent palette · 15.8 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_number_field_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_number_field_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Blue accent
Version
1.0.0
Digest
Full digest
sha256-92e056a5b3fbe868719acc9aeffa43a203dbd18ad1e8c2a90d3b7bd3a4b54198

This component needs all 2 files. Download the ZIP

parse-number.ts TypeScript · 7.1 KB Raw
/**
 * Locale-aware number parsing and formatting for text inputs, built on Intl.NumberFormat alone.
 * Self-contained so a currency, percentage or measurement field can copy it as is.
 *
 * Parsing is deliberately forgiving about how a number was typed and strict about what it means:
 * it accepts the locale's own digits and separators, Latin digits, any minus sign and any space,
 * and it rejects anything left over ('12a', '1.2.3', '5 kg').
 */

export interface NumberSymbols {
	/** The locale's decimal separator, such as '.' in en-GB or ',' in de-DE. */
	decimal: string;
	/** The locale's group separator: ',' in en-GB, '.' in de-DE, a narrow space in fr-FR. */
	group: string;
	/** Digits in the last group and in the groups before it: 3 and 3, or 3 and 2 in en-IN. */
	grouping: [primary: number, secondary: number];
	/** Maps each of the locale's own digits (Arabic-Indic, for example) to its Latin digit. */
	digits: Map<string, string>;
}

export type ParseResult =
	{ kind: 'empty' } | { kind: 'number'; value: number } | { kind: 'invalid' };

const symbolCache = new Map<string, NumberSymbols>();

/** The locale's separators and digits, read from Intl.NumberFormat's own output. */
export function numberSymbols(locale: string): NumberSymbols {
	const cached = symbolCache.get(locale);
	if (cached) return cached;
	const format = new Intl.NumberFormat(locale, { useGrouping: true });
	const parts = format.formatToParts(12345.6);
	const digits = new Map<string, string>();
	const nativeDigits = new Intl.NumberFormat(locale, { useGrouping: false }).format(9876543210);
	[...nativeDigits].forEach((d, i) => digits.set(d, String(9 - i)));
	const groups = format
		.formatToParts(1234567890)
		.filter((p) => p.type === 'integer')
		.map((p) => p.value.length);
	const primary = groups.at(-1) ?? 3;
	const symbols: NumberSymbols = {
		decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
		group: parts.find((p) => p.type === 'group')?.value ?? ',',
		grouping: [primary, groups.length > 2 ? groups.at(-2)! : primary],
		digits
	};
	symbolCache.set(locale, symbols);
	return symbols;
}

/** Spaces of every width: NBSP, narrow NBSP (fr-FR's group separator), figure and thin spaces. */
const SPACES = /[\s\u00a0\u2007\u2009\u202f]/gu;
/** Bidi marks and other invisible format characters that Intl puts around Arabic numbers. */
const FORMAT_CHARS = /\p{Cf}/gu;
/** Hyphen-minus, the minus sign, and the dashes people paste in their place. */
const MINUS = /^[-\u2212\u2012\u2013\ufe63\uff0d]/u;
/** Arabic-Indic and extended Arabic-Indic digits, accepted in every locale. */
const ARABIC_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/gu;

function latinDigits(text: string, symbols: NumberSymbols): string {
	let out = '';
	for (const ch of text) out += symbols.digits.get(ch) ?? ch;
	return out.replace(ARABIC_DIGITS, (d) => String((d.codePointAt(0)! - 0x0660) % 0x90));
}

/** Whether chunks split at the group separator follow the locale's grouping, as 12,34,567 does in en-IN. */
function grouped(chunks: string[], [primary, secondary]: [number, number]): boolean {
	if (chunks.length < 2) return true;
	const [first, ...rest] = chunks;
	const last = rest.pop()!;
	const lead = rest.length ? secondary : primary;
	return (
		new RegExp(`^\\d{1,${lead}}$`).test(first) &&
		new RegExp(`^\\d{${primary}}$`).test(last) &&
		rest.every((c) => new RegExp(`^\\d{${secondary}}$`).test(c))
	);
}

/**
 * Parse what a person typed. '1.234,5' in de-DE is 1234.5, '1,234.5' in en-GB is 1234.5.
 *
 * Group separators must sit where the locale puts them, so '1,23.4' is rejected rather than
 * read as 123.4. Without a decimal separator, a single group separator that does not group is
 * read as a decimal point, because phone keyboards often offer only the separator of the
 * device's region ('2.5' typed into a de-DE field means 2,5). The other of '.' and ',' is
 * accepted as a decimal point too, unless the locale groups with it.
 */
export function parseNumber(text: string, locale: string): ParseResult {
	const symbols = numberSymbols(locale);
	let s = latinDigits(text.replace(FORMAT_CHARS, ''), symbols).trim();
	if (s === '') return { kind: 'empty' };

	let sign = '';
	if (MINUS.test(s)) {
		sign = '-';
		s = s.slice(1);
	} else if (s.startsWith('+')) {
		s = s.slice(1);
	}

	/* Spaces group digits in many locales and are harmless in the rest, so they all go. */
	const groupIsSpace = symbols.group.replace(SPACES, '') === '';
	s = s.replace(SPACES, '');

	const group = groupIsSpace ? null : symbols.group;
	const alternate = symbols.decimal === ',' ? '.' : ',';
	const decimals = [symbols.decimal, ...(alternate !== group ? [alternate] : [])];

	let whole = s;
	let fraction = '';
	const decimalAt = decimals.map((d) => s.indexOf(d)).find((i) => i >= 0);
	if (decimalAt !== undefined) {
		whole = s.slice(0, decimalAt);
		fraction = s.slice(decimalAt + 1);
	}

	if (group && whole.includes(group)) {
		const chunks = whole.split(group);
		if (grouped(chunks, symbols.grouping)) {
			whole = chunks.join('');
		} else if (decimalAt === undefined && chunks.length === 2) {
			[whole, fraction] = chunks;
		} else {
			return { kind: 'invalid' };
		}
	}

	if (!/^\d*$/.test(whole) || !/^\d*$/.test(fraction)) return { kind: 'invalid' };
	if (whole === '' && fraction === '') return { kind: 'invalid' };

	const value = Number(`${sign}${whole || '0'}.${fraction || '0'}`);
	return Number.isFinite(value)
		? { kind: 'number', value: value === 0 ? 0 : value }
		: { kind: 'invalid' };
}

/** Digits after the point in a step such as 0.25, used to round away floating-point noise. */
export function decimalsOf(n: number): number {
	if (Number.isInteger(n)) return 0;
	const [, fraction = '', exponent] = /^-?\d*\.?(\d*)(?:e-(\d+))?$/i.exec(String(n)) ?? [];
	return fraction.length + Number(exponent ?? 0);
}

/** Round to a number of decimals without 0.1 + 0.2 drift. */
export function roundTo(n: number, decimals: number): number {
	const factor = 10 ** decimals;
	return Math.round(n * factor) / factor;
}

/** Whether the value sits on the step grid that starts at base (min, or 0). */
export function onStep(value: number, step: number, base: number): boolean {
	const steps = (value - base) / step;
	/* Tolerance scaled to the quotient: absorbs 0.3 / 0.1 = 2.9999999999999996, nothing more. */
	return Math.abs(steps - Math.round(steps)) <= 1e-10 * Math.max(1, Math.abs(steps));
}

/** The nearest value on the step grid, preferring the larger one on a tie. */
export function snapToStep(value: number, step: number, base: number): number {
	const places = Math.max(decimalsOf(step), decimalsOf(base));
	return roundTo(base + Math.round((value - base) / step) * step, places);
}

/** The text a person edits: the locale's decimal separator, no grouping, every digit kept. */
export function formatEditable(value: number, locale: string): string {
	return new Intl.NumberFormat(locale, { useGrouping: false, maximumFractionDigits: 20 }).format(
		value
	);
}

/** The value a form submits: dot decimal, ASCII minus, no grouping, no exponent. */
export function toCanonical(value: number): string {
	return new Intl.NumberFormat('en-US', {
		useGrouping: false,
		maximumFractionDigits: 20,
		numberingSystem: 'latn'
	})
		.format(value)
		.replace('−', '-');
}

Give it a label, a name and bounds, and bind value to a number or null. It parses what people type in your locale, states the bounds, and shows its own error or clamps on blur. It does not handle currency or percentages, does not keep more precision than a JavaScript number, and is not a substitute for checking the value on the server.

Suggested location
src/lib/components/number-field-01
Required props
label

Limitations

  • Values are JavaScript numbers: about 15 significant digits and at most 20 decimal places, no arbitrary-precision decimals. Use a string-based field for money or identifiers.
  • text restores a draft on mount only. To change the field from outside afterwards, set value.
  • A native form reset restores the value or text the field was mounted with.
  • formatOptions is for plain decimal display (fraction digits, grouping). Currency, percent and unit styles are separate components; their output will not parse back.
  • The visible input has no name. The hidden input submits the value, so a form posted before hydration sends the initial value, not what was typed.
  • Errors and clamping happen on blur; a value that was given out of range is shown with its error at once but only clamped when someone leaves the field.
  • While the text cannot be accepted the field sets a custom validity message, so a native submit stops on it. Forms with novalidate submit an empty value instead.
  • Fields that accept negatives use the full keyboard on phones, because numeric and decimal keypads have no minus key.
  • Server code must check the bounds again; the browser can be bypassed.
  • reserveError holds one line, or the number of lines you pass; a longer error or note still grows the field. Size it to your longest message where layout stability matters.

Example

Svelte
<script lang="ts">
	import NumberField from '$lib/components/number-field-01/NumberField.svelte';

	let teamSize = $state<number | null>(48);
</script>

<form method="POST" class="max-w-sm">
	<NumberField
		label="Team size"
		name="team-size"
		min={1}
		max={10000}
		hint="People who will plan in Halcyon."
		bind:value={teamSize}
	/>
</form>

The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.

Number field#

A text input that holds a number. It is the base for every numeric field: the quantity stepper, currency, percentage and measurement inputs copy its parser, source/parse-number.ts.

Svelte
<NumberField label="Team size" name="team-size" min={1} max={10000} bind:value={teamSize} />

Why not type="number"#

A native number input changes when someone scrolls past it, empties value when the text is not a number (so 12a silently becomes nothing), ignores the page's locale and announces itself as a spin button with buttons that are hard to hit. This field is type="text" with inputmode="numeric" or "decimal", so the right keyboard still appears and the text someone typed stays put until they fix it.

Value and text#

value is a number or null. It is null while the field is empty and while the text cannot be accepted: not a number, outside the bounds, or off the step grid. text is the raw text. Bind it to keep a draft; pass it when the field mounts to restore one, and the field checks it straight away. Changes from outside after that go through value. A native form reset restores what the field was mounted with.

The field shows value formatted (1,234.5, or 1.234,5 in de-DE) and swaps in plain digits (1234.5) while it has focus, so editing never fights the group separators.

Parsing#

Separators and digits come from Intl.NumberFormat for the locale:

Typed in de-DE Reads as
1.234,5 1234.5
1234,5 1234.5
2.5 2.5
1.234 1234
12a an error

A group separator followed by groups of three digits is grouping; a single one followed by anything else is a decimal point, because a phone keyboard often offers only its own region's separator. Spaces of every width, the locale's own digits, Arabic-Indic digits and the Unicode minus sign are all accepted.

Bounds, errors and clamping#

min and max become a sentence after the hint ("Between 1 and 10,000."). On blur, text that cannot be accepted keeps its place and gets an error, and onInvalid is called with the reason. With clamp, an out-of-range or off-step number moves to the nearest accepted value instead, with a note under the field ("Changed to 20 cards, the highest allowed.") that is also announced. Use clamp for settings, not for orders or bookings, where the person should decide.

Set error to show your own message; it wins over the field's. Rewrite the field's sentences with messages:

Svelte
<NumberField
	label="Beckenvolumen"
	locale="de-DE"
	unit="m³"
	min={1}
	max={5000}
	step={0.5}
	messages={{ between: 'Zwischen {min} und {max} {unit}.', aboveMax: 'Höchstens {max} {unit}.' }}
/>

Keyboard#

Up and Down add or take one step; Page Up and Page Down ten. Steps land on the grid that starts at min and never leave the bounds. The wheel does nothing. Home and End move the caret, as in any text field.

Submitting#

A hidden input carries name and the canonical value: dot decimal, no grouping, ASCII minus (1234.5, -3). It is empty when the field is empty or invalid, and the visible input reports a custom validity message while invalid, so a native submit stops on it. Check the bounds on the server again.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label, always shown. The placeholder never stands in for it.
valuenumber | nullNonullBindable parsed value. Null when the field is empty or its text cannot be accepted (not a number, out of range, off the step grid).
textstringNoNoneBindable raw text in the field. Pass it when the field mounts to restore a draft, which is checked at once; otherwise the field shows value formatted. Later changes from outside go through value, not text.
namestringNoNoneForm field name, given to a hidden input that submits the value as a dot-decimal string, or an empty string when there is none.
hintstringNoNoneHelp text under the label. The bounds sentence follows on its own line; both are linked by aria-describedby.
errorstring | nullNonullError from your own validation. Wins over the field's own message while set and sets aria-invalid.
minnumberNoNoneLowest accepted value. Also the start of the step grid.
maxnumberNoNoneHighest accepted value.
stepnumberNo1Arrow-key step and the grid accepted values sit on, counted from min (or 0).
mode'integer' | 'decimal'NoNone'integer' accepts whole numbers only and asks for a numeric keypad; 'decimal' accepts a decimal separator. Defaults to integer for a whole step and decimal otherwise.
localestringNo'en-GB'BCP 47 locale for parsing and formatting. 'de-DE' reads and shows 1.234,5; 'ar-EG' shows Arabic-Indic digits.
formatOptionsIntl.NumberFormatOptionsNoNoneDisplay formatting applied when the field loses focus, such as { minimumFractionDigits: 2 } or { useGrouping: false }. Plain decimal styles only.
clampbooleanNofalseOn blur, move an out-of-range or off-step number to the nearest accepted value and show a note saying so, instead of an error. Text that is not a number is still an error.
unitstringNoNoneUnit shown inside the field's end edge, such as 'kg' or 'weeks'. Hidden from screen readers there and read with the bounds instead.
align'start' | 'end'No'start'Text alignment inside the input. 'end' suits figures in a table or settings column.
width'fit' | 'full'No'fit''fit' sizes the input to its longest bound plus the step's decimals, so the field's width says what it expects, and widens for longer text such as an invalid draft; 'full' fills the container.
requiredbooleanNofalseNative required attribute. Shows the Required marker unless requiredIndicator says otherwise.
requiredIndicator'required' | 'optional' | 'none'NoNoneWhich marker follows the label. Defaults to 'required' for required fields and 'none' otherwise.
requiredLabelstringNo'Required'Marker text for required fields.
optionalLabelstringNo'Optional'Marker text for optional fields.
errorPrefixstringNo'Error:'Visually hidden text read before the error.
messagesPartial<NumberFieldMessages>NoNoneReplace any sentence the field writes: the bounds (between, atLeast, atMost), the errors (notANumber, belowMin, aboveMax, wholeNumber, step) and the clamp notes (clampedMin, clampedMax, rounded). {min}, {max}, {step} and {value} are formatted for the locale; {unit} is the unit. In the step error {value} is the nearest accepted value, in the clamp notes the value the field moved to.
size'sm' | 'md' | 'lg'No'md'32, 36 or 40 px tall with 13, 14 or 16 px text, matching buttons of the same size. Every size is 44 px with 16 px text on touch screens.
reserveErrorboolean | numberNofalseHold space under the field for the error or clamp note, so a message moves nothing below: true holds one line, a number holds that many lines. A message longer than the space still grows the field.
onInvalid(reason: 'not-a-number' | 'below-min' | 'above-max' | 'step') => voidNoNoneCalled on blur when the text cannot be accepted. Not called when clamp fixes the value.
onfocusFocusEventHandler<HTMLInputElement>NoNoneRuns before the field swaps in its editable text.
onblurFocusEventHandler<HTMLInputElement>NoNoneRuns after the field has checked and formatted its text.
oninputFormEventHandler<HTMLInputElement>NoNoneRuns on every keystroke, before the field parses the text.
onkeydownKeyboardEventHandler<HTMLInputElement>NoNoneRuns before the field's own arrow-key stepping; call preventDefault to stop it.
idstringNoNoneInput id. Defaults to one generated per instance; the hint and error always take generated ids.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

Change the copy through props and messages, and retone the field through seven --number-field-* CSS variables. Hover and the read-only fill are mixed from those, so they follow any retone.

  • Accent: --number-field-accent colours the focus edge and its halo. Keep it at 3:1 against the page.
  • Danger: --number-field-danger colours the error edge, the error focus ring, the icon and the message. The default is red-700 (6.5:1 on white).
  • Neutrals: --number-field-ink is the label and value, --number-field-muted the hint, bounds, unit and clamp note, --number-field-placeholder the placeholder, --number-field-hairline the resting edge (keep it at 3:1 against the page) and --number-field-surface the fill.
  • Worked retone for a #09090b band: --number-field-accent: #fafafa; --number-field-ink: #fafafa; --number-field-muted: #a1a1aa; --number-field-placeholder: #a1a1aa; --number-field-hairline: rgb(255 255 255 / 0.35); --number-field-surface: #18181b; --number-field-danger: #f87171. Add color-scheme: dark on the band.
  • Cream page such as a bakery's #fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set --number-field-surface: #fffdf9.
  • Width: keep 'fit' in forms, where a narrow field tells people the number is short; use width 'full' with align 'end' in a settings column or beside other figures.
  • Bounds: the hint states them from min and max. Rewrite the sentence through messages.between, atLeast and atMost; put the unit where your language wants it with {unit}.
  • Another language: pass locale and translate messages, requiredLabel, optionalLabel and errorPrefix. Separators and digits come from the locale.
  • Behaviour: leave clamp off where an out-of-range number is a mistake worth telling someone about (a booking, an order); turn it on for settings where the nearest allowed value is obviously what they meant (a limit, a zoom level).

Public CSS variables

VariableToken
--number-field-accentaccent
--number-field-dangerdanger
--number-field-inkink
--number-field-mutedmuted
--number-field-placeholderplaceholder
--number-field-hairlinehairline
--number-field-surfacesurface

Accessibility#

On this page
  • The input is type=text, not type=number, and has no spinbutton role because it has no stepper buttons (see the quantity stepper for that). Arrow keys still step it, and the new value is announced in a polite status region.
  • A native <label for> names the input. aria-describedby lists the hint, then the bounds, then the error or clamp note, then any aria-describedby you pass. aria-invalid is set only while an error shows.
  • The bounds are text in the hint, including the unit, so a screen reader hears 'Between 1 and 20 cards' rather than nothing. The visual unit beside the value is hidden from assistive technology.
  • Errors are never colour alone: an icon, text, and a visually hidden errorPrefix. They are not live regions; when a submit fails, move focus to the first invalid field or an error summary.
  • Clamping says what changed in a visible note and announces it politely, so a value never changes silently.
  • Inputs use 16 px text and grow to 44 px on touch screens, so iOS does not zoom and targets clear 44 px. The fit width keeps at least two characters of input and widens for longer text.

Release details#

On this page
Integration
  • Local interaction
  • Requires client-side JavaScript to be interactive
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 22 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.