@happyvertical/smrt-ui 0.43.5 → 0.43.6
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 +28 -0
- package/dist/components/display/CurrencyDisplay.contract.d.ts +7 -0
- package/dist/components/display/CurrencyDisplay.contract.d.ts.map +1 -0
- package/dist/components/display/CurrencyDisplay.contract.js +1 -0
- package/dist/components/display/CurrencyDisplay.svelte +148 -17
- package/dist/components/display/CurrencyDisplay.svelte.d.ts +8 -5
- package/dist/components/display/CurrencyDisplay.svelte.d.ts.map +1 -1
- package/dist/components/display/__tests__/CurrencyDisplay.test.js +193 -3
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte +20 -0
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts +19 -0
- package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts.map +1 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte +7 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts +19 -0
- package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts.map +1 -0
- package/dist/components/display/currency-metadata.d.ts +2 -0
- package/dist/components/display/currency-metadata.d.ts.map +1 -0
- package/dist/components/display/currency-metadata.js +14 -0
- package/dist/i18n/strings.d.ts +3 -0
- package/dist/i18n/strings.d.ts.map +1 -1
- package/dist/i18n/strings.js +3 -0
- package/package.json +2 -2
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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
15
|
-
currency?:
|
|
16
|
-
/** Whether amount is
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
129
|
+
const formatOptions: Intl.NumberFormatOptions = {
|
|
47
130
|
style: 'currency',
|
|
48
|
-
currency,
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
13
|
-
currency?:
|
|
14
|
-
/** Whether amount is
|
|
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":"
|
|
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
|
-
|
|
21
|
-
|
|
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,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 @@
|
|
|
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
|
+
]);
|
package/dist/i18n/strings.d.ts
CHANGED
|
@@ -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
|
|
1
|
+
{"version":3,"file":"strings.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCZ,CAAC"}
|
package/dist/i18n/strings.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.43.6",
|
|
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.
|
|
132
|
+
"@happyvertical/smrt-types": "0.43.6"
|
|
133
133
|
},
|
|
134
134
|
"peerDependencies": {
|
|
135
135
|
"svelte": "^5.56.4"
|