# Currency amount field

> A money amount field that stores integer minor units plus an ISO 4217 code, shows the currency's mark on the side the locale writes it, formats on blur, and can attach a currency select that never converts.

- ID: `cmp_currency_amount_field_01`
- Slug: `currency-amount-field-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T04:26:15Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/currency-amount-field-01?variant=blue
- Preview: https://pagesugar.com/preview/currency-amount-field-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-a246d99551d7b58d414a0c77b840668f54724811ff3e1014a1175ca154e0e21e` |
| `blue` | Blue accent | no | `sha256-c69a3faaa0b6f63a74dc99b290688388d1956223fd0a7bf4d6350a0da6202580` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/currency-amount-field-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

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.

Required props: `label`, `currency`

```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>
```

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.

## Usage guide

### 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:

```ts
{ 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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label. The currency's name in words is added after it for assistive technology. |
| `value` | CurrencyAmount \| null | no | `null` | Bindable { amountMinor, currency }, for example { amountMinor: 1250, currency: 'GBP' } for £12.50. Null when the field is empty or what was typed cannot be read. |
| `currency` | `string` | yes |  | ISO 4217 code. The field's currency, or the first selection when currencies is set and value has none. |
| `currencies` | `string[]` | no |  | Shows a currency select, attached as the field's last segment, limited to these codes. |
| `locale` | `string` | no | `'en-US'` | Locale for separators, the mark and which side it sits on, and the currency's name. |
| `minMinor` | `number` | no |  | Smallest allowed amount in minor units. Shown in the hint as money; below it the field shows an error and blocks submission. |
| `maxMinor` | `number` | no |  | Largest allowed amount in minor units, shown and enforced the same way. |
| `error` | string \| null | no | `null` | Validation 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) =\> void | no |  | Called with the new value when the person leaves the field, presses Enter or changes the currency, only when the value changed. |
| `hint` | `string` | no |  | Help 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. |
| `required` | `boolean` | no | `false` | Native required on the visible field, so an empty field blocks submission. |
| `disabled` | `boolean` | no | `false` | Disables the field and the select; nothing submits. |
| `readonly` | `boolean` | no | `false` | Read-only: focusable and selectable, the select is locked, and the value still submits. |
| `placeholder` | `string` | no |  | Placeholder. Defaults to zero in the currency's format: 0.00, 0 or 0,00. |
| `amountName` | `string` | no | `'amount_minor'` | Name of the hidden field carrying amountMinor. |
| `currencyName` | `string` | no | `'currency'` | Name of the hidden field, or of the select, carrying the currency code. |
| `currencyLabel` | `string` | no | `'Currency'` | Visible label of the currency select. |
| `errorPrefix` | `string` | no | `'Error:'` | Visually hidden text read before an error. |
| `messages` | `Partial<CurrencyAmountMessages>` | no |  | Replaces any sentence the field writes: currencyName, min, max, range, invalid, decimals, wholeNumber, tooLarge, belowMin, aboveMax, changed. Placeholders: {currency}, {code}, {min}, {max}, {example}, {digits}. |
| `reserveMessage` | `boolean` | no |  | Hold 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. |
| `id` | `string` | no |  | Input id. Defaults to one generated per instance; the hint, select and message ids are always generated. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--currency-amount-field-accent` |
| `danger` | `--currency-amount-field-danger` |
| `ink` | `--currency-amount-field-ink` |
| `muted` | `--currency-amount-field-muted` |
| `placeholder` | `--currency-amount-field-placeholder` |
| `hairline` | `--currency-amount-field-hairline` |
| `surface` | `--currency-amount-field-surface` |

## Accessibility

- 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Blue accent (`blue`)
- Entry: `CurrencyAmountField.svelte`
- Suggested directory: `src/lib/components/currency-amount-field-01`
- Files: 2
- Artifact digest: `sha256-c69a3faaa0b6f63a74dc99b290688388d1956223fd0a7bf4d6350a0da6202580`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `CurrencyAmountField.svelte`

Role: entry · 24382 bytes · SHA-256 `86d18491f1882abc17a5751bfa4505464ebd1b0d2a7c24954ec376281b193f45`

