@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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/connect.d.ts +135 -0
- package/dist/connect.js +296 -0
- package/dist/index.d.ts +704 -0
- package/dist/index.js +1675 -0
- package/dist/json-CVZFwEw1.js +29 -0
- package/dist/sumHoldings-BS0bHl7z.d.ts +94 -0
- package/package.json +74 -0
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 };
|