@happyvertical/smrt-ui 0.43.5 → 0.43.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,34 @@ Svelte-free `/data-surface` entry exposes the registry contracts and shared
45
45
  protocol limits for server adapters. The package root remains a compatibility
46
46
  barrel.
47
47
 
48
+ ### Currency display
49
+
50
+ `CurrencyDisplay` accepts ISO 4217 codes as a public `string` prop so persisted
51
+ Commerce currency fields can be passed directly. Codes are trimmed and
52
+ uppercased before `Intl.NumberFormat` formatting; the default remains CAD.
53
+ Malformed or unsupported codes render an accessible inline error instead of
54
+ throwing and interrupting a surrounding collection render.
55
+ With the default historical `unit="cents"` setting, amounts are interpreted as
56
+ the selected currency's ISO minor units (for example, 0 digits for JPY and 3
57
+ for BHD). Minor-unit amounts must be finite safe integers; fractional or unsafe
58
+ numeric values render an accessible inline error instead of being rounded.
59
+ `unit="dollars"` means the value is already in major units.
60
+ ISO fund, metal, test, and no-currency codes whose minor unit is `N.A.` require
61
+ `unit="dollars"`; the default minor-unit mode renders an accessible inline
62
+ error for those codes. Major-unit values for these codes use a stable two-digit
63
+ display policy across server and browser runtimes. CAD and USD retain their
64
+ symbol display; all other codes render their ISO code so SSR output does not
65
+ depend on runtime-specific symbol data.
66
+
67
+ ```svelte
68
+ <script lang="ts">
69
+ import { CurrencyDisplay } from '@happyvertical/smrt-ui';
70
+ let invoiceCurrency: string = 'eur';
71
+ </script>
72
+
73
+ <CurrencyDisplay amount={12345} currency={invoiceCurrency} />
74
+ ```
75
+
48
76
  ## Component standard
49
77
 
50
78
  Foundation components follow one contract:
@@ -0,0 +1,7 @@
1
+ import type { ComponentProps } from 'svelte';
2
+ import type CurrencyDisplay from './CurrencyDisplay.svelte';
3
+ type Assert<T extends true> = T;
4
+ /** Compile-time guard for Commerce models whose currency field is `string`. */
5
+ export type CurrencyDisplayAcceptsCommerceCurrency = Assert<string extends NonNullable<ComponentProps<typeof CurrencyDisplay>['currency']> ? true : false>;
6
+ export {};
7
+ //# sourceMappingURL=CurrencyDisplay.contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CurrencyDisplay.contract.d.ts","sourceRoot":"","sources":["../../../src/components/display/CurrencyDisplay.contract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,KAAK,eAAe,MAAM,0BAA0B,CAAC;AAE5D,KAAK,MAAM,CAAC,CAAC,SAAS,IAAI,IAAI,CAAC,CAAC;AAEhC,+EAA+E;AAC/E,MAAM,MAAM,sCAAsC,GAAG,MAAM,CACzD,MAAM,SAAS,WAAW,CAAC,cAAc,CAAC,OAAO,eAAe,CAAC,CAAC,UAAU,CAAC,CAAC,GAC1E,IAAI,GACJ,KAAK,CACV,CAAC"}
@@ -1,19 +1,89 @@
1
+ <script module lang="ts">
2
+ import { ISO_4217_MINOR_UNITS } from './currency-metadata.js';
3
+
4
+ interface NormalizedCurrency {
5
+ code: string;
6
+ minorUnitDigits: number | null;
7
+ }
8
+
9
+ function normalizeCurrencyCode(value: unknown): NormalizedCurrency | null {
10
+ if (typeof value !== 'string') return null;
11
+ const trimmed = value.trim();
12
+ if (!/^[A-Za-z]{3}$/.test(trimmed)) return null;
13
+ const code = trimmed.toUpperCase();
14
+
15
+ const minorUnitDigits = ISO_4217_MINOR_UNITS.get(code);
16
+ return minorUnitDigits === undefined ? null : { code, minorUnitDigits };
17
+ }
18
+
19
+ function invalidCurrencyCode(value: unknown): string {
20
+ if (typeof value !== 'string') return '(non-string)';
21
+ const characters = Array.from(value.trim());
22
+ if (characters.length === 0) return '(empty)';
23
+
24
+ let diagnostic = '';
25
+ let consumed = 0;
26
+ for (const character of characters) {
27
+ const codePoint = character.codePointAt(0);
28
+ if (codePoint === undefined) continue;
29
+ const visibleCharacter =
30
+ codePoint >= 0x20 && codePoint <= 0x7e
31
+ ? character.replace(/[a-z]/g, (ascii) => ascii.toUpperCase())
32
+ : `\\u{${codePoint.toString(16).toUpperCase()}}`;
33
+ if (diagnostic.length + visibleCharacter.length > 12) break;
34
+ diagnostic += visibleCharacter;
35
+ consumed += 1;
36
+ }
37
+
38
+ return consumed < characters.length ? `${diagnostic}…` : diagnostic;
39
+ }
40
+
41
+ function isStringNumericLiteral(
42
+ value: string,
43
+ ): value is Intl.StringNumericLiteral {
44
+ return /^(?:0|[1-9]\d*)\.\d+$/.test(value);
45
+ }
46
+
47
+ function exactMajorUnitValue(
48
+ amount: number,
49
+ minorUnitDigits: number,
50
+ ): bigint | Intl.StringNumericLiteral | null {
51
+ const absoluteAmount = Math.abs(amount);
52
+ if (!Number.isSafeInteger(absoluteAmount)) return null;
53
+
54
+ const minorUnits = BigInt(absoluteAmount);
55
+ if (minorUnitDigits === 0) return minorUnits;
56
+
57
+ const scale = 10n ** BigInt(minorUnitDigits);
58
+ const exactValue = `${minorUnits / scale}.${(minorUnits % scale)
59
+ .toString()
60
+ .padStart(minorUnitDigits, '0')}`;
61
+ return isStringNumericLiteral(exactValue) ? exactValue : null;
62
+ }
63
+ </script>
64
+
1
65
  <script lang="ts">