```svelte
<!--
	A money amount field. The value is an integer in the currency's minor unit plus its ISO 4217
	code ({ amountMinor: 1250, currency: 'GBP' } is £12.50), kept apart from the text the person
	sees. The currency's mark sits inside the field's one border on the side the locale writes it,
	an optional currency select is attached as the field's last segment, and the amount is
	formatted with grouping when the person leaves the field, never while they type. The value
	submits as two hidden fields, amount_minor and currency.
-->
<script lang="ts" module>
	export interface CurrencyAmount {
		/** Integer in the currency's minor unit: 1250 GBP is £12.50, 1500 JPY is ¥1,500. */
		amountMinor: number;
		/** ISO 4217 code. */
		currency: string;
	}

	export type CurrencyAmountFieldSize = 'md' | 'lg';
	export type CurrencyAmountFieldAdornment = 'symbol' | 'code';

	/**
	 * Every sentence the field writes itself. {currency} is the currency's name in words in the
	 * field's locale, {code} its ISO code, {min} and {max} formatted bounds, {example} a formatted
	 * sample amount and {digits} the currency's decimal places.
	 */
	export interface CurrencyAmountMessages {
		/** Read after the label so the currency is in the field's name: "Your gift (British Pound)". */
		currencyName: string;
		min: string;
		max: string;
		range: string;
		invalid: string;
		decimals: string;
		wholeNumber: string;
		tooLarge: string;
		belowMin: string;
		aboveMax: string;
		/** Shown and announced after the currency changes. */
		changed: string;
	}

	export const defaultMessages: CurrencyAmountMessages = {
		currencyName: '({currency})',
		min: 'Minimum {min}.',
		max: 'Maximum {max}.',
		range: '{min} to {max}.',
		invalid: 'Enter an amount in numbers, like {example}.',
		decimals: 'Use no more than {digits} decimal places.',
		wholeNumber: 'Enter a whole number. {currency} has no smaller unit.',
		tooLarge: 'Enter a smaller amount.',
		belowMin: 'Enter at least {min}.',
		aboveMax: 'Enter no more than {max}.',
		changed: '{code} selected. Amount not converted.'
	};
</script>

<script lang="ts">
	import { flushSync, untrack } from 'svelte';
	import type { HTMLInputAttributes } from 'svelte/elements';
	import { currencyFormat, formatAmount, formatMoney, parseAmount } from './money';

	type Passthrough = Omit<
		HTMLInputAttributes,
		| 'type'
		| 'value'
		| 'id'
		| 'name'
		| 'size'
		| 'required'
		| 'disabled'
		| 'readonly'
		| 'inputmode'
		| 'placeholder'
		| 'class'
		| 'children'
		| 'aria-invalid'
		| 'onblur'
		| 'onkeydown'
		| 'oninput'
		| 'onbeforeinput'
		| 'onchange'
	>;

	interface Props extends Passthrough {
		/** Visible label. The currency's name is added for assistive technology. */
		label: string;
		/** Bindable value in minor units, or null when the field is empty or unreadable. */
		value?: CurrencyAmount | null;
		/** ISO 4217 code: the field's currency, or the first selection when currencies is set. */
		currency: string;
		/** Shows a currency select limited to these codes. */
		currencies?: string[];
		/** Locale for separators, the mark and its side, and the currency's name. */
		locale?: string;
		/** Smallest allowed amount, in minor units of the selected currency. */
		minMinor?: number;
		/** Largest allowed amount, in minor units of the selected currency. */
		maxMinor?: number;
		/** Validation message from your code. Shown instead of the field's own messages. */
		error?: string | null;
		/** Called with the new value when the person leaves the field or changes the currency. */
		onChange?: (value: CurrencyAmount | null) => void;
		/** Help text under the label. The bounds follow it. */
		hint?: string;
		/** 'symbol' shows the locale's sign (£, US$, €); 'code' shows GBP, USD, EUR. */
		adornment?: CurrencyAmountFieldAdornment;
		/** md is an app field, 36 px; lg is donation-style entry, 56 px with 24 px figures. */
		size?: CurrencyAmountFieldSize;
		/** Native required on the visible field. */
		required?: boolean;
		disabled?: boolean;
		readonly?: boolean;
		/** Placeholder. Defaults to zero in the currency's format: 0.00, 0 or 0,00. */
		placeholder?: string;
		/** Hidden field carrying amountMinor. */
		amountName?: string;
		/** Hidden field (or the select's name) carrying the currency code. */
		currencyName?: string;
		/** Visible label of the currency select. */
		currencyLabel?: string;
		/** Read before an error by screen readers only. */
		errorPrefix?: string;
		/** Replace any of the sentences the field writes, for other languages. */
		messages?: Partial<CurrencyAmountMessages>;
		/** Hold one line under the field for messages. On by default unless disabled or read-only. */
		reserveMessage?: boolean;
		/** Input id. Defaults to one generated per instance. */
		id?: string;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		label,
		value = $bindable(null),
		currency,
		currencies,
		locale = 'en-US',
		minMinor,
		maxMinor,
		error = null,
		onChange,
		hint,
		adornment = 'symbol',
		size = 'md',
		required = false,
		disabled = false,
		readonly = false,
		placeholder,
		amountName = 'amount_minor',
		currencyName = 'currency',
		currencyLabel = 'Currency',
		errorPrefix = 'Error:',
		messages,
		reserveMessage,
		id,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	const selectId = `${uid}-currency`;
	const hintId = `${uid}-hint`;
	const messageId = `${uid}-message`;

	const m = $derived({ ...defaultMessages, ...messages });
	const hasSelect = $derived(!!currencies?.length);

	let selected = $state(untrack(() => (value?.currency ?? currency).toUpperCase()));
	const format = $derived(currencyFormat(locale, selected, adornment));
	/* With a select in code mode the select already shows the code, so the field shows no mark. */
	const showMark = $derived(!(hasSelect && adornment === 'code'));
	const suffix = $derived(showMark && format.position === 'after');

	/* What the person sees and edits. Formatted on blur, left alone while they type. */
	let text = $state(untrack(() => (value ? formatAmount(value.amountMinor, format) : '')));
	let parseError = $state<'invalid' | 'decimals' | 'too-large' | null>(null);
	let changed = $state(false);
	let announcement = $state('');
	/* What a form reset returns to. */
	const initial = untrack(() => (value ? { ...value } : null));
	/* A snapshot of the last value this field wrote or formatted, so an outside change (a new
	   object, or a property set through bind:value) can be told apart from its own writes. */
	const snapshot = (v: CurrencyAmount | null | undefined) =>
		v ? `${v.amountMinor}|${v.currency.toUpperCase()}` : '';
	let last = untrack(() => snapshot(value));
	let lastFormat = untrack(() => `${locale}|${adornment}`);
	let input: HTMLInputElement | undefined = $state();
	let select: HTMLSelectElement | undefined = $state();

	const fill = (template: string, vars: Record<string, string | number>) =>
		template.replace(/\{(\w+)\}/g, (match, key: string) => String(vars[key] ?? match));

	const vars = $derived({
		currency: format.name,
		code: format.code,
		digits: format.digits,
		min: minMinor != null ? formatMoney(minMinor, format) : '',
		max: maxMinor != null ? formatMoney(maxMinor, format) : '',
		example: formatAmount(1250 * 10 ** format.digits, format)
	});

	const boundsText = $derived(
		minMinor != null && maxMinor != null
			? fill(m.range, vars)
			: minMinor != null
				? fill(m.min, vars)
				: maxMinor != null
					? fill(m.max, vars)
					: ''
	);

	const shownPlaceholder = $derived(placeholder ?? formatAmount(0, format));

	const ownError = $derived.by(() => {
		if (parseError === 'invalid') return fill(m.invalid, vars);
		if (parseError === 'decimals')
			return fill(format.digits === 0 ? m.wholeNumber : m.decimals, vars);
		if (parseError === 'too-large') return fill(m.tooLarge, vars);
		if (!value || value.currency.toUpperCase() !== selected) return null;
		if (minMinor != null && value.amountMinor < minMinor) return fill(m.belowMin, vars);
		if (maxMinor != null && value.amountMinor > maxMinor) return fill(m.aboveMax, vars);
		return null;
	});
	const shownError = $derived(error || ownError);
	const invalid = $derived(!!shownError);
	/* Any editable field can raise a message of its own (a typo, too many decimals), so it holds
	   the line by default and nothing below moves when one appears. */
	const reserve = $derived(reserveMessage ?? !(disabled || readonly));

	const describedBy = $derived(
		[
			...new Set(
				[
					(hint || boundsText) && hintId,
					(invalid || changed) && messageId,
					...(rest['aria-describedby'] ?? '').split(/\s+/)
				].filter(Boolean)
			)
		].join(' ') || undefined
	);

	/* A value set from outside (bind:value, a reset) replaces the text and the currency. */
	$effect.pre(() => {
		const next = snapshot(value);
		if (next === last) return;
		last = next;
		untrack(() => {
			if (value) selected = value.currency.toUpperCase();
			text = value
				? formatAmount(value.amountMinor, currencyFormat(locale, selected, adornment))
				: '';
			parseError = null;
		});
	});

	/* A new locale or mark rewrites a committed amount in the new format. */
	$effect.pre(() => {
		const next = `${locale}|${adornment}`;
		if (next === lastFormat) return;
		lastFormat = next;
		untrack(() => {
			if (value && !parseError) text = formatAmount(value.amountMinor, format);
		});
	});

	/* A selected currency that leaves the list gives way to the list's first, without converting. */
	$effect.pre(() => {
		const codes = (currencies ?? []).map((code) => code.toUpperCase());
		if (!codes.length || codes.includes(untrack(() => selected))) return;
		untrack(() => setCurrency(codes[0], false));
	});

	/* A native form reset returns the field to the value it was created with. */
	$effect(() => {
		const form = input?.form;
		if (!form) return;
		let timer: ReturnType<typeof setTimeout> | undefined;
		const onReset = () => {
			/* The browser resets the controls after the event, so restore once it has. */
			timer = setTimeout(() => {
				selected = (initial?.currency ?? currency).toUpperCase();
				if (select) select.value = selected;
				text = initial ? formatAmount(initial.amountMinor, format) : '';
				parseError = null;
				changed = false;
				announcement = '';
				write(initial ? { ...initial } : null);
			});
		};
		form.addEventListener('reset', onReset);
		return () => {
			form.removeEventListener('reset', onReset);
			clearTimeout(timer);
		};
	});

	/* Without a select, the currency prop is the currency. */
	$effect.pre(() => {
		const code = currency.toUpperCase();
		if (hasSelect || untrack(() => selected) === code) return;
		untrack(() => setCurrency(code, false));
	});

	/* The field's own errors block native submission, as a native bound would. */
	$effect(() => {
		input?.setCustomValidity(ownError ?? '');
	});

	function write(next: CurrencyAmount | null) {
		const same =
			next === value ||
			(!!next &&
				!!value &&
				next.amountMinor === value.amountMinor &&
				next.currency === value.currency);
		if (same) return;
		last = snapshot(next);
		value = next;
		onChange?.(next);
	}

	/** Reads the text in the current (or a new) currency, formats it and writes the value. */
	function commit(code = selected) {
		const target = currencyFormat(locale, code, adornment);
		const result = parseAmount(text, target);
		if (!result.ok) {
			parseError = result.reason;
			write(null);
			return;
		}
		parseError = null;
		if (result.minor === null) {
			text = '';
			write(null);
			return;
		}
		text = formatAmount(result.minor, target);
		write({ amountMinor: result.minor, currency: code });
	}

	/** Keeps the figures the person entered and never converts them. */
	function setCurrency(code: string, announce = true) {
		selected = code;
		commit(code);
		if (!announce) return;
		changed = true;
		announcement = fill(m.changed, {
			...vars,
			currency: currencyFormat(locale, code, adornment).name,
			code
		});
	}

	/* Typing a character that can never be part of an amount does nothing. A decimal separator
	   always types, even for yen: dropping it would turn 3000.50 into 300050, so a fraction is
	   refused with a message on blur instead. Pasted text is read leniently on blur. */
	function guard(event: InputEvent) {
		if (event.inputType !== 'insertText' || !event.data) return;
		if (!/^[\p{Nd}\s.,'’\u066b\u066c]+$/u.test(event.data)) event.preventDefault();
	}

	function onkeydown(event: KeyboardEvent) {
		/* Enter submits the form before blur, so format and write the hidden fields first. */
		if (event.key === 'Enter' && !event.isComposing) flushSync(() => commit());
	}

	/* A press on the mark or the border focuses the input, as a press inside one box should. */
	function focusInput(event: PointerEvent) {
		if (!input || event.button !== 0 || event.target === input || disabled) return;
		if ((event.target as Element).closest('select')) return;
		event.preventDefault();
		input.focus();
	}

	/* The input's padding; the fitted sizer repeats it so its hidden copy measures the same box. */
	const padClass = $derived([
		showMark && format.position === 'before' ? 'ps-1' : size === 'lg' ? 'ps-4' : 'ps-3',
		suffix ? 'pe-1' : size === 'lg' ? 'pe-4' : 'pe-3'
	]);

	const markClass = $derived([
		'currency-amount-field__mark flex shrink-0 items-center whitespace-nowrap text-(--_muted) tabular-nums select-none',
		adornment === 'code'
			? 'text-[13px] leading-5 font-medium tracking-[0.02em] pointer-coarse:text-sm'
			: size === 'lg'
				? 'text-2xl leading-8'
				: 'text-sm leading-5 pointer-coarse:text-base'
	]);
</script>

<div
	class={['currency-amount-field min-w-0', className]}
	data-size={size}
	data-invalid={invalid ? '' : undefined}
>
	<div class={['grid', hasSelect ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-1']}>
		<div class={['min-w-0', hasSelect && 'pe-3']}>
			<label
				for={inputId}
				class="block text-sm leading-5 font-medium text-pretty break-words text-(--_ink)"
			>
				{label}<span class="sr-only">{` ${fill(m.currencyName, vars)}`}</span>
			</label>
			{#if hint || boundsText}
				<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
					{hint ?? ''}{hint && boundsText ? ' ' : ''}<span class="tabular-nums">{boundsText}</span>
				</p>
			{/if}
		</div>
		{#if hasSelect}
			<label
				for={selectId}
				class="currency-amount-field__select-label self-start text-sm leading-5 font-medium text-(--_ink)"
			>
				{currencyLabel}
			</label>
		{/if}

		<!-- svelte-ignore a11y_no_static_element_interactions -->
		<div
			class={[
				'currency-amount-field__control col-span-full mt-2 grid min-h-(--_h) grid-cols-subgrid overflow-hidden border',
				'border-(--_edge) bg-(--_surface) text-(--_ink)',
				'rounded-lg'
			]}
			onpointerdown={focusInput}
		>
			<div class="flex min-w-0 items-stretch">
				{#if showMark && format.position === 'before'}
					<span
						class={[markClass, size === 'lg' ? 'ps-4' : 'ps-3', adornment === 'code' && 'pe-1']}
						aria-hidden="true">{format.mark}</span
					>
				{/if}
				<!-- With the mark after the figures, the input sizes to its text (a hidden copy of it
				     in the same grid cell sets the width), so the sign follows the last digit instead of
				     waiting at the far edge. With the mark before, the input simply fills the row. -->
				<span
					class={[
						'currency-amount-field__sizer relative grid min-w-0 tabular-nums',
						suffix ? 'currency-amount-field__sizer--fit' : 'flex-1',
						size === 'lg'
							? 'text-2xl leading-8 font-medium tracking-[-0.02em] rtl:tracking-normal'
							: 'text-sm leading-5 pointer-coarse:text-base',
						suffix && padClass
					]}
					data-value={text || shownPlaceholder}
				>
					<input
						bind:this={input}
						{...rest}
						bind:value={text}
						type="text"
						inputmode="decimal"
						id={inputId}
						placeholder={shownPlaceholder}
						{required}
						{disabled}
						{readonly}
						aria-describedby={describedBy}
						aria-invalid={invalid ? 'true' : undefined}
						onbeforeinput={guard}
						oninput={() => (changed = false)}
						onblur={() => commit()}
						{onkeydown}
						class={[
							'currency-amount-field__input w-full min-w-0 bg-transparent text-(--_ink) tabular-nums outline-none',
							'placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
							/* Placed in no grid cell, so it covers the sizer's padding box like its hidden copy. */
							suffix ? 'absolute inset-0' : 'col-start-1 row-start-1',
							padClass
						]}
					/>
				</span>
				{#if suffix}
					<span
						class={[markClass, size === 'lg' ? 'pe-4' : 'pe-3', adornment === 'code' && 'ps-1']}
						aria-hidden="true">{format.mark}</span
					>
				{/if}
			</div>

			{#if hasSelect}
				<div class="currency-amount-field__segment relative grid">
					<select
						bind:this={select}
						id={selectId}
						form={rest.form}
						name={readonly ? undefined : currencyName}
						disabled={disabled || readonly}
						value={selected}
						onchange={(event) => setCurrency(event.currentTarget.value)}
						class={[
							'col-start-1 row-start-1 min-h-full cursor-pointer appearance-none bg-transparent ps-3 pe-9 text-(--_ink) outline-none',
							'font-medium tabular-nums disabled:cursor-not-allowed',
							size === 'lg' ? 'text-base' : 'text-sm pointer-coarse:text-base'
						]}
					>
						{#each currencies ?? [] as code (code)}
							<option value={code.toUpperCase()}>{code.toUpperCase()}</option>
						{/each}
					</select>
					<svg
						class="pointer-events-none col-start-1 row-start-1 me-3 size-4 self-center justify-self-end text-(--_muted)"
						viewBox="0 0 16 16"
						fill="none"
						aria-hidden="true"
					>
						<path
							d="m4.5 6.25 3.5 3.5 3.5-3.5"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							stroke-linejoin="round"
						/>
					</svg>
				</div>
			{/if}
		</div>

		<div class="col-span-full min-w-0">
			{#if invalid}
				<p
					id={messageId}
					class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_danger)"
				>
					<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
						<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
							<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
							<path
								d="M8 4.75v3.5"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
							/>
							<circle cx="8" cy="11" r="1" fill="currentColor" />
						</svg>
					</span>
					<span class="min-w-0 text-pretty break-words"
						><span class="sr-only">{`${errorPrefix} `}</span>{shownError}</span
					>
				</p>
			{:else if changed}
				<p id={messageId} class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_muted)">
					<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
						<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
							<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
							<path
								d="M8 7.25v4"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
							/>
							<circle cx="8" cy="4.75" r="1" fill="currentColor" />
						</svg>
					</span>
					<span class="min-w-0 text-pretty break-words">{announcement}</span>
				</p>
			{:else if reserve}
				<div class="mt-1 h-5" aria-hidden="true"></div>
			{/if}
		</div>
	</div>

	{#if hasSelect}
		<p class="sr-only" role="status">{announcement}</p>
	{/if}
	<input
		type="hidden"
		name={amountName}
		value={value?.amountMinor ?? ''}
		form={rest.form}
		{disabled}
	/>
	{#if !hasSelect || readonly}
		<input type="hidden" name={currencyName} value={selected} form={rest.form} {disabled} />
	{/if}
</div>

<style>
	/* Public tokens: set --currency-amount-field-* on the field or any ancestor to retone it. */
	.currency-amount-field {
		--_accent: var(--currency-amount-field-accent, #1d4ed8);
		--_danger: var(--currency-amount-field-danger, #b91c1c);
		--_ink: var(--currency-amount-field-ink, #18181b);
		--_muted: var(--currency-amount-field-muted, #52525b);
		--_placeholder: var(--currency-amount-field-placeholder, #71717a);
		/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
		--_hairline: var(--currency-amount-field-hairline, rgb(0 0 0 / 0.44));
		--_surface: var(--currency-amount-field-surface, #ffffff);

		--_h: 2.25rem;
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
		--_raised: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}
	.currency-amount-field[data-size='lg'] {
		--_h: 3.5rem;
	}
	@media (pointer: coarse) {
		/* 46 px outside, so the input and the select are each 44 px inside the border. */
		.currency-amount-field[data-size='md'] {
			--_h: 2.875rem;
		}
	}

	/* Invalid: the edge and the focus ring both carry the error colour. */
	.currency-amount-field[data-invalid] {
		--_edge: var(--_danger);
		--_ring: var(--_danger);
	}

	/* The edge, fill and halo change through the rules below, so their transition lives here. */
	.currency-amount-field__control {
		transition-property: border-color, box-shadow, background-color;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	.currency-amount-field__segment {
		background-color: var(--_raised);
		border-inline-start: 1px solid color-mix(in oklab, var(--_hairline) 50%, transparent);
		transition: background-color 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	@media (prefers-reduced-motion: reduce) {
		.currency-amount-field__control,
		.currency-amount-field__segment {
			transition: none;
		}
	}

	/* Hover only where typing or choosing is possible: one step darker edge, or fill on the segment. */
	@media (hover: hover) {
		.currency-amount-field__control:hover:not(:has(input:disabled, input[readonly])) {
			--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
		}
		.currency-amount-field[data-invalid] .currency-amount-field__control:hover {
			--_edge: var(--_danger);
		}
		.currency-amount-field__segment:has(select:enabled:hover) {
			background-color: color-mix(in oklab, var(--_surface), var(--_ink) 8%);
		}
	}

	/* Focus anywhere in the shape: a two-pixel edge in the accent, with a soft halo outside. */
	.currency-amount-field__control:has(:focus-visible) {
		border-color: var(--_ring);
		box-shadow:
			0 0 0 1px var(--_ring),
			0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
	}
	/* The select marks itself as the focused part with a tinted fill. */
	.currency-amount-field__segment:has(select:focus-visible) {
		background-color: color-mix(in oklab, var(--_surface), var(--_accent) 10%);
	}

	/* Read-only: a raised fill, full-strength text, still focusable and selectable. */
	.currency-amount-field__control:has(input[readonly]) {
		background-color: var(--_raised);
	}

	/* Disabled: raised fill, faded, and not interactive. */
	.currency-amount-field__control:has(input:disabled) {
		background-color: var(--_raised);
		opacity: 0.55;
		cursor: not-allowed;
	}

	/*
	 * The fitted sizer: a hidden copy of the text shares the input's grid cell, so the cell is as
	 * wide as the figures and the mark after it sits beside the last digit. The copy is a pseudo
	 * element because it carries no meaning and has more than two properties.
	 */
	.currency-amount-field__sizer--fit {
		flex: 0 1 auto;
	}
	.currency-amount-field__sizer--fit::after {
		content: attr(data-value);
		grid-area: 1 / 1;
		/* Room for the caret after the last digit. */
		padding-inline-end: 2px;
		visibility: hidden;
		white-space: pre;
		overflow: hidden;
	}

	/* Autofill paints the input alone; keep it transparent and tint the whole shape instead. */
	.currency-amount-field__input:autofill {
		-webkit-text-fill-color: var(--_ink);
		transition: background-color 0s 86400s;
	}
	.currency-amount-field__control:has(input:autofill) {
		background-color: color-mix(in oklab, var(--_surface), var(--_accent) 6%);
	}

	/* Forced colours drop shadows and repaint borders, so focus becomes a system-colour outline. */
	@media (forced-colors: active) {
		.currency-amount-field__control:has(:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>
```

#### `money.ts`

Role: helper · 7573 bytes · SHA-256 `82330165fdf6ec42eac211c1874d9735b8ea4177167470e147dc1945a8b360e6`

```ts
/**
 * 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 };
}
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-c69a3faaa0b6f63a74dc99b290688388d1956223fd0a7bf4d6350a0da6202580`
- Entry: `CurrencyAmountField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_currency_amount_field_01/1.0.0/blue/sha256-c69a3faaa0b6f63a74dc99b290688388d1956223fd0a7bf4d6350a0da6202580/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_currency_amount_field_01/1.0.0/blue/sha256-c69a3faaa0b6f63a74dc99b290688388d1956223fd0a7bf4d6350a0da6202580/bundle.zip (15427 bytes, sha256 `41f56cd8330037ce9f1b52a5cf754cf9fed8906d56d7ecc0781246fb03a82fce`)

Files:

- `CurrencyAmountField.svelte` (entry, 24382 bytes): https://pagesugar.com/artifacts/cmp_currency_amount_field_01/1.0.0/blue/sha256-c69a3faaa0b6f63a74dc99b290688388d1956223fd0a7bf4d6350a0da6202580/source/CurrencyAmountField.svelte
- `money.ts` (helper, 7573 bytes): https://pagesugar.com/artifacts/cmp_currency_amount_field_01/1.0.0/blue/sha256-c69a3faaa0b6f63a74dc99b290688388d1956223fd0a7bf4d6350a0da6202580/source/money.ts
