rune-lab 0.2.3 → 0.3.1
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 +39 -0
- package/dist/core/money/index.d.ts +1 -1
- package/dist/core/money/index.d.ts.map +1 -1
- package/dist/core/money/index.js +1 -1
- package/dist/core/money/money.d.ts +26 -6
- package/dist/core/money/money.d.ts.map +1 -1
- package/dist/core/money/money.js +59 -6
- package/dist/index.js +1 -1
- package/dist/state/app.svelte.d.ts +8 -1
- package/dist/state/app.svelte.js +10 -1
- package/dist/state/composables/useMoney.d.ts +6 -6
- package/dist/state/composables/useMoney.js +28 -14
- package/dist/state/currency.svelte.d.ts +7 -6
- package/dist/state/currency.svelte.js +32 -2
- package/dist/state/index.d.ts +1 -1
- package/dist/ui/components/Icon.svelte +32 -13
- package/dist/ui/components/Icon.svelte.d.ts +5 -0
- package/dist/ui/components/Kyntharil.svelte +48 -0
- package/dist/ui/components/Kyntharil.svelte.d.ts +6 -0
- package/dist/ui/components/RuneProvider.svelte +5 -1
- package/dist/ui/components/RuneProvider.svelte.d.ts +2 -1
- package/dist/ui/components/money/MoneyDisplay.svelte +33 -23
- package/dist/ui/components/money/MoneyDisplay.svelte.d.ts +15 -7
- package/dist/ui/components/money/MoneyDisplay.svelte.test.d.ts +1 -0
- package/dist/ui/components/money/MoneyDisplay.svelte.test.js +74 -0
- package/dist/ui/components/money/MoneyInput.svelte +46 -21
- package/dist/ui/components/money/MoneyInput.svelte.d.ts +14 -8
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/layout/WorkspaceLayout.svelte +17 -10
- package/package.json +22 -20
package/README.md
CHANGED
|
@@ -140,6 +140,45 @@ also scan the `rune-lab` dist output:
|
|
|
140
140
|
> component classes used by `rune-lab` will be included in your build and theme
|
|
141
141
|
> switching will work across library components and your own code alike.
|
|
142
142
|
|
|
143
|
+
## Money & Currency
|
|
144
|
+
|
|
145
|
+
Rune Lab provides a robust, Dinero.js-backed money layer that handles precision
|
|
146
|
+
arithmetic and locale-aware formatting.
|
|
147
|
+
|
|
148
|
+
### MoneyDisplay
|
|
149
|
+
|
|
150
|
+
```svelte
|
|
151
|
+
<script>
|
|
152
|
+
import { MoneyDisplay } from "rune-lab";
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
<!-- Minor units (default): $150.00 -->
|
|
156
|
+
<MoneyDisplay amount={15000} currency="USD" />
|
|
157
|
+
|
|
158
|
+
<!-- Major units: $150.00 -->
|
|
159
|
+
<MoneyDisplay amount={150} unit="major" currency="USD" />
|
|
160
|
+
|
|
161
|
+
<!-- Compact notation: $1.2M -->
|
|
162
|
+
<MoneyDisplay amount={1200000} unit="major" compact />
|
|
163
|
+
|
|
164
|
+
<!-- Null handling with fallback: "—" -->
|
|
165
|
+
<MoneyDisplay amount={null} fallback="N/A" />
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### MoneyInput
|
|
169
|
+
|
|
170
|
+
A masked input that prevents floating-point precision errors by working
|
|
171
|
+
exclusively with integers.
|
|
172
|
+
|
|
173
|
+
```svelte
|
|
174
|
+
<script>
|
|
175
|
+
import { MoneyInput } from "rune-lab";
|
|
176
|
+
let price = $state(150.00);
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<MoneyInput bind:amount={price} unit="major" currency="USD" />
|
|
180
|
+
```
|
|
181
|
+
|
|
143
182
|
## Persistence Drivers
|
|
144
183
|
|
|
145
184
|
Rune Lab provides built-in drivers to remember user preferences (like theme,
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { addMoney, createMoney, CURRENCY_MAP, type Dinero, type DineroCurrency, formatAmount, formatMoney, multiplyMoney, subtractMoney, } from "./money";
|
|
1
|
+
export { addMoney, createMoney, CURRENCY_MAP, type Dinero, type DineroCurrency, formatAmount, formatMoney, type ISO4217Code, multiplyMoney, registerCurrency, safeAmount, subtractMoney, toMinorUnit, } from "./money";
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/sdk/core/src/money/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,QAAQ,EACR,WAAW,EACX,YAAY,EACZ,KAAK,MAAM,EACX,KAAK,cAAc,EACnB,YAAY,EACZ,WAAW,EACX,aAAa,EACb,aAAa,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/sdk/core/src/money/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,QAAQ,EACR,WAAW,EACX,YAAY,EACZ,KAAK,MAAM,EACX,KAAK,cAAc,EACnB,YAAY,EACZ,WAAW,EACX,KAAK,WAAW,EAChB,aAAa,EACb,gBAAgB,EAChB,UAAU,EACV,aAAa,EACb,WAAW,GACZ,MAAM,SAAS,CAAC"}
|
package/dist/core/money/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// sdk/core/src/money/index.ts
|
|
2
|
-
export { addMoney, createMoney, CURRENCY_MAP, formatAmount, formatMoney, multiplyMoney, subtractMoney, } from "./money";
|
|
2
|
+
export { addMoney, createMoney, CURRENCY_MAP, formatAmount, formatMoney, multiplyMoney, registerCurrency, safeAmount, subtractMoney, toMinorUnit, } from "./money";
|
|
@@ -4,11 +4,31 @@ import { type Dinero, type DineroCurrency } from "dinero.js";
|
|
|
4
4
|
*/
|
|
5
5
|
export declare const CURRENCY_MAP: Record<string, DineroCurrency<number>>;
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Derived type of all built-in ISO 4217 currency codes.
|
|
8
|
+
* Enables IDE autocomplete while allowing any string for dynamic currencies.
|
|
9
|
+
*/
|
|
10
|
+
export type ISO4217Code = keyof typeof CURRENCY_MAP;
|
|
11
|
+
/**
|
|
12
|
+
* Register a new currency in CURRENCY_MAP atomically.
|
|
13
|
+
* Ensures the core registry and any store-level registries stay in sync.
|
|
14
|
+
*/
|
|
15
|
+
export declare function registerCurrency(code: string, currency: DineroCurrency<number>): void;
|
|
16
|
+
/**
|
|
17
|
+
* Normalizes an unknown input into a valid integer for Dinero.js.
|
|
18
|
+
* Exported so consumers can apply the same defensive coercion.
|
|
19
|
+
*/
|
|
20
|
+
export declare function safeAmount(amount: unknown): number;
|
|
21
|
+
/**
|
|
22
|
+
* Converts a major-unit amount (e.g., pesos) to a minor-unit integer (e.g., centavos).
|
|
23
|
+
* Uses the currency's exponent from CURRENCY_MAP.
|
|
24
|
+
*/
|
|
25
|
+
export declare function toMinorUnit(amount: unknown, currencyCode: ISO4217Code | string): number;
|
|
26
|
+
/**
|
|
27
|
+
* Create a Dinero monetary value from an amount.
|
|
28
|
+
* @param amount - Amount (normalized internally via safeAmount)
|
|
9
29
|
* @param currencyCode - ISO 4217 currency code (e.g., "USD")
|
|
10
30
|
*/
|
|
11
|
-
export declare function createMoney(amount:
|
|
31
|
+
export declare function createMoney(amount: unknown, currencyCode: ISO4217Code | string): Dinero<number>;
|
|
12
32
|
/**
|
|
13
33
|
* Format a Dinero value as a locale-aware string
|
|
14
34
|
* @param money - Dinero monetary value
|
|
@@ -17,10 +37,10 @@ export declare function createMoney(amount: number, currencyCode: string): Diner
|
|
|
17
37
|
*/
|
|
18
38
|
export declare function formatMoney(money: Dinero<number>, locale?: string, currencyCode?: string): string;
|
|
19
39
|
/**
|
|
20
|
-
* Format a raw amount
|
|
21
|
-
* Convenience wrapper around createMoney + formatMoney
|
|
40
|
+
* Format a raw amount as a locale-aware currency string.
|
|
41
|
+
* Convenience wrapper around createMoney + formatMoney.
|
|
22
42
|
*/
|
|
23
|
-
export declare function formatAmount(amount:
|
|
43
|
+
export declare function formatAmount(amount: unknown, currencyCode: ISO4217Code | string, locale?: string): string;
|
|
24
44
|
/**
|
|
25
45
|
* Add two monetary values (must be same currency)
|
|
26
46
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"money.d.ts","sourceRoot":"","sources":["../../../src/sdk/core/src/money/money.ts"],"names":[],"mappings":"AAIA,OAAO,EAML,KAAK,MAAM,EAEX,KAAK,cAAc,EAYpB,MAAM,WAAW,CAAC;AAEnB;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,MAAM,CAAC,CAY/D,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,
|
|
1
|
+
{"version":3,"file":"money.d.ts","sourceRoot":"","sources":["../../../src/sdk/core/src/money/money.ts"],"names":[],"mappings":"AAIA,OAAO,EAML,KAAK,MAAM,EAEX,KAAK,cAAc,EAYpB,MAAM,WAAW,CAAC;AAEnB;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,MAAM,CAAC,CAY/D,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,YAAY,CAAC;AAEpD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,cAAc,CAAC,MAAM,CAAC,GAC/B,IAAI,CAEN;AA2BD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAElD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,OAAO,EACf,YAAY,EAAE,WAAW,GAAG,MAAM,GACjC,MAAM,CAUR;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,OAAO,EACf,YAAY,EAAE,WAAW,GAAG,MAAM,GACjC,MAAM,CAAC,MAAM,CAAC,CAQhB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,EACrB,MAAM,GAAE,MAAgB,EACxB,YAAY,CAAC,EAAE,MAAM,GACpB,MAAM,CAeR;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,OAAO,EACf,YAAY,EAAE,WAAW,GAAG,MAAM,EAClC,MAAM,GAAE,MAAgB,GACvB,MAAM,CAGR;AAED;;GAEG;AACH,wBAAgB,QAAQ,CACtB,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,EACjB,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,GAChB,MAAM,CAAC,MAAM,CAAC,CAEhB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,EACjB,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,GAChB,MAAM,CAAC,MAAM,CAAC,CAEhB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,EACrB,MAAM,EAAE,MAAM,GACb,MAAM,CAAC,MAAM,CAAC,CAEhB;AAGD,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC"}
|
package/dist/core/money/money.js
CHANGED
|
@@ -21,16 +21,69 @@ export const CURRENCY_MAP = {
|
|
|
21
21
|
INR,
|
|
22
22
|
};
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Register a new currency in CURRENCY_MAP atomically.
|
|
25
|
+
* Ensures the core registry and any store-level registries stay in sync.
|
|
26
|
+
*/
|
|
27
|
+
export function registerCurrency(code, currency) {
|
|
28
|
+
CURRENCY_MAP[code] = currency;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Normalizes an unknown input into a valid finite number.
|
|
32
|
+
* Handles null, undefined, bigints, and SurrealDB Decimal objects via toString().
|
|
33
|
+
*/
|
|
34
|
+
function toNumber(amount) {
|
|
35
|
+
if (amount === null || amount === undefined)
|
|
36
|
+
return 0;
|
|
37
|
+
if (typeof amount === "number")
|
|
38
|
+
return Number.isFinite(amount) ? amount : 0;
|
|
39
|
+
if (typeof amount === "bigint")
|
|
40
|
+
return Number(amount);
|
|
41
|
+
// Handle SurrealDB Decimal (which has a toString method) or other objects
|
|
42
|
+
if (typeof amount === "object") {
|
|
43
|
+
try {
|
|
44
|
+
// SurrealDB Decimal.toString() returns a numeric string
|
|
45
|
+
const str = String(amount);
|
|
46
|
+
const parsed = parseFloat(str);
|
|
47
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const coerced = Number(amount);
|
|
54
|
+
return Number.isFinite(coerced) ? coerced : 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Normalizes an unknown input into a valid integer for Dinero.js.
|
|
58
|
+
* Exported so consumers can apply the same defensive coercion.
|
|
59
|
+
*/
|
|
60
|
+
export function safeAmount(amount) {
|
|
61
|
+
return Math.round(toNumber(amount));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Converts a major-unit amount (e.g., pesos) to a minor-unit integer (e.g., centavos).
|
|
65
|
+
* Uses the currency's exponent from CURRENCY_MAP.
|
|
66
|
+
*/
|
|
67
|
+
export function toMinorUnit(amount, currencyCode) {
|
|
68
|
+
const currency = CURRENCY_MAP[currencyCode];
|
|
69
|
+
if (!currency) {
|
|
70
|
+
throw new Error(`Unknown currency code: ${currencyCode}. Register it first via registerCurrency().`);
|
|
71
|
+
}
|
|
72
|
+
const base = Array.isArray(currency.base) ? currency.base[0] : currency.base;
|
|
73
|
+
const factor = Math.pow(Number(base), Number(currency.exponent));
|
|
74
|
+
return Math.round(toNumber(amount) * factor);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a Dinero monetary value from an amount.
|
|
78
|
+
* @param amount - Amount (normalized internally via safeAmount)
|
|
26
79
|
* @param currencyCode - ISO 4217 currency code (e.g., "USD")
|
|
27
80
|
*/
|
|
28
81
|
export function createMoney(amount, currencyCode) {
|
|
29
82
|
const currency = CURRENCY_MAP[currencyCode];
|
|
30
83
|
if (!currency) {
|
|
31
|
-
throw new Error(`Unknown currency code: ${currencyCode}.
|
|
84
|
+
throw new Error(`Unknown currency code: ${currencyCode}. Register it first via registerCurrency().`);
|
|
32
85
|
}
|
|
33
|
-
return dinero({ amount, currency });
|
|
86
|
+
return dinero({ amount: safeAmount(amount), currency });
|
|
34
87
|
}
|
|
35
88
|
/**
|
|
36
89
|
* Format a Dinero value as a locale-aware string
|
|
@@ -52,8 +105,8 @@ export function formatMoney(money, locale = "en-US", currencyCode) {
|
|
|
52
105
|
});
|
|
53
106
|
}
|
|
54
107
|
/**
|
|
55
|
-
* Format a raw amount
|
|
56
|
-
* Convenience wrapper around createMoney + formatMoney
|
|
108
|
+
* Format a raw amount as a locale-aware currency string.
|
|
109
|
+
* Convenience wrapper around createMoney + formatMoney.
|
|
57
110
|
*/
|
|
58
111
|
export function formatAmount(amount, currencyCode, locale = "en-US") {
|
|
59
112
|
const money = createMoney(amount, currencyCode);
|
package/dist/index.js
CHANGED
|
@@ -25,9 +25,16 @@ export declare class AppStore {
|
|
|
25
25
|
homepage: string;
|
|
26
26
|
customIcons: Record<string, string>;
|
|
27
27
|
/**
|
|
28
|
-
* Initialize app store with metadata
|
|
28
|
+
* Initialize app store with metadata.
|
|
29
|
+
*
|
|
30
|
+
* @contract init() is idempotent. Call it once at app startup via RuneProvider.
|
|
31
|
+
* Subsequent calls are silently ignored to maintain stability across SSR/CSR cycles.
|
|
29
32
|
*/
|
|
30
33
|
init(data: Partial<AppData>): void;
|
|
34
|
+
/**
|
|
35
|
+
* @internal Test-only. Resets initialization guard.
|
|
36
|
+
*/
|
|
37
|
+
__reset(): void;
|
|
31
38
|
/**
|
|
32
39
|
* Get full app information object
|
|
33
40
|
*/
|
package/dist/state/app.svelte.js
CHANGED
|
@@ -18,7 +18,10 @@ export class AppStore {
|
|
|
18
18
|
customIcons = $state({});
|
|
19
19
|
#initialized = false;
|
|
20
20
|
/**
|
|
21
|
-
* Initialize app store with metadata
|
|
21
|
+
* Initialize app store with metadata.
|
|
22
|
+
*
|
|
23
|
+
* @contract init() is idempotent. Call it once at app startup via RuneProvider.
|
|
24
|
+
* Subsequent calls are silently ignored to maintain stability across SSR/CSR cycles.
|
|
22
25
|
*/
|
|
23
26
|
init(data) {
|
|
24
27
|
if (this.#initialized) {
|
|
@@ -43,6 +46,12 @@ export class AppStore {
|
|
|
43
46
|
this.homepage = data.homepage;
|
|
44
47
|
this.#initialized = true;
|
|
45
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* @internal Test-only. Resets initialization guard.
|
|
51
|
+
*/
|
|
52
|
+
__reset() {
|
|
53
|
+
this.#initialized = false;
|
|
54
|
+
}
|
|
46
55
|
/**
|
|
47
56
|
* Get full app information object
|
|
48
57
|
*/
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { type Dinero } from "rune-lab/core";
|
|
1
|
+
import { type Dinero, type ISO4217Code } from "rune-lab/core";
|
|
2
2
|
/**
|
|
3
3
|
* Context-aware money composable.
|
|
4
4
|
* Reads CurrencyStore and LanguageStore from rune-lab context.
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* const { format, toDinero, add, subtract } = useMoney();
|
|
8
|
-
* const display = format(
|
|
8
|
+
* const display = format(150.00, undefined, 'major'); // "$150.00"
|
|
9
9
|
*/
|
|
10
10
|
export declare function useMoney(): {
|
|
11
|
-
toDinero: (amount: number, currencyCode?: string) => Dinero<number>;
|
|
12
|
-
format: (amount: number, currencyCode?: string) => string;
|
|
13
|
-
add: (a: number, b: number, currencyCode?: string) => Dinero<number>;
|
|
14
|
-
subtract: (a: number, b: number, currencyCode?: string) => Dinero<number>;
|
|
11
|
+
toDinero: (amount: number | null | undefined, currencyCode?: ISO4217Code | string, unit?: "major" | "minor") => Dinero<number>;
|
|
12
|
+
format: (amount: number | null | undefined, currencyCode?: ISO4217Code | string, unit?: "major" | "minor") => string;
|
|
13
|
+
add: (a: number, b: number, currencyCode?: ISO4217Code | string, unit?: "major" | "minor") => Dinero<number>;
|
|
14
|
+
subtract: (a: number, b: number, currencyCode?: ISO4217Code | string, unit?: "major" | "minor") => Dinero<number>;
|
|
15
15
|
};
|
|
@@ -2,47 +2,61 @@
|
|
|
2
2
|
// Context-aware money composable that reads CurrencyStore + LanguageStore
|
|
3
3
|
import { getContext } from "svelte";
|
|
4
4
|
import { RUNE_LAB_CONTEXT } from "../context";
|
|
5
|
-
import { addMoney, createMoney, formatMoney, subtractMoney, } from "rune-lab/core";
|
|
5
|
+
import { addMoney, createMoney, formatMoney, subtractMoney, toMinorUnit, } from "rune-lab/core";
|
|
6
6
|
/**
|
|
7
7
|
* Context-aware money composable.
|
|
8
8
|
* Reads CurrencyStore and LanguageStore from rune-lab context.
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
11
11
|
* const { format, toDinero, add, subtract } = useMoney();
|
|
12
|
-
* const display = format(
|
|
12
|
+
* const display = format(150.00, undefined, 'major'); // "$150.00"
|
|
13
13
|
*/
|
|
14
14
|
export function useMoney() {
|
|
15
15
|
const currencyStore = getContext(RUNE_LAB_CONTEXT.currency);
|
|
16
16
|
const languageStore = getContext(RUNE_LAB_CONTEXT.language);
|
|
17
17
|
/**
|
|
18
|
-
* Convert
|
|
18
|
+
* Convert amount to a Dinero object using current currency
|
|
19
19
|
*/
|
|
20
|
-
function toDinero(amount, currencyCode) {
|
|
20
|
+
function toDinero(amount, currencyCode, unit = "minor") {
|
|
21
21
|
const code = currencyCode ?? String(currencyStore.current);
|
|
22
|
-
|
|
22
|
+
const minorAmount = unit === "major" && amount !== null && amount !== undefined
|
|
23
|
+
? toMinorUnit(Number(amount), code)
|
|
24
|
+
: amount;
|
|
25
|
+
return createMoney(minorAmount, code);
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
25
|
-
* Format an amount
|
|
28
|
+
* Format an amount as a locale-aware currency string
|
|
26
29
|
*/
|
|
27
|
-
function format(amount, currencyCode) {
|
|
30
|
+
function format(amount, currencyCode, unit = "minor") {
|
|
31
|
+
if (amount === null || amount === undefined ||
|
|
32
|
+
(typeof amount === "number" && isNaN(amount))) {
|
|
33
|
+
return "—";
|
|
34
|
+
}
|
|
28
35
|
const code = currencyCode ?? String(currencyStore.current);
|
|
29
36
|
const locale = String(languageStore.current) || "en";
|
|
30
|
-
const
|
|
37
|
+
const minorAmount = unit === "major"
|
|
38
|
+
? toMinorUnit(Number(amount), code)
|
|
39
|
+
: amount;
|
|
40
|
+
const money = createMoney(minorAmount, code);
|
|
31
41
|
return formatMoney(money, locale, code);
|
|
32
42
|
}
|
|
33
43
|
/**
|
|
34
|
-
* Add two amounts
|
|
44
|
+
* Add two amounts in the current currency
|
|
35
45
|
*/
|
|
36
|
-
function add(a, b, currencyCode) {
|
|
46
|
+
function add(a, b, currencyCode, unit = "minor") {
|
|
37
47
|
const code = currencyCode ?? String(currencyStore.current);
|
|
38
|
-
|
|
48
|
+
const aMoney = toDinero(a, code, unit);
|
|
49
|
+
const bMoney = toDinero(b, code, unit);
|
|
50
|
+
return addMoney(aMoney, bMoney);
|
|
39
51
|
}
|
|
40
52
|
/**
|
|
41
|
-
* Subtract two amounts
|
|
53
|
+
* Subtract two amounts in the current currency
|
|
42
54
|
*/
|
|
43
|
-
function subtract(a, b, currencyCode) {
|
|
55
|
+
function subtract(a, b, currencyCode, unit = "minor") {
|
|
44
56
|
const code = currencyCode ?? String(currencyStore.current);
|
|
45
|
-
|
|
57
|
+
const aMoney = toDinero(a, code, unit);
|
|
58
|
+
const bMoney = toDinero(b, code, unit);
|
|
59
|
+
return subtractMoney(aMoney, bMoney);
|
|
46
60
|
}
|
|
47
61
|
return { toDinero, format, add, subtract };
|
|
48
62
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type ConfigStore } from "./createConfigStore.svelte";
|
|
2
1
|
/**
|
|
3
2
|
* Currency configuration
|
|
4
3
|
* Based on ISO 4217 currency codes
|
|
@@ -17,11 +16,13 @@ export interface CurrencyStoreOptions {
|
|
|
17
16
|
defaultCurrency?: string;
|
|
18
17
|
}
|
|
19
18
|
export declare function createCurrencyStore(driverOrOptions?: PersistenceDriver | (() => PersistenceDriver | undefined) | CurrencyStoreOptions): {
|
|
19
|
+
addCurrency: (meta: Currency, dineroDef?: any) => void;
|
|
20
|
+
current: string | number;
|
|
21
|
+
available: Currency[];
|
|
22
|
+
};
|
|
23
|
+
export type CurrencyStore = ReturnType<typeof createCurrencyStore>;
|
|
24
|
+
export declare function getCurrencyStore(): {
|
|
25
|
+
addCurrency: (meta: Currency, dineroDef?: any) => void;
|
|
20
26
|
current: string | number;
|
|
21
27
|
available: Currency[];
|
|
22
|
-
set(id: string | number): void;
|
|
23
|
-
get(id: string | number): Currency | undefined;
|
|
24
|
-
getProp<K extends keyof Currency>(prop: K, id?: string | number | undefined): Currency[K] | undefined;
|
|
25
|
-
addItems(newItems: Currency[]): void;
|
|
26
28
|
};
|
|
27
|
-
export declare function getCurrencyStore(): ConfigStore<Currency>;
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { createConfigStore, } from "./createConfigStore.svelte";
|
|
2
2
|
import { getContext } from "svelte";
|
|
3
3
|
import { RUNE_LAB_CONTEXT } from "./context";
|
|
4
|
+
import { registerCurrency } from "rune-lab/core";
|
|
5
|
+
/**
|
|
6
|
+
* Helper to build a minimal Dinero definition from Currency metadata.
|
|
7
|
+
* Assumes base 10 (standard decimal) for auto-registration.
|
|
8
|
+
*/
|
|
9
|
+
function buildDineroDef(meta) {
|
|
10
|
+
return {
|
|
11
|
+
code: meta.code,
|
|
12
|
+
base: 10,
|
|
13
|
+
exponent: meta.decimals,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
4
16
|
const CURRENCIES = [
|
|
5
17
|
{ code: "USD", symbol: "$", decimals: 2 },
|
|
6
18
|
{ code: "EUR", symbol: "€", decimals: 2 },
|
|
@@ -33,8 +45,23 @@ export function createCurrencyStore(driverOrOptions) {
|
|
|
33
45
|
icon: "💰",
|
|
34
46
|
driver: resolvedDriver,
|
|
35
47
|
});
|
|
36
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Extension: Atomic currency registration.
|
|
50
|
+
* Updates both the Dinero registry (core) and the reactive store (UI).
|
|
51
|
+
*
|
|
52
|
+
* @remarks Custom currencies with non-decimal base systems must use
|
|
53
|
+
* registerCurrency() from @internal/core explicitly before addItems().
|
|
54
|
+
*/
|
|
55
|
+
function addCurrency(meta, dineroDef) {
|
|
56
|
+
const def = dineroDef || buildDineroDef(meta);
|
|
57
|
+
registerCurrency(meta.code, def);
|
|
58
|
+
store.addItems([meta]);
|
|
59
|
+
}
|
|
60
|
+
// Append and auto-register custom currencies if provided
|
|
37
61
|
if (opts.customCurrencies?.length) {
|
|
62
|
+
for (const c of opts.customCurrencies) {
|
|
63
|
+
registerCurrency(c.code, buildDineroDef(c));
|
|
64
|
+
}
|
|
38
65
|
store.addItems(opts.customCurrencies);
|
|
39
66
|
}
|
|
40
67
|
// Set default currency if provided and no persisted value found
|
|
@@ -43,7 +70,10 @@ export function createCurrencyStore(driverOrOptions) {
|
|
|
43
70
|
store.set(opts.defaultCurrency);
|
|
44
71
|
}
|
|
45
72
|
}
|
|
46
|
-
return
|
|
73
|
+
return {
|
|
74
|
+
...store,
|
|
75
|
+
addCurrency,
|
|
76
|
+
};
|
|
47
77
|
}
|
|
48
78
|
export function getCurrencyStore() {
|
|
49
79
|
return getContext(RUNE_LAB_CONTEXT.currency);
|
package/dist/state/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { type AppData, AppStore, createAppStore, getAppStore, } from "./app.svel
|
|
|
8
8
|
export { createLayoutStore, getLayoutStore, LayoutStore, type NavigationItem, type NavigationSection, type WorkspaceItem, } from "./layout.svelte";
|
|
9
9
|
export { ApiStore, type ConnectionState, createApiStore, getApiStore, } from "./api.svelte";
|
|
10
10
|
export { createLanguageStore, getLanguageStore, type Language, } from "./language.svelte";
|
|
11
|
-
export { createCurrencyStore, type Currency, type CurrencyStoreOptions, getCurrencyStore, } from "./currency.svelte";
|
|
11
|
+
export { createCurrencyStore, type Currency, type CurrencyStore, type CurrencyStoreOptions, getCurrencyStore, } from "./currency.svelte";
|
|
12
12
|
export { createToastStore, getToastStore, ToastStore } from "./toast.svelte";
|
|
13
13
|
export { type Command, CommandStore, createCommandStore, getCommandStore, } from "./commands.svelte";
|
|
14
14
|
export { createShortcutStore, getShortcutStore, LAYOUT_SHORTCUTS, type ShortcutEntry, shortcutListener, type ShortcutMeta, ShortcutStore, } from "./shortcuts.svelte";
|
|
@@ -14,11 +14,21 @@
|
|
|
14
14
|
size = "w-5 h-5",
|
|
15
15
|
class: className = "",
|
|
16
16
|
icons = {},
|
|
17
|
+
provider = "svg",
|
|
18
|
+
fill = 0,
|
|
19
|
+
weight = 400,
|
|
20
|
+
grade = 0,
|
|
21
|
+
opsz = 24,
|
|
17
22
|
} = $props<{
|
|
18
23
|
name: string;
|
|
19
24
|
size?: string;
|
|
20
25
|
class?: string;
|
|
21
26
|
icons?: Record<string, string>;
|
|
27
|
+
provider?: "svg" | "material";
|
|
28
|
+
fill?: 0 | 1;
|
|
29
|
+
weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700;
|
|
30
|
+
grade?: -25 | 0 | 200;
|
|
31
|
+
opsz?: 20 | 24 | 40 | 48;
|
|
22
32
|
}>();
|
|
23
33
|
|
|
24
34
|
let appStore: ReturnType<typeof getAppStore> | undefined;
|
|
@@ -74,16 +84,25 @@
|
|
|
74
84
|
});
|
|
75
85
|
</script>
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
{#if provider === "material"}
|
|
88
|
+
<span
|
|
89
|
+
class="material-symbols-outlined {size} {className} select-none"
|
|
90
|
+
style="font-variation-settings: 'FILL' {fill}, 'wght' {weight}, 'GRAD' {grade}, 'opsz' {opsz}; display: inline-flex; align-items: center; justify-content: center;"
|
|
91
|
+
>
|
|
92
|
+
{name}
|
|
93
|
+
</span>
|
|
94
|
+
{:else}
|
|
95
|
+
<svg
|
|
96
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
97
|
+
viewBox="0 0 24 24"
|
|
98
|
+
fill="none"
|
|
99
|
+
stroke="currentColor"
|
|
100
|
+
stroke-width="2"
|
|
101
|
+
stroke-linecap="round"
|
|
102
|
+
stroke-linejoin="round"
|
|
103
|
+
class="{size} {className}"
|
|
104
|
+
>
|
|
105
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
106
|
+
{@html path}
|
|
107
|
+
</svg>
|
|
108
|
+
{/if}
|
|
@@ -3,6 +3,11 @@ type $$ComponentProps = {
|
|
|
3
3
|
size?: string;
|
|
4
4
|
class?: string;
|
|
5
5
|
icons?: Record<string, string>;
|
|
6
|
+
provider?: "svg" | "material";
|
|
7
|
+
fill?: 0 | 1;
|
|
8
|
+
weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700;
|
|
9
|
+
grade?: -25 | 0 | 200;
|
|
10
|
+
opsz?: 20 | 24 | 40 | 48;
|
|
6
11
|
};
|
|
7
12
|
declare const Icon: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
13
|
type Icon = ReturnType<typeof Icon>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DEV } from "esm-env";
|
|
3
|
+
import { portal } from "../actions/portal";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Kyntharil — Reactive Value Inspector
|
|
7
|
+
* A dev-mode-only component that displays a floating panel of reactive values.
|
|
8
|
+
*/
|
|
9
|
+
let { observe = {} } = $props<{
|
|
10
|
+
observe?: Record<string, unknown>;
|
|
11
|
+
}>();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#if DEV}
|
|
15
|
+
<div
|
|
16
|
+
use:portal
|
|
17
|
+
class="rl-kyntharil fixed bottom-4 right-4 z-[9999] max-w-sm max-h-[80vh] overflow-y-auto bg-base-300 border border-base-content/10 rounded-lg shadow-2xl p-4 font-mono text-xs select-none pointer-events-auto"
|
|
18
|
+
>
|
|
19
|
+
<div class="flex items-center justify-between mb-2 border-b border-base-content/10 pb-2">
|
|
20
|
+
<span class="font-bold text-primary flex items-center gap-1">
|
|
21
|
+
<span class="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
|
|
22
|
+
Kyntharil DevTools
|
|
23
|
+
</span>
|
|
24
|
+
<span class="opacity-50 text-[10px] uppercase tracking-wider">Reactive Observer</span>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="space-y-2">
|
|
28
|
+
{#each Object.entries(observe) as [label, value]}
|
|
29
|
+
<div class="flex flex-col gap-0.5">
|
|
30
|
+
<span class="text-base-content/50 text-[10px]">{label}</span>
|
|
31
|
+
<pre class="bg-base-100 p-1.5 rounded overflow-x-auto text-success whitespace-pre-wrap break-all">{JSON.stringify(value, null, 2)}</pre>
|
|
32
|
+
</div>
|
|
33
|
+
{:else}
|
|
34
|
+
<div class="text-base-content/30 italic text-center py-4">No values observed</div>
|
|
35
|
+
{/each}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
{/if}
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
.rl-kyntharil::-webkit-scrollbar {
|
|
42
|
+
width: 4px;
|
|
43
|
+
}
|
|
44
|
+
.rl-kyntharil::-webkit-scrollbar-thumb {
|
|
45
|
+
background: rgba(0, 0, 0, 0.1);
|
|
46
|
+
border-radius: 10px;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
dictionary?: Record<string, any>;
|
|
33
33
|
locales?: readonly string[];
|
|
34
34
|
onLanguageChange?: (code: string) => void;
|
|
35
|
-
|
|
35
|
+
/** Icon provider to use. 'material' injects Google Material Symbols font link. */
|
|
36
|
+
icons?: 'material' | 'none';
|
|
36
37
|
|
|
37
38
|
// Theming (DaisyUI)
|
|
38
39
|
/** Additional custom themes to register alongside the built-in DaisyUI set */
|
|
@@ -182,6 +183,9 @@
|
|
|
182
183
|
{#each metaTags as meta}
|
|
183
184
|
<meta name={meta.name} content={meta.content} />
|
|
184
185
|
{/each}
|
|
186
|
+
{#if config.icons === "material"}
|
|
187
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
|
188
|
+
{/if}
|
|
185
189
|
{/if}
|
|
186
190
|
</svelte:head>
|
|
187
191
|
|
|
@@ -14,7 +14,8 @@ export interface RuneLabConfig {
|
|
|
14
14
|
dictionary?: Record<string, any>;
|
|
15
15
|
locales?: readonly string[];
|
|
16
16
|
onLanguageChange?: (code: string) => void;
|
|
17
|
-
|
|
17
|
+
/** Icon provider to use. 'material' injects Google Material Symbols font link. */
|
|
18
|
+
icons?: 'material' | 'none';
|
|
18
19
|
/** Additional custom themes to register alongside the built-in DaisyUI set */
|
|
19
20
|
customThemes?: Theme[];
|
|
20
21
|
/** Theme to use when no persisted value exists (after system preference detection) */
|
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
MoneyDisplay — Renders a monetary amount with locale-aware formatting.
|
|
3
|
-
Uses CurrencyStore + LanguageStore from context for defaults.
|
|
4
|
-
Zero domain knowledge.
|
|
5
|
-
-->
|
|
6
|
-
<script module lang="ts">
|
|
7
|
-
export interface MoneyDisplayProps {
|
|
8
|
-
/** Amount in minor units (e.g., 15000 = $150.00 for USD) */
|
|
9
|
-
amount: number;
|
|
10
|
-
/** Override currency code (defaults to CurrencyStore.current) */
|
|
11
|
-
currency?: string;
|
|
12
|
-
/** Override locale (defaults to LanguageStore.current) */
|
|
13
|
-
locale?: string;
|
|
14
|
-
/** Use compact notation (e.g., $150K) */
|
|
15
|
-
compact?: boolean;
|
|
16
|
-
}
|
|
17
|
-
</script>
|
|
18
|
-
|
|
19
1
|
<script lang="ts">
|
|
20
2
|
import { getLanguageStore, getCurrencyStore } from "rune-lab/state";
|
|
21
|
-
import { formatAmount } from "rune-lab/core";
|
|
3
|
+
import { formatAmount, toMinorUnit, type ISO4217Code } from "rune-lab/core";
|
|
22
4
|
|
|
23
5
|
let {
|
|
24
6
|
amount,
|
|
7
|
+
unit = 'minor',
|
|
8
|
+
fallback = "—",
|
|
25
9
|
currency,
|
|
26
10
|
locale,
|
|
27
11
|
compact = false,
|
|
28
|
-
}
|
|
12
|
+
} = $props<{
|
|
13
|
+
/** Amount in minor or major units (see unit prop) */
|
|
14
|
+
amount: number | null | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the amount is in 'major' (e.g., pesos) or 'minor' (e.g., centavos) units.
|
|
17
|
+
* Defaults to 'minor' for backward compatibility.
|
|
18
|
+
*/
|
|
19
|
+
unit?: 'major' | 'minor';
|
|
20
|
+
/** String to display if amount is null, undefined, or NaN. Defaults to "—" */
|
|
21
|
+
fallback?: string;
|
|
22
|
+
/** Override currency code (defaults to CurrencyStore.current) */
|
|
23
|
+
currency?: ISO4217Code | string;
|
|
24
|
+
/** Override locale (defaults to LanguageStore.current) */
|
|
25
|
+
locale?: string;
|
|
26
|
+
/** Use compact notation (e.g., $1.2M) */
|
|
27
|
+
compact?: boolean;
|
|
28
|
+
}>();
|
|
29
29
|
|
|
30
30
|
const currencyStore = getCurrencyStore();
|
|
31
31
|
const languageStore = getLanguageStore();
|
|
@@ -38,13 +38,22 @@
|
|
|
38
38
|
locale ?? (String(languageStore.current) || "en"),
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
// M-02 FIX: Derive decimals from the RESOLVED currency, not the store's current
|
|
42
41
|
const currencyMeta = $derived(currencyStore.get(resolvedCurrency));
|
|
43
42
|
const decimals = $derived(currencyMeta?.decimals ?? 2);
|
|
44
43
|
|
|
45
44
|
const formatted = $derived.by(() => {
|
|
45
|
+
// If amount is null/undefined/NaN, use fallback
|
|
46
|
+
if (amount === null || amount === undefined || (typeof amount === 'number' && isNaN(amount))) {
|
|
47
|
+
return fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Convert to minor units if necessary
|
|
51
|
+
const minorAmount = unit === 'major'
|
|
52
|
+
? toMinorUnit(Number(amount), resolvedCurrency)
|
|
53
|
+
: amount;
|
|
54
|
+
|
|
46
55
|
if (compact) {
|
|
47
|
-
const majorUnits =
|
|
56
|
+
const majorUnits = Number(minorAmount) / Math.pow(10, decimals);
|
|
48
57
|
return new Intl.NumberFormat(resolvedLocale, {
|
|
49
58
|
style: "currency",
|
|
50
59
|
currency: resolvedCurrency,
|
|
@@ -52,7 +61,8 @@
|
|
|
52
61
|
maximumFractionDigits: 1,
|
|
53
62
|
}).format(majorUnits);
|
|
54
63
|
}
|
|
55
|
-
|
|
64
|
+
|
|
65
|
+
return formatAmount(minorAmount, resolvedCurrency, resolvedLocale);
|
|
56
66
|
});
|
|
57
67
|
</script>
|
|
58
68
|
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { type ISO4217Code } from "rune-lab/core";
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** Amount in minor or major units (see unit prop) */
|
|
4
|
+
amount: number | null | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Whether the amount is in 'major' (e.g., pesos) or 'minor' (e.g., centavos) units.
|
|
7
|
+
* Defaults to 'minor' for backward compatibility.
|
|
8
|
+
*/
|
|
9
|
+
unit?: 'major' | 'minor';
|
|
10
|
+
/** String to display if amount is null, undefined, or NaN. Defaults to "—" */
|
|
11
|
+
fallback?: string;
|
|
4
12
|
/** Override currency code (defaults to CurrencyStore.current) */
|
|
5
|
-
currency?: string;
|
|
13
|
+
currency?: ISO4217Code | string;
|
|
6
14
|
/** Override locale (defaults to LanguageStore.current) */
|
|
7
15
|
locale?: string;
|
|
8
|
-
/** Use compact notation (e.g., $
|
|
16
|
+
/** Use compact notation (e.g., $1.2M) */
|
|
9
17
|
compact?: boolean;
|
|
10
|
-
}
|
|
11
|
-
declare const MoneyDisplay: import("svelte").Component
|
|
18
|
+
};
|
|
19
|
+
declare const MoneyDisplay: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
20
|
type MoneyDisplay = ReturnType<typeof MoneyDisplay>;
|
|
13
21
|
export default MoneyDisplay;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/svelte";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import MoneyDisplay from "./MoneyDisplay.svelte";
|
|
4
|
+
import { RUNE_LAB_CONTEXT } from "rune-lab/state";
|
|
5
|
+
// Mock stores for context
|
|
6
|
+
const mockCurrencyStore = {
|
|
7
|
+
current: "USD",
|
|
8
|
+
get: (code) => ({ symbol: "$", decimals: 2 }),
|
|
9
|
+
};
|
|
10
|
+
const mockLanguageStore = {
|
|
11
|
+
current: "en-US",
|
|
12
|
+
};
|
|
13
|
+
const context = new Map([
|
|
14
|
+
[RUNE_LAB_CONTEXT.currency, mockCurrencyStore],
|
|
15
|
+
[RUNE_LAB_CONTEXT.language, mockLanguageStore],
|
|
16
|
+
]);
|
|
17
|
+
describe("MoneyDisplay.svelte", () => {
|
|
18
|
+
it("renders fallback when amount is null", () => {
|
|
19
|
+
render(MoneyDisplay, {
|
|
20
|
+
props: { amount: null, fallback: "N/A" },
|
|
21
|
+
context,
|
|
22
|
+
});
|
|
23
|
+
expect(screen.getByText("N/A")).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
it("renders fallback when amount is undefined", () => {
|
|
26
|
+
render(MoneyDisplay, {
|
|
27
|
+
props: { amount: undefined, fallback: "N/A" },
|
|
28
|
+
context,
|
|
29
|
+
});
|
|
30
|
+
expect(screen.getByText("N/A")).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
it("renders fallback when amount is NaN", () => {
|
|
33
|
+
render(MoneyDisplay, {
|
|
34
|
+
props: { amount: NaN, fallback: "N/A" },
|
|
35
|
+
context,
|
|
36
|
+
});
|
|
37
|
+
expect(screen.getByText("N/A")).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
it("renders default fallback when amount is NaN and no fallback prop provided", () => {
|
|
40
|
+
render(MoneyDisplay, {
|
|
41
|
+
props: { amount: NaN },
|
|
42
|
+
context,
|
|
43
|
+
});
|
|
44
|
+
expect(screen.getByText("—")).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
it("renders zero correctly when passed literal 0", () => {
|
|
47
|
+
render(MoneyDisplay, {
|
|
48
|
+
props: { amount: 0 },
|
|
49
|
+
context,
|
|
50
|
+
});
|
|
51
|
+
expect(screen.getByText(/\$0\.00/)).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
it('handles unit="major"', () => {
|
|
54
|
+
render(MoneyDisplay, {
|
|
55
|
+
props: { amount: 125.50, unit: "major", currency: "USD" },
|
|
56
|
+
context,
|
|
57
|
+
});
|
|
58
|
+
expect(screen.getByText(/\$125\.50/)).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
it('handles unit="minor" (default)', () => {
|
|
61
|
+
render(MoneyDisplay, {
|
|
62
|
+
props: { amount: 12550, unit: "minor", currency: "USD" },
|
|
63
|
+
context,
|
|
64
|
+
});
|
|
65
|
+
expect(screen.getByText(/\$125\.50/)).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
it("supports compact notation", () => {
|
|
68
|
+
render(MoneyDisplay, {
|
|
69
|
+
props: { amount: 1200000, unit: "major", currency: "USD", compact: true },
|
|
70
|
+
context,
|
|
71
|
+
});
|
|
72
|
+
expect(screen.getByText(/\$1\.2M/)).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -3,19 +3,26 @@
|
|
|
3
3
|
No floats cross the boundary — values are always integer minor units.
|
|
4
4
|
-->
|
|
5
5
|
<script module lang="ts">
|
|
6
|
+
import type { ISO4217Code } from "rune-lab/core";
|
|
7
|
+
|
|
6
8
|
export interface MoneyInputProps {
|
|
7
|
-
/** Current value in minor units (
|
|
8
|
-
|
|
9
|
+
/** Current value in minor or major units (see unit prop) */
|
|
10
|
+
amount?: number | null | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the amount is in 'major' (e.g., pesos) or 'minor' (e.g., centavos) units.
|
|
13
|
+
* Defaults to 'minor' for backward compatibility.
|
|
14
|
+
*/
|
|
15
|
+
unit?: 'major' | 'minor';
|
|
9
16
|
/** Override currency code (defaults to CurrencyStore.current) */
|
|
10
|
-
currency?: string;
|
|
11
|
-
/** Minimum value in
|
|
17
|
+
currency?: ISO4217Code | string;
|
|
18
|
+
/** Minimum value in same units as amount */
|
|
12
19
|
min?: number;
|
|
13
|
-
/** Maximum value in
|
|
20
|
+
/** Maximum value in same units as amount */
|
|
14
21
|
max?: number;
|
|
15
22
|
/** Placeholder text */
|
|
16
23
|
placeholder?: string;
|
|
17
|
-
/** Fired when the value changes (
|
|
18
|
-
oninput?: (
|
|
24
|
+
/** Fired when the value changes (unit matches the unit prop) */
|
|
25
|
+
oninput?: (val: number) => void;
|
|
19
26
|
/** Input disabled state */
|
|
20
27
|
disabled?: boolean;
|
|
21
28
|
}
|
|
@@ -23,9 +30,11 @@
|
|
|
23
30
|
|
|
24
31
|
<script lang="ts">
|
|
25
32
|
import { getCurrencyStore } from "rune-lab/state";
|
|
33
|
+
import { toMinorUnit } from "rune-lab/core";
|
|
26
34
|
|
|
27
35
|
let {
|
|
28
|
-
|
|
36
|
+
amount = $bindable(0),
|
|
37
|
+
unit = 'minor',
|
|
29
38
|
currency,
|
|
30
39
|
min,
|
|
31
40
|
max,
|
|
@@ -40,22 +49,22 @@
|
|
|
40
49
|
currency ?? String(currencyStore.current),
|
|
41
50
|
);
|
|
42
51
|
|
|
43
|
-
// M-02 parallel: derive symbol/decimals from resolved currency, not store current
|
|
44
52
|
const currencyMeta = $derived(currencyStore.get(resolvedCurrency));
|
|
45
53
|
const symbol = $derived(currencyMeta?.symbol ?? "$");
|
|
46
54
|
const decimals = $derived(currencyMeta?.decimals ?? 2);
|
|
47
55
|
|
|
48
|
-
// Convert minor units
|
|
56
|
+
// Convert to minor units internally for consistent arithmetic if needed,
|
|
57
|
+
// but here we just need to display it.
|
|
49
58
|
const displayValue = $derived.by(() => {
|
|
50
|
-
|
|
59
|
+
const val = amount ?? 0;
|
|
60
|
+
if (unit === 'major') {
|
|
61
|
+
return Number(val).toFixed(decimals);
|
|
62
|
+
}
|
|
63
|
+
if (decimals === 0) return String(val);
|
|
51
64
|
const divisor = Math.pow(10, decimals);
|
|
52
|
-
return (
|
|
65
|
+
return (Number(val) / divisor).toFixed(decimals);
|
|
53
66
|
});
|
|
54
67
|
|
|
55
|
-
/**
|
|
56
|
-
* M-03 FIX: Parse user input to minor units using string-based integer parsing
|
|
57
|
-
* to avoid floating-point precision traps (e.g., 1.005 * 100 = 100.49999...)
|
|
58
|
-
*/
|
|
59
68
|
function handleInput(e: Event) {
|
|
60
69
|
const target = e.target as HTMLInputElement;
|
|
61
70
|
const raw = target.value.replace(/[^0-9.,-]/g, "");
|
|
@@ -77,11 +86,27 @@
|
|
|
77
86
|
let cents = parseInt(combined, 10);
|
|
78
87
|
if (isNaN(cents)) return;
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
// Apply constraints in minor units
|
|
90
|
+
let finalCents = cents;
|
|
91
|
+
|
|
92
|
+
if (unit === 'major') {
|
|
93
|
+
// If we are working in major units, we need to convert min/max to cents for comparison
|
|
94
|
+
const minCents = min !== undefined ? toMinorUnit(min, resolvedCurrency) : undefined;
|
|
95
|
+
const maxCents = max !== undefined ? toMinorUnit(max, resolvedCurrency) : undefined;
|
|
96
|
+
|
|
97
|
+
if (minCents !== undefined) finalCents = Math.max(finalCents, minCents);
|
|
98
|
+
if (maxCents !== undefined) finalCents = Math.min(finalCents, maxCents);
|
|
99
|
+
|
|
100
|
+
const majorValue = finalCents / Math.pow(10, decimals);
|
|
101
|
+
amount = majorValue;
|
|
102
|
+
oninput?.(majorValue);
|
|
103
|
+
} else {
|
|
104
|
+
if (min !== undefined) finalCents = Math.max(finalCents, min);
|
|
105
|
+
if (max !== undefined) finalCents = Math.min(finalCents, max);
|
|
106
|
+
|
|
107
|
+
amount = finalCents;
|
|
108
|
+
oninput?.(finalCents);
|
|
109
|
+
}
|
|
85
110
|
}
|
|
86
111
|
</script>
|
|
87
112
|
|
|
@@ -1,19 +1,25 @@
|
|
|
1
|
+
import type { ISO4217Code } from "rune-lab/core";
|
|
1
2
|
export interface MoneyInputProps {
|
|
2
|
-
/** Current value in minor units (
|
|
3
|
-
|
|
3
|
+
/** Current value in minor or major units (see unit prop) */
|
|
4
|
+
amount?: number | null | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Whether the amount is in 'major' (e.g., pesos) or 'minor' (e.g., centavos) units.
|
|
7
|
+
* Defaults to 'minor' for backward compatibility.
|
|
8
|
+
*/
|
|
9
|
+
unit?: 'major' | 'minor';
|
|
4
10
|
/** Override currency code (defaults to CurrencyStore.current) */
|
|
5
|
-
currency?: string;
|
|
6
|
-
/** Minimum value in
|
|
11
|
+
currency?: ISO4217Code | string;
|
|
12
|
+
/** Minimum value in same units as amount */
|
|
7
13
|
min?: number;
|
|
8
|
-
/** Maximum value in
|
|
14
|
+
/** Maximum value in same units as amount */
|
|
9
15
|
max?: number;
|
|
10
16
|
/** Placeholder text */
|
|
11
17
|
placeholder?: string;
|
|
12
|
-
/** Fired when the value changes (
|
|
13
|
-
oninput?: (
|
|
18
|
+
/** Fired when the value changes (unit matches the unit prop) */
|
|
19
|
+
oninput?: (val: number) => void;
|
|
14
20
|
/** Input disabled state */
|
|
15
21
|
disabled?: boolean;
|
|
16
22
|
}
|
|
17
|
-
declare const MoneyInput: import("svelte").Component<MoneyInputProps, {}, "">;
|
|
23
|
+
declare const MoneyInput: import("svelte").Component<MoneyInputProps, {}, "amount">;
|
|
18
24
|
type MoneyInput = ReturnType<typeof MoneyInput>;
|
|
19
25
|
export default MoneyInput;
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { default as RuneProvider } from "./components/RuneProvider.svelte";
|
|
|
3
3
|
export { default as Icon } from "./components/Icon.svelte";
|
|
4
4
|
export { default as Toaster } from "./components/Toaster.svelte";
|
|
5
5
|
export { default as ApiMonitor } from "./components/ApiMonitor.svelte";
|
|
6
|
+
export { default as Kyntharil } from "./components/Kyntharil.svelte";
|
|
6
7
|
export { default as CommandPalette } from "./features/command-palette/CommandPalette.svelte";
|
|
7
8
|
export { default as ShortcutPalette } from "./features/shortcuts/ShortcutPalette.svelte";
|
|
8
9
|
export { default as AppSettingSelector } from "./features/config/AppSettingSelector.svelte";
|
package/dist/ui/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export { default as RuneProvider } from "./components/RuneProvider.svelte";
|
|
|
6
6
|
export { default as Icon } from "./components/Icon.svelte";
|
|
7
7
|
export { default as Toaster } from "./components/Toaster.svelte";
|
|
8
8
|
export { default as ApiMonitor } from "./components/ApiMonitor.svelte";
|
|
9
|
+
export { default as Kyntharil } from "./components/Kyntharil.svelte";
|
|
9
10
|
// ── Features ──────────────────────────────────────────────────────────────────
|
|
10
11
|
export { default as CommandPalette } from "./features/command-palette/CommandPalette.svelte";
|
|
11
12
|
export { default as ShortcutPalette } from "./features/shortcuts/ShortcutPalette.svelte";
|
|
@@ -80,6 +80,17 @@
|
|
|
80
80
|
onMount(() => {
|
|
81
81
|
layoutStore.init({ namespace });
|
|
82
82
|
|
|
83
|
+
// Apply global scroll lock while WorkspaceLayout is mounted
|
|
84
|
+
const originalHtmlOverflow = document.documentElement.style.overflow;
|
|
85
|
+
const originalBodyOverflow = document.body.style.overflow;
|
|
86
|
+
const originalHtmlHeight = document.documentElement.style.height;
|
|
87
|
+
const originalBodyHeight = document.body.style.height;
|
|
88
|
+
|
|
89
|
+
document.documentElement.style.overflow = "hidden";
|
|
90
|
+
document.body.style.overflow = "hidden";
|
|
91
|
+
document.documentElement.style.height = "100%";
|
|
92
|
+
document.body.style.height = "100%";
|
|
93
|
+
|
|
83
94
|
// Register default layout shortcuts
|
|
84
95
|
const shortcuts = [
|
|
85
96
|
{
|
|
@@ -103,6 +114,12 @@
|
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
return () => {
|
|
117
|
+
// Restore original styles
|
|
118
|
+
document.documentElement.style.overflow = originalHtmlOverflow;
|
|
119
|
+
document.body.style.overflow = originalBodyOverflow;
|
|
120
|
+
document.documentElement.style.height = originalHtmlHeight;
|
|
121
|
+
document.body.style.height = originalBodyHeight;
|
|
122
|
+
|
|
106
123
|
shortcutStore.unregister(LAYOUT_SHORTCUTS.TOGGLE_NAV.id);
|
|
107
124
|
shortcutStore.unregister(LAYOUT_SHORTCUTS.TOGGLE_DETAIL.id);
|
|
108
125
|
};
|
|
@@ -203,16 +220,6 @@
|
|
|
203
220
|
</div>
|
|
204
221
|
|
|
205
222
|
<style>
|
|
206
|
-
/* Global lock for entire screen, relying on WorkspaceLayout scroll areas */
|
|
207
|
-
:global(html),
|
|
208
|
-
:global(body) {
|
|
209
|
-
overflow: hidden;
|
|
210
|
-
height: 100%;
|
|
211
|
-
width: 100%;
|
|
212
|
-
margin: 0;
|
|
213
|
-
padding: 0;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
223
|
:global(.rl-layout) {
|
|
217
224
|
--rl-strip-width: 72px;
|
|
218
225
|
--rl-nav-width: 240px;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rune-lab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Modern toolkit for Svelte 5 Runes applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,38 +43,40 @@
|
|
|
43
43
|
"./package.json": "./package.json"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"svelte": "^5.
|
|
47
|
-
"tailwindcss": "^4.2.
|
|
48
|
-
"daisyui": "^5.5.19"
|
|
46
|
+
"svelte": "^5.54.1",
|
|
47
|
+
"tailwindcss": "^4.2.2",
|
|
48
|
+
"daisyui": "^5.5.19",
|
|
49
|
+
"dinero.js": "npm:dinero.js@^2.0.2",
|
|
50
|
+
"better-auth": "^1.5.5"
|
|
49
51
|
},
|
|
50
52
|
"peerDependenciesMeta": {
|
|
51
53
|
"better-auth": {
|
|
52
54
|
"optional": true
|
|
53
|
-
},
|
|
54
|
-
"dinero.js": {
|
|
55
|
-
"optional": true
|
|
56
55
|
}
|
|
57
56
|
},
|
|
58
57
|
"dependencies": {
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"dinero.js": "npm:dinero.js@^2.0.0"
|
|
58
|
+
"gwa": "^0.0.1",
|
|
59
|
+
"hotkeys-js": "npm:hotkeys-js@^4.0.2"
|
|
62
60
|
},
|
|
63
61
|
"devDependencies": {
|
|
64
|
-
"@inlang/paraglide-js": "npm:@inlang/paraglide-js@^2.
|
|
62
|
+
"@inlang/paraglide-js": "npm:@inlang/paraglide-js@^2.15.1",
|
|
63
|
+
"@internal/core": "0.2.0",
|
|
64
|
+
"@internal/state": "0.2.0",
|
|
65
|
+
"@internal/ui": "0.2.0",
|
|
65
66
|
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@^3.0.10",
|
|
66
|
-
"@sveltejs/kit": "npm:@sveltejs/kit@^2.
|
|
67
|
-
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@^7.0.0",
|
|
67
|
+
"@sveltejs/kit": "npm:@sveltejs/kit@^2.55.0",
|
|
68
68
|
"@sveltejs/package": "^2.5.7",
|
|
69
|
+
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@^7.0.0",
|
|
69
70
|
"@tailwindcss/forms": "npm:@tailwindcss/forms@^0.5.11",
|
|
70
71
|
"@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.19",
|
|
71
|
-
"@tailwindcss/vite": "npm:@tailwindcss/vite@^4.2.
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
72
|
+
"@tailwindcss/vite": "npm:@tailwindcss/vite@^4.2.2",
|
|
73
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
74
|
+
"@testing-library/svelte": "^5.3.1",
|
|
75
|
+
"jsdom": "^29.0.1",
|
|
75
76
|
"publint": "^0.3.18",
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
77
|
+
"svelte-check": "npm:svelte-check@^4.4.5",
|
|
78
|
+
"typescript": "npm:typescript@^5.9.3",
|
|
79
|
+
"vite": "npm:vite@^8.0.1",
|
|
80
|
+
"vitest": "^4.1.0"
|
|
79
81
|
}
|
|
80
82
|
}
|