66
+ import { M } from '../../i18n/strings.js';
67
+ import { useI18n } from '../../i18n/use-i18n.js';
68
+
2
69
  /**
3
70
  * CurrencyDisplay - Formats and displays monetary values
4
71
  *
5
72
  * Displays formatted currency with configurable unit.
6
- * Use `unit="cents"` (default) when amount is in cents, or `unit="dollars"` for dollar values.
7
- * Supports CAD/USD with appropriate symbols and locale formatting.
73
+ * Use `unit="cents"` (default) when amount is in the currency's minor units,
74
+ * or `unit="dollars"` when it is already in major units.
75
+ * Accepts ISO 4217 currency codes, normalized by trimming whitespace and
76
+ * uppercasing before locale formatting. Unsupported codes render an accessible
77
+ * inline error instead of throwing during a collection render.
8
78
  */
9
79
 
10
80
  /** Props for CurrencyDisplay component */
11
81
  export interface Props {
12
82
  /** Amount value */
13
83
  amount: number;
14
- /** Currency code */
15
- currency?: 'CAD' | 'USD';
16
- /** Whether amount is in cents or dollars (default: cents) */
84
+ /** ISO 4217 currency code. Whitespace is trimmed and letters are uppercased. */
85
+ currency?: string;
86
+ /** Whether amount is a safe integer of ISO minor units or a major-unit number */
17
87
  unit?: 'cents' | 'dollars';
18
88
  /** Show +/- sign for non-zero values */
19
89
  showSign?: boolean;
@@ -38,19 +108,75 @@ const {
38
108
  class: className = '',
39
109
  }: Props = $props();
40
110
 
41
- // Format amount using Intl.NumberFormat
42
- const formatted = $derived.by(() => {
43
- const dollars = unit === 'cents' ? amount / 100 : amount;
44
- const absValue = Math.abs(dollars);
111
+ const { t } = useI18n();
112
+
113
+ interface FormattedCurrency {
114
+ text: string;
115
+ invalidCode: string | null;
116
+ }
117
+
118
+ // Format amount using the platform's canonical currency formatter.
119
+ const formatted = $derived.by((): FormattedCurrency => {
120
+ const normalizedCurrency = normalizeCurrencyCode(currency);
121
+ if (!normalizedCurrency) {
122
+ const invalidCode = invalidCurrencyCode(currency);
123
+ return {
124
+ text: t(M['ui.currency_display.invalid_code'], { code: invalidCode }),
125
+ invalidCode,
126
+ };
127
+ }
45
128
 
46
- const formatter = new Intl.NumberFormat('en-CA', {
129
+ const formatOptions: Intl.NumberFormatOptions = {
47
130
  style: 'currency',
48
- currency,
49
- minimumFractionDigits: 2,
50
- maximumFractionDigits: 2,
51
- });
131
+ currency: normalizedCurrency.code,
132
+ // CAD and USD retain their historical symbol display. Using the ISO code
133
+ // for every other currency avoids ICU-dependent narrow-symbol differences
134
+ // between server and browser runtimes.
135
+ currencyDisplay:
136
+ normalizedCurrency.code === 'CAD' || normalizedCurrency.code === 'USD'
137
+ ? 'symbol'
138
+ : 'code',
139
+ };
140
+ const displayDigits = normalizedCurrency.minorUnitDigits ?? 2;
141
+ formatOptions.minimumFractionDigits = displayDigits;
142
+ formatOptions.maximumFractionDigits = displayDigits;
143
+
144
+ let formatter: Intl.NumberFormat;
145
+ try {
146
+ formatter = new Intl.NumberFormat('en-CA', formatOptions);
147
+ } catch {
148
+ return {
149
+ text: t(M['ui.currency_display.invalid_code'], {
150
+ code: normalizedCurrency.code,
151
+ }),
152
+ invalidCode: normalizedCurrency.code,
153
+ };
154
+ }
52
155
 
53
- let display = formatter.format(absValue);
156
+ let majorAmount: number | bigint | Intl.StringNumericLiteral =
157
+ Math.abs(amount);
158
+ if (unit === 'cents') {
159
+ if (normalizedCurrency.minorUnitDigits == null) {
160
+ return {
161
+ text: t(M['ui.currency_display.no_minor_unit'], {
162
+ code: normalizedCurrency.code,
163
+ }),
164
+ invalidCode: normalizedCurrency.code,
165
+ };
166
+ }
167
+ const exactAmount = exactMajorUnitValue(
168
+ amount,
169
+ normalizedCurrency.minorUnitDigits,
170
+ );
171
+ if (exactAmount === null) {
172
+ return {
173
+ text: t(M['ui.currency_display.invalid_minor_unit_amount']),
174
+ invalidCode: normalizedCurrency.code,
175
+ };
176
+ }
177
+ majorAmount = exactAmount;
178
+ }
179
+ let display = formatter.format(majorAmount);
54
180
 
55
181
  // Add sign if requested
56
182
  if (showSign && amount !== 0) {
@@ -60,7 +186,7 @@ const formatted = $derived.by(() => {
60
186
  display = `-${display}`;
61
187
  }
62
188
 
63
- return display;
189
+ return { text: display, invalidCode: null };
64
190
  });
65
191
 
66
192
  // Determine color class
@@ -77,8 +203,9 @@ const colorClass = $derived.by(() => {
77
203
  class:lg={size === 'lg'}
78
204
  class:negative={colorClass === 'negative'}
79
205
  class:positive={colorClass === 'positive'}
206
+ class:invalid={formatted.invalidCode !== null}
80
207
  >
81
- {formatted}
208
+ {formatted.text}
82
209
  </span>
83
210
 
84
211
  <style>
@@ -103,4 +230,8 @@ const colorClass = $derived.by(() => {
103
230
  .currency-display.positive {
104
231
  color: var(--smrt-color-tertiary, #16a34a);
105
232
  }
233
+
234
+ .currency-display.invalid {
235
+ color: var(--smrt-color-error, #dc2626);
236
+ }
106
237
  </style>
@@ -2,16 +2,19 @@
2
2
  * CurrencyDisplay - Formats and displays monetary values
3
3
  *
4
4
  * Displays formatted currency with configurable unit.
5
- * Use `unit="cents"` (default) when amount is in cents, or `unit="dollars"` for dollar values.
6
- * Supports CAD/USD with appropriate symbols and locale formatting.
5
+ * Use `unit="cents"` (default) when amount is in the currency's minor units,
6
+ * or `unit="dollars"` when it is already in major units.
7
+ * Accepts ISO 4217 currency codes, normalized by trimming whitespace and
8
+ * uppercasing before locale formatting. Unsupported codes render an accessible
9
+ * inline error instead of throwing during a collection render.
7
10
  */
8
11
  /** Props for CurrencyDisplay component */
9
12
  export interface Props {
10
13
  /** Amount value */
11
14
  amount: number;
12
- /** Currency code */
13
- currency?: 'CAD' | 'USD';
14
- /** Whether amount is in cents or dollars (default: cents) */
15
+ /** ISO 4217 currency code. Whitespace is trimmed and letters are uppercased. */
16
+ currency?: string;
17
+ /** Whether amount is a safe integer of ISO minor units or a major-unit number */
15
18
  unit?: 'cents' | 'dollars';
16
19
  /** Show +/- sign for non-zero values */
17
20
  showSign?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"CurrencyDisplay.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/display/CurrencyDisplay.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AAEH,0CAA0C;AAC1C,MAAM,WAAW,KAAK;IACpB,mBAAmB;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,oBAAoB;IACpB,QAAQ,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IACzB,6DAA6D;IAC7D,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B,wCAAwC;IACxC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mBAAmB;IACnB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,uCAAuC;IACvC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,yCAAyC;IACzC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAwDD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"CurrencyDisplay.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/display/CurrencyDisplay.svelte.ts"],"names":[],"mappings":"AAsEA;;;;;;;;;GASG;AAEH,0CAA0C;AAC1C,MAAM,WAAW,KAAK;IACpB,mBAAmB;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B,wCAAwC;IACxC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mBAAmB;IACnB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,uCAAuC;IACvC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,yCAAyC;IACzC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAqHD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -8,20 +8,39 @@
8
8
  * code, sign handling (negative/zero/showSign), highlight classes, size
9
9
  * classes, and axe-cleanliness.
10
10
  */
11
+ import { createHash } from 'node:crypto';
12
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
11
13
  import { render, screen } from '@testing-library/svelte';
14
+ import { hydrate, unmount } from 'svelte';
15
+ import { createServer } from 'vite';
12
16
  import { describe, expect, it } from 'vitest';
13
17
  import { expectNoA11yViolations } from '../../../test-support/a11y';
14
18
  import CurrencyDisplay from '../CurrencyDisplay.svelte';
19
+ import { ISO_4217_MINOR_UNITS } from '../currency-metadata.js';
20
+ import CurrencyDisplayI18nHarness from './CurrencyDisplayI18nHarness.svelte';
21
+ import CurrencyDisplaySsrHarness from './CurrencyDisplaySsrHarness.svelte';
22
+ function metadataDigest(metadata) {
23
+ const canonical = [...metadata.entries()]
24
+ .sort(([left], [right]) => left.localeCompare(right))
25
+ .map(([code, minorUnits]) => `${code}:${minorUnits ?? 'N.A.'}`)
26
+ .join('\n');
27
+ return createHash('sha256').update(canonical).digest('hex');
28
+ }
15
29
  /** Mirror the component's absolute-value currency formatting. */
16
- function money(absDollars, currency = 'CAD') {
30
+ function money(absDollars, currency = 'CAD', minorUnitDigits) {
17
31
  return new Intl.NumberFormat('en-CA', {
18
32
  style: 'currency',
19
33
  currency,
20
- minimumFractionDigits: 2,
21
- maximumFractionDigits: 2,
34
+ currencyDisplay: currency === 'CAD' || currency === 'USD' ? 'symbol' : 'code',
35
+ minimumFractionDigits: minorUnitDigits,
36
+ maximumFractionDigits: minorUnitDigits,
22
37
  }).format(absDollars);
23
38
  }
24
39
  describe('CurrencyDisplay', () => {
40
+ it('matches the canonical digest of SIX List One 2026-01-01', () => {
41
+ expect(ISO_4217_MINOR_UNITS.size).toBe(178);
42
+ expect(metadataDigest(ISO_4217_MINOR_UNITS)).toBe('e1a3c502511fa784b38dd7ac2b4056d00f3f1a9f5781df93b0f2352f8eedc976');
43
+ });
25
44
  it('formats cents into dollars by default', () => {
26
45
  render(CurrencyDisplay, { props: { amount: 12345 } }); // cents → $123.45
27
46
  expect(screen.getByText(money(123.45))).toBeInTheDocument();
@@ -44,6 +63,142 @@ describe('CurrencyDisplay', () => {
44
63
  });
45
64
  expect(screen.getByText(money(1000, 'USD'))).toBeInTheDocument();
46
65
  });
66
+ it('formats an EUR amount through the public string currency prop', () => {
67
+ const commerceCurrency = 'EUR';
68
+ const { container } = render(CurrencyDisplay, {
69
+ props: { amount: 12345, currency: commerceCurrency },
70
+ });
71
+ expect(container.querySelector('span')?.textContent).toBe(money(123.45, 'EUR'));
72
+ });
73
+ it.each([
74
+ ['JPY', 12345, '12,345'],
75
+ ['BHD', 12345, '12.345'],
76
+ ['IQD', 12345, '12.345'],
77
+ ])('uses the ISO minor-unit scale for %s', (currency, amount, expected) => {
78
+ const { container } = render(CurrencyDisplay, {
79
+ props: { amount, currency },
80
+ });
81
+ expect(container.querySelector('span')?.textContent).toContain(expected);
82
+ });
83
+ it.each([
84
+ ['IQD', 3, '9,007,199,254,740.991'],
85
+ ['AFN', 2, '90,071,992,547,409.91'],
86
+ ['CLF', 4, '900,719,925,474.0991'],
87
+ ])('preserves the least-significant minor unit for large safe %s values', (currency, digits, expected) => {
88
+ const { container } = render(CurrencyDisplay, {
89
+ props: { amount: Number.MAX_SAFE_INTEGER, currency },
90
+ });
91
+ const text = container.querySelector('span')?.textContent;
92
+ expect(text).toContain(expected);
93
+ expect(text?.split('.').at(-1)).toHaveLength(digits);
94
+ });
95
+ it.each([
96
+ ['positive fractional', 1.5],
97
+ ['negative fractional', -1.5],
98
+ ['unsafe positive integer', Number.MAX_SAFE_INTEGER + 1],
99
+ ['unsafe negative integer', -(Number.MAX_SAFE_INTEGER + 1)],
100
+ ['NaN', Number.NaN],
101
+ ['positive infinity', Number.POSITIVE_INFINITY],
102
+ ['negative infinity', Number.NEGATIVE_INFINITY],
103
+ ])('rejects a %s amount in minor-unit mode', (_scenario, amount) => {
104
+ const { container } = render(CurrencyDisplay, {
105
+ props: { amount, currency: 'CAD' },
106
+ });
107
+ const display = container.querySelector('.currency-display');
108
+ expect(display).toHaveClass('invalid');
109
+ expect(display).toHaveTextContent('Invalid minor-unit amount');
110
+ });
111
+ it('preserves a negative safe integer through the exact string formatter path', () => {
112
+ const { container } = render(CurrencyDisplay, {
113
+ props: { amount: -Number.MAX_SAFE_INTEGER, currency: 'IQD' },
114
+ });
115
+ const text = container.querySelector('span')?.textContent;
116
+ expect(text).toContain('9,007,199,254,740.991');
117
+ expect(text?.startsWith('-IQD')).toBe(true);
118
+ });
119
+ it.each([
120
+ ['cad', 'CAD'],
121
+ [' eur ', 'EUR'],
122
+ ['ved', 'VED'],
123
+ ['xad', 'XAD'],
124
+ ])('normalizes the currency code %j to %s', (currency, normalized) => {
125
+ const { container } = render(CurrencyDisplay, {
126
+ props: { amount: 12345, currency },
127
+ });
128
+ expect(container.querySelector('span')?.textContent).toBe(money(123.45, normalized, 2));
129
+ });
130
+ it.each([
131
+ 'US',
132
+ 'AAA',
133
+ 'ANG',
134
+ 'BGN',
135
+ 'CUC',
136
+ 'HRK',
137
+ 'SLL',
138
+ 'ZWL',
139
+ 'ZZZ',
140
+ '',
141
+ ])('renders invalid code %j without throwing', (currency) => {
142
+ const { container } = render(CurrencyDisplay, {
143
+ props: { amount: 12345, currency },
144
+ });
145
+ const normalized = currency
146
+ .trim()
147
+ .replace(/[a-z]/g, (character) => character.toUpperCase()) || '(empty)';
148
+ const display = container.querySelector('.currency-display');
149
+ expect(display).toHaveClass('invalid');
150
+ expect(display).toHaveTextContent(`Invalid currency code: ${normalized}`);
151
+ });
152
+ it.each([
153
+ ['uſd', 'U\\u{17F}D'],
154
+ ['ıqd', '\\u{131}QD'],
155
+ ['ßp', '\\u{DF}P'],
156
+ ['U\u200bSD', 'U\\u{200B}SD'],
157
+ ['USD\u202e', 'USD\\u{202E}'],
158
+ ['U\u2066SD', 'U\\u{2066}SD'],
159
+ ])('renders rejected non-ASCII input %j visibly as %s', (currency, diagnostic) => {
160
+ const { container } = render(CurrencyDisplay, {
161
+ props: { amount: 12345, currency },
162
+ });
163
+ expect(container.querySelector('.currency-display')).toHaveTextContent(`Invalid currency code: ${diagnostic}`);
164
+ });
165
+ it('bounds escaped astral input without splitting a code-point token', () => {
166
+ const { container } = render(CurrencyDisplay, {
167
+ props: { amount: 12345, currency: '😀😀' },
168
+ });
169
+ expect(container.querySelector('.currency-display')).toHaveTextContent('Invalid currency code: \\u{1F600}…');
170
+ });
171
+ it('bounds malformed currency text so one row cannot force table overflow', () => {
172
+ const { container } = render(CurrencyDisplay, {
173
+ props: { amount: 12345, currency: 'invalid-currency-code' },
174
+ });
175
+ expect(container.querySelector('.currency-display')).toHaveTextContent('Invalid currency code: INVALID-CURR…');
176
+ });
177
+ it('rejects a non-string currency from an untyped runtime caller without throwing', () => {
178
+ const { container } = render(CurrencyDisplay, {
179
+ // @ts-expect-error JavaScript callers can pass values outside the public type.
180
+ props: { amount: 12345, currency: null },
181
+ });
182
+ const display = container.querySelector('.currency-display');
183
+ expect(display).toHaveClass('invalid');
184
+ expect(display).toHaveTextContent('Invalid currency code: (non-string)');
185
+ });
186
+ it('requires major-unit input for ISO codes without a minor unit', async () => {
187
+ const { rerender } = render(CurrencyDisplay, {
188
+ props: { amount: 12.5, currency: 'XAU' },
189
+ });
190
+ const display = document.querySelector('.currency-display');
191
+ expect(display).toHaveClass('invalid');
192
+ expect(display).toHaveTextContent('Currency code has no minor unit: XAU');
193
+ await rerender({ amount: 12.5, currency: 'XAU', unit: 'dollars' });
194
+ expect(document.querySelector('.currency-display')?.textContent).toBe(money(12.5, 'XAU', 2));
195
+ });
196
+ it('resolves invalid-code prose through the active i18n snapshot', () => {
197
+ render(CurrencyDisplayI18nHarness);
198
+ expect(screen.getByText('Code monétaire invalide : ZZZ')).toBeVisible();
199
+ expect(screen.getByText('Devise sans unité mineure : XAU')).toBeVisible();
200
+ expect(screen.getByText('Montant en unité mineure invalide')).toBeVisible();
201
+ });
47
202
  it('shows an explicit + sign for positive amounts when showSign is set', () => {
48
203
  render(CurrencyDisplay, {
49
204
  props: { amount: 1000, unit: 'dollars', showSign: true },
@@ -111,4 +266,39 @@ describe('CurrencyDisplay', () => {
111
266
  });
112
267
  await expectNoA11yViolations(container);
113
268
  });
269
+ it('is axe-clean for an invalid currency code', async () => {
270
+ const { container } = render(CurrencyDisplay, {
271
+ props: { amount: 12345, currency: 'ZZZ' },
272
+ });
273
+ await expectNoA11yViolations(container);
274
+ });
275
+ it('renders and hydrates valid and invalid currencies safely', async () => {
276
+ const vite = await createServer({
277
+ appType: 'custom',
278
+ configFile: false,
279
+ plugins: [svelte()],
280
+ root: process.cwd(),
281
+ server: { middlewareMode: true },
282
+ });
283
+ try {
284
+ const { default: SsrHarness } = await vite.ssrLoadModule('/src/components/display/__tests__/CurrencyDisplaySsrHarness.svelte');
285
+ const { render: renderSsr } = await vite.ssrLoadModule('svelte/server');
286
+ const result = renderSsr(SsrHarness);
287
+ expect(result.body).toContain(money(123.45, 'EUR'));
288
+ expect(result.body).toContain('Invalid currency code: ZZZ');
289
+ expect(result.body).toContain('Currency code has no minor unit: XAU');
290
+ const host = document.createElement('div');
291
+ host.innerHTML = result.body;
292
+ document.body.append(host);
293
+ const instance = hydrate(CurrencyDisplaySsrHarness, { target: host });
294
+ expect(host.textContent).toContain(money(123.45, 'EUR'));
295
+ expect(host.textContent).toContain('Invalid currency code: ZZZ');
296
+ expect(host.textContent).toContain('Currency code has no minor unit: XAU');
297
+ await unmount(instance);
298
+ host.remove();
299
+ }
300
+ finally {
301
+ await vite.close();
302
+ }
303
+ });
114
304
  });
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import { createI18nContext, setI18nContext } from '../../../i18n/index.js';
3
+ import CurrencyDisplay from '../CurrencyDisplay.svelte';
4
+
5
+ setI18nContext(
6
+ createI18nContext({
7
+ locale: 'fr',
8
+ messages: {
9
+ 'ui.currency_display.invalid_code': 'Code monétaire invalide : {code}',
10
+ 'ui.currency_display.invalid_minor_unit_amount':
11
+ 'Montant en unité mineure invalide',
12
+ 'ui.currency_display.no_minor_unit': 'Devise sans unité mineure : {code}',
13
+ },
14
+ }),
15
+ );
16
+ </script>
17
+
18
+ <CurrencyDisplay amount={12345} currency="ZZZ" />
19
+ <CurrencyDisplay amount={12345} currency="XAU" />
20
+ <CurrencyDisplay amount={1.5} currency="CAD" />
@@ -0,0 +1,19 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const CurrencyDisplayI18nHarness: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type CurrencyDisplayI18nHarness = InstanceType<typeof CurrencyDisplayI18nHarness>;
18
+ export default CurrencyDisplayI18nHarness;
19
+ //# sourceMappingURL=CurrencyDisplayI18nHarness.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CurrencyDisplayI18nHarness.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.ts"],"names":[],"mappings":"AA6BA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,0BAA0B;;kBAA+E,CAAC;AAC9F,KAAK,0BAA0B,GAAG,YAAY,CAAC,OAAO,0BAA0B,CAAC,CAAC;AACpF,eAAe,0BAA0B,CAAC"}
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ import CurrencyDisplay from '../CurrencyDisplay.svelte';
3
+ </script>
4
+
5
+ <CurrencyDisplay amount={12345} currency="EUR" />
6
+ <CurrencyDisplay amount={12345} currency="ZZZ" />
7
+ <CurrencyDisplay amount={12345} currency="XAU" />
@@ -0,0 +1,19 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const CurrencyDisplaySsrHarness: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type CurrencyDisplaySsrHarness = InstanceType<typeof CurrencyDisplaySsrHarness>;
18
+ export default CurrencyDisplaySsrHarness;
19
+ //# sourceMappingURL=CurrencyDisplaySsrHarness.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CurrencyDisplaySsrHarness.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.ts"],"names":[],"mappings":"AAeA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,yBAAyB;;kBAA+E,CAAC;AAC7F,KAAK,yBAAyB,GAAG,YAAY,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAClF,eAAe,yBAAyB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare const ISO_4217_MINOR_UNITS: Map<string, number | null>;
2
+ //# sourceMappingURL=currency-metadata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"currency-metadata.d.ts","sourceRoot":"","sources":["../../../src/components/display/currency-metadata.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,oBAAoB,4BAe/B,CAAC"}
@@ -0,0 +1,14 @@
1
+ function minorUnitEntries(codes, minorUnits) {
2
+ return codes.split(' ').map((code) => [code, minorUnits]);
3
+ }
4
+ // ISO 4217 List One, published by the ISO maintenance agency SIX on
5
+ // 2026-01-01. Keeping both membership and minor-unit exponents here makes SSR
6
+ // and browser rendering independent of their potentially different ICU data.
7
+ // Source: https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml
8
+ export const ISO_4217_MINOR_UNITS = new Map([
9
+ ...minorUnitEntries('XOF BIF XAF CLP KMF DJF XPF GNF ISK JPY KRW PYG RWF UGX UYI VUV VND', 0),
10
+ ...minorUnitEntries('AFN EUR ALL DZD USD AOA XCD XAD ARS AMD AWG AUD AZN BSD BDT BBD BYN BZD BMD INR BTN BOB BOV BAM BWP NOK BRL BND CVE KHR CAD KYD CNY COP COU CDF NZD CRC CUP XCG CZK DKK DOP EGP SVC ERN SZL ETB FKP FJD GMD GEL GHS GIP GTQ GBP GYD HTG HNL HKD HUF IDR IRR ILS JMD KZT KES KPW KGS LAK LBP LSL ZAR LRD CHF MOP MKD MGA MWK MYR MVR MRU MUR MXN MXV MDL MNT MAD MZN MMK NAD NPR NIO NGN PKR PAB PGK PEN PHP PLN QAR RON RUB SHP WST STN SAR RSD SCR SLE SGD SBD SOS SSP LKR SDG SRD SEK CHE CHW SYP TWD TJS TZS THB TOP TTD TRY TMT UAH AED USN UYU UZS VES VED YER ZMW ZWG', 2),
11
+ ...minorUnitEntries('BHD IQD JOD KWD LYD OMR TND', 3),
12
+ ...minorUnitEntries('CLF UYW', 4),
13
+ ...minorUnitEntries('XDR XUA XSU XBA XBB XBC XBD XTS XXX XAU XPD XPT XAG', null),
14
+ ]);
@@ -1,4 +1,7 @@
1
1
  export declare const M: {
2
+ readonly 'ui.currency_display.invalid_code': "ui.currency_display.invalid_code";
3
+ readonly 'ui.currency_display.invalid_minor_unit_amount': "ui.currency_display.invalid_minor_unit_amount";
4
+ readonly 'ui.currency_display.no_minor_unit': "ui.currency_display.no_minor_unit";
2
5
  readonly 'ui.data_table.select_all': "ui.data_table.select_all";
3
6
  readonly 'ui.data_table.select_current_page': "ui.data_table.select_current_page";
4
7
  readonly 'ui.data_table.select_row': "ui.data_table.select_row";
@@ -1 +1 @@
1
- {"version":3,"file":"strings.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCZ,CAAC"}
1
+ {"version":3,"file":"strings.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCZ,CAAC"}
@@ -12,6 +12,9 @@
12
12
  */
13
13
  import { defineMessages } from './registry.js';
14
14
  export const M = defineMessages({
15
+ 'ui.currency_display.invalid_code': 'Invalid currency code: {code}',
16
+ 'ui.currency_display.invalid_minor_unit_amount': 'Invalid minor-unit amount',
17
+ 'ui.currency_display.no_minor_unit': 'Currency code has no minor unit: {code}',
15
18
  'ui.data_table.select_all': 'Select all rows',
16
19
  'ui.data_table.select_current_page': 'Select all rows on this page',
17
20
  'ui.data_table.select_row': 'Select {row}',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-ui",
3
- "version": "0.43.5",
3
+ "version": "0.43.7",
4
4
  "description": "Domain-agnostic Svelte 5 UI runtime for SMRT: primitives, i18n client, theme system, and module UI registry",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -129,7 +129,7 @@
129
129
  },
130
130
  "dependencies": {
131
131
  "esm-env": "^1.2.2",
132
- "@happyvertical/smrt-types": "0.43.5"
132
+ "@happyvertical/smrt-types": "0.43.7"
133
133
  },
134
134
  "peerDependencies": {
135
135
  "svelte": "^5.56.4"