@happyvertical/smrt-ui 0.43.4 → 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.
Files changed (108) hide show
  1. package/AGENTS.md +33 -0
  2. package/README.md +78 -4
  3. package/dist/components/display/CurrencyDisplay.contract.d.ts +7 -0
  4. package/dist/components/display/CurrencyDisplay.contract.d.ts.map +1 -0
  5. package/dist/components/display/CurrencyDisplay.contract.js +1 -0
  6. package/dist/components/display/CurrencyDisplay.svelte +148 -17
  7. package/dist/components/display/CurrencyDisplay.svelte.d.ts +8 -5
  8. package/dist/components/display/CurrencyDisplay.svelte.d.ts.map +1 -1
  9. package/dist/components/display/__tests__/CurrencyDisplay.test.js +193 -3
  10. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte +20 -0
  11. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts +19 -0
  12. package/dist/components/display/__tests__/CurrencyDisplayI18nHarness.svelte.d.ts.map +1 -0
  13. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte +7 -0
  14. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts +19 -0
  15. package/dist/components/display/__tests__/CurrencyDisplaySsrHarness.svelte.d.ts.map +1 -0
  16. package/dist/components/display/currency-metadata.d.ts +2 -0
  17. package/dist/components/display/currency-metadata.d.ts.map +1 -0
  18. package/dist/components/display/currency-metadata.js +14 -0
  19. package/dist/components/forms/Checkbox.svelte +15 -6
  20. package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
  21. package/dist/components/forms/Combobox.svelte +45 -10
  22. package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
  23. package/dist/components/forms/FilePicker.svelte +5 -2
  24. package/dist/components/forms/Form.svelte +57 -0
  25. package/dist/components/forms/Form.svelte.d.ts +2 -0
  26. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  27. package/dist/components/forms/Input.svelte +59 -13
  28. package/dist/components/forms/Input.svelte.d.ts.map +1 -1
  29. package/dist/components/forms/Listbox.svelte +33 -6
  30. package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
  31. package/dist/components/forms/MultiSelect.svelte +41 -19
  32. package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
  33. package/dist/components/forms/RadioGroup.svelte +14 -3
  34. package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
  35. package/dist/components/forms/RangeSlider.svelte +61 -6
  36. package/dist/components/forms/RangeSlider.svelte.d.ts.map +1 -1
  37. package/dist/components/forms/SegmentedControl.svelte +33 -6
  38. package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
  39. package/dist/components/forms/Select.svelte +32 -9
  40. package/dist/components/forms/Select.svelte.d.ts.map +1 -1
  41. package/dist/components/forms/Slider.svelte +26 -6
  42. package/dist/components/forms/Slider.svelte.d.ts.map +1 -1
  43. package/dist/components/forms/StagedControlReview.svelte +637 -0
  44. package/dist/components/forms/StagedControlReview.svelte.d.ts +13 -0
  45. package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -0
  46. package/dist/components/forms/Switch.svelte +16 -7
  47. package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
  48. package/dist/components/forms/TagsInput.svelte +36 -8
  49. package/dist/components/forms/TagsInput.svelte.d.ts.map +1 -1
  50. package/dist/components/forms/Textarea.svelte +20 -8
  51. package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
  52. package/dist/components/forms/ToggleButton.svelte +20 -3
  53. package/dist/components/forms/ToggleButton.svelte.d.ts.map +1 -1
  54. package/dist/components/forms/__tests__/Form.test.js +106 -0
  55. package/dist/components/forms/__tests__/StagedControlReview.test.js +668 -0
  56. package/dist/components/forms/__tests__/composite-controls.fixture.svelte +22 -4
  57. package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts +6 -2
  58. package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts.map +1 -1
  59. package/dist/components/forms/__tests__/composite-controls.test.js +96 -1
  60. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte +41 -0
  61. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts +13 -0
  62. package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts.map +1 -0
  63. package/dist/components/forms/__tests__/control-interaction.test.js +3323 -15
  64. package/dist/components/forms/__tests__/core-controls.fixture.svelte +16 -5
  65. package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts +1 -0
  66. package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts.map +1 -1
  67. package/dist/components/forms/__tests__/core-controls.test.js +223 -2
  68. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte +25 -0
  69. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts +8 -0
  70. package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts.map +1 -0
  71. package/dist/components/forms/__tests__/select-interaction.fixture.svelte +25 -0
  72. package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts +9 -0
  73. package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts.map +1 -0
  74. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte +21 -0
  75. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts +8 -0
  76. package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts.map +1 -0
  77. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte +23 -0
  78. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts +8 -0
  79. package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts.map +1 -0
  80. package/dist/components/forms/__tests__/staged-review.fixture.svelte +56 -0
  81. package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts +12 -0
  82. package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts.map +1 -0
  83. package/dist/components/forms/control-interaction-context.d.ts +9 -1
  84. package/dist/components/forms/control-interaction-context.d.ts.map +1 -1
  85. package/dist/components/forms/control-interaction-context.js +16 -0
  86. package/dist/components/forms/control-interaction.d.ts +97 -7
  87. package/dist/components/forms/control-interaction.d.ts.map +1 -1
  88. package/dist/components/forms/control-interaction.js +1019 -92
  89. package/dist/components/forms/control-value-validation.d.ts +19 -0
  90. package/dist/components/forms/control-value-validation.d.ts.map +1 -0
  91. package/dist/components/forms/control-value-validation.js +152 -0
  92. package/dist/components/forms/index.d.ts +3 -1
  93. package/dist/components/forms/index.d.ts.map +1 -1
  94. package/dist/components/forms/index.js +2 -1
  95. package/dist/components/forms/staged-control-review.d.ts +21 -0
  96. package/dist/components/forms/staged-control-review.d.ts.map +1 -0
  97. package/dist/components/forms/staged-control-review.js +1 -0
  98. package/dist/components/forms/use-control-registration.svelte.d.ts.map +1 -1
  99. package/dist/components/forms/use-control-registration.svelte.js +23 -4
  100. package/dist/i18n/strings.d.ts +3 -0
  101. package/dist/i18n/strings.d.ts.map +1 -1
  102. package/dist/i18n/strings.js +3 -0
  103. package/dist/i18n/strings.ui.d.ts +18 -0
  104. package/dist/i18n/strings.ui.d.ts.map +1 -1
  105. package/dist/i18n/strings.ui.js +19 -0
  106. package/dist/svelte/playground/InteractiveControlsPreview.svelte +3 -4
  107. package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
  108. package/package.json +2 -2
package/AGENTS.md CHANGED
@@ -112,6 +112,39 @@ other text pairing clears WCAG AA in both schemes.
112
112
  interaction registry. Keep chat/voice transports outside this package;
113
113
  mutations must retain sensitivity checks and the stage → confirmed apply
114
114
  policy.
115
+ - Agent-originated form values remain proposals: the registry records their
116
+ provenance, timestamp, and revision, while `StagedControlReview` is the shared
117
+ review/apply surface mounted by both base and rich Forms. Agents can stage but
118
+ cannot self-confirm; only a local human handler submits confirmed apply,
119
+ discard, clear, or undo commands after the registry validates a trusted DOM
120
+ gesture while its event is actively dispatching. Retained events are rejected,
121
+ and the complete command or batch is snapshotted synchronously before gesture
122
+ consumption. Serialized or programmatic `source: 'user', confirmed: true`
123
+ input is not confirmation.
124
+ `StagedControlReview` marks its complete edited Apply value with
125
+ `reviewedValueIsCanonical: true`, but the marker grants no authority and is
126
+ honored only for a current staged entry under the registry's exact local
127
+ gesture proof. A control with proposal-relative preparation implements
128
+ `prepareReviewedValue` to validate/canonicalize the complete displayed value;
129
+ without that hook, marked edits still pass through ordinary `prepareValue`.
130
+ Only an unchanged value exactly equal to the stored staged canonical value can
131
+ reuse that trusted stored value without either hook.
132
+ Secret controls never accept staging, and sensitive/secret
133
+ values stay redacted in snapshots, events, policy callbacks, and review UI.
134
+ Batch execution is ordered best-effort and returns one result per command;
135
+ review-surface batch actions exclude stale and invalid proposals, which remain
136
+ visible with per-field feedback.
137
+ `executeBatch` is additive and optional for injected legacy registries; the
138
+ framework falls back to ordered `execute` calls without weakening the private
139
+ one-shot gesture proof on factory-created registries.
140
+ Rejecting async custom setters and clear handlers roll back by default;
141
+ controls that accept concurrent direct edits expose a monotonic revision and
142
+ user value from `getUserEditSnapshot()` so rollback restores newer human input;
143
+ fallible async setters expose an infallible `restoreValue()` path. Async hooks
144
+ issue nested commands through their optional final `ControlExtensionContext`;
145
+ same-control mutations are rejected there without timing out unrelated queued
146
+ commands. Context-aware setters implement the additive `setValueWithContext`
147
+ hook; legacy `setValue` is always invoked with exactly one argument.
115
148
 
116
149
  ## Gotchas
117
150
 
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:
@@ -99,10 +127,56 @@ without coupling controls to a transport:
99
127
  ```
100
128
 
101
129
  Controls publish serializable metadata, constraints, options, sensitivity, and
102
- capabilities. Adapters can focus, reveal, highlight, explain, validate, stage,
103
- apply, clear, or undo. Agent mutations are denied for secret/read-only controls
104
- and require explicit confirmation before apply, clear, or undo. Staging is
105
- separate so the UI can show a proposal before it changes user state.
130
+ capabilities. Adapters can focus, reveal, highlight, explain, validate, and
131
+ stage reviewable proposals. Agents cannot apply, discard, clear, or undo;
132
+ those value-changing actions require a trusted local gesture handled by the
133
+ framework review surface. Secret/read-only controls reject agent mutations.
134
+ Staging remains separate so proposals never change user state before review.
135
+ Custom local review controls can call `executeLocalControlCommand` or
136
+ `executeLocalControlBatch` synchronously from their DOM handlers; the registry
137
+ requires the event to still be actively dispatching, snapshots the complete
138
+ command, and consumes the gesture before authorizing a value-changing command.
139
+ Retaining an event for later use is rejected even when it remains trusted.
140
+ Serialized or programmatic `source: 'user', confirmed: true` input is never
141
+ confirmation. Sensitive and secret values, validation details, failures, and
142
+ events remain redacted from every public surface.
143
+ The shared review surface marks its complete edited Apply value with
144
+ `reviewedValueIsCanonical: true`. That marker carries no authority: the registry
145
+ honors it only for a current staged entry after validating the exact command
146
+ under an actively dispatching local gesture. Controls with non-idempotent
147
+ proposal preparation can implement `prepareReviewedValue(value)` to validate or
148
+ canonicalize the complete displayed value without re-applying proposal-relative
149
+ behavior. Controls without that hook route marked edits through their ordinary
150
+ `prepareValue`, so a generic marker never bypasses custom normalization or
151
+ rejection. An unchanged value exactly equal to the stored staged canonical value
152
+ uses that trusted stored value directly.
153
+ Registries expose optional `refresh(formId)` notification for hosts whose live
154
+ metadata or runtime-state getters change without a registration event; it
155
+ updates subscribers without discarding an internal staged proposal.
156
+ `executeBatch` is an additive optional registry method; Forms fall back to
157
+ ordered `execute` calls for older injected registries. Factory-created
158
+ registries retain the framework's private, one-shot gesture proof, while an
159
+ older custom registry remains responsible for its pre-existing execution
160
+ policy and accepts review actions only from a trusted browser event.
161
+ Custom controls whose clear operation is intentionally idempotent should return
162
+ `true` from `clear()` to affirm that the unchanged cleared value was accepted.
163
+ Async custom setters and clear handlers are rolled back when they reject. A
164
+ control that permits direct edits while an async mutation is pending can expose
165
+ `getUserEditSnapshot()` and update its revision and value only for direct user
166
+ edits so rollback restores newer human input even if the handler mutates again
167
+ before rejecting. A fallible async setter should also expose `restoreValue()` as
168
+ an infallible state restoration path that does not repeat the external workflow.
169
+ Async policy, validation, setter, clear, and restoration hooks receive an
170
+ optional final `ControlExtensionContext`. Hooks that need to issue another
171
+ control command should use `extension.execute()`; it rejects a mutation of the
172
+ same control immediately, while commands from independent callers remain in
173
+ the normal ordered queue regardless of how long the hook takes. Existing hooks
174
+ that omit the additional argument remain compatible. Setters retain their exact
175
+ legacy `setValue(value)` invocation; a setter that needs this context implements
176
+ the additive `setValueWithContext(value, extension)` hook instead. A hook must
177
+ not await a same-control mutation through a captured registry reference: that
178
+ call is indistinguishable from an independent caller in browser runtimes and,
179
+ like any hook that never settles, can hold the ordered queue indefinitely.
106
180
 
107
181
  ## DataTable controller
108
182
 
@@ -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