Skip to content
Download ZIP

Blue accent palette · 15.1 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_currency_amount_field_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_currency_amount_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-c69a3faaa0b6f63a74dc99b290688388d1956223fd0a7bf4d6350a0da6202580

This component needs all 2 files. Download the ZIP

money.ts TypeScript · 7.4 KB Raw
/**
 * Money helpers for the currency amount field. Amounts are integers in the currency's minor unit
 * (1250 GBP is £12.50, 1500 JPY is ¥1,500), and every conversion between text and minor units
 * works on digit strings, so 19.99 becomes 1999 and never 1998.9999999999998.
 */

/** How one currency is written in one locale. */
export interface CurrencyFormat {
	/** ISO 4217 code, upper case. */
	code: string;
	locale: string;
	/** Decimal places in the minor unit: 2 for GBP, 0 for JPY, 3 for KWD. */
	digits: number;
	/** What the adornment shows: the locale's symbol (£, US$, ¥) or the code. */
	mark: string;
	/** Whether the locale writes the mark before or after the number. */
	position: 'before' | 'after';
	/** The locale's decimal separator: '.', ',' or the Arabic decimal mark. */
	decimal: string;
	/** Every way the currency may be written in this locale (symbol, narrow symbol, code), for parsing. */
	signs: string[];
	/** The locale's currency name in words, such as "British Pound". */
	name: string;
}

export type ParseResult =
	{ ok: true; minor: number | null } | { ok: false; reason: 'invalid' | 'decimals' | 'too-large' };

const cache = new Map<string, CurrencyFormat>();

/** Reads the currency's decimal places, mark, mark position and name from Intl. */
export function currencyFormat(
	locale: string,
	currency: string,
	display: 'symbol' | 'code' = 'symbol'
): CurrencyFormat {
	const code = currency.toUpperCase();
	const key = `${locale}|${code}|${display}`;
	const hit = cache.get(key);
	if (hit) return hit;

	const money = new Intl.NumberFormat(locale, {
		style: 'currency',
		currency: code,
		currencyDisplay: display
	});
	const parts = money.formatToParts(1);
	const markAt = parts.findIndex((p) => p.type === 'currency');
	const numberAt = parts.findIndex((p) => p.type === 'integer');
	const decimal =
		new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === 'decimal')?.value ??
		'.';
	const sign = (currencyDisplay: 'symbol' | 'narrowSymbol' | 'code') =>
		new Intl.NumberFormat(locale, { style: 'currency', currency: code, currencyDisplay })
			.formatToParts(1)
			.find((p) => p.type === 'currency')?.value ?? code;
	const format: CurrencyFormat = {
		code,
		locale,
		digits: money.resolvedOptions().maximumFractionDigits ?? 2,
		mark: parts[markAt]?.value ?? code,
		position: markAt > numberAt ? 'after' : 'before',
		decimal,
		/* Longest first, so "US$" goes before "$" can leave "US" behind. */
		signs: [...new Set([sign('symbol'), sign('narrowSymbol'), code])].sort(
			(a, b) => b.length - a.length
		),
		name: new Intl.DisplayNames([locale], { type: 'currency' }).of(code) ?? code
	};
	cache.set(key, format);
	return format;
}

/** Minor units as an exact decimal string: 1999 with 2 digits is "19.99". */
export function toDecimalString(minor: number, digits: number): string {
	const raw = String(Math.trunc(Math.abs(minor))).padStart(digits + 1, '0');
	if (digits === 0) return raw;
	return `${raw.slice(0, -digits)}.${raw.slice(-digits)}`;
}

/**
 * The number as the field shows it, without the mark: "1,234.56" in en, "1.234,56" in de-DE,
 * "1,500" for yen. The decimal string goes to Intl as a string, so no float is involved.
 */
export function formatAmount(minor: number, format: CurrencyFormat): string {
	const formatter = new Intl.NumberFormat(format.locale, {
		minimumFractionDigits: format.digits,
		maximumFractionDigits: format.digits
	});
	return formatter.format(
		toDecimalString(minor, format.digits) as unknown as Parameters<typeof formatter.format>[0]
	);
}

/** The amount with its mark, as the locale writes money: "£5.00", "1.234,56 €". */
export function formatMoney(minor: number, format: CurrencyFormat): string {
	const formatter = new Intl.NumberFormat(format.locale, {
		style: 'currency',
		currency: format.code,
		minimumFractionDigits: format.digits,
		maximumFractionDigits: format.digits
	});
	return formatter.format(
		toDecimalString(minor, format.digits) as unknown as Parameters<typeof formatter.format>[0]
	);
}

/* Spaces, including the no-break and narrow no-break spaces some locales group with, and the
   direction marks Intl puts around Hebrew and Arabic amounts. */
const SPACES = /[\s\u00a0\u202f\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g;
/* Grouping and decimal characters any locale might use, Arabic's decimal and grouping marks included. */
const SEPARATORS = /[.,'’\u066b\u066c]/g;

/* Zeros of the decimal digit sets Intl formats with: Arabic-Indic, Persian, Devanagari, Bengali,
   Thai, Lao, Tibetan, Myanmar, Khmer, Mongolian and full-width. */
const ZEROS = [
	0x660, 0x6f0, 0x966, 0x9e6, 0xa66, 0xae6, 0xb66, 0xbe6, 0xc66, 0xce6, 0xd66, 0xe50, 0xed0, 0xf20,
	0x1040, 0x17e0, 0x1810, 0xff10
];

/** Non-Latin decimal digits read as 0 to 9. */
function latinDigits(text: string): string {
	return text.replace(/\p{Nd}/gu, (d) => {
		const code = d.codePointAt(0)!;
		const zero = ZEROS.find((z) => code >= z && code <= z + 9);
		return zero === undefined ? d : String(code - zero);
	});
}

/**
 * Reads what a person typed or pasted into minor units. Marks, codes and spaces are ignored, so
 * "£1,234.50", "1 234,50 €" and "EUR 12" all read, and non-Latin digits read as 0 to 9. The
 * locale's decimal separator always means decimals. A point or comma that is not the locale's
 * means decimals only when it appears once, with one or two digits after it and no other
 * separator before it, so "12,5" in en is 12.50 and "1,250" is one thousand two hundred and
 * fifty. Empty text is a valid null.
 */
export function parseAmount(text: string, format: CurrencyFormat): ParseResult {
	let cleaned = latinDigits(text.replace(SPACES, ''));
	if (!cleaned) return { ok: true, minor: null };

	/* Drop the currency's own signs and any currency symbol; anything else left is not a number. */
	for (const sign of format.signs) cleaned = cleaned.split(sign).join('');
	cleaned = cleaned.replace(new RegExp(format.code, 'gi'), '').replace(/\p{Sc}/gu, '');
	if (!/\d/.test(cleaned) || /[^\d.,'’\u066b\u066c]/.test(cleaned)) {
		return { ok: false, reason: 'invalid' };
	}

	let whole = cleaned;
	let fraction = '';
	const at = cleaned.lastIndexOf(format.decimal);
	if (at !== -1) {
		if (cleaned.indexOf(format.decimal) !== at) return { ok: false, reason: 'invalid' };
		whole = cleaned.slice(0, at);
		fraction = cleaned.slice(at + format.decimal.length);
	} else {
		for (const other of ['.', ','].filter((c) => c !== format.decimal)) {
			const first = cleaned.indexOf(other);
			const tail = cleaned.slice(first + 1);
			if (
				first !== -1 &&
				first === cleaned.lastIndexOf(other) &&
				/^\d{1,2}$/.test(tail) &&
				!/[.,'’\u066b\u066c]/.test(cleaned.slice(0, first))
			) {
				whole = cleaned.slice(0, first);
				fraction = tail;
				break;
			}
		}
	}

	/* Grouping separators may sit anywhere in the whole part; a separator in the fraction may not. */
	whole = whole.replace(SEPARATORS, '');
	if (!/^\d*$/.test(fraction)) return { ok: false, reason: 'invalid' };
	if (!whole && !fraction) return { ok: false, reason: 'invalid' };

	/* Trailing zeros are not precision: 25.00 is fine for pounds, 1500.0 for yen. */
	const significant = fraction.replace(/0+$/, '');
	if (significant.length > format.digits) return { ok: false, reason: 'decimals' };

	const digits = `${whole || '0'}${significant.padEnd(format.digits, '0')}`.replace(
		/^0+(?=\d)/,
		''
	);
	const minor = Number(digits);
	if (!Number.isSafeInteger(minor)) return { ok: false, reason: 'too-large' };
	return { ok: true, minor };
}

Give it a label and a currency and bind value; it reads what the person types into integer minor units with the currency's own decimal places and submits amount_minor and currency as hidden fields. It does not convert between currencies, take payments, collect card details or calculate tax.

Suggested location
src/lib/components/currency-amount-field-01
Required props
labelcurrency

Limitations

  • Amounts are zero or positive. Negative amounts (refunds, credits) are refused as unreadable.
  • Amounts are JavaScript numbers, so the largest is Number.MAX_SAFE_INTEGER minor units (about 90 trillion pounds); past it the field asks for a smaller amount.
  • minMinor and maxMinor are in minor units of whichever currency is selected. When the select offers currencies with different decimal places, pass bounds that follow the selected currency.
  • Changing currency keeps the figures the person entered. Between currencies with the same decimal places (GBP, USD, EUR) amountMinor is unchanged; from GBP to JPY, 25.00 becomes 25 yen. Nothing is converted.
  • The hidden fields update on blur, on Enter and on a currency change, so the form needs JavaScript to submit what was typed. The server-rendered value submits before hydration.
  • locale defaults to en-US rather than the browser's, so the server and the browser render the same text. Pass the page's locale.

Example

Svelte
<script lang="ts">
	import CurrencyAmountField, {
		type CurrencyAmount
	} from '$lib/components/currency-amount-field-01/CurrencyAmountField.svelte';

	let gift = $state<CurrencyAmount | null>({ amountMinor: 2500, currency: 'GBP' });
</script>

<form method="POST" class="max-w-sm">
	<CurrencyAmountField
		label="Your donation"
		currency="GBP"
		locale="en-GB"
		size="lg"
		minMinor={500}
		hint="Gift Aid adds 25p to every £1 if you pay UK tax."
		bind:value={gift}
	/>
</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.

Currency amount field#

A money field that keeps the stored value apart from what the person sees. The value is an integer in the currency's minor unit plus its ISO 4217 code:

TypeScript
{ amountMinor: 1250, currency: 'GBP' } // £12.50
{ amountMinor: 1500, currency: 'JPY' } // ¥1,500, yen has no minor unit
Svelte
<CurrencyAmountField
	label="Your donation"
	currency="GBP"
	locale="en-GB"
	size="lg"
	minMinor={500}
	bind:value={gift}
/>

What it reads#

Text is read as digit strings, never through a float, so 19.99 stores 1999. The mark, the code and spaces are ignored, so pasted £1,234.50, GBP 12 and 1.234,56 € all read. The locale's decimal separator always means decimals; the other separator means decimals only when it appears once with one or two digits after it (12,5 in en-GB is £12.50, 1,250 is £1,250.00). Grouping is added when the person leaves the field, never while they type.

Decimal places come from Intl.NumberFormat(...).resolvedOptions().maximumFractionDigits: two for GBP, none for JPY, three for KWD. For a currency with none, typing a decimal separator does nothing, and pasted fractions get an error. Trailing zeros are fine (1500.00 yen is 1500).

Submitting#

The visible input has no name. The value submits as two hidden fields, amount_minor and currency (rename with amountName and currencyName), which update on blur, on Enter and on a currency change. With a select, the select itself carries currency.

The field's own errors (unreadable text, too many decimals, below minMinor, above maxMinor) set a custom validity message on the input, so a native submit stops on them. Your own error replaces the message but does not block submission; do that in your submit handler.

Every editable field holds one 20 px line under the border for its messages, so an error or the currency note appears without pushing the rest of the form down. Pass reserveMessage={false} to drop it in a dense table.

Choosing a currency#

currencies adds a select as the field's last segment, inside the same border, with its own visible label above it. Changing it keeps the figures and never converts them; a note under the field says so and a polite status region announces it. Between currencies with the same decimal places amountMinor does not change. From GBP to JPY, 25.00 becomes ¥25.

Bounds#

minMinor and maxMinor are in minor units and appear after the hint as money in the field's locale: "Minimum £5.00.", "Maximum $2,400.00.", "£5.00 to £10,000.00.".

Other languages#

Pass locale and translate the sentences the field writes with messages:

Svelte
<CurrencyAmountField
	label="Rechnungsbetrag"
	currency="EUR"
	locale="de-DE"
	errorPrefix="Fehler:"
	messages={{ invalid: 'Geben Sie einen Betrag in Ziffern ein, zum Beispiel {example}.' }}
/>

{currency} is the currency's name from Intl.DisplayNames in that locale, {code} the ISO code, {min}, {max} and {example} formatted amounts, {digits} the decimal places.

Retoning#

On a #09090b band:

CSS
.band {
	background: #09090b;
	color-scheme: dark;
	--currency-amount-field-accent: #fafafa;
	--currency-amount-field-ink: #fafafa;
	--currency-amount-field-muted: #a1a1aa;
	--currency-amount-field-placeholder: #a1a1aa;
	--currency-amount-field-hairline: rgb(255 255 255 / 0.35);
	--currency-amount-field-surface: #18181b;
	--currency-amount-field-danger: #f87171;
}

The parsing helper#

money.ts holds the parsing and formatting (currencyFormat, parseAmount, formatAmount, formatMoney) with no Svelte in it, so a number or percentage field can reuse it.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label. The currency's name in words is added after it for assistive technology.
valueCurrencyAmount | nullNonullBindable { amountMinor, currency }, for example { amountMinor: 1250, currency: 'GBP' } for £12.50. Null when the field is empty or what was typed cannot be read.
currencystringYesNoneISO 4217 code. The field's currency, or the first selection when currencies is set and value has none.
currenciesstring[]NoNoneShows a currency select, attached as the field's last segment, limited to these codes.
localestringNo'en-US'Locale for separators, the mark and which side it sits on, and the currency's name.
minMinornumberNoNoneSmallest allowed amount in minor units. Shown in the hint as money; below it the field shows an error and blocks submission.
maxMinornumberNoNoneLargest allowed amount in minor units, shown and enforced the same way.
errorstring | nullNonullValidation message from your code. Shown in place of the field's own messages, with an icon, the danger edge and aria-invalid.
onChange(value: CurrencyAmount | null) => voidNoNoneCalled with the new value when the person leaves the field, presses Enter or changes the currency, only when the value changed.
hintstringNoNoneHelp text under the label. The bounds follow it in the same line.
adornment'symbol' | 'code'No'symbol''symbol' shows the locale's sign (£, US$, €); 'code' shows GBP, USD, EUR. With a select in code mode the select carries the code and no mark is shown.
size'md' | 'lg'No'md'md is an app field, 36 px with 14 px figures (46 px and 16 px on touch screens, so the input and select are each 44 px inside the border); lg is donation-style entry, 56 px with 24 px figures.
requiredbooleanNofalseNative required on the visible field, so an empty field blocks submission.
disabledbooleanNofalseDisables the field and the select; nothing submits.
readonlybooleanNofalseRead-only: focusable and selectable, the select is locked, and the value still submits.
placeholderstringNoNonePlaceholder. Defaults to zero in the currency's format: 0.00, 0 or 0,00.
amountNamestringNo'amount_minor'Name of the hidden field carrying amountMinor.
currencyNamestringNo'currency'Name of the hidden field, or of the select, carrying the currency code.
currencyLabelstringNo'Currency'Visible label of the currency select.
errorPrefixstringNo'Error:'Visually hidden text read before an error.
messagesPartial<CurrencyAmountMessages>NoNoneReplaces any sentence the field writes: currencyName, min, max, range, invalid, decimals, wholeNumber, tooLarge, belowMin, aboveMax, changed. Placeholders: {currency}, {code}, {min}, {max}, {example}, {digits}.
reserveMessagebooleanNoNoneHold one line under the field for messages, so one appearing moves nothing below. On by default for every editable field, since any of them can raise a message of its own; set false to drop the line in dense layouts.
idstringNoNoneInput id. Defaults to one generated per instance; the hint, select and message ids are always generated.
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 --currency-amount-field-* CSS variables. Hover, the select segment and the read-only fill are mixed from those, so they follow any retone.

  • Accent: --currency-amount-field-accent colours the focus edge and halo and tints the focused select. Keep it at 3:1 against the page.
  • Danger: --currency-amount-field-danger colours the error edge, ring, icon and message. The default is red-700 (6.5:1 on white).
  • Neutrals: --currency-amount-field-ink is the label and figures, -muted the hint, mark and notes, -placeholder the zero placeholder, -hairline the resting edge (keep it at 3:1, it is all that marks an empty field) and -surface the fill.
  • Worked retone for a #09090b band: --currency-amount-field-accent: #fafafa; -ink: #fafafa; -muted: #a1a1aa; -placeholder: #a1a1aa; -hairline: rgb(255 255 255 / 0.35); -surface: #18181b; -danger: #f87171. Add color-scheme: dark on the band so the select's list is dark too.
  • Cream page such as a bakery's #fbf7f0: leave the hairline alone (it is alpha black) and set --currency-amount-field-surface: #fffdf9.
  • Sizes: md for settings, invoices and admin forms; lg for a donation, tip or pay-what-you-want amount that is the point of the page.
  • Mark: symbol for a single-currency page; code where several currencies sit side by side or the symbol is ambiguous ($ for USD, CAD and AUD).
  • Language: pass locale and translate currencyLabel, errorPrefix and messages together. {currency} is the currency's name from Intl.DisplayNames in that locale.
  • Layout: the field fills its container. Stack fields 20 to 24 px apart and pass class for placement.

Public CSS variables

VariableToken
--currency-amount-field-accentaccent
--currency-amount-field-dangerdanger
--currency-amount-field-inkink
--currency-amount-field-mutedmuted
--currency-amount-field-placeholderplaceholder
--currency-amount-field-hairlinehairline
--currency-amount-field-surfacesurface

Accessibility#

On this page
  • A native <label for> names the input, followed by the currency's name in words in a visually hidden span: 'Your donation (British Pound)'. The mark inside the border is aria-hidden.
  • The input is type=text with inputmode decimal, never type=number, so a scroll wheel cannot change an amount and grouping separators can be typed or pasted.
  • aria-describedby lists the hint and bounds, then the current error or currency note, then any aria-describedby you pass. aria-invalid is set while an error shows.
  • The currency select has its own visible label. A change is announced through a polite status region and shown as a note under the field.
  • Errors carry an icon, text and a visually hidden errorPrefix, never colour alone. They are not live regions; move focus to the field or an error summary when a submit fails.
  • The field's own errors (unreadable, too many decimals, out of bounds) set a custom validity message, so native submission stops on them as it would on required.
  • Translate messages, currencyLabel and errorPrefix with the page; the currency name follows locale automatically.

Known limitations

  • Screen readers read the select's options as codes (GBP, USD). Use codes your audience knows, or translate the label to say what they mean.

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.