rune-lab 0.4.8-alpha.2 → 0.4.8
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/dist/RuneProvider.svelte +2 -1
- package/dist/i18n/paraglide/README.md +1 -1
- package/dist/kernel/src/context/accessors.test.d.ts +1 -0
- package/dist/kernel/src/context/accessors.test.js +68 -0
- package/dist/kernel/src/context/app.svelte.js +7 -1
- package/dist/kernel/src/context/stores.svelte.d.ts +5 -3
- package/dist/kernel/src/context/stores.svelte.js +49 -7
- package/dist/kernel/src/persistence/createConfigStore.svelte.d.ts +12 -12
- package/dist/kernel/src/persistence/createConfigStore.svelte.js +1 -1
- package/dist/kernel/src/persistence/drivers.d.ts +13 -0
- package/dist/kernel/src/persistence/drivers.js +18 -0
- package/dist/kernel/src/registry/mod.js +5 -1
- package/dist/mod.js +1 -1
- package/dist/runes/layout/src/ResourceSelector.svelte +7 -7
- package/dist/runes/layout/src/ResourceSelector.svelte.d.ts +1 -1
- package/dist/runes/layout/src/language.svelte.d.ts +7 -3
- package/dist/runes/layout/src/theme.svelte.d.ts +5 -2
- package/dist/runes/palettes/src/commands/store.svelte.d.ts +3 -3
- package/dist/runes/palettes/src/notifications/Toaster.svelte +13 -4
- package/dist/runes/palettes/src/notifications/store.svelte.d.ts +11 -2
- package/dist/runes/palettes/src/notifications/store.svelte.js +55 -4
- package/dist/runes/palettes/src/notifications/store.test.d.ts +1 -0
- package/dist/runes/palettes/src/notifications/store.test.js +83 -0
- package/dist/runes/plugins/money/src/currency.svelte.d.ts +1 -1
- package/dist/runes/plugins/money/src/exchange-rate.svelte.d.ts +1 -2
- package/dist/runes/plugins/money/src/exchange-rate.svelte.js +76 -83
- package/dist/runes/plugins/money/src/useMoney.js +15 -0
- package/package.json +3 -3
package/dist/RuneProvider.svelte
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
RUNE_LAB_CONTEXT,
|
|
5
5
|
localStorageDriver,
|
|
6
|
+
namespaced,
|
|
6
7
|
initializeStores,
|
|
7
8
|
defineRune,
|
|
8
9
|
createAppStore,
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
}>();
|
|
44
45
|
|
|
45
46
|
const initialPlugins = untrack(() => plugins);
|
|
46
|
-
const initialPersistence = untrack(() => config.persistence ?? localStorageDriver);
|
|
47
|
+
const initialPersistence = untrack(() => namespaced(config.persistence ?? localStorageDriver, "rl:"));
|
|
47
48
|
|
|
48
49
|
// 0. Create and provide the built-in AppStore
|
|
49
50
|
const appStore = createAppStore();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vite-plus/test";
|
|
2
|
+
// Mock Svelte's getContext so we can test the behavior when a context is not present
|
|
3
|
+
// without triggering Svelte's lifecycle_outside_component error.
|
|
4
|
+
vi.mock("svelte", () => ({
|
|
5
|
+
getContext: () => undefined,
|
|
6
|
+
}));
|
|
7
|
+
import {
|
|
8
|
+
getCommandStore,
|
|
9
|
+
getCurrencyStore,
|
|
10
|
+
getLanguageStore,
|
|
11
|
+
getLayoutStore,
|
|
12
|
+
getShortcutStore,
|
|
13
|
+
getThemeStore,
|
|
14
|
+
getToastStore,
|
|
15
|
+
} from "./stores.svelte.js";
|
|
16
|
+
import { getAppStore } from "./app.svelte.js";
|
|
17
|
+
import { getExchangeRateStore } from "../../../runes/plugins/money/src/exchange-rate.svelte.js";
|
|
18
|
+
import { useMoney } from "../../../runes/plugins/money/src/useMoney.js";
|
|
19
|
+
describe("Fail-fast context accessors", () => {
|
|
20
|
+
it("should throw a helpful error for getAppStore", () => {
|
|
21
|
+
expect(() => getAppStore()).toThrow(/getAppStore\(\) found no AppStore/i);
|
|
22
|
+
});
|
|
23
|
+
it("should throw a helpful error for getLayoutStore", () => {
|
|
24
|
+
expect(() => getLayoutStore()).toThrow(
|
|
25
|
+
/getLayoutStore\(\) found no LayoutStore.*LayoutPlugin/i,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
it("should throw a helpful error for getLanguageStore", () => {
|
|
29
|
+
expect(() => getLanguageStore()).toThrow(
|
|
30
|
+
/getLanguageStore\(\) found no LanguageStore.*LayoutPlugin/i,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
it("should throw a helpful error for getThemeStore", () => {
|
|
34
|
+
expect(() => getThemeStore()).toThrow(
|
|
35
|
+
/getThemeStore\(\) found no ThemeStore.*LayoutPlugin/i,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
it("should throw a helpful error for getShortcutStore", () => {
|
|
39
|
+
expect(() => getShortcutStore()).toThrow(
|
|
40
|
+
/getShortcutStore\(\) found no ShortcutStore.*PalettesPlugin/i,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
it("should throw a helpful error for getCommandStore", () => {
|
|
44
|
+
expect(() => getCommandStore()).toThrow(
|
|
45
|
+
/getCommandStore\(\) found no CommandStore.*PalettesPlugin/i,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
it("should throw a helpful error for getToastStore", () => {
|
|
49
|
+
expect(() => getToastStore()).toThrow(
|
|
50
|
+
/getToastStore\(\) found no ToastStore.*PalettesPlugin/i,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
it("should throw a helpful error for getCurrencyStore", () => {
|
|
54
|
+
expect(() => getCurrencyStore()).toThrow(
|
|
55
|
+
/getCurrencyStore\(\) found no CurrencyStore.*MoneyPlugin/i,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
it("should throw a helpful error for getExchangeRateStore", () => {
|
|
59
|
+
expect(() => getExchangeRateStore()).toThrow(
|
|
60
|
+
/getExchangeRateStore\(\) found no ExchangeRateStore.*MoneyPlugin/i,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
it("should throw a helpful error for useMoney", () => {
|
|
64
|
+
expect(() => useMoney()).toThrow(
|
|
65
|
+
/useMoney\(\) found no CurrencyStore.*MoneyPlugin/i,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -85,5 +85,11 @@ export function createAppStore() {
|
|
|
85
85
|
return new AppStore();
|
|
86
86
|
}
|
|
87
87
|
export function getAppStore() {
|
|
88
|
-
|
|
88
|
+
const store = getContext(RUNE_LAB_CONTEXT.app);
|
|
89
|
+
if (!store) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"[rune-lab] getAppStore() found no AppStore. Did you wrap your application in <RuneProvider>?",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return store;
|
|
89
95
|
}
|
|
@@ -26,8 +26,10 @@ export interface IToastStore {
|
|
|
26
26
|
warn(msg: string): void;
|
|
27
27
|
info(msg: string): void;
|
|
28
28
|
dismiss(id: string): void;
|
|
29
|
+
pause(id: string): void;
|
|
30
|
+
resume(id: string): void;
|
|
29
31
|
}
|
|
30
|
-
export interface ICurrencyStore extends ConfigStore<Currency> {
|
|
32
|
+
export interface ICurrencyStore extends ConfigStore<Currency, "code"> {
|
|
31
33
|
canConvert: boolean;
|
|
32
34
|
convertAmount(amount: number, fromCode: string, toCode?: string): number;
|
|
33
35
|
addCurrency(meta: Currency, dineroDef?: unknown): void;
|
|
@@ -36,8 +38,8 @@ export interface ICurrencyStore extends ConfigStore<Currency> {
|
|
|
36
38
|
* Store context accessors
|
|
37
39
|
*/
|
|
38
40
|
export declare function getLayoutStore(): LayoutStore;
|
|
39
|
-
export declare function getLanguageStore(): ConfigStore<Language>;
|
|
40
|
-
export declare function getThemeStore(): ConfigStore<Theme>;
|
|
41
|
+
export declare function getLanguageStore(): ConfigStore<Language, "code">;
|
|
42
|
+
export declare function getThemeStore(): ConfigStore<Theme, "name">;
|
|
41
43
|
export declare function getShortcutStore(): ShortcutStore;
|
|
42
44
|
export declare function getCommandStore(): ICommandStore;
|
|
43
45
|
export declare function getToastStore(): IToastStore;
|
|
@@ -4,25 +4,67 @@ import { RUNE_LAB_CONTEXT } from "./context.js";
|
|
|
4
4
|
* Store context accessors
|
|
5
5
|
*/
|
|
6
6
|
export function getLayoutStore() {
|
|
7
|
-
|
|
7
|
+
const store = getContext(RUNE_LAB_CONTEXT.layout);
|
|
8
|
+
if (!store) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"[rune-lab] getLayoutStore() found no LayoutStore. Did you register LayoutPlugin in <RuneProvider plugins={[…]}>?",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return store;
|
|
8
14
|
}
|
|
9
15
|
export function getLanguageStore() {
|
|
10
|
-
|
|
16
|
+
const store = getContext(RUNE_LAB_CONTEXT.language);
|
|
17
|
+
if (!store) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"[rune-lab] getLanguageStore() found no LanguageStore. Did you register LayoutPlugin in <RuneProvider plugins={[…]}>?",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return store;
|
|
11
23
|
}
|
|
12
24
|
export function getThemeStore() {
|
|
13
|
-
|
|
25
|
+
const store = getContext(RUNE_LAB_CONTEXT.theme);
|
|
26
|
+
if (!store) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"[rune-lab] getThemeStore() found no ThemeStore. Did you register LayoutPlugin in <RuneProvider plugins={[…]}>?",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return store;
|
|
14
32
|
}
|
|
15
33
|
export function getShortcutStore() {
|
|
16
|
-
|
|
34
|
+
const store = getContext(RUNE_LAB_CONTEXT.shortcut);
|
|
35
|
+
if (!store) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"[rune-lab] getShortcutStore() found no ShortcutStore. Did you register PalettesPlugin in <RuneProvider plugins={[…]}>?",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return store;
|
|
17
41
|
}
|
|
18
42
|
export function getCommandStore() {
|
|
19
|
-
|
|
43
|
+
const store = getContext(RUNE_LAB_CONTEXT.commands);
|
|
44
|
+
if (!store) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"[rune-lab] getCommandStore() found no CommandStore. Did you register PalettesPlugin in <RuneProvider plugins={[…]}>?",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return store;
|
|
20
50
|
}
|
|
21
51
|
export function getToastStore() {
|
|
22
|
-
|
|
52
|
+
const store = getContext(RUNE_LAB_CONTEXT.toast);
|
|
53
|
+
if (!store) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"[rune-lab] getToastStore() found no ToastStore. Did you register PalettesPlugin in <RuneProvider plugins={[…]}>?",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return store;
|
|
23
59
|
}
|
|
24
60
|
export function getCurrencyStore() {
|
|
25
|
-
|
|
61
|
+
const store = getContext(RUNE_LAB_CONTEXT.currency);
|
|
62
|
+
if (!store) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"[rune-lab] getCurrencyStore() found no CurrencyStore. Did you register MoneyPlugin in <RuneProvider plugins={[…]}>?",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return store;
|
|
26
68
|
}
|
|
27
69
|
/**
|
|
28
70
|
* Standard layout shortcuts
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import type { PersistenceDriver } from "./types.js";
|
|
2
|
-
export type ConfigStore<T =
|
|
3
|
-
current:
|
|
2
|
+
export type ConfigStore<T, K extends keyof T = keyof T> = {
|
|
3
|
+
current: T[K];
|
|
4
4
|
available: T[];
|
|
5
|
-
set
|
|
6
|
-
get
|
|
7
|
-
getProp
|
|
8
|
-
addItems
|
|
5
|
+
set(id: T[K]): void;
|
|
6
|
+
get(id: T[K]): T | undefined;
|
|
7
|
+
getProp<P extends keyof T>(prop: P, id?: T[K]): T[P] | undefined;
|
|
8
|
+
addItems(newItems: T[]): void;
|
|
9
9
|
/**
|
|
10
10
|
* Register a callback to fire when the current item changes.
|
|
11
11
|
* Returns an unsubscribe function.
|
|
12
12
|
*/
|
|
13
|
-
onChange: (cb: (newId:
|
|
13
|
+
onChange: (cb: (newId: T[K], oldId: T[K]) => void) => () => void;
|
|
14
14
|
/**
|
|
15
15
|
* Inject (or replace) the persistence driver at runtime.
|
|
16
16
|
* Call this inside your plugin factory so the real driver
|
|
@@ -20,7 +20,7 @@ export type ConfigStore<T = unknown> = {
|
|
|
20
20
|
*/
|
|
21
21
|
setDriver: (driver: PersistenceDriver) => void;
|
|
22
22
|
};
|
|
23
|
-
export interface ConfigStoreOptions<T =
|
|
23
|
+
export interface ConfigStoreOptions<T, K extends keyof T = keyof T> {
|
|
24
24
|
/** Array of available items */
|
|
25
25
|
items: readonly T[];
|
|
26
26
|
/** Storage key used by the persistence driver */
|
|
@@ -28,7 +28,7 @@ export interface ConfigStoreOptions<T = unknown> {
|
|
|
28
28
|
/** Display name for logging (e.g., "Theme", "Language") */
|
|
29
29
|
displayName: string;
|
|
30
30
|
/** Key to use as identifier (e.g., "code", "name") */
|
|
31
|
-
idKey:
|
|
31
|
+
idKey: K;
|
|
32
32
|
/** Icon for logs */
|
|
33
33
|
icon?: string;
|
|
34
34
|
/** Persistence driver */
|
|
@@ -37,6 +37,6 @@ export interface ConfigStoreOptions<T = unknown> {
|
|
|
37
37
|
/**
|
|
38
38
|
* Creates a reactive configuration store with persistence
|
|
39
39
|
*/
|
|
40
|
-
export declare function createConfigStore<T
|
|
41
|
-
options: ConfigStoreOptions<T>,
|
|
42
|
-
): ConfigStore<T>;
|
|
40
|
+
export declare function createConfigStore<T, K extends keyof T>(
|
|
41
|
+
options: ConfigStoreOptions<T, K>,
|
|
42
|
+
): ConfigStore<T, K>;
|
|
@@ -16,7 +16,7 @@ class ConfigStoreImpl {
|
|
|
16
16
|
options;
|
|
17
17
|
this.#driver = driver;
|
|
18
18
|
this.available = [...items];
|
|
19
|
-
this.current = items[0]
|
|
19
|
+
this.current = items[0][idKey];
|
|
20
20
|
const saved = this.#driver.get(storageKey);
|
|
21
21
|
// Only load saved if it actually exists in our available items
|
|
22
22
|
if (saved && this.get(saved)) {
|
|
@@ -7,6 +7,19 @@ export declare const createCookieDriver: (options?: {
|
|
|
7
7
|
path?: string;
|
|
8
8
|
maxAge?: number;
|
|
9
9
|
sameSite?: "Lax" | "Strict" | "None";
|
|
10
|
+
secure?: boolean;
|
|
10
11
|
}) => PersistenceDriver;
|
|
11
12
|
/** Default cookie driver singleton (path='/') */
|
|
12
13
|
export declare const cookieDriver: PersistenceDriver;
|
|
14
|
+
/**
|
|
15
|
+
* Wraps a persistence driver so all keys are prefixed with the given string.
|
|
16
|
+
* Use this to namespace storage keys and avoid cross-app collisions.
|
|
17
|
+
*
|
|
18
|
+
* @param driver - The underlying persistence driver to wrap
|
|
19
|
+
* @param prefix - The prefix to prepend to all keys (e.g., "rl:")
|
|
20
|
+
* @returns A new PersistenceDriver that transparently namespaces keys
|
|
21
|
+
*/
|
|
22
|
+
export declare function namespaced(
|
|
23
|
+
driver: PersistenceDriver,
|
|
24
|
+
prefix: string,
|
|
25
|
+
): PersistenceDriver;
|
|
@@ -72,6 +72,9 @@ export const createCookieDriver = (options = {}) => ({
|
|
|
72
72
|
if (options.sameSite) {
|
|
73
73
|
cookie += `; samesite=${options.sameSite}`;
|
|
74
74
|
}
|
|
75
|
+
if (options.secure) {
|
|
76
|
+
cookie += "; Secure";
|
|
77
|
+
}
|
|
75
78
|
document.cookie = cookie;
|
|
76
79
|
},
|
|
77
80
|
remove: (key) => {
|
|
@@ -85,3 +88,18 @@ export const createCookieDriver = (options = {}) => ({
|
|
|
85
88
|
export const cookieDriver = createCookieDriver({
|
|
86
89
|
path: "/",
|
|
87
90
|
});
|
|
91
|
+
/**
|
|
92
|
+
* Wraps a persistence driver so all keys are prefixed with the given string.
|
|
93
|
+
* Use this to namespace storage keys and avoid cross-app collisions.
|
|
94
|
+
*
|
|
95
|
+
* @param driver - The underlying persistence driver to wrap
|
|
96
|
+
* @param prefix - The prefix to prepend to all keys (e.g., "rl:")
|
|
97
|
+
* @returns A new PersistenceDriver that transparently namespaces keys
|
|
98
|
+
*/
|
|
99
|
+
export function namespaced(driver, prefix) {
|
|
100
|
+
return {
|
|
101
|
+
get: (key) => driver.get(`${prefix}${key}`),
|
|
102
|
+
set: (key, value) => driver.set(`${prefix}${key}`, value),
|
|
103
|
+
remove: (key) => driver.remove(`${prefix}${key}`),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createInMemoryDriver } from "../persistence/drivers.js";
|
|
1
2
|
/**
|
|
2
3
|
* The global store registry.
|
|
3
4
|
* Pre-populated with built-in stores by RuneProvider.
|
|
@@ -127,7 +128,10 @@ export function initializeStores(
|
|
|
127
128
|
if (entry.conditional && !(entry.conditional in pluginConfig)) {
|
|
128
129
|
continue;
|
|
129
130
|
}
|
|
130
|
-
const
|
|
131
|
+
const effectiveDriver = entry.noPersistence
|
|
132
|
+
? createInMemoryDriver()
|
|
133
|
+
: driver;
|
|
134
|
+
const store = entry.factory(pluginConfig, effectiveDriver, stores);
|
|
131
135
|
if (store === null && entry.optional) {
|
|
132
136
|
continue; // Optional store chose not to create
|
|
133
137
|
}
|
package/dist/mod.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
interface Props {
|
|
8
8
|
/** ConfigStore instance to bind to */
|
|
9
|
-
store: ConfigStore<T>;
|
|
9
|
+
store: ConfigStore<T, keyof T>;
|
|
10
10
|
/** Key to use as identifier (e.g., "name", "code") */
|
|
11
11
|
idKey: string;
|
|
12
12
|
/** Optional subset of IDs to show */
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
}: Props = $props();
|
|
36
36
|
|
|
37
37
|
const userDictionary =
|
|
38
|
-
getContext<Record<string,
|
|
38
|
+
getContext<Record<string, (...args: unknown[]) => string>>("rl:dictionary") ?? {};
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Resolve a label for an item through the message chain:
|
|
@@ -43,11 +43,11 @@
|
|
|
43
43
|
* 2. Paraglide messages
|
|
44
44
|
* 3. Raw key fallback
|
|
45
45
|
*/
|
|
46
|
-
function resolveLabel(item:
|
|
47
|
-
const key = String(item[idKey]);
|
|
46
|
+
function resolveLabel(item: T): string {
|
|
47
|
+
const key = String((item as Record<string, unknown>)[idKey]);
|
|
48
48
|
if (typeof userDictionary[key] === "function")
|
|
49
49
|
return userDictionary[key]();
|
|
50
|
-
if (typeof (m as
|
|
50
|
+
if (typeof (m as Record<string, (...args: unknown[]) => string>)[key] === "function") return (m as Record<string, (...args: unknown[]) => string>)[key]();
|
|
51
51
|
return key;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
|
|
56
56
|
let available = $derived(
|
|
57
57
|
filterKeys.length > 0
|
|
58
|
-
? store.available.filter((item:
|
|
59
|
-
filterKeys.includes(String(item[idKey])),
|
|
58
|
+
? store.available.filter((item: T) =>
|
|
59
|
+
filterKeys.includes(String((item as Record<string, unknown>)[idKey])),
|
|
60
60
|
)
|
|
61
61
|
: store.available,
|
|
62
62
|
);
|
|
@@ -3,7 +3,7 @@ import { type Snippet } from "svelte";
|
|
|
3
3
|
declare function $$render<T>(): {
|
|
4
4
|
props: {
|
|
5
5
|
/** ConfigStore instance to bind to */
|
|
6
|
-
store: ConfigStore<T>;
|
|
6
|
+
store: ConfigStore<T, keyof T>;
|
|
7
7
|
/** Key to use as identifier (e.g., "name", "code") */
|
|
8
8
|
idKey: string;
|
|
9
9
|
/** Optional subset of IDs to show */
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type ConfigStore,
|
|
3
2
|
createConfigStore,
|
|
4
3
|
getLanguageStore,
|
|
5
4
|
} from "../../../kernel/src/mod.js";
|
|
@@ -49,6 +48,11 @@ export declare const LANGUAGES: readonly [{
|
|
|
49
48
|
readonly code: "vi";
|
|
50
49
|
readonly flag: "🇻🇳";
|
|
51
50
|
}];
|
|
52
|
-
export declare const languageStore: ConfigStore<
|
|
53
|
-
|
|
51
|
+
export declare const languageStore: import("../../../mod.ts").ConfigStore<
|
|
52
|
+
Language,
|
|
53
|
+
"code"
|
|
54
|
+
>;
|
|
55
|
+
export type LanguageStore = ReturnType<
|
|
56
|
+
typeof createConfigStore<Language, "code">
|
|
57
|
+
>;
|
|
54
58
|
export { getLanguageStore };
|
|
@@ -5,6 +5,9 @@ export type { Theme };
|
|
|
5
5
|
* Resolver to get the display name of a theme in the current locale
|
|
6
6
|
*/
|
|
7
7
|
export declare const getThemeName: (option: Theme) => string;
|
|
8
|
-
export declare const themeStore: ConfigStore<
|
|
9
|
-
|
|
8
|
+
export declare const themeStore: import("../../../mod.ts").ConfigStore<
|
|
9
|
+
Theme,
|
|
10
|
+
"name"
|
|
11
|
+
>;
|
|
12
|
+
export type ThemeStore = ReturnType<typeof createConfigStore<Theme, "name">>;
|
|
10
13
|
export { getThemeStore };
|
|
@@ -9,9 +9,9 @@ export interface CommandServices {
|
|
|
9
9
|
appStore: unknown;
|
|
10
10
|
apiStore: unknown;
|
|
11
11
|
toastStore: IToastStore;
|
|
12
|
-
themeStore: ConfigStore<unknown>;
|
|
13
|
-
languageStore: ConfigStore<unknown>;
|
|
14
|
-
currencyStore: ConfigStore<unknown>;
|
|
12
|
+
themeStore: ConfigStore<Record<string, unknown>, string>;
|
|
13
|
+
languageStore: ConfigStore<Record<string, unknown>, string>;
|
|
14
|
+
currencyStore: ConfigStore<Record<string, unknown>, string>;
|
|
15
15
|
}
|
|
16
16
|
export declare class CommandStore {
|
|
17
17
|
#private;
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getToastStore, portal } from "../../../../kernel/src/mod.js";
|
|
3
|
-
import { flip } from "svelte/animate";
|
|
4
|
-
import { fade, fly } from "svelte/transition";
|
|
5
3
|
|
|
6
4
|
const toastStore = getToastStore();
|
|
7
5
|
|
|
@@ -28,7 +26,7 @@
|
|
|
28
26
|
"bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400",
|
|
29
27
|
iconColor: "text-amber-500",
|
|
30
28
|
iconPath:
|
|
31
|
-
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
|
|
29
|
+
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
|
|
32
30
|
},
|
|
33
31
|
error: {
|
|
34
32
|
colors: "bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400",
|
|
@@ -39,6 +37,15 @@
|
|
|
39
37
|
};
|
|
40
38
|
</script>
|
|
41
39
|
|
|
40
|
+
<style>
|
|
41
|
+
@media (prefers-reduced-motion: reduce) {
|
|
42
|
+
.toast-item {
|
|
43
|
+
animation: none !important;
|
|
44
|
+
transition: none !important;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
48
|
+
|
|
42
49
|
<div
|
|
43
50
|
use:portal
|
|
44
51
|
class="toast toast-end toast-bottom z-[9999] p-4 gap-3 pointer-events-none"
|
|
@@ -47,8 +54,10 @@
|
|
|
47
54
|
{@const styles = typeDetails[toast.type] || typeDetails.info}
|
|
48
55
|
|
|
49
56
|
<div
|
|
50
|
-
class="pointer-events-auto relative flex w-full max-w-sm items-start gap-4 overflow-hidden rounded-xl border p-4 shadow-lg backdrop-blur-xl transition-all duration-300 sm:min-w-[320px] {styles.colors} animate-in fade-in slide-in-from-bottom-4 zoom-in-95 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right-full"
|
|
57
|
+
class="toast-item pointer-events-auto relative flex w-full max-w-sm items-start gap-4 overflow-hidden rounded-xl border p-4 shadow-lg backdrop-blur-xl transition-all duration-300 sm:min-w-[320px] {styles.colors} animate-in fade-in slide-in-from-bottom-4 zoom-in-95 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right-full"
|
|
51
58
|
role="alert"
|
|
59
|
+
onmouseenter={() => toastStore.pause(toast.id)}
|
|
60
|
+
onmouseleave={() => toastStore.resume(toast.id)}
|
|
52
61
|
>
|
|
53
62
|
<!-- Icon -->
|
|
54
63
|
<div class="shrink-0 {styles.iconColor} mt-0.5">
|
|
@@ -5,15 +5,24 @@ import { getToastStore } from "../../../../kernel/src/mod.js";
|
|
|
5
5
|
import type { Toast, ToastType } from "../../../../kernel/src/mod.js";
|
|
6
6
|
export type { Toast, ToastType };
|
|
7
7
|
export declare class ToastStore {
|
|
8
|
+
#private;
|
|
8
9
|
toasts: Toast[];
|
|
9
10
|
/**
|
|
10
|
-
* Add a new toast
|
|
11
|
+
* Add a new toast with FIFO eviction capped at exactly 5
|
|
11
12
|
*/
|
|
12
13
|
send(message: string, type?: ToastType, duration?: number): void;
|
|
13
14
|
/**
|
|
14
|
-
* Remove a toast by id
|
|
15
|
+
* Remove a toast by id and clean up its active timer
|
|
15
16
|
*/
|
|
16
17
|
dismiss(id: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* Pause the auto-dismiss timer for a toast (e.g. on hover)
|
|
20
|
+
*/
|
|
21
|
+
pause(id: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Resume the auto-dismiss timer for a toast (e.g. on mouse leave)
|
|
24
|
+
*/
|
|
25
|
+
resume(id: string): void;
|
|
17
26
|
success(msg: string): void;
|
|
18
27
|
error(msg: string): void;
|
|
19
28
|
warn(msg: string): void;
|
|
@@ -4,25 +4,76 @@
|
|
|
4
4
|
import { getToastStore } from "../../../../kernel/src/mod.js";
|
|
5
5
|
export class ToastStore {
|
|
6
6
|
toasts = $state([]);
|
|
7
|
+
#meta = new Map();
|
|
7
8
|
/**
|
|
8
|
-
* Add a new toast
|
|
9
|
+
* Add a new toast with FIFO eviction capped at exactly 5
|
|
9
10
|
*/
|
|
10
11
|
send(message, type = "info", duration = 3000) {
|
|
11
|
-
|
|
12
|
+
// Evict oldest if we are at cap (FIFO)
|
|
13
|
+
while (this.toasts.length >= 5) {
|
|
14
|
+
const oldest = this.toasts[0];
|
|
15
|
+
this.dismiss(oldest.id);
|
|
16
|
+
}
|
|
17
|
+
// Generate id: use crypto.randomUUID if available, else fallback
|
|
18
|
+
let id;
|
|
19
|
+
if (
|
|
20
|
+
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
21
|
+
) {
|
|
22
|
+
id = crypto.randomUUID();
|
|
23
|
+
} else {
|
|
24
|
+
id = "toast-" + Math.random().toString(36).substring(2, 9) + "-" +
|
|
25
|
+
Date.now();
|
|
26
|
+
}
|
|
12
27
|
const toast = { id, message, type, duration };
|
|
13
28
|
this.toasts.push(toast);
|
|
14
29
|
if (duration > 0) {
|
|
15
|
-
|
|
30
|
+
const meta = {
|
|
31
|
+
remaining: duration,
|
|
32
|
+
lastStarted: Date.now(),
|
|
33
|
+
};
|
|
34
|
+
this.#meta.set(id, meta);
|
|
35
|
+
meta.timeoutId = setTimeout(() => {
|
|
16
36
|
this.dismiss(id);
|
|
17
37
|
}, duration);
|
|
18
38
|
}
|
|
19
39
|
}
|
|
20
40
|
/**
|
|
21
|
-
* Remove a toast by id
|
|
41
|
+
* Remove a toast by id and clean up its active timer
|
|
22
42
|
*/
|
|
23
43
|
dismiss(id) {
|
|
44
|
+
const meta = this.#meta.get(id);
|
|
45
|
+
if (meta) {
|
|
46
|
+
if (meta.timeoutId) {
|
|
47
|
+
clearTimeout(meta.timeoutId);
|
|
48
|
+
}
|
|
49
|
+
this.#meta.delete(id);
|
|
50
|
+
}
|
|
24
51
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
|
25
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Pause the auto-dismiss timer for a toast (e.g. on hover)
|
|
55
|
+
*/
|
|
56
|
+
pause(id) {
|
|
57
|
+
const meta = this.#meta.get(id);
|
|
58
|
+
if (meta && meta.timeoutId) {
|
|
59
|
+
clearTimeout(meta.timeoutId);
|
|
60
|
+
const elapsed = Date.now() - meta.lastStarted;
|
|
61
|
+
meta.remaining = Math.max(0, meta.remaining - elapsed);
|
|
62
|
+
meta.timeoutId = undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resume the auto-dismiss timer for a toast (e.g. on mouse leave)
|
|
67
|
+
*/
|
|
68
|
+
resume(id) {
|
|
69
|
+
const meta = this.#meta.get(id);
|
|
70
|
+
if (meta && !meta.timeoutId && meta.remaining > 0) {
|
|
71
|
+
meta.lastStarted = Date.now();
|
|
72
|
+
meta.timeoutId = setTimeout(() => {
|
|
73
|
+
this.dismiss(id);
|
|
74
|
+
}, meta.remaining);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
26
77
|
// Helper methods
|
|
27
78
|
success(msg) {
|
|
28
79
|
this.send(msg, "success");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vite-plus/test";
|
|
2
|
+
import { ToastStore } from "./store.svelte.js";
|
|
3
|
+
describe("ToastStore", () => {
|
|
4
|
+
it("should initialize with empty toasts", () => {
|
|
5
|
+
const store = new ToastStore();
|
|
6
|
+
expect(store.toasts).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
it("should cap at exactly 5 toasts and evict the oldest first (FIFO)", () => {
|
|
9
|
+
const store = new ToastStore();
|
|
10
|
+
store.send("Msg 1");
|
|
11
|
+
store.send("Msg 2");
|
|
12
|
+
store.send("Msg 3");
|
|
13
|
+
store.send("Msg 4");
|
|
14
|
+
store.send("Msg 5");
|
|
15
|
+
expect(store.toasts.length).toBe(5);
|
|
16
|
+
expect(store.toasts[0].message).toBe("Msg 1");
|
|
17
|
+
store.send("Msg 6");
|
|
18
|
+
expect(store.toasts.length).toBe(5);
|
|
19
|
+
expect(store.toasts[0].message).toBe("Msg 2");
|
|
20
|
+
expect(store.toasts.map((t) => t.message)).toEqual([
|
|
21
|
+
"Msg 2",
|
|
22
|
+
"Msg 3",
|
|
23
|
+
"Msg 4",
|
|
24
|
+
"Msg 5",
|
|
25
|
+
"Msg 6",
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
it("should generate valid fallback IDs when crypto is not present", () => {
|
|
29
|
+
const store = new ToastStore();
|
|
30
|
+
const originalCrypto = globalThis.crypto;
|
|
31
|
+
// Temporarily hide crypto
|
|
32
|
+
// @ts-ignore: Mocking globalThis.crypto for test
|
|
33
|
+
delete globalThis.crypto;
|
|
34
|
+
try {
|
|
35
|
+
store.send("No crypto id test");
|
|
36
|
+
expect(store.toasts.length).toBe(1);
|
|
37
|
+
expect(store.toasts[0].id).toContain("toast-");
|
|
38
|
+
} finally {
|
|
39
|
+
// Restore crypto
|
|
40
|
+
globalThis.crypto = originalCrypto;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
it("should dismiss a toast manually and clean up its timer", () => {
|
|
44
|
+
vi.useFakeTimers();
|
|
45
|
+
try {
|
|
46
|
+
const store = new ToastStore();
|
|
47
|
+
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
|
48
|
+
store.send("Timer test", "info", 5000);
|
|
49
|
+
const toastId = store.toasts[0].id;
|
|
50
|
+
store.dismiss(toastId);
|
|
51
|
+
expect(store.toasts).toEqual([]);
|
|
52
|
+
expect(clearTimeoutSpy).toHaveBeenCalled();
|
|
53
|
+
clearTimeoutSpy.mockRestore();
|
|
54
|
+
} finally {
|
|
55
|
+
vi.useRealTimers();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
it("should pause auto-dismiss timer on pause() and resume on resume()", () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
try {
|
|
61
|
+
const store = new ToastStore();
|
|
62
|
+
store.send("Pause test", "info", 1000);
|
|
63
|
+
const toastId = store.toasts[0].id;
|
|
64
|
+
// Advance time by 400ms
|
|
65
|
+
vi.advanceTimersByTime(400);
|
|
66
|
+
// Hover: pause
|
|
67
|
+
store.pause(toastId);
|
|
68
|
+
// Advance time by another 1000ms. Toast should still be present because it was paused!
|
|
69
|
+
vi.advanceTimersByTime(1000);
|
|
70
|
+
expect(store.toasts.length).toBe(1);
|
|
71
|
+
// Leave: resume. Remaining duration should be 600ms.
|
|
72
|
+
store.resume(toastId);
|
|
73
|
+
// Advance 500ms. Still present.
|
|
74
|
+
vi.advanceTimersByTime(500);
|
|
75
|
+
expect(store.toasts.length).toBe(1);
|
|
76
|
+
// Advance final 100ms. Toast should be dismissed.
|
|
77
|
+
vi.advanceTimersByTime(100);
|
|
78
|
+
expect(store.toasts.length).toBe(0);
|
|
79
|
+
} finally {
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -27,7 +27,7 @@ declare function convertAmount(
|
|
|
27
27
|
fromCode: string,
|
|
28
28
|
toCode?: string,
|
|
29
29
|
): number;
|
|
30
|
-
export declare const currencyStore: ConfigStore<Currency> & {
|
|
30
|
+
export declare const currencyStore: ConfigStore<Currency, "code"> & {
|
|
31
31
|
addCurrency: typeof addCurrency;
|
|
32
32
|
convertAmount: typeof convertAmount;
|
|
33
33
|
readonly canConvert: boolean;
|
|
@@ -29,8 +29,7 @@ export declare class ExchangeRateStore {
|
|
|
29
29
|
*/
|
|
30
30
|
getRate(fromCode: string, toCode: string): ScaledRate | number | undefined;
|
|
31
31
|
/**
|
|
32
|
-
* Internal conversion logic that
|
|
33
|
-
* Delegates all math to ConversionStrategies (directConversion, inverseConversion, triangularConversion).
|
|
32
|
+
* Internal conversion logic that reads directly from the derived cross-rate matrix.
|
|
34
33
|
*/
|
|
35
34
|
convertAmount(amount: number, fromCode: string, toCode: string): number;
|
|
36
35
|
}
|
|
@@ -1,20 +1,59 @@
|
|
|
1
1
|
// sdk/state/src/exchange-rate.svelte.ts
|
|
2
2
|
import { getContext } from "svelte";
|
|
3
|
-
import {
|
|
4
|
-
convertMoney,
|
|
5
|
-
createMoney,
|
|
6
|
-
CURRENCY_MAP,
|
|
7
|
-
directConversion,
|
|
8
|
-
inverseConversion,
|
|
9
|
-
resolveRate,
|
|
10
|
-
scaledRate,
|
|
11
|
-
toMoneySnapshot,
|
|
12
|
-
triangularConversion,
|
|
13
|
-
} from "./mod.js";
|
|
3
|
+
import { CURRENCY_MAP, resolveRate, scaledRate } from "./mod.js";
|
|
14
4
|
import { RUNE_LAB_CONTEXT } from "../../../../kernel/src/mod.js";
|
|
15
5
|
export class ExchangeRateStore {
|
|
16
6
|
#rates = $state({});
|
|
17
7
|
#baseCurrency = $state("USD");
|
|
8
|
+
/**
|
|
9
|
+
* Combined reactive cross-rate matrix: from -> to -> floatRate
|
|
10
|
+
*/
|
|
11
|
+
#matrix = $derived.by(() => {
|
|
12
|
+
const base = this.#baseCurrency;
|
|
13
|
+
const rates = this.#rates;
|
|
14
|
+
if (!rates || Object.keys(rates).length === 0) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
const currencies = new Set([base, ...Object.keys(rates)]);
|
|
18
|
+
const mat = {};
|
|
19
|
+
for (const from of currencies) {
|
|
20
|
+
mat[from] = {};
|
|
21
|
+
for (const to of currencies) {
|
|
22
|
+
if (from === to) {
|
|
23
|
+
mat[from][to] = 1.0;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Direct: base -> to
|
|
27
|
+
if (from === base) {
|
|
28
|
+
const rateTo = rates[to];
|
|
29
|
+
if (rateTo !== undefined) {
|
|
30
|
+
mat[from][to] = resolveRate(rateTo);
|
|
31
|
+
}
|
|
32
|
+
} // Inverse: from -> base
|
|
33
|
+
else if (to === base) {
|
|
34
|
+
const rateFrom = rates[from];
|
|
35
|
+
if (rateFrom !== undefined) {
|
|
36
|
+
const r = resolveRate(rateFrom);
|
|
37
|
+
if (r !== 0) {
|
|
38
|
+
mat[from][to] = 1.0 / r;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} // Triangular: from -> base -> to
|
|
42
|
+
else {
|
|
43
|
+
const rateFrom = rates[from];
|
|
44
|
+
const rateTo = rates[to];
|
|
45
|
+
if (rateFrom !== undefined && rateTo !== undefined) {
|
|
46
|
+
const rFrom = resolveRate(rateFrom);
|
|
47
|
+
if (rFrom !== 0) {
|
|
48
|
+
const rTo = resolveRate(rateTo);
|
|
49
|
+
mat[from][to] = rTo / rFrom;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return mat;
|
|
56
|
+
});
|
|
18
57
|
/**
|
|
19
58
|
* The current exchange rates relative to the base currency.
|
|
20
59
|
*/
|
|
@@ -69,56 +108,26 @@ export class ExchangeRateStore {
|
|
|
69
108
|
if (!this.hasRates) {
|
|
70
109
|
return undefined;
|
|
71
110
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const oneUnit = Math.pow(
|
|
75
|
-
Array.isArray(fromCurrency.base)
|
|
76
|
-
? fromCurrency.base[0]
|
|
77
|
-
: fromCurrency.base,
|
|
78
|
-
fromCurrency.exponent,
|
|
79
|
-
);
|
|
80
|
-
const currentRates = this.#rates;
|
|
81
|
-
const currentBase = this.#baseCurrency;
|
|
82
|
-
const sourceMoney = createMoney(oneUnit, fromCode);
|
|
83
|
-
let targetMoney;
|
|
84
|
-
if (fromCode === currentBase) {
|
|
85
|
-
// Direct: base → target
|
|
86
|
-
targetMoney = convertMoney(sourceMoney, toCode, currentRates);
|
|
87
|
-
} else if (toCode === currentBase) {
|
|
88
|
-
// Inverse: target → base
|
|
89
|
-
const rateToBase = currentRates[fromCode];
|
|
90
|
-
if (!rateToBase) {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
const r = resolveRate(rateToBase);
|
|
94
|
-
targetMoney = convertMoney(sourceMoney, toCode, {
|
|
95
|
-
[toCode]: scaledRate(1 / r, 6),
|
|
96
|
-
});
|
|
97
|
-
} else {
|
|
98
|
-
// Triangular: source → base → target
|
|
99
|
-
const rateToBase = currentRates[fromCode];
|
|
100
|
-
const rateToTarget = currentRates[toCode];
|
|
101
|
-
if (!rateToBase || !rateToTarget) {
|
|
102
|
-
return undefined;
|
|
103
|
-
}
|
|
104
|
-
const r1 = resolveRate(rateToBase);
|
|
105
|
-
const r2 = resolveRate(rateToTarget);
|
|
106
|
-
targetMoney = convertMoney(sourceMoney, toCode, {
|
|
107
|
-
[toCode]: scaledRate(r2 / r1, 6),
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
const snapshot = toMoneySnapshot(targetMoney);
|
|
111
|
-
return {
|
|
112
|
-
amount: snapshot.amount,
|
|
113
|
-
scale: snapshot.scale,
|
|
114
|
-
};
|
|
115
|
-
} catch (_err) {
|
|
111
|
+
const rate = this.#matrix[fromCode]?.[toCode];
|
|
112
|
+
if (rate === undefined) {
|
|
116
113
|
return undefined;
|
|
117
114
|
}
|
|
115
|
+
let scale = 6;
|
|
116
|
+
if (fromCode === this.#baseCurrency) {
|
|
117
|
+
const rateEntry = this.#rates[toCode];
|
|
118
|
+
const rateScale = (rateEntry && typeof rateEntry === "object")
|
|
119
|
+
? rateEntry.scale
|
|
120
|
+
: 4;
|
|
121
|
+
const exponent = toCurrency.exponent;
|
|
122
|
+
scale = exponent + rateScale;
|
|
123
|
+
} else {
|
|
124
|
+
const exponent = toCurrency.exponent;
|
|
125
|
+
scale = exponent + 6;
|
|
126
|
+
}
|
|
127
|
+
return scaledRate(rate, scale);
|
|
118
128
|
}
|
|
119
129
|
/**
|
|
120
|
-
* Internal conversion logic that
|
|
121
|
-
* Delegates all math to ConversionStrategies (directConversion, inverseConversion, triangularConversion).
|
|
130
|
+
* Internal conversion logic that reads directly from the derived cross-rate matrix.
|
|
122
131
|
*/
|
|
123
132
|
convertAmount(amount, fromCode, toCode) {
|
|
124
133
|
if (fromCode === toCode) {
|
|
@@ -127,33 +136,11 @@ export class ExchangeRateStore {
|
|
|
127
136
|
if (!this.hasRates) {
|
|
128
137
|
return amount;
|
|
129
138
|
}
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
const rateToTarget = this.#rates[toCode];
|
|
133
|
-
if (!rateToTarget) {
|
|
134
|
-
return amount;
|
|
135
|
-
}
|
|
136
|
-
return directConversion(amount, resolveRate(rateToTarget));
|
|
137
|
-
}
|
|
138
|
-
// Inverse: Target → Base
|
|
139
|
-
if (toCode === this.#baseCurrency) {
|
|
140
|
-
const rateFromSource = this.#rates[fromCode];
|
|
141
|
-
if (!rateFromSource) {
|
|
142
|
-
return amount;
|
|
143
|
-
}
|
|
144
|
-
return inverseConversion(amount, resolveRate(rateFromSource));
|
|
145
|
-
}
|
|
146
|
-
// Triangulation: From → Base → To
|
|
147
|
-
const rateFromSource = this.#rates[fromCode];
|
|
148
|
-
const rateToTarget = this.#rates[toCode];
|
|
149
|
-
if (!rateFromSource || !rateToTarget) {
|
|
139
|
+
const rate = this.#matrix[fromCode]?.[toCode];
|
|
140
|
+
if (rate === undefined) {
|
|
150
141
|
return amount;
|
|
151
142
|
}
|
|
152
|
-
return
|
|
153
|
-
amount,
|
|
154
|
-
resolveRate(rateFromSource),
|
|
155
|
-
resolveRate(rateToTarget),
|
|
156
|
-
);
|
|
143
|
+
return Math.round(amount * rate);
|
|
157
144
|
}
|
|
158
145
|
}
|
|
159
146
|
/**
|
|
@@ -166,5 +153,11 @@ export function createExchangeRateStore() {
|
|
|
166
153
|
* Consumer to retrieve the ExchangeRateStore from context.
|
|
167
154
|
*/
|
|
168
155
|
export function getExchangeRateStore() {
|
|
169
|
-
|
|
156
|
+
const store = getContext(RUNE_LAB_CONTEXT.exchangeRate);
|
|
157
|
+
if (!store) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"[rune-lab] getExchangeRateStore() found no ExchangeRateStore. Did you register MoneyPlugin in <RuneProvider plugins={[…]}>?",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return store;
|
|
170
163
|
}
|
|
@@ -26,8 +26,23 @@ import {
|
|
|
26
26
|
*/
|
|
27
27
|
export function useMoney() {
|
|
28
28
|
const currencyStore = getContext(RUNE_LAB_CONTEXT.currency);
|
|
29
|
+
if (!currencyStore) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"[rune-lab] useMoney() found no CurrencyStore. Did you register MoneyPlugin in <RuneProvider plugins={[…]}>?",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
29
34
|
const languageStore = getContext(RUNE_LAB_CONTEXT.language);
|
|
35
|
+
if (!languageStore) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"[rune-lab] useMoney() found no LanguageStore. Did you register LayoutPlugin in <RuneProvider plugins={[…]}>?",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
30
40
|
const exchangeRateStore = getContext(RUNE_LAB_CONTEXT.exchangeRate);
|
|
41
|
+
if (!exchangeRateStore) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"[rune-lab] useMoney() found no ExchangeRateStore. Did you register MoneyPlugin in <RuneProvider plugins={[…]}>?",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
31
46
|
/**
|
|
32
47
|
* Convert amount to a Dinero object using current currency
|
|
33
48
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rune-lab",
|
|
3
|
-
"version": "0.4.8
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "Modern toolkit for Svelte 5 Runes applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Fernando Bryan Reza Campos",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"vite-plus": "npm:vite-plus",
|
|
35
35
|
"svelte": "npm:svelte",
|
|
36
|
-
"@sveltejs/package": "npm:@sveltejs/package
|
|
36
|
+
"@sveltejs/package": "npm:@sveltejs/package",
|
|
37
37
|
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte",
|
|
38
38
|
"jsdom": "npm:jsdom",
|
|
39
39
|
"@testing-library/jest-dom": "npm:@testing-library/jest-dom"
|
|
@@ -45,4 +45,4 @@
|
|
|
45
45
|
"ui",
|
|
46
46
|
"components"
|
|
47
47
|
]
|
|
48
|
-
}
|
|
48
|
+
}
|