@bootnodedev/canton-dappbooster 0.3.0

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/index.js ADDED
@@ -0,0 +1,1675 @@
1
+ import { i as cx, r as SR_ONLY, t as asRecord } from "./json-CVZFwEw1.js";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
+ import * as dialog from "@zag-js/dialog";
5
+ import { Portal, normalizeProps, useMachine } from "@zag-js/react";
6
+ //#region src/components/ExplorerLink/anatomy.ts
7
+ const anatomy$5 = { parts: { root: "cnc-explorer-link" } };
8
+ //#endregion
9
+ //#region src/icons/Svg.tsx
10
+ const Svg = ({ children }) => /* @__PURE__ */ jsx("svg", {
11
+ width: "1em",
12
+ height: "1em",
13
+ viewBox: "0 0 24 24",
14
+ fill: "none",
15
+ stroke: "currentColor",
16
+ strokeWidth: 1.8,
17
+ strokeLinecap: "round",
18
+ strokeLinejoin: "round",
19
+ "aria-hidden": "true",
20
+ focusable: "false",
21
+ children
22
+ });
23
+ //#endregion
24
+ //#region src/icons/CheckIcon.tsx
25
+ const CheckIcon = () => /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" }) });
26
+ //#endregion
27
+ //#region src/icons/CloseIcon.tsx
28
+ const CloseIcon = () => /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M6 6l12 12M18 6L6 18" }) });
29
+ //#endregion
30
+ //#region src/icons/CopyIcon.tsx
31
+ const CopyIcon = () => /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("rect", {
32
+ x: "9",
33
+ y: "9",
34
+ width: "11",
35
+ height: "11",
36
+ rx: "2"
37
+ }), /* @__PURE__ */ jsx("path", { d: "M5 15V5a2 2 0 0 1 2-2h10" })] });
38
+ //#endregion
39
+ //#region src/icons/ExternalLinkIcon.tsx
40
+ const ExternalLinkIcon = () => /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M14 4h6v6M20 4 11 13" }), /* @__PURE__ */ jsx("path", { d: "M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4" })] });
41
+ //#endregion
42
+ //#region src/icons/LockIcon.tsx
43
+ const LockIcon = () => /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("rect", {
44
+ x: "4",
45
+ y: "10",
46
+ width: "16",
47
+ height: "11",
48
+ rx: "2"
49
+ }), /* @__PURE__ */ jsx("path", { d: "M8 10V7a4 4 0 0 1 8 0v3" })] });
50
+ //#endregion
51
+ //#region src/icons/SearchIcon.tsx
52
+ const SearchIcon = () => /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("circle", {
53
+ cx: "11",
54
+ cy: "11",
55
+ r: "7"
56
+ }), /* @__PURE__ */ jsx("path", { d: "M20 20l-3.9-3.9" })] });
57
+ //#endregion
58
+ //#region src/components/ExplorerLink/index.tsx
59
+ /**
60
+ * An icon-only external link to a block explorer. Composes no URLs; pair it with `useExplorerLink`
61
+ * or `getExplorerLink`, which turn an identifier into an href.
62
+ *
63
+ * Reach for it directly when the link stands alone. Inside `Identifier` it is already the `href`
64
+ * slot, so pass the href there instead of nesting one.
65
+ *
66
+ * @example
67
+ * <ExplorerLink href="https://scan.example/party/nico" aria-label="View party id in explorer" />
68
+ *
69
+ * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/ExplorerLink/anatomy.ts) for the part classes and state attributes the theme selects.
70
+ *
71
+ * @category Components
72
+ */
73
+ const ExplorerLink = ({ className, href, ...rest }) => {
74
+ return /* @__PURE__ */ jsx("a", {
75
+ ...rest,
76
+ className: cx(anatomy$5.parts.root, className),
77
+ href,
78
+ rel: "noopener noreferrer",
79
+ target: "_blank",
80
+ children: /* @__PURE__ */ jsx(ExternalLinkIcon, {})
81
+ });
82
+ };
83
+ //#endregion
84
+ //#region src/components/Identifier/anatomy.ts
85
+ const anatomy$4 = {
86
+ parts: {
87
+ root: "cnc-identifier",
88
+ value: "cnc-identifier__value",
89
+ copy: "cnc-identifier__copy",
90
+ link: "cnc-identifier__link",
91
+ status: "cnc-identifier__status"
92
+ },
93
+ states: { copy: "data-state" }
94
+ };
95
+ const FINGERPRINT = /^[0-9a-f]{68}$/i;
96
+ /**
97
+ * Checks the shape of a party id: a non-blank hint, the `::` separator, and a 68-character hex
98
+ * fingerprint. Returns `undefined` when nothing is wrong. Shape only — whether the party exists is
99
+ * the ledger's answer, not this function's.
100
+ *
101
+ * Reach for this over {@link isValidPartyId} when the caller needs to say what went wrong.
102
+ *
103
+ * @example
104
+ * validatePartyId('nico:1220df94') // 'missing-separator'
105
+ * validatePartyId('nico::1220df94') // 'invalid-fingerprint': 8 hex characters, not 68
106
+ *
107
+ * @category Utilities
108
+ */
109
+ const validatePartyId = (value) => {
110
+ const separator = value.indexOf("::");
111
+ if (separator === -1) return "missing-separator";
112
+ const hint = value.slice(0, separator);
113
+ if (hint.trim() === "" || /\s/.test(hint)) return "invalid-hint";
114
+ const fingerprint = value.slice(separator + 2);
115
+ return FINGERPRINT.test(fingerprint) ? void 0 : "invalid-fingerprint";
116
+ };
117
+ /**
118
+ * Whether a party id is well-formed. Reach for {@link validatePartyId} instead where the reason
119
+ * matters.
120
+ *
121
+ * @example
122
+ * isValidPartyId(partyId) // true
123
+ * isValidPartyId('nico::1220df94') // false: 8 hex characters where 68 are required
124
+ *
125
+ * @category Utilities
126
+ */
127
+ const isValidPartyId = (value) => validatePartyId(value) === void 0;
128
+ //#endregion
129
+ //#region src/components/Identifier/truncate.ts
130
+ const PARTY_HEAD = 6;
131
+ const PLAIN_HEAD = 12;
132
+ const TAIL = 8;
133
+ const THRESHOLD = 22;
134
+ const ELLIPSIS = "…";
135
+ const middle = (value, head, tail, threshold) => {
136
+ if (value.length <= threshold) return value;
137
+ const start = value.slice(0, Math.max(head, 0));
138
+ const end = tail > 0 ? value.slice(-tail) : "";
139
+ return start.length + end.length >= value.length ? value : `${start}${ELLIPSIS}${end}`;
140
+ };
141
+ /**
142
+ * Truncates an identifier for display. A Canton party id is `hint::fingerprint`, so the hint
143
+ * survives whole and only the fingerprint shrinks, unless `hint` bounds it too. Anything else is
144
+ * middle-truncated as one segment, never longer than the input, and cut on UTF-16 code units, so a
145
+ * non-ASCII value can split a surrogate pair.
146
+ *
147
+ * Reach for this over `<Identifier>` inside a sentence or another `<button>`, where the copy
148
+ * control would nest a button in a button. Pass `hint` where the result must fit a bounded width.
149
+ *
150
+ * @example
151
+ * truncateIdentifier('nico::1220df946c5b01ad0f2d2b480f1f43b1d1f2e498f5a49c2f0b1cbb46')
152
+ * // 'nico::1220df…0b1cbb46'
153
+ * truncateIdentifier('treasury-operations::1220df94…', { hint: 12 })
154
+ * // 'treasury-ope…::1220df94…'
155
+ *
156
+ * @category Utilities
157
+ */
158
+ const truncateIdentifier = (value, options) => {
159
+ const tail = options?.tail ?? TAIL;
160
+ const threshold = options?.threshold ?? THRESHOLD;
161
+ const separator = value.indexOf("::");
162
+ if (separator === -1) return middle(value, options?.head ?? PLAIN_HEAD, tail, threshold);
163
+ const hint = value.slice(0, separator);
164
+ const fingerprint = value.slice(separator + 2);
165
+ const short = middle(fingerprint, options?.head ?? PARTY_HEAD, tail, threshold);
166
+ return `${options?.hint === void 0 ? hint : middle(hint, options.hint, 0, options.hint)}::${short}`;
167
+ };
168
+ /**
169
+ * The readable half of a party id. Returns the whole value when there is no separator. Reach for
170
+ * this over {@link truncateIdentifier} where the fingerprint should be dropped rather than
171
+ * shortened, such as a table column or a chart label.
172
+ *
173
+ * @example
174
+ * partyHint('nico::1220df94') // 'nico'
175
+ *
176
+ * @category Utilities
177
+ */
178
+ const partyHint = (value) => {
179
+ const separator = value.indexOf("::");
180
+ return separator === -1 ? value : value.slice(0, separator);
181
+ };
182
+ //#endregion
183
+ //#region src/hooks/useCopyToClipboard.ts
184
+ const RESET_MS = 1200;
185
+ /**
186
+ * Clipboard write with a transient result state. Callers that need their own feedback (a toast,
187
+ * say) use the returned outcome; callers that only need an affordance style off `state`.
188
+ *
189
+ * Reach for this over `<Identifier>` when the copy control must be a sibling of the value rather
190
+ * than a child of it.
191
+ *
192
+ * @example
193
+ * const { state, copy } = useCopyToClipboard({ resetMs: 500 })
194
+ * <button onClick={() => void copy(partyId)} data-state={state}>Copy</button>
195
+ *
196
+ * @category Hooks
197
+ */
198
+ const useCopyToClipboard = (options) => {
199
+ const resetMs = options?.resetMs ?? RESET_MS;
200
+ const [state, setState] = useState("idle");
201
+ const timer = useRef(void 0);
202
+ const live = useRef(true);
203
+ useEffect(() => {
204
+ live.current = true;
205
+ return () => {
206
+ live.current = false;
207
+ clearTimeout(timer.current);
208
+ };
209
+ }, []);
210
+ return {
211
+ state,
212
+ copy: useCallback(async (value) => {
213
+ const settle = (next) => {
214
+ clearTimeout(timer.current);
215
+ if (!live.current) return;
216
+ setState(next);
217
+ timer.current = setTimeout(() => setState("idle"), resetMs);
218
+ };
219
+ try {
220
+ const clipboard = globalThis.navigator?.clipboard;
221
+ if (clipboard === void 0) throw new Error("Clipboard unavailable");
222
+ await clipboard.writeText(value);
223
+ settle("copied");
224
+ return {
225
+ ok: true,
226
+ value
227
+ };
228
+ } catch (cause) {
229
+ settle("error");
230
+ return {
231
+ ok: false,
232
+ error: cause instanceof Error ? cause : /* @__PURE__ */ new Error("Copy failed")
233
+ };
234
+ }
235
+ }, [resetMs])
236
+ };
237
+ };
238
+ //#endregion
239
+ //#region src/components/Identifier/index.tsx
240
+ const DEFAULT_LABEL = "identifier";
241
+ const statusText = (state, label) => {
242
+ if (state === "copied") return `Copied ${label}`;
243
+ if (state === "error") return `Could not copy ${label}`;
244
+ return "";
245
+ };
246
+ /**
247
+ * Displays a Canton identifier: truncated for reading, copyable in full, optionally linked to an
248
+ * explorer. Copy always writes the whole value, never the truncated display value. The href is
249
+ * built by the caller; this component composes no URLs. Renders a `span`, so it is legal anywhere
250
+ * inline text is.
251
+ *
252
+ * @example
253
+ * <Identifier value={partyId} label="party id" href={explorerLink(partyId)} announce={false} />
254
+ *
255
+ * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/Identifier/anatomy.ts) for the part classes and state attributes the theme selects.
256
+ *
257
+ * @category Components
258
+ */
259
+ const Identifier = ({ value, label = DEFAULT_LABEL, truncate, copy = true, announce = true, href, onCopy, className, ...rest }) => {
260
+ const { state, copy: writeToClipboard } = useCopyToClipboard();
261
+ const display = truncate === false ? value : truncateIdentifier(value, truncate);
262
+ return /* @__PURE__ */ jsxs("span", {
263
+ className: cx(anatomy$4.parts.root, className),
264
+ ...rest,
265
+ children: [
266
+ /* @__PURE__ */ jsx("code", {
267
+ className: anatomy$4.parts.value,
268
+ title: value,
269
+ children: display
270
+ }),
271
+ copy && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
272
+ type: "button",
273
+ className: anatomy$4.parts.copy,
274
+ "aria-label": `Copy ${label}`,
275
+ onClick: () => void writeToClipboard(value).then(onCopy),
276
+ [anatomy$4.states.copy]: state,
277
+ children: state === "copied" ? /* @__PURE__ */ jsx(CheckIcon, {}) : /* @__PURE__ */ jsx(CopyIcon, {})
278
+ }), announce && /* @__PURE__ */ jsx("span", {
279
+ className: anatomy$4.parts.status,
280
+ role: "status",
281
+ style: SR_ONLY,
282
+ children: statusText(state, label)
283
+ })] }),
284
+ href !== void 0 && /* @__PURE__ */ jsx(ExplorerLink, {
285
+ "aria-label": `View ${label} in explorer`,
286
+ className: anatomy$4.parts.link,
287
+ href
288
+ })
289
+ ]
290
+ });
291
+ };
292
+ //#endregion
293
+ //#region src/components/PartyIdInput/anatomy.ts
294
+ const anatomy$3 = {
295
+ parts: { root: "cnc-party-id-input" },
296
+ states: { invalid: "data-invalid" }
297
+ };
298
+ //#endregion
299
+ //#region src/utils/invalid.ts
300
+ /**
301
+ * Resolves a field's invalid state into the `aria-invalid` it carries for assistive tech and the
302
+ * boolean the theme's `data-invalid` hook is written from. A consumer's own `aria-invalid` wins, so
303
+ * an app can flag a field for a reason the kit cannot know.
304
+ *
305
+ * @example
306
+ * const [invalid, flagged] = resolveInvalid(ariaInvalid, error !== undefined)
307
+ */
308
+ const resolveInvalid = (consumer, hasError) => {
309
+ const invalid = consumer ?? (hasError || void 0);
310
+ return [invalid, Boolean(invalid) && invalid !== "false"];
311
+ };
312
+ //#endregion
313
+ //#region src/components/PartyIdInput/index.tsx
314
+ const errorOf = (value) => value === "" ? void 0 : validatePartyId(value);
315
+ /**
316
+ * A controlled text field for a Canton party id.
317
+ *
318
+ * It flags a malformed value with `aria-invalid` and hands the reason to `onChange`. Pass
319
+ * `aria-invalid` to flag the field for a reason the kit cannot know, such as a party the app
320
+ * rejects.
321
+ *
322
+ * @example
323
+ * <PartyIdInput value={receiver} onChange={setReceiver} aria-describedby="receiver-error" />
324
+ *
325
+ * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/PartyIdInput/anatomy.ts) for the part classes and state attributes the theme selects.
326
+ *
327
+ * @category Components
328
+ */
329
+ const PartyIdInput = ({ "aria-invalid": ariaInvalid, className, onBlur, onChange, value, ...rest }) => {
330
+ const [touched, setTouched] = useState(false);
331
+ const shownError = touched ? errorOf(value) : void 0;
332
+ const [invalid, flagged] = resolveInvalid(ariaInvalid, shownError !== void 0);
333
+ const handleBlur = (event) => {
334
+ setTouched(true);
335
+ const settled = value.trim();
336
+ if (!touched || settled !== value) onChange(settled, errorOf(settled));
337
+ onBlur?.(event);
338
+ };
339
+ return /* @__PURE__ */ jsx("input", {
340
+ autoCapitalize: "off",
341
+ autoComplete: "off",
342
+ autoCorrect: "off",
343
+ spellCheck: false,
344
+ ...rest,
345
+ "aria-invalid": invalid,
346
+ [anatomy$3.states.invalid]: flagged || void 0,
347
+ className: cx(anatomy$3.parts.root, className),
348
+ onBlur: handleBlur,
349
+ onChange: (event) => {
350
+ const next = event.target.value;
351
+ onChange(next, touched ? errorOf(next) : void 0);
352
+ },
353
+ type: "text",
354
+ value
355
+ });
356
+ };
357
+ //#endregion
358
+ //#region src/components/TokenInput/anatomy.ts
359
+ const anatomy$2 = {
360
+ parts: {
361
+ balance: "cnc-token-input__balance",
362
+ field: "cnc-token-input__field",
363
+ label: "cnc-token-input__label",
364
+ max: "cnc-token-input__max",
365
+ meta: "cnc-token-input__meta",
366
+ root: "cnc-token-input",
367
+ row: "cnc-token-input__row",
368
+ token: "cnc-token-input__token",
369
+ usdValue: "cnc-token-input__usd-value"
370
+ },
371
+ states: {
372
+ balance: "data-state",
373
+ disabled: "data-disabled",
374
+ interactive: "data-interactive",
375
+ invalid: "data-invalid"
376
+ }
377
+ };
378
+ const dialogAnatomy = { parts: {
379
+ backdrop: "cnc-token-select-dialog__backdrop",
380
+ close: "cnc-token-select-dialog__close",
381
+ content: "cnc-token-select-dialog",
382
+ empty: "cnc-token-select-dialog__empty",
383
+ favorite: "cnc-token-select-dialog__favorite",
384
+ favoriteLogo: "cnc-token-select-dialog__favorite-logo",
385
+ favorites: "cnc-token-select-dialog__favorites",
386
+ favoriteSymbol: "cnc-token-select-dialog__favorite-symbol",
387
+ header: "cnc-token-select-dialog__header",
388
+ list: "cnc-token-select-dialog__list",
389
+ positioner: "cnc-token-select-dialog__positioner",
390
+ row: "cnc-token-select-dialog__row",
391
+ rowBalance: "cnc-token-select-dialog__row-balance",
392
+ rowFigures: "cnc-token-select-dialog__row-figures",
393
+ rowLocked: "cnc-token-select-dialog__row-locked",
394
+ rowLogo: "cnc-token-select-dialog__row-logo",
395
+ rowName: "cnc-token-select-dialog__row-name",
396
+ rows: "cnc-token-select-dialog__rows",
397
+ rowSymbol: "cnc-token-select-dialog__row-symbol",
398
+ rowText: "cnc-token-select-dialog__row-text",
399
+ search: "cnc-token-select-dialog__search",
400
+ searchField: "cnc-token-select-dialog__search-field",
401
+ searchIcon: "cnc-token-select-dialog__search-icon",
402
+ sizer: "cnc-token-select-dialog__sizer",
403
+ status: "cnc-token-select-dialog__status",
404
+ title: "cnc-token-select-dialog__title"
405
+ } };
406
+ //#endregion
407
+ //#region src/components/TokenInput/constants.ts
408
+ const ROW_HEIGHT_REM = 3.25;
409
+ const NO_TOKENS = "No tokens found";
410
+ //#endregion
411
+ //#region src/utils/tokenAmount.ts
412
+ /**
413
+ * Decimal places Daml `Decimal` (`Numeric 10`) accepts. Pass a smaller one where a token's own
414
+ * precision is tighter.
415
+ *
416
+ * @example
417
+ * parseAmount('1.5', DEFAULT_PRECISION)
418
+ *
419
+ * @category Utilities
420
+ */
421
+ const DEFAULT_PRECISION = 10;
422
+ const TOTAL_DIGITS = 38;
423
+ const DECIMAL = /^\d*(\.\d*)?$/;
424
+ const localeCache = /* @__PURE__ */ new Map();
425
+ const partsOf = (locale) => {
426
+ const cached = localeCache.get(locale ?? "");
427
+ if (cached !== void 0) return cached;
428
+ const grouper = new Intl.NumberFormat(locale, { numberingSystem: "latn" });
429
+ const parts = grouper.formatToParts(12345.6);
430
+ const entry = {
431
+ group: parts.find((part) => part.type === "group")?.value ?? ",",
432
+ decimal: parts.find((part) => part.type === "decimal")?.value ?? ".",
433
+ grouper
434
+ };
435
+ localeCache.set(locale ?? "", entry);
436
+ return entry;
437
+ };
438
+ const split = (value) => {
439
+ const dot = value.indexOf(".");
440
+ return dot === -1 ? [value, ""] : [value.slice(0, dot), value.slice(dot + 1)];
441
+ };
442
+ /**
443
+ * Scales a decimal string to an integer at `precision`, which is the only exact way to compare two
444
+ * amounts. `undefined` when the value is not a decimal or carries more places than `precision` can
445
+ * hold, since dropping a digit would silently change the amount.
446
+ *
447
+ * Reach for {@link validateAmount} instead where the caller needs to say what went wrong.
448
+ *
449
+ * @example
450
+ * parseAmount('1.5') // 15000000000n
451
+ *
452
+ * @category Utilities
453
+ */
454
+ const parseAmount = (value, precision = 10) => {
455
+ if (value === "" || !DECIMAL.test(value)) return void 0;
456
+ const [int, frac] = split(value);
457
+ if (frac.length > precision) return void 0;
458
+ return BigInt(`${int === "" ? "0" : int}${frac.padEnd(precision, "0")}`);
459
+ };
460
+ /**
461
+ * The inverse of {@link parseAmount}: a scaled integer back to a canonical decimal string with no
462
+ * trailing zeros.
463
+ *
464
+ * @example
465
+ * formatScaled(15000000000n) // '1.5'
466
+ *
467
+ * @category Utilities
468
+ */
469
+ const formatScaled = (scaled, precision = 10) => {
470
+ const digits = scaled.toString().padStart(precision + 1, "0");
471
+ const cut = digits.length - precision;
472
+ const frac = digits.slice(cut).replace(/0+$/, "");
473
+ return frac === "" ? digits.slice(0, cut) : `${digits.slice(0, cut)}.${frac}`;
474
+ };
475
+ /**
476
+ * Groups the integer part for reading and leaves the fraction verbatim, so a value still being
477
+ * typed (`1.`, `1.50`) survives. `Intl` is handed the string unparsed, which formats it exactly
478
+ * where the float would drift. The grouping and decimal separators are the locale's, so
479
+ * {@link sanitizeAmountInput} has to read the result back under the same locale.
480
+ *
481
+ * @example
482
+ * formatAmount('8421337.1234567891') // '8,421,337.1234567891'
483
+ *
484
+ * @category Utilities
485
+ */
486
+ const formatAmount = (value, locale) => {
487
+ if (!DECIMAL.test(value)) return value;
488
+ const [int, frac] = split(value);
489
+ const { decimal, grouper } = partsOf(locale);
490
+ const grouped = int === "" ? "" : grouper.format(BigInt(int));
491
+ return value.includes(".") ? `${grouped}${decimal}${frac}` : grouped;
492
+ };
493
+ /**
494
+ * Reduces raw field input to a decimal: grouping separators and anything that can never belong to
495
+ * an amount are dropped rather than flagged, so a paste of `1,234.5` lands as `1234.5`. A sign or
496
+ * an exponent is kept instead of stripped, so `-5` and `1.5e3` reach {@link validateAmount} as
497
+ * `not-a-number` rather than being salvaged into an amount nobody entered.
498
+ *
499
+ * The inverse of {@link formatAmount}, and it must be passed the same `locale`: under a
500
+ * comma-decimal locale a mismatched pair reads a value a thousand times too small.
501
+ *
502
+ * @example
503
+ * sanitizeAmountInput('.5') // '0.5'
504
+ *
505
+ * @category Utilities
506
+ */
507
+ const sanitizeAmountInput = (input, locale) => {
508
+ const { group, decimal } = partsOf(locale);
509
+ const mapped = input.replaceAll(group, "").replaceAll(decimal, ".");
510
+ if (/[eE+-]/.test(mapped)) return mapped;
511
+ const [int, ...frac] = mapped.replace(/[^\d.]/g, "").split(".");
512
+ const head = int.replace(/^0+(?=\d)/, "");
513
+ return frac.length === 0 ? head : `${head === "" ? "0" : head}.${frac.join("")}`;
514
+ };
515
+ /**
516
+ * Settles a value the user has finished editing. Only a dangling separator goes: trailing fraction
517
+ * zeros are the user's to keep, and they parse to the same amount either way.
518
+ *
519
+ * @example
520
+ * settleAmount('1.') // '1'
521
+ *
522
+ * @internal
523
+ */
524
+ const settleAmount = (value) => value.endsWith(".") ? value.slice(0, -1) : value;
525
+ /**
526
+ * Checks an amount against the token's precision, the `Numeric 38,10` ceiling, and an optional
527
+ * range. Returns `undefined` when nothing is wrong, including for the empty string: empty is empty,
528
+ * and required-ness belongs to the form. A `max` that is not itself a decimal returns `invalid-max`
529
+ * rather than reading as no ceiling, so a malformed balance cannot silently uncap the amount.
530
+ *
531
+ * @example
532
+ * validateAmount('1.5000000001', { max: '1.5' }) // 'above-max'
533
+ *
534
+ * @category Utilities
535
+ */
536
+ const validateAmount = (value, { precision = 10, max } = {}) => {
537
+ if (value === "") return void 0;
538
+ const scaled = parseAmount(value, precision);
539
+ if (scaled === void 0) return DECIMAL.test(value) ? "too-many-decimals" : "not-a-number";
540
+ if (split(value)[0].replace(/^0+/, "").length > TOTAL_DIGITS - precision) return "too-large";
541
+ if (max !== void 0 && max !== "") {
542
+ const scaledMax = parseAmount(max, precision);
543
+ if (scaledMax === void 0) return "invalid-max";
544
+ if (scaled > scaledMax) return "above-max";
545
+ }
546
+ };
547
+ //#endregion
548
+ //#region src/components/TokenInput/formatFigure.ts
549
+ const PLACES = 2;
550
+ /**
551
+ * Rounds an amount to a fixed number of decimals and groups it for reading, so a column of figures
552
+ * compares at a glance where {@link formatAmount} keeps every digit the ledger carries. An amount
553
+ * it cannot read formats to nothing rather than to a zero that would read as a real balance.
554
+ *
555
+ * @example
556
+ * formatFigure('1234.5') // '1,234.50'
557
+ * formatFigure('abc') // undefined
558
+ *
559
+ * @category Utilities
560
+ */
561
+ const formatFigure = (value, { locale, places = PLACES } = {}) => {
562
+ const scaled = value === void 0 ? void 0 : parseAmount(value);
563
+ if (scaled === void 0) return void 0;
564
+ const at = Math.max(0, Math.min(Math.trunc(places), 10));
565
+ const step = 10n ** BigInt(10 - at);
566
+ const [int, frac = ""] = formatScaled((scaled + step / 2n) / step, at).split(".");
567
+ return formatAmount(at === 0 ? int : `${int}.${frac.padEnd(at, "0")}`, locale);
568
+ };
569
+ //#endregion
570
+ //#region src/components/TokenInput/getLockedFigure.ts
571
+ const getLockedFigure = ({ locked }) => locked !== void 0 && parseAmount(locked) === 0n ? void 0 : formatFigure(locked);
572
+ //#endregion
573
+ //#region src/components/TokenInput/getTokenLabel.ts
574
+ const getTokenLabel = (token) => {
575
+ const balance = formatFigure(token.balance);
576
+ const locked = getLockedFigure(token);
577
+ const parts = [`${token.name} ${token.symbol}`];
578
+ if (balance !== void 0) parts.push(`balance ${balance}`);
579
+ if (locked !== void 0) parts.push(`${locked} locked`);
580
+ return parts.join(", ");
581
+ };
582
+ //#endregion
583
+ //#region src/components/TokenLogo/anatomy.ts
584
+ const anatomy$1 = {
585
+ parts: { root: "cnc-token-logo" },
586
+ states: {
587
+ fallback: "data-fallback",
588
+ swatch: "data-swatch"
589
+ }
590
+ };
591
+ /**
592
+ * Picks the placeholder swatch for a token symbol: arbitrary, but the same symbol always lands on
593
+ * the same one. The answer indexes the theme's `--cnc-swatch-*` roles, so the colour itself stays
594
+ * in CSS and follows the mode.
595
+ *
596
+ * @example
597
+ * swatchOf('CC') === swatchOf('CC') // true, and unrelated to swatchOf('USDC')
598
+ */
599
+ const swatchOf = (symbol) => {
600
+ let hash = 0;
601
+ for (const char of symbol) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 100003;
602
+ return hash * 3 % 8 + 1;
603
+ };
604
+ //#endregion
605
+ //#region src/components/TokenLogo/index.tsx
606
+ const INITIALS = 3;
607
+ const initialsOf = (symbol) => [...symbol].slice(0, INITIALS).join("");
608
+ /**
609
+ * A token's logo, falling back to the symbol's initials on a coloured disc when there is no
610
+ * artwork. The disc is `aria-hidden` either way, so whatever renders it must name the token itself.
611
+ *
612
+ * A subcomponent of {@link TokenInput} and its token select, not a public export: a consumer
613
+ * rendering a token badge of their own owes it an accessible name, which this deliberately has not.
614
+ *
615
+ * @example
616
+ * <TokenLogo logo={token.logo} symbol={token.symbol} />
617
+ *
618
+ * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/TokenLogo/anatomy.ts) for the part classes and state attributes the theme selects.
619
+ *
620
+ * @internal
621
+ */
622
+ const TokenLogo = ({ className, logo, symbol }) => {
623
+ const fallback = logo === void 0 || logo === null;
624
+ return /* @__PURE__ */ jsx("span", {
625
+ "aria-hidden": true,
626
+ className: cx(anatomy$1.parts.root, className),
627
+ [anatomy$1.states.fallback]: fallback || void 0,
628
+ [anatomy$1.states.swatch]: fallback ? swatchOf(symbol) : void 0,
629
+ children: fallback ? initialsOf(symbol) : logo
630
+ });
631
+ };
632
+ //#endregion
633
+ //#region src/providers/TokenListProvider/context.ts
634
+ const TokenListContext = createContext(void 0);
635
+ //#endregion
636
+ //#region src/providers/TokenListProvider/useTokenList.ts
637
+ /**
638
+ * Reads the token list a {@link TokenListProvider} supplies. Reach for it where a screen renders
639
+ * its own token UI; `<TokenInput onTokenSelect>` already reads it itself.
640
+ *
641
+ * @throws with no {@link TokenListProvider} above it.
642
+ *
643
+ * @example
644
+ * const { tokens } = useTokenList()
645
+ * tokens.map((token) => <TokenRow key={tokenKey(token.instrumentId)} token={token} />)
646
+ *
647
+ * @category Hooks
648
+ */
649
+ const useTokenList = () => {
650
+ const value = useContext(TokenListContext);
651
+ if (value === void 0) throw new Error("useTokenList must be used inside a <TokenListProvider>");
652
+ return value;
653
+ };
654
+ //#endregion
655
+ //#region src/utils/tokenKey.ts
656
+ const SEPARATOR = "/";
657
+ /**
658
+ * The string identity of an instrument, for a map key, a React key or an equality check. Compare
659
+ * these rather than the `id` alone: two registries can both issue a `USDC`.
660
+ *
661
+ * @example
662
+ * tokenKey({ admin: 'DSO::1220ab', id: 'Amulet' }) // 'DSO::1220ab/Amulet'
663
+ *
664
+ * @category Utilities
665
+ */
666
+ const tokenKey = ({ admin, id }) => `${admin}${SEPARATOR}${id}`;
667
+ //#endregion
668
+ //#region src/components/TokenInput/TokenFavorites.tsx
669
+ /**
670
+ * The token select's shortcut row. Takes the first `MAX_FAVORITES` ids the list provider holds, in
671
+ * the order given, and drops the rest.
672
+ *
673
+ * @example
674
+ * <TokenFavorites ids={[{ admin: 'DSO::1220ab', id: 'Amulet' }]} onSelect={setToken} />
675
+ */
676
+ const TokenFavorites = ({ ids = [], onSelect }) => {
677
+ const { byKey } = useTokenList();
678
+ const favorites = ids.map((id) => byKey.get(tokenKey(id))).filter((token) => token !== void 0).slice(0, 8);
679
+ if (favorites.length === 0) return null;
680
+ return /* @__PURE__ */ jsx("section", {
681
+ "aria-label": "Favorite tokens",
682
+ className: dialogAnatomy.parts.favorites,
683
+ children: favorites.map((token) => /* @__PURE__ */ jsxs("button", {
684
+ "aria-label": getTokenLabel(token),
685
+ className: dialogAnatomy.parts.favorite,
686
+ onClick: () => onSelect(token),
687
+ type: "button",
688
+ children: [/* @__PURE__ */ jsx(TokenLogo, {
689
+ className: dialogAnatomy.parts.favoriteLogo,
690
+ logo: token.logo,
691
+ symbol: token.symbol
692
+ }), /* @__PURE__ */ jsx("span", {
693
+ className: dialogAnatomy.parts.favoriteSymbol,
694
+ children: token.symbol
695
+ })]
696
+ }, tokenKey(token.instrumentId)))
697
+ });
698
+ };
699
+ //#endregion
700
+ //#region src/components/TokenInput/filterTokens.ts
701
+ const haystacks = /* @__PURE__ */ new WeakMap();
702
+ const haystack = (token) => {
703
+ const cached = haystacks.get(token);
704
+ if (cached !== void 0) return cached;
705
+ const next = {
706
+ admin: token.instrumentId.admin.toLowerCase(),
707
+ id: token.instrumentId.id.toLowerCase(),
708
+ name: token.name.toLowerCase(),
709
+ symbol: token.symbol.toLowerCase()
710
+ };
711
+ haystacks.set(token, next);
712
+ return next;
713
+ };
714
+ /**
715
+ * Normalizes a query to what {@link filterTokens} matches on. Key a memo on this rather than on the
716
+ * raw query, so typing a space either side re-runs nothing.
717
+ *
718
+ * @example
719
+ * const tokens = useMemo(() => filterTokens(all, toNeedle(query)), [all, toNeedle(query)])
720
+ */
721
+ const toNeedle = (query) => query.trim().toLowerCase();
722
+ /**
723
+ * Narrows a token list to what a query matches: symbol matches first, then name, then either half
724
+ * of the instrument id. The admin party is what tells two registries issuing one symbol apart, so
725
+ * it is matched by prefix, which is how a pasted party id finds its token.
726
+ *
727
+ * @example
728
+ * const shown = filterTokens(tokens, 'ca')
729
+ */
730
+ const filterTokens = (tokens, query) => {
731
+ const needle = toNeedle(query);
732
+ if (needle === "") return tokens;
733
+ const bySymbol = [];
734
+ const byName = [];
735
+ const byInstrument = [];
736
+ for (const token of tokens) {
737
+ const { admin, id, name, symbol } = haystack(token);
738
+ if (symbol.includes(needle)) bySymbol.push(token);
739
+ else if (name.includes(needle)) byName.push(token);
740
+ else if (id.startsWith(needle) || admin.startsWith(needle)) byInstrument.push(token);
741
+ }
742
+ return [
743
+ ...bySymbol,
744
+ ...byName,
745
+ ...byInstrument
746
+ ];
747
+ };
748
+ //#endregion
749
+ //#region src/components/TokenInput/TokenRow.tsx
750
+ /**
751
+ * One row of the token select's list. `tabbable` is the list's roving tab stop, so exactly one row
752
+ * carries it: a tab stop per row is what the windowing exists to avoid.
753
+ *
754
+ * @example
755
+ * <TokenRow onFocus={() => setActiveKey(tokenKey(token.instrumentId))} onKeyDown={move}
756
+ * onSelect={setToken} tabbable={index === active} token={token} />
757
+ */
758
+ const TokenRow = ({ onFocus, onKeyDown, onSelect, tabbable, token }) => {
759
+ const balance = formatFigure(token.balance);
760
+ const locked = getLockedFigure(token);
761
+ return /* @__PURE__ */ jsxs("button", {
762
+ "aria-label": getTokenLabel(token),
763
+ className: dialogAnatomy.parts.row,
764
+ onClick: () => onSelect(token),
765
+ onFocus,
766
+ onKeyDown,
767
+ style: { height: `${ROW_HEIGHT_REM}rem` },
768
+ tabIndex: tabbable ? 0 : -1,
769
+ type: "button",
770
+ children: [
771
+ /* @__PURE__ */ jsx(TokenLogo, {
772
+ className: dialogAnatomy.parts.rowLogo,
773
+ logo: token.logo,
774
+ symbol: token.symbol
775
+ }),
776
+ /* @__PURE__ */ jsxs("span", {
777
+ className: dialogAnatomy.parts.rowText,
778
+ children: [/* @__PURE__ */ jsx("span", {
779
+ className: dialogAnatomy.parts.rowName,
780
+ children: token.name
781
+ }), /* @__PURE__ */ jsx("span", {
782
+ className: dialogAnatomy.parts.rowSymbol,
783
+ children: token.symbol
784
+ })]
785
+ }),
786
+ (balance !== void 0 || locked !== void 0) && /* @__PURE__ */ jsxs("span", {
787
+ className: dialogAnatomy.parts.rowFigures,
788
+ children: [balance !== void 0 && /* @__PURE__ */ jsx("span", {
789
+ className: dialogAnatomy.parts.rowBalance,
790
+ children: balance
791
+ }), locked !== void 0 && /* @__PURE__ */ jsxs("span", {
792
+ className: dialogAnatomy.parts.rowLocked,
793
+ children: [locked, /* @__PURE__ */ jsx(LockIcon, {})]
794
+ })]
795
+ })
796
+ ]
797
+ });
798
+ };
799
+ //#endregion
800
+ //#region src/components/TokenInput/useRemPx.ts
801
+ const FALLBACK = 16;
802
+ const rootFontSize = () => Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || FALLBACK;
803
+ /**
804
+ * Resolves a rem length to the px a layout calculation needs, following the root font size.
805
+ *
806
+ * @example
807
+ * const rowHeight = useRemPx(3.25)
808
+ */
809
+ const useRemPx = (rem) => {
810
+ const [root, setRoot] = useState(FALLBACK);
811
+ useLayoutEffect(() => {
812
+ const measure = () => setRoot(rootFontSize());
813
+ measure();
814
+ window.addEventListener("resize", measure);
815
+ return () => window.removeEventListener("resize", measure);
816
+ }, []);
817
+ return rem * root;
818
+ };
819
+ //#endregion
820
+ //#region src/components/TokenInput/nextIndex.ts
821
+ /**
822
+ * The row a key moves the tab stop to, or `undefined` for a key that moves nothing and so must
823
+ * keep its default behaviour. Out-of-range results are the caller's to clamp.
824
+ *
825
+ * @example
826
+ * nextIndex('PageDown', 4, 10, 99) // 14
827
+ */
828
+ const nextIndex = (key, active, page, last) => {
829
+ switch (key) {
830
+ case "ArrowDown": return active + 1;
831
+ case "ArrowUp": return active - 1;
832
+ case "PageDown": return active + page;
833
+ case "PageUp": return active - page;
834
+ case "Home": return 0;
835
+ case "End": return last;
836
+ default: return;
837
+ }
838
+ };
839
+ //#endregion
840
+ //#region src/components/TokenInput/useRovingFocus.ts
841
+ /**
842
+ * The one tab stop a windowed list walks on: which row holds it, the keys that move it, and the
843
+ * focus a scroll would otherwise drop when it unmounts the row holding it.
844
+ *
845
+ * @example
846
+ * const { active, onKeyDown, onRowFocus } = useRovingFocus({
847
+ * needle,
848
+ * rowHeight,
849
+ * scrollRef,
850
+ * scrollRowIntoView,
851
+ * tokens,
852
+ * })
853
+ */
854
+ const useRovingFocus = ({ needle, rowHeight, scrollRef, scrollRowIntoView, tokens }) => {
855
+ const [activeKey, setActiveKey] = useState();
856
+ const active = useMemo(() => Math.max(0, tokens.findIndex((token) => tokenKey(token.instrumentId) === activeKey)), [activeKey, tokens]);
857
+ const pullFocus = useRef(false);
858
+ const hadFocus = useRef(false);
859
+ const [applied, setApplied] = useState(needle);
860
+ if (applied !== needle) {
861
+ setApplied(needle);
862
+ setActiveKey(void 0);
863
+ }
864
+ const focusActive = () => {
865
+ scrollRef.current?.querySelector(`.${dialogAnatomy.parts.row}[tabindex="0"]`)?.focus();
866
+ };
867
+ useLayoutEffect(() => {
868
+ const node = scrollRef.current;
869
+ if (node === null) return;
870
+ if (pullFocus.current || hadFocus.current && document.activeElement === document.body) {
871
+ pullFocus.current = false;
872
+ focusActive();
873
+ }
874
+ hadFocus.current = node.contains(document.activeElement);
875
+ });
876
+ const moveTo = (index) => {
877
+ const next = Math.max(0, Math.min(tokens.length - 1, index));
878
+ scrollRowIntoView(next);
879
+ if (next === active) {
880
+ focusActive();
881
+ return;
882
+ }
883
+ pullFocus.current = true;
884
+ setActiveKey(tokenKey(tokens[next].instrumentId));
885
+ };
886
+ return {
887
+ active,
888
+ onKeyDown: (event) => {
889
+ const page = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? 0) / rowHeight));
890
+ const next = nextIndex(event.key, active, page, tokens.length - 1);
891
+ if (next === void 0) return;
892
+ event.preventDefault();
893
+ moveTo(next);
894
+ },
895
+ onRowFocus: (token) => {
896
+ hadFocus.current = true;
897
+ setActiveKey(tokenKey(token.instrumentId));
898
+ }
899
+ };
900
+ };
901
+ //#endregion
902
+ //#region src/components/TokenInput/useVirtualRows.ts
903
+ const DEFAULT_OVERSCAN = 4;
904
+ /**
905
+ * Windows a uniform-height list. The hook owns every scroll write, so move it with
906
+ * `scrollRowIntoView`, or hand it a `resetKey` that changes when the rendered sequence does and it
907
+ * rewinds itself: the window is computed from state, which a `scrollTop` written on the node behind
908
+ * the hook's back leaves pointing at the offset it was computed for.
909
+ *
910
+ * @example
911
+ * const { end, offset, start, totalHeight } = useVirtualRows({
912
+ * count: rows.length,
913
+ * rowHeight: 56,
914
+ * scrollRef,
915
+ * })
916
+ * rows.slice(start, end)
917
+ */
918
+ const useVirtualRows = ({ count, overscan = DEFAULT_OVERSCAN, resetKey, rowHeight, scrollRef }) => {
919
+ const [scrollTop, setScrollTop] = useState(0);
920
+ const [viewport, setViewport] = useState(0);
921
+ useLayoutEffect(() => {
922
+ const node = scrollRef.current;
923
+ if (node === null) return;
924
+ const measure = () => setViewport(node.clientHeight);
925
+ measure();
926
+ const observer = new ResizeObserver(measure);
927
+ observer.observe(node);
928
+ return () => observer.disconnect();
929
+ }, [scrollRef]);
930
+ useEffect(() => {
931
+ const node = scrollRef.current;
932
+ if (node === null) return;
933
+ const onScroll = () => setScrollTop(node.scrollTop);
934
+ node.addEventListener("scroll", onScroll, { passive: true });
935
+ return () => node.removeEventListener("scroll", onScroll);
936
+ }, [scrollRef]);
937
+ const scrollTo = useCallback((top) => {
938
+ const node = scrollRef.current;
939
+ if (node !== null) node.scrollTop = top;
940
+ setScrollTop(top);
941
+ }, [scrollRef]);
942
+ const scrollRowIntoView = useCallback((index) => {
943
+ const node = scrollRef.current;
944
+ if (node === null) return;
945
+ const top = index * rowHeight;
946
+ scrollTo(Math.max(top + rowHeight - node.clientHeight, Math.min(node.scrollTop, top)));
947
+ }, [
948
+ rowHeight,
949
+ scrollRef,
950
+ scrollTo
951
+ ]);
952
+ const applied = useRef(resetKey);
953
+ useLayoutEffect(() => {
954
+ if (applied.current === resetKey) return;
955
+ applied.current = resetKey;
956
+ scrollTo(0);
957
+ }, [resetKey, scrollTo]);
958
+ const first = Math.min(Math.floor(scrollTop / rowHeight), Math.max(0, count - 1));
959
+ const start = Math.max(0, first - overscan);
960
+ return {
961
+ end: Math.min(count, start + Math.ceil(viewport / rowHeight) + 1 + overscan * 2),
962
+ offset: start * rowHeight,
963
+ scrollRowIntoView,
964
+ start,
965
+ totalHeight: count * rowHeight
966
+ };
967
+ };
968
+ //#endregion
969
+ //#region src/components/TokenInput/TokenList.tsx
970
+ const announce = (needle, count) => {
971
+ if (needle === "") return "";
972
+ if (count === 0) return NO_TOKENS;
973
+ return count === 1 ? "1 token found" : `${count} tokens found`;
974
+ };
975
+ /**
976
+ * The token select's scrolling list. Windowed, so it walks on one roving tab stop and the arrow
977
+ * keys instead of a tab stop per token: the rows out of view are not in the DOM to tab to.
978
+ *
979
+ * @example
980
+ * <TokenList onSelect={(token) => { onTokenSelect(token); onClose() }} />
981
+ */
982
+ const TokenList = ({ onSelect, query = "" }) => {
983
+ const scrollRef = useRef(null);
984
+ const { tokens: all } = useTokenList();
985
+ const needle = toNeedle(query);
986
+ const tokens = useMemo(() => filterTokens(all, needle), [all, needle]);
987
+ const rowHeight = useRemPx(ROW_HEIGHT_REM);
988
+ const { end, offset, scrollRowIntoView, start, totalHeight } = useVirtualRows({
989
+ count: tokens.length,
990
+ resetKey: needle,
991
+ rowHeight,
992
+ scrollRef
993
+ });
994
+ const { active, onKeyDown, onRowFocus } = useRovingFocus({
995
+ needle,
996
+ rowHeight,
997
+ scrollRef,
998
+ scrollRowIntoView,
999
+ tokens
1000
+ });
1001
+ const row = (token, index) => /* @__PURE__ */ jsx(TokenRow, {
1002
+ onFocus: () => onRowFocus(token),
1003
+ onKeyDown,
1004
+ onSelect,
1005
+ tabbable: index === active,
1006
+ token
1007
+ }, tokenKey(token.instrumentId));
1008
+ const stray = tokens.length > 0 && (active < start || active >= end) ? /* @__PURE__ */ jsx("div", {
1009
+ className: dialogAnatomy.parts.rows,
1010
+ style: { transform: `translateY(${active * rowHeight}px)` },
1011
+ children: row(tokens[active], active)
1012
+ }) : null;
1013
+ return /* @__PURE__ */ jsxs("section", {
1014
+ "aria-label": "Tokens",
1015
+ className: dialogAnatomy.parts.list,
1016
+ ref: scrollRef,
1017
+ children: [
1018
+ /* @__PURE__ */ jsx("span", {
1019
+ className: dialogAnatomy.parts.status,
1020
+ role: "status",
1021
+ style: SR_ONLY,
1022
+ children: announce(needle, tokens.length)
1023
+ }),
1024
+ tokens.length === 0 && /* @__PURE__ */ jsx("p", {
1025
+ className: dialogAnatomy.parts.empty,
1026
+ children: "No tokens found"
1027
+ }),
1028
+ /* @__PURE__ */ jsxs("div", {
1029
+ className: dialogAnatomy.parts.sizer,
1030
+ style: { height: totalHeight },
1031
+ children: [
1032
+ active < start && stray,
1033
+ /* @__PURE__ */ jsx("div", {
1034
+ className: dialogAnatomy.parts.rows,
1035
+ style: { transform: `translateY(${offset}px)` },
1036
+ children: tokens.slice(start, end).map((token, index) => row(token, start + index))
1037
+ }),
1038
+ active >= end && stray
1039
+ ]
1040
+ })
1041
+ ]
1042
+ });
1043
+ };
1044
+ //#endregion
1045
+ //#region src/components/TokenInput/TokenSearch.tsx
1046
+ /**
1047
+ * The token select's filter field.
1048
+ *
1049
+ * @example
1050
+ * <TokenSearch onChange={setQuery} ref={searchRef} value={query} />
1051
+ */
1052
+ const TokenSearch = ({ onChange, ref, value }) => /* @__PURE__ */ jsxs("div", {
1053
+ className: dialogAnatomy.parts.searchField,
1054
+ children: [/* @__PURE__ */ jsx("span", {
1055
+ className: dialogAnatomy.parts.searchIcon,
1056
+ children: /* @__PURE__ */ jsx(SearchIcon, {})
1057
+ }), /* @__PURE__ */ jsx("input", {
1058
+ "aria-label": "Search tokens",
1059
+ autoComplete: "off",
1060
+ className: dialogAnatomy.parts.search,
1061
+ onChange: (event) => onChange(event.target.value),
1062
+ placeholder: "Search by name, symbol, id or issuer",
1063
+ ref,
1064
+ spellCheck: false,
1065
+ type: "search",
1066
+ value
1067
+ })]
1068
+ });
1069
+ //#endregion
1070
+ //#region src/components/TokenInput/TokenSelectDialog.tsx
1071
+ const TokenSelect = ({ contentId, favoriteIds, onClose, onSelect, returnFocusTo }) => {
1072
+ const searchRef = useRef(null);
1073
+ const [query, setQuery] = useState("");
1074
+ const service = useMachine(dialog.machine, {
1075
+ finalFocusEl: () => returnFocusTo.current,
1076
+ id: useId(),
1077
+ ids: { content: contentId },
1078
+ initialFocusEl: () => searchRef.current,
1079
+ onEscapeKeyDown: (event) => {
1080
+ if (query === "" || document.activeElement !== searchRef.current) return;
1081
+ event.preventDefault();
1082
+ setQuery("");
1083
+ },
1084
+ onOpenChange: (details) => {
1085
+ if (!details.open) onClose();
1086
+ },
1087
+ open: true
1088
+ });
1089
+ const api = dialog.connect(service, normalizeProps);
1090
+ const select = (token) => {
1091
+ onSelect(token);
1092
+ api.setOpen(false);
1093
+ };
1094
+ return /* @__PURE__ */ jsxs(Portal, { children: [/* @__PURE__ */ jsx("div", {
1095
+ ...api.getBackdropProps(),
1096
+ className: dialogAnatomy.parts.backdrop
1097
+ }), /* @__PURE__ */ jsx("div", {
1098
+ ...api.getPositionerProps(),
1099
+ className: dialogAnatomy.parts.positioner,
1100
+ children: /* @__PURE__ */ jsxs("div", {
1101
+ ...api.getContentProps(),
1102
+ className: dialogAnatomy.parts.content,
1103
+ children: [
1104
+ /* @__PURE__ */ jsxs("header", {
1105
+ className: dialogAnatomy.parts.header,
1106
+ children: [/* @__PURE__ */ jsx("h2", {
1107
+ ...api.getTitleProps(),
1108
+ className: dialogAnatomy.parts.title,
1109
+ children: "Select a token"
1110
+ }), /* @__PURE__ */ jsx("button", {
1111
+ ...api.getCloseTriggerProps(),
1112
+ "aria-label": "Close",
1113
+ className: dialogAnatomy.parts.close,
1114
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
1115
+ })]
1116
+ }),
1117
+ /* @__PURE__ */ jsx(TokenSearch, {
1118
+ onChange: setQuery,
1119
+ ref: searchRef,
1120
+ value: query
1121
+ }),
1122
+ /* @__PURE__ */ jsx(TokenFavorites, {
1123
+ ids: favoriteIds,
1124
+ onSelect: select
1125
+ }),
1126
+ /* @__PURE__ */ jsx(TokenList, {
1127
+ onSelect: select,
1128
+ query
1129
+ })
1130
+ ]
1131
+ })
1132
+ })] });
1133
+ };
1134
+ /**
1135
+ * The dialog `<TokenInput>`'s token button opens.
1136
+ *
1137
+ * @example
1138
+ * <TokenSelectDialog contentId={selectId} onClose={() => setOpen(false)} onSelect={setToken}
1139
+ * open={open} returnFocusTo={triggerRef} />
1140
+ */
1141
+ const TokenSelectDialog = ({ open, ...rest }) => open ? /* @__PURE__ */ jsx(TokenSelect, { ...rest }) : null;
1142
+ //#endregion
1143
+ //#region src/components/TokenInput/caret.ts
1144
+ const DIGIT = /\d/;
1145
+ const countDigitsAfter = (value, start) => {
1146
+ let digits = 0;
1147
+ for (let index = start; index < value.length; index += 1) if (DIGIT.test(value[index])) digits += 1;
1148
+ return digits;
1149
+ };
1150
+ const caretBeforeDigits = (display, digits) => {
1151
+ let seen = 0;
1152
+ let index = display.length;
1153
+ while (seen < digits && index > 0) {
1154
+ index -= 1;
1155
+ if (DIGIT.test(display[index])) seen += 1;
1156
+ }
1157
+ return index;
1158
+ };
1159
+ const dropDigit = (value, caret, forward) => {
1160
+ const step = forward ? 1 : -1;
1161
+ let at = forward ? caret : Math.min(caret, value.length) - 1;
1162
+ while (at >= 0 && at < value.length && !DIGIT.test(value[at])) at += step;
1163
+ if (at < 0 || at >= value.length) return [value, caret];
1164
+ return [`${value.slice(0, at)}${value.slice(at + 1)}`, at];
1165
+ };
1166
+ //#endregion
1167
+ //#region src/components/TokenInput/useFormattedField.ts
1168
+ /**
1169
+ * Drives a text field whose display is regrouped on every keystroke, keeping the caret where the
1170
+ * user left it and undoing edits the sanitizer rejects.
1171
+ *
1172
+ * @example
1173
+ * const field = useFormattedField({ format, sanitize, value, onChange: setValue })
1174
+ * return <input onChange={field.onChange} ref={field.ref} value={field.value} />
1175
+ */
1176
+ const useFormattedField = ({ format, onChange, sanitize, value }) => {
1177
+ const ref = useRef(null);
1178
+ const pending = useRef(null);
1179
+ const display = format(value);
1180
+ useLayoutEffect(() => {
1181
+ const intent = pending.current;
1182
+ pending.current = null;
1183
+ const field = ref.current;
1184
+ if (field === null || intent === null || intent.value !== value) return;
1185
+ const caret = caretBeforeDigits(field.value, intent.digits);
1186
+ field.setSelectionRange(caret, caret);
1187
+ });
1188
+ return {
1189
+ onChange: (event) => {
1190
+ const typed = event.target.value;
1191
+ const typedAt = event.target.selectionStart ?? typed.length;
1192
+ const inputType = event.nativeEvent.inputType ?? "";
1193
+ const sanitized = sanitize(typed);
1194
+ const stalled = inputType.startsWith("delete") && sanitized === value;
1195
+ const [raw, at] = stalled ? dropDigit(typed, typedAt, inputType.endsWith("Forward")) : [typed, typedAt];
1196
+ const digitsAfter = countDigitsAfter(raw, at);
1197
+ const next = stalled ? sanitize(raw) : sanitized;
1198
+ if (next === value) {
1199
+ const caret = caretBeforeDigits(display, digitsAfter);
1200
+ event.target.value = display;
1201
+ event.target.setSelectionRange(caret, caret);
1202
+ } else pending.current = {
1203
+ digits: digitsAfter,
1204
+ value: next
1205
+ };
1206
+ onChange(next);
1207
+ },
1208
+ ref,
1209
+ value: display
1210
+ };
1211
+ };
1212
+ //#endregion
1213
+ //#region src/components/TokenInput/index.tsx
1214
+ const ZERO = formatAmount("0.00");
1215
+ /**
1216
+ * A controlled field for a Canton token amount. The value stays a decimal string end to end, since
1217
+ * a `number` cannot carry `Numeric 10` without losing digits; grouping separators are added for
1218
+ * reading and stripped on the way back. Max fills from `balance`, which is also the ceiling
1219
+ * `onChange` validates against, so the button never offers more than the field will accept.
1220
+ * Passing `onTokenSelect` turns the symbol into a picker over the `TokenListProvider` list.
1221
+ *
1222
+ * @example
1223
+ * <TokenInput label="Amount" token={{ symbol: 'CC' }} value={amount} balance={balance}
1224
+ * onChange={(next, error) => { setAmount(next); setError(error) }} />
1225
+ *
1226
+ * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/components/TokenInput/anatomy.ts) for the part classes and state attributes the theme selects.
1227
+ *
1228
+ * @category Components
1229
+ */
1230
+ const TokenInput = ({ "aria-describedby": describedBy, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, balance, balanceState, className, disabled, favoriteIds, id, label, onBlur, onChange, onFocus, onTokenSelect, usdValue, token, value, ...rest }) => {
1231
+ const generatedId = useId();
1232
+ const fieldId = id ?? generatedId;
1233
+ const balanceId = `${fieldId}-balance`;
1234
+ const tokenId = `${fieldId}-token`;
1235
+ const selectId = `${fieldId}-token-select`;
1236
+ const triggerRef = useRef(null);
1237
+ const bounds = { max: balance };
1238
+ const error = validateAmount(value, bounds);
1239
+ const [invalid, flagged] = resolveInvalid(ariaInvalid, error !== void 0);
1240
+ const [selectOpen, setSelectOpen] = useState(false);
1241
+ const noBalance = !parseAmount(balance ?? "");
1242
+ const balanceText = balanceState === "error" ? "Balance: N/A" : `Balance: ${balance === void 0 ? ZERO : formatAmount(balance)}`;
1243
+ const field = useFormattedField({
1244
+ format: formatAmount,
1245
+ onChange: (next) => onChange(next, validateAmount(next, bounds)),
1246
+ sanitize: sanitizeAmountInput,
1247
+ value
1248
+ });
1249
+ const handleBlur = (event) => {
1250
+ const settled = settleAmount(value);
1251
+ if (settled !== value) onChange(settled, validateAmount(settled, bounds));
1252
+ onBlur?.(event);
1253
+ };
1254
+ return /* @__PURE__ */ jsxs("div", {
1255
+ className: cx(anatomy$2.parts.root, className),
1256
+ ...rest,
1257
+ [anatomy$2.states.invalid]: flagged || void 0,
1258
+ [anatomy$2.states.disabled]: disabled || void 0,
1259
+ children: [
1260
+ label !== void 0 && /* @__PURE__ */ jsx("label", {
1261
+ className: anatomy$2.parts.label,
1262
+ htmlFor: fieldId,
1263
+ children: label
1264
+ }),
1265
+ /* @__PURE__ */ jsxs("div", {
1266
+ className: anatomy$2.parts.row,
1267
+ children: [/* @__PURE__ */ jsx("input", {
1268
+ "aria-describedby": cx(describedBy, tokenId, balanceId) || void 0,
1269
+ "aria-invalid": invalid,
1270
+ "aria-label": ariaLabel,
1271
+ "aria-labelledby": ariaLabelledBy,
1272
+ autoComplete: "off",
1273
+ className: anatomy$2.parts.field,
1274
+ disabled,
1275
+ id: fieldId,
1276
+ inputMode: "decimal",
1277
+ onBlur: handleBlur,
1278
+ onChange: field.onChange,
1279
+ onFocus,
1280
+ placeholder: ZERO,
1281
+ ref: field.ref,
1282
+ spellCheck: false,
1283
+ type: "text",
1284
+ value: field.value
1285
+ }), onTokenSelect === void 0 ? /* @__PURE__ */ jsxs("span", {
1286
+ className: anatomy$2.parts.token,
1287
+ id: tokenId,
1288
+ children: [/* @__PURE__ */ jsx(TokenLogo, {
1289
+ logo: token.logo,
1290
+ symbol: token.symbol
1291
+ }), token.symbol]
1292
+ }) : /* @__PURE__ */ jsxs("button", {
1293
+ "aria-controls": selectOpen ? selectId : void 0,
1294
+ "aria-expanded": selectOpen,
1295
+ "aria-haspopup": "dialog",
1296
+ className: anatomy$2.parts.token,
1297
+ disabled,
1298
+ onClick: () => setSelectOpen(true),
1299
+ ref: triggerRef,
1300
+ type: "button",
1301
+ [anatomy$2.states.interactive]: true,
1302
+ children: [/* @__PURE__ */ jsx(TokenLogo, {
1303
+ logo: token.logo,
1304
+ symbol: token.symbol
1305
+ }), /* @__PURE__ */ jsx("span", {
1306
+ id: tokenId,
1307
+ children: token.symbol
1308
+ })]
1309
+ })]
1310
+ }),
1311
+ /* @__PURE__ */ jsxs("div", {
1312
+ className: anatomy$2.parts.meta,
1313
+ children: [
1314
+ "~$",
1315
+ usdValue !== void 0 ? /* @__PURE__ */ jsx("span", {
1316
+ className: anatomy$2.parts.usdValue,
1317
+ children: usdValue
1318
+ }) : "0.00",
1319
+ /* @__PURE__ */ jsx("span", {
1320
+ "aria-busy": balanceState === "loading" || void 0,
1321
+ className: anatomy$2.parts.balance,
1322
+ id: balanceId,
1323
+ role: "status",
1324
+ [anatomy$2.states.balance]: balanceState ?? "ready",
1325
+ children: balanceText
1326
+ }),
1327
+ /* @__PURE__ */ jsx("button", {
1328
+ "aria-describedby": balanceId,
1329
+ className: anatomy$2.parts.max,
1330
+ disabled: disabled || balanceState !== void 0 || noBalance,
1331
+ onClick: () => {
1332
+ if (balance !== void 0) onChange(balance, validateAmount(balance, bounds));
1333
+ },
1334
+ type: "button",
1335
+ children: "Max"
1336
+ })
1337
+ ]
1338
+ }),
1339
+ onTokenSelect !== void 0 && /* @__PURE__ */ jsx(TokenSelectDialog, {
1340
+ contentId: selectId,
1341
+ favoriteIds,
1342
+ onClose: () => setSelectOpen(false),
1343
+ onSelect: onTokenSelect,
1344
+ open: selectOpen,
1345
+ returnFocusTo: triggerRef
1346
+ })
1347
+ ]
1348
+ });
1349
+ };
1350
+ //#endregion
1351
+ //#region src/hooks/useExplorerLink.ts
1352
+ const PATHS = {
1353
+ party: "/party",
1354
+ contract: "/contract",
1355
+ update: "/update"
1356
+ };
1357
+ const HASH = /^[0-9a-f]{64}$/i;
1358
+ const CONTRACT_ID = /^00[0-9a-f]{64,}$/i;
1359
+ const detectEntity = (value) => {
1360
+ if (isValidPartyId(value)) return "party";
1361
+ if (HASH.test(value)) return "update";
1362
+ if (CONTRACT_ID.test(value)) return "contract";
1363
+ };
1364
+ const requireBaseUrl = (baseUrl) => {
1365
+ const trimmed = baseUrl.trim();
1366
+ if (!trimmed) throw new Error("Explorer baseUrl is required");
1367
+ return trimmed.replace(/\/$/, "");
1368
+ };
1369
+ /**
1370
+ * Builds an explorer URL for a Canton identifier. Returns `undefined` whenever no link can be made.
1371
+ *
1372
+ * @throws when `explorer.baseUrl` is empty or blank, which is a misconfigured app rather than a
1373
+ * missing link.
1374
+ *
1375
+ * @example
1376
+ * getExplorerLink({ explorer, value: partyId })
1377
+ * // 'https://scan.example/party/…', the party shape having matched on its own
1378
+ * getExplorerLink({ explorer, value: '1220df94a1', entity: 'update' })
1379
+ * // 'https://scan.example/update/1220df94a1', the entity overriding a shape that matched nothing
1380
+ * getExplorerLink({ explorer, value: 'nico' })
1381
+ * // undefined: no shape matched, and no entity said otherwise
1382
+ *
1383
+ * @category Utilities
1384
+ */
1385
+ const getExplorerLink = ({ explorer, value, entity }) => {
1386
+ const baseUrl = requireBaseUrl(explorer.baseUrl);
1387
+ if (!value) return void 0;
1388
+ const resolved = entity ?? detectEntity(value);
1389
+ if (resolved === void 0) return void 0;
1390
+ return `${baseUrl}${PATHS[resolved]}/${encodeURIComponent(value)}`;
1391
+ };
1392
+ /**
1393
+ * Holds an explorer config so call sites pass only an identifier, and returns
1394
+ * {@link getExplorerLink} bound to it.
1395
+ *
1396
+ * @throws on render when `explorer.baseUrl` is empty or blank, as {@link getExplorerLink} does.
1397
+ *
1398
+ * @example
1399
+ * const explorerLink = useExplorerLink({ baseUrl: 'https://scan.example' })
1400
+ * <Identifier value={partyId} href={explorerLink(partyId)} />
1401
+ * <Identifier value={cid} href={explorerLink(cid, 'contract')} />
1402
+ * // no href at all where the value matched no shape and no entity said otherwise
1403
+ *
1404
+ * @category Hooks
1405
+ */
1406
+ const useExplorerLink = (explorer) => {
1407
+ const baseUrl = requireBaseUrl(explorer.baseUrl);
1408
+ return useCallback((value, entity) => getExplorerLink({
1409
+ explorer: { baseUrl },
1410
+ value,
1411
+ entity
1412
+ }), [baseUrl]);
1413
+ };
1414
+ //#endregion
1415
+ //#region src/providers/ThemeProvider/anatomy.ts
1416
+ const anatomy = { states: { theme: "data-theme" } };
1417
+ //#endregion
1418
+ //#region src/providers/ThemeProvider/constants.ts
1419
+ const DEFAULT_STORAGE_KEY = "cnc-theme";
1420
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
1421
+ //#endregion
1422
+ //#region src/providers/ThemeProvider/context.ts
1423
+ const ThemeContext = createContext(void 0);
1424
+ //#endregion
1425
+ //#region src/providers/ThemeProvider/index.tsx
1426
+ const prefersDark = () => window.matchMedia(DARK_QUERY).matches;
1427
+ const subscribePrefersDark = (onChange) => {
1428
+ const query = window.matchMedia(DARK_QUERY);
1429
+ query.addEventListener("change", onChange);
1430
+ return () => query.removeEventListener("change", onChange);
1431
+ };
1432
+ const readMode = (storageKey) => {
1433
+ try {
1434
+ const stored = localStorage.getItem(storageKey);
1435
+ return stored === "light" || stored === "dark" ? stored : "system";
1436
+ } catch {
1437
+ return "system";
1438
+ }
1439
+ };
1440
+ /**
1441
+ * Owns the light / dark / system choice: persists it, follows the OS while on `system`, tracks the
1442
+ * key across tabs, and writes the resolved value to `data-theme` on `<html>`, which is what
1443
+ * `@bootnodedev/canton-theme` keys its dark values on. Renders no DOM of its own.
1444
+ *
1445
+ * The attribute lands before the tree below it paints, but not before the page background; that
1446
+ * flash is accepted, and `architecture.md` has the reasoning. Client-only, because it reads the OS
1447
+ * preference while picking its initial state, so a server render throws.
1448
+ *
1449
+ * @see [anatomy.ts](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-dappbooster/src/providers/ThemeProvider/anatomy.ts) for the state attribute the theme selects.
1450
+ *
1451
+ * @example
1452
+ * createRoot(el).render(
1453
+ * <ThemeProvider>
1454
+ * <App />
1455
+ * </ThemeProvider>,
1456
+ * )
1457
+ *
1458
+ * @category Components
1459
+ */
1460
+ const ThemeProvider = ({ children, storageKey = DEFAULT_STORAGE_KEY }) => {
1461
+ const [mode, setStoredMode] = useState(() => readMode(storageKey));
1462
+ const systemDark = useSyncExternalStore(subscribePrefersDark, prefersDark);
1463
+ const resolved = mode === "system" ? systemDark ? "dark" : "light" : mode;
1464
+ useLayoutEffect(() => {
1465
+ document.documentElement.setAttribute(anatomy.states.theme, resolved);
1466
+ }, [resolved]);
1467
+ useEffect(() => {
1468
+ const onStorage = (event) => {
1469
+ if (event.key === null || event.key === storageKey) setStoredMode(readMode(storageKey));
1470
+ };
1471
+ window.addEventListener("storage", onStorage);
1472
+ return () => window.removeEventListener("storage", onStorage);
1473
+ }, [storageKey]);
1474
+ const setMode = useCallback((next) => {
1475
+ setStoredMode(next);
1476
+ try {
1477
+ localStorage.setItem(storageKey, next);
1478
+ } catch {}
1479
+ }, [storageKey]);
1480
+ const value = useMemo(() => ({
1481
+ mode,
1482
+ resolved,
1483
+ setMode,
1484
+ toggle: () => setMode(resolved === "dark" ? "light" : "dark")
1485
+ }), [
1486
+ mode,
1487
+ resolved,
1488
+ setMode
1489
+ ]);
1490
+ return /* @__PURE__ */ jsx(ThemeContext.Provider, {
1491
+ value,
1492
+ children
1493
+ });
1494
+ };
1495
+ //#endregion
1496
+ //#region src/providers/ThemeProvider/useTheme.ts
1497
+ /**
1498
+ * Reads and sets the theme mode.
1499
+ *
1500
+ * @throws with no {@link ThemeProvider} above it. There is no ambient fallback, because a control
1501
+ * that silently fails to switch is worse than one that throws in dev.
1502
+ *
1503
+ * @example
1504
+ * const { resolved, toggle } = useTheme()
1505
+ * <button onClick={toggle}>{resolved === 'dark' ? 'Light' : 'Dark'} mode</button>
1506
+ *
1507
+ * @category Hooks
1508
+ */
1509
+ const useTheme = () => {
1510
+ const value = useContext(ThemeContext);
1511
+ if (value === void 0) throw new Error("useTheme must be used inside a <ThemeProvider>");
1512
+ return value;
1513
+ };
1514
+ //#endregion
1515
+ //#region src/providers/TokenListProvider/index.tsx
1516
+ const held = (token) => parseAmount(token.balance ?? "") ?? -1n;
1517
+ const byBalance = (left, right) => {
1518
+ if (left.held === right.held) return 0;
1519
+ return left.held > right.held ? -1 : 1;
1520
+ };
1521
+ /**
1522
+ * Supplies the token list every picker in the tree chooses from.
1523
+ *
1524
+ * @example
1525
+ * <TokenListProvider tokens={tokens}>
1526
+ * <TokenInput label="Amount" token={selected} value={amount} onChange={setAmount}
1527
+ * onTokenSelect={setSelected} />
1528
+ * </TokenListProvider>
1529
+ *
1530
+ * @category Components
1531
+ */
1532
+ const TokenListProvider = ({ children, tokens }) => {
1533
+ const value = useMemo(() => {
1534
+ const sorted = tokens.map((token) => ({
1535
+ held: held(token),
1536
+ token
1537
+ })).sort(byBalance).map(({ token }) => token);
1538
+ return {
1539
+ byKey: new Map(sorted.map((token) => [tokenKey(token.instrumentId), token])),
1540
+ tokens: sorted
1541
+ };
1542
+ }, [tokens]);
1543
+ return /* @__PURE__ */ jsx(TokenListContext.Provider, {
1544
+ value,
1545
+ children
1546
+ });
1547
+ };
1548
+ //#endregion
1549
+ //#region src/utils/mergeTokens.ts
1550
+ const fill = (into, from) => {
1551
+ const merged = { ...into };
1552
+ for (const [field, value] of Object.entries(from)) if (value !== void 0) Object.assign(merged, { [field]: value });
1553
+ return merged;
1554
+ };
1555
+ /**
1556
+ * Builds one row per instrument out of every source that knows about it, later sources winning
1557
+ * field by field. A row is a token the picker can offer, so the union is the catalogue and a
1558
+ * holdings source only annotates it: a token nobody holds is still a row, and one nothing named is
1559
+ * a row under its own id.
1560
+ *
1561
+ * @example
1562
+ * mergeTokens([[{ instrumentId, symbol: 'CC' }], sumHoldings(holdings)])
1563
+ *
1564
+ * @category Utilities
1565
+ */
1566
+ const mergeTokens = (sources) => {
1567
+ const rows = /* @__PURE__ */ new Map();
1568
+ for (const source of sources) for (const partial of source) {
1569
+ const key = tokenKey(partial.instrumentId);
1570
+ rows.set(key, fill(rows.get(key) ?? { instrumentId: partial.instrumentId }, partial));
1571
+ }
1572
+ return [...rows.values()].map((row) => ({
1573
+ ...row,
1574
+ name: row.name ?? row.instrumentId.id,
1575
+ symbol: row.symbol ?? row.instrumentId.id
1576
+ }));
1577
+ };
1578
+ //#endregion
1579
+ //#region src/utils/readInstruments.ts
1580
+ const METADATA = "registry/metadata/v1";
1581
+ const MAX_PAGES = 100;
1582
+ const get = async (url) => {
1583
+ const response = await fetch(url, { headers: { accept: "application/json" } });
1584
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`);
1585
+ return await response.json();
1586
+ };
1587
+ const toInstrument = (admin, value) => {
1588
+ const { decimals, id, name, symbol } = asRecord(value) ?? {};
1589
+ if (typeof id !== "string" || typeof name !== "string" || typeof symbol !== "string") return;
1590
+ return {
1591
+ decimals: typeof decimals === "number" ? decimals : 10,
1592
+ instrumentId: {
1593
+ admin,
1594
+ id
1595
+ },
1596
+ name,
1597
+ symbol
1598
+ };
1599
+ };
1600
+ const toPage = (value) => {
1601
+ const { instruments, nextPageToken } = asRecord(value) ?? {};
1602
+ return {
1603
+ instruments: Array.isArray(instruments) ? instruments : [],
1604
+ nextPageToken: typeof nextPageToken === "string" && nextPageToken !== "" ? nextPageToken : void 0
1605
+ };
1606
+ };
1607
+ /**
1608
+ * Reads a registry's instrument metadata, following its pages up to a limit of 100, so the answer
1609
+ * is the registry's whole catalogue and a registry that will not stop paging cannot hang the caller.
1610
+ *
1611
+ * @throws where either request answers anything but 200, or the reply is not JSON.
1612
+ *
1613
+ * @example
1614
+ * const instruments = await readInstruments('https://registry.example/api')
1615
+ *
1616
+ * @category Utilities
1617
+ */
1618
+ const readInstruments = async (registryUrl) => {
1619
+ const base = `${registryUrl.replace(/\/$/, "")}/${METADATA}`;
1620
+ const admin = asRecord(await get(`${base}/info`))?.adminId;
1621
+ if (typeof admin !== "string") throw new Error(`${base}/info returned no adminId`);
1622
+ const found = [];
1623
+ const followed = /* @__PURE__ */ new Set();
1624
+ let pageToken;
1625
+ while (followed.size <= MAX_PAGES) {
1626
+ const query = pageToken === void 0 ? "" : `?pageToken=${encodeURIComponent(pageToken)}`;
1627
+ const page = toPage(await get(`${base}/instruments${query}`));
1628
+ for (const value of page.instruments) {
1629
+ const instrument = toInstrument(admin, value);
1630
+ if (instrument !== void 0) found.push(instrument);
1631
+ }
1632
+ if (page.nextPageToken === void 0 || followed.has(page.nextPageToken)) break;
1633
+ followed.add(page.nextPageToken);
1634
+ pageToken = page.nextPageToken;
1635
+ }
1636
+ return found;
1637
+ };
1638
+ //#endregion
1639
+ //#region src/utils/sumHoldings.ts
1640
+ /**
1641
+ * Groups a holdings read by instrument and sums it, spendable apart from locked.
1642
+ *
1643
+ * @example
1644
+ * sumHoldings([{ amount: '1.5', instrumentId, isLocked: false }])
1645
+ * // [{ balance: '1.5', instrumentId, locked: '0' }]
1646
+ *
1647
+ * @category Utilities
1648
+ */
1649
+ const sumHoldings = (holdings) => {
1650
+ const totals = /* @__PURE__ */ new Map();
1651
+ for (const { amount, instrumentId, isLocked } of holdings) {
1652
+ const scaled = parseAmount(amount);
1653
+ if (scaled === void 0) continue;
1654
+ const key = tokenKey(instrumentId);
1655
+ const at = totals.get(key) ?? {
1656
+ balance: 0n,
1657
+ instrumentId,
1658
+ locked: 0n
1659
+ };
1660
+ totals.set(key, isLocked ? {
1661
+ ...at,
1662
+ locked: at.locked + scaled
1663
+ } : {
1664
+ ...at,
1665
+ balance: at.balance + scaled
1666
+ });
1667
+ }
1668
+ return [...totals.values()].map(({ balance, instrumentId, locked }) => ({
1669
+ balance: formatScaled(balance),
1670
+ instrumentId,
1671
+ locked: formatScaled(locked)
1672
+ }));
1673
+ };
1674
+ //#endregion
1675
+ export { DEFAULT_PRECISION, ExplorerLink, Identifier, PartyIdInput, ThemeProvider, TokenInput, TokenListProvider, formatAmount, formatFigure, formatScaled, getExplorerLink, isValidPartyId, mergeTokens, parseAmount, partyHint, readInstruments, sanitizeAmountInput, sumHoldings, tokenKey, truncateIdentifier, useCopyToClipboard, useExplorerLink, useTheme, useTokenList, validateAmount, validatePartyId };