@openzeppelin/ui-components 3.3.1 → 3.5.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/README.md +4 -0
- package/dist/index.cjs +1046 -192
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +438 -15
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +444 -21
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1029 -196
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -38,7 +38,7 @@ let sonner = require("sonner");
|
|
|
38
38
|
let next_themes = require("next-themes");
|
|
39
39
|
|
|
40
40
|
//#region src/version.ts
|
|
41
|
-
const VERSION = "3.
|
|
41
|
+
const VERSION = "3.5.0";
|
|
42
42
|
|
|
43
43
|
//#endregion
|
|
44
44
|
//#region src/components/ui/accordion.tsx
|
|
@@ -131,6 +131,50 @@ const TooltipContent = react.forwardRef(({ className, sideOffset = 4, ...props }
|
|
|
131
131
|
}) }));
|
|
132
132
|
TooltipContent.displayName = _radix_ui_react_tooltip.Content.displayName;
|
|
133
133
|
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/components/ui/address-display/address-avatar.tsx
|
|
136
|
+
/**
|
|
137
|
+
* Allowlist for name-owner-controlled avatar URLs at the render site.
|
|
138
|
+
* Only `https:` and `data:image/*` pass; `http:` (mixed content), `javascript:`,
|
|
139
|
+
* and other schemes are dropped. Does not rely on adapter sanitization.
|
|
140
|
+
*/
|
|
141
|
+
function isAllowedAvatarSrc(src) {
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL(src);
|
|
144
|
+
if (url.protocol === "https:") return true;
|
|
145
|
+
if (url.protocol === "data:") return /^data:image\/[a-z0-9.+-]+/i.test(src);
|
|
146
|
+
return false;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* @internal Avatar image for a resolved name, hiding itself on load error
|
|
153
|
+
* (never a broken-image icon) — the name renders as a complete, avatar-less
|
|
154
|
+
* row. Purely presentational; its only state is the per-instance load-error
|
|
155
|
+
* flag (INV-121).
|
|
156
|
+
*
|
|
157
|
+
* The error state is keyed to the current `src` rather than a bare boolean,
|
|
158
|
+
* so it resets automatically when `src` changes — a virtualized/reused list
|
|
159
|
+
* row that scrolls to a new address re-attempts its avatar instead of staying
|
|
160
|
+
* permanently hidden by a prior URL's failure (INV-59).
|
|
161
|
+
*
|
|
162
|
+
* @param props - {@link AddressAvatarProps}.
|
|
163
|
+
* @returns The avatar `<img>`, or `null` when the load failed for this `src`.
|
|
164
|
+
*/
|
|
165
|
+
function AddressAvatar({ src, alt = "", variant = "fill", sizeClassName }) {
|
|
166
|
+
const [failedSrc, setFailedSrc] = react.useState(null);
|
|
167
|
+
if (!isAllowedAvatarSrc(src)) return null;
|
|
168
|
+
if (failedSrc === src) return null;
|
|
169
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
170
|
+
src,
|
|
171
|
+
alt,
|
|
172
|
+
className: (0, _openzeppelin_ui_utils.cn)("object-cover", sizeClassName ?? (variant === "fill" ? "absolute inset-0 h-full w-full rounded-none" : "h-4 w-4 shrink-0 rounded-full")),
|
|
173
|
+
referrerPolicy: "no-referrer",
|
|
174
|
+
onError: () => setFailedSrc(src)
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
134
178
|
//#endregion
|
|
135
179
|
//#region src/components/ui/address-display/context.ts
|
|
136
180
|
/**
|
|
@@ -139,6 +183,14 @@ TooltipContent.displayName = _radix_ui_react_tooltip.Content.displayName;
|
|
|
139
183
|
* only components (required by React Fast Refresh).
|
|
140
184
|
*/
|
|
141
185
|
const AddressLabelContext = (0, react.createContext)(null);
|
|
186
|
+
/**
|
|
187
|
+
* @internal Shared context instance consumed by AddressDisplay,
|
|
188
|
+
* AddressNameProvider, and useAddressName. Sibling of AddressLabelContext
|
|
189
|
+
* (SF-4 INV-122): carries a synchronous, value-only reverse-name resolver.
|
|
190
|
+
* Defaults to `null` — no provider means no resolution and a byte-identical
|
|
191
|
+
* zero-injection render (INV-54).
|
|
192
|
+
*/
|
|
193
|
+
const AddressNameContext = (0, react.createContext)(null);
|
|
142
194
|
|
|
143
195
|
//#endregion
|
|
144
196
|
//#region src/components/ui/address-display/address-display.tsx
|
|
@@ -155,16 +207,37 @@ function usePrefersHover() {
|
|
|
155
207
|
}, []), () => typeof window !== "undefined" && typeof window.matchMedia !== "undefined" ? window.matchMedia("(hover: hover)").matches : true, () => true);
|
|
156
208
|
}
|
|
157
209
|
/**
|
|
210
|
+
* Strip C0/C1 controls, bidi embeds/isolates, and zero-width characters from a
|
|
211
|
+
* verified name at the display seam. ENSIP-15 normalization remains the
|
|
212
|
+
* adapter's job; this only removes invisible directionality/control glyphs so
|
|
213
|
+
* a raw render cannot spoof adjacent UI (homoglyph confusables are out of scope).
|
|
214
|
+
*/
|
|
215
|
+
function sanitizeVerifiedNameForDisplay(name) {
|
|
216
|
+
return name.replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/g, "");
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
158
219
|
* Displays a blockchain address with optional truncation, copy button,
|
|
159
|
-
* explorer link, tooltip,
|
|
220
|
+
* explorer link, tooltip, human-readable label, and (when an already-resolved
|
|
221
|
+
* record is injected) a forward-verified name + avatar.
|
|
160
222
|
*
|
|
161
|
-
* Labels are resolved in priority order:
|
|
223
|
+
* Labels are resolved in priority order (INV-56):
|
|
162
224
|
* 1. Explicit `label` prop
|
|
163
225
|
* 2. `AddressLabelContext` resolver (via `AddressLabelProvider`)
|
|
164
|
-
* 3.
|
|
226
|
+
* 3. Forward-verified resolved name (`resolvedName` prop, else
|
|
227
|
+
* `AddressNameContext` via `AddressNameProvider`) — a record with
|
|
228
|
+
* `forwardVerified: false` is suppressed to hex (INV-52, LOCKED)
|
|
229
|
+
* 4. No label (renders address only, identical to previous behavior)
|
|
230
|
+
*
|
|
231
|
+
* The component is synchronous and capability-free (INV-121): it never
|
|
232
|
+
* resolves anything itself — it reads injected values. Hex renders on first
|
|
233
|
+
* commit; the name+avatar swap in when the react layer feeds an updated
|
|
234
|
+
* record (progressive enhancement, INV-61). With no `resolvedName` and no
|
|
235
|
+
* `AddressNameProvider`, output is byte-identical to the pre-enhancement
|
|
236
|
+
* component (INV-54, LOCKED).
|
|
165
237
|
*
|
|
166
|
-
* Pass `disableLabel` to
|
|
167
|
-
*
|
|
238
|
+
* Pass `disableLabel` to render fully raw — it suppresses both the context
|
|
239
|
+
* alias and any injected resolved name (e.g. when the surrounding UI already
|
|
240
|
+
* shows a name, such as a contract selector).
|
|
168
241
|
*
|
|
169
242
|
* @example
|
|
170
243
|
* ```tsx
|
|
@@ -191,18 +264,37 @@ function usePrefersHover() {
|
|
|
191
264
|
* // Inline variant (no chip background) — useful inside wallet bars
|
|
192
265
|
* <AddressDisplay address="0x742d35Cc..." variant="inline" showTooltip showCopyButton />
|
|
193
266
|
*
|
|
267
|
+
* // Reverse-resolved name + avatar via an injected value (react layer owns the async)
|
|
268
|
+
* <AddressDisplay address="0x742d35Cc..." resolvedName={record} showCopyButton />
|
|
269
|
+
*
|
|
270
|
+
* // Or subtree-wide via context (rows stay plain <AddressDisplay/>)
|
|
271
|
+
* <AddressNameProvider resolveAddressName={readFromResolutionCache}>
|
|
272
|
+
* <AddressDisplay address="0x742d35Cc..." />
|
|
273
|
+
* </AddressNameProvider>
|
|
274
|
+
*
|
|
194
275
|
* // Truncate only when an alias/label is present (full address when unlabeled)
|
|
195
276
|
* <AddressDisplay address="G..." truncateWhenLabeled />
|
|
196
277
|
* ```
|
|
197
278
|
*/
|
|
198
|
-
function AddressDisplay({ address, truncate: truncateProp, truncateWhenLabeled = false, untruncateOnHover = false, startChars = 6, endChars = 4, showCopyButton = false, showCopyButtonOnHover = false, explorerUrl, label: labelProp, onLabelEdit: onLabelEditProp, networkId, disableLabel = false, showTooltip = false, variant = "chip", className, onPointerEnter, onPointerLeave, onMouseEnter, onMouseLeave, onClick, ...props }) {
|
|
279
|
+
function AddressDisplay({ address, truncate: truncateProp, truncateWhenLabeled = false, untruncateOnHover = false, startChars = 6, endChars = 4, showCopyButton = false, showCopyButtonOnHover = false, explorerUrl, label: labelProp, onLabelEdit: onLabelEditProp, networkId, disableLabel = false, showTooltip = false, variant = "chip", avatar, avatarVariant = "fill", resolvedName, showCrossNetworkFallbackDisclaimer = true, className, onPointerEnter, onPointerLeave, onMouseEnter, onMouseLeave, onClick, ...props }) {
|
|
199
280
|
const [copied, setCopied] = react.useState(false);
|
|
200
281
|
const [isHovered, setIsHovered] = react.useState(false);
|
|
201
282
|
const copyTimeoutRef = react.useRef(null);
|
|
202
283
|
const prefersHover = usePrefersHover();
|
|
203
284
|
const resolver = react.useContext(AddressLabelContext);
|
|
285
|
+
const nameResolver = react.useContext(AddressNameContext);
|
|
204
286
|
const resolvedLabel = disableLabel ? void 0 : labelProp ?? resolver?.resolveLabel(address, networkId);
|
|
205
|
-
const
|
|
287
|
+
const record = disableLabel ? void 0 : resolvedName ?? nameResolver?.resolveAddressName(address, networkId);
|
|
288
|
+
const ensName = record?.forwardVerified === true ? sanitizeVerifiedNameForDisplay(record.name) : void 0;
|
|
289
|
+
const effectiveLabel = resolvedLabel ?? ensName;
|
|
290
|
+
const showFallbackNote = showCrossNetworkFallbackDisclaimer && ensName !== void 0 && effectiveLabel === ensName && record !== void 0 && (0, _openzeppelin_ui_utils.isCrossNetworkFallback)(record.provenance);
|
|
291
|
+
const fallbackNote = (() => {
|
|
292
|
+
if (!showFallbackNote) return void 0;
|
|
293
|
+
const networks = (0, _openzeppelin_ui_utils.getFallbackNetworks)(record.provenance);
|
|
294
|
+
if (networks === void 0) return void 0;
|
|
295
|
+
return (0, _openzeppelin_ui_utils.nameResolutionCrossNetworkFallbackMessage)(networks, (0, _openzeppelin_ui_utils.crossNetworkFallbackMessageNames)(networks, nameResolver?.resolveNetworkLabel));
|
|
296
|
+
})();
|
|
297
|
+
const effectiveTruncate = truncateProp !== void 0 ? truncateProp : truncateWhenLabeled ? Boolean(effectiveLabel) : true;
|
|
206
298
|
const contextEditHandler = react.useCallback(() => {
|
|
207
299
|
resolver?.onEditLabel?.(address, networkId);
|
|
208
300
|
}, [
|
|
@@ -276,6 +368,17 @@ function AddressDisplay({ address, truncate: truncateProp, truncateWhenLabeled =
|
|
|
276
368
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Pencil, { className: "h-3.5 w-3.5" })
|
|
277
369
|
})
|
|
278
370
|
] });
|
|
371
|
+
const ensAvatarUrl = ensName !== void 0 && resolvedLabel === void 0 ? record?.avatarUrl : void 0;
|
|
372
|
+
const effectiveAvatar = avatar ?? (ensAvatarUrl ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressAvatar, {
|
|
373
|
+
src: ensAvatarUrl,
|
|
374
|
+
variant: avatarVariant
|
|
375
|
+
}) : void 0);
|
|
376
|
+
const isFillAvatar = avatarVariant === "fill" && Boolean(effectiveAvatar);
|
|
377
|
+
const fillAvatarChipClassName = isFillAvatar && isChip ? "overflow-hidden" : void 0;
|
|
378
|
+
const avatarSlot = effectiveAvatar ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
379
|
+
className: (0, _openzeppelin_ui_utils.cn)("flex shrink-0", isFillAvatar ? (0, _openzeppelin_ui_utils.cn)("relative w-9 self-stretch overflow-hidden mr-2", isChip && "-my-1 -ml-2") : "mr-1.5 items-center"),
|
|
380
|
+
children: effectiveAvatar
|
|
381
|
+
}) : null;
|
|
279
382
|
const shouldShowTooltip = showTooltip && effectiveTruncate;
|
|
280
383
|
const wrapWithTooltip = (content) => {
|
|
281
384
|
if (!shouldShowTooltip) return content;
|
|
@@ -290,33 +393,63 @@ function AddressDisplay({ address, truncate: truncateProp, truncateWhenLabeled =
|
|
|
290
393
|
})] })
|
|
291
394
|
});
|
|
292
395
|
};
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
396
|
+
if (effectiveLabel) {
|
|
397
|
+
const labelColumn = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
398
|
+
className: "inline-flex min-w-0 max-w-full items-center gap-1",
|
|
399
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
400
|
+
className: "truncate font-sans font-medium text-slate-900 leading-snug",
|
|
401
|
+
children: effectiveLabel
|
|
402
|
+
}), fallbackNote && showCrossNetworkFallbackDisclaimer ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TooltipProvider, {
|
|
403
|
+
delayDuration: 300,
|
|
404
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Tooltip, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TooltipTrigger, {
|
|
405
|
+
asChild: true,
|
|
406
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
407
|
+
type: "button",
|
|
408
|
+
className: "inline-flex shrink-0 rounded-sm text-amber-500 outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50",
|
|
409
|
+
"aria-label": fallbackNote,
|
|
410
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.TriangleAlert, {
|
|
411
|
+
className: "h-3 w-3",
|
|
412
|
+
"aria-hidden": true
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TooltipContent, {
|
|
416
|
+
className: "max-w-xs font-sans text-xs font-normal normal-case",
|
|
417
|
+
children: fallbackNote
|
|
418
|
+
})] })
|
|
419
|
+
}) : null]
|
|
302
420
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
303
421
|
className: "flex min-w-0 items-center font-mono text-[10px] text-slate-400 leading-snug",
|
|
304
422
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
305
423
|
className: addressTextClassName,
|
|
306
424
|
children: displayAddress
|
|
307
425
|
}), actionButtons]
|
|
308
|
-
})]
|
|
309
|
-
|
|
426
|
+
})] });
|
|
427
|
+
return wrapWithTooltip(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
428
|
+
className: (0, _openzeppelin_ui_utils.cn)("group inline-flex max-w-full min-w-0", effectiveAvatar ? "items-center" : "flex-col", isChip && "rounded-md bg-slate-100 px-2 py-1", fillAvatarChipClassName, "text-xs text-slate-700", expandInteractionClassName, className),
|
|
429
|
+
onPointerEnter: handlePointerEnter,
|
|
430
|
+
onPointerLeave: handlePointerLeave,
|
|
431
|
+
onClick: handleUntruncateClick,
|
|
432
|
+
...props,
|
|
433
|
+
children: effectiveAvatar ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [avatarSlot, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
434
|
+
className: "inline-flex min-w-0 flex-col",
|
|
435
|
+
children: labelColumn
|
|
436
|
+
})] }) : labelColumn
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
310
439
|
return wrapWithTooltip(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
311
|
-
className: (0, _openzeppelin_ui_utils.cn)("group inline-flex max-w-full min-w-0 items-center", isChip && "rounded-md bg-slate-100 px-2 py-1", "text-xs font-mono text-slate-700", expandInteractionClassName, className),
|
|
440
|
+
className: (0, _openzeppelin_ui_utils.cn)("group inline-flex max-w-full min-w-0 items-center", isChip && "rounded-md bg-slate-100 px-2 py-1", fillAvatarChipClassName, "text-xs font-mono text-slate-700", expandInteractionClassName, className),
|
|
312
441
|
onPointerEnter: handlePointerEnter,
|
|
313
442
|
onPointerLeave: handlePointerLeave,
|
|
314
443
|
onClick: handleUntruncateClick,
|
|
315
444
|
...props,
|
|
316
|
-
children: [
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
445
|
+
children: [
|
|
446
|
+
avatarSlot,
|
|
447
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
448
|
+
className: addressTextClassName,
|
|
449
|
+
children: displayAddress
|
|
450
|
+
}),
|
|
451
|
+
actionButtons
|
|
452
|
+
]
|
|
320
453
|
}));
|
|
321
454
|
}
|
|
322
455
|
|
|
@@ -414,6 +547,82 @@ function useAddressLabel(address, networkId) {
|
|
|
414
547
|
};
|
|
415
548
|
}
|
|
416
549
|
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/components/ui/address-display/address-name-context.tsx
|
|
552
|
+
/**
|
|
553
|
+
* Address Name Context
|
|
554
|
+
*
|
|
555
|
+
* Provides a React context for surfacing already-resolved reverse name
|
|
556
|
+
* records (`ResolvedName`) for blockchain addresses. When an
|
|
557
|
+
* `AddressNameProvider` is mounted, every `AddressDisplay` in the subtree
|
|
558
|
+
* reads its record synchronously — no call-site changes — and renders the
|
|
559
|
+
* name/avatar only when the record is forward-verified (INV-52).
|
|
560
|
+
*
|
|
561
|
+
* Structural mirror of `AddressLabelProvider` (INV-122): synchronous,
|
|
562
|
+
* value-only, no-op by default. The resolver implementation lives in the
|
|
563
|
+
* react/renderer layer, which bridges async resolution state into this
|
|
564
|
+
* synchronous read; this file stays capability-free (INV-121).
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* ```tsx
|
|
568
|
+
* import { AddressNameProvider } from '@openzeppelin/ui-components';
|
|
569
|
+
*
|
|
570
|
+
* <AddressNameProvider resolveAddressName={readFromResolutionCache}>
|
|
571
|
+
* <TxHistory /> {// rows are plain <AddressDisplay/> — verified names fill in }
|
|
572
|
+
* </AddressNameProvider>
|
|
573
|
+
* ```
|
|
574
|
+
*/
|
|
575
|
+
/**
|
|
576
|
+
* Provides reverse-name resolution to all `AddressDisplay` instances in the
|
|
577
|
+
* subtree. `resolveAddressName` MUST be synchronous — it is called during
|
|
578
|
+
* render (INV-122). A resolver that returns `undefined` for an address is
|
|
579
|
+
* behaviorally identical to mounting no provider (INV-54).
|
|
580
|
+
*
|
|
581
|
+
* @param props - Resolver function and children
|
|
582
|
+
*/
|
|
583
|
+
function AddressNameProvider({ children, resolveAddressName, resolveNetworkLabel }) {
|
|
584
|
+
const value = react.useMemo(() => ({
|
|
585
|
+
resolveAddressName,
|
|
586
|
+
resolveNetworkLabel
|
|
587
|
+
}), [resolveAddressName, resolveNetworkLabel]);
|
|
588
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressNameContext.Provider, {
|
|
589
|
+
value,
|
|
590
|
+
children
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/components/ui/address-display/use-address-name.ts
|
|
596
|
+
/**
|
|
597
|
+
* Convenience hook for reading a reverse name record from the nearest
|
|
598
|
+
* `AddressNameProvider`.
|
|
599
|
+
*
|
|
600
|
+
* Kept in its own file so that `address-name-context.tsx` exports only
|
|
601
|
+
* components (required by React Fast Refresh) — mirrors `use-address-label.ts`.
|
|
602
|
+
*/
|
|
603
|
+
/**
|
|
604
|
+
* Reads the `AddressNameContext` for one address, synchronously (INV-122).
|
|
605
|
+
* Returns `{ record: undefined }` when no provider is mounted — never throws,
|
|
606
|
+
* never suspends. Note the record is returned verbatim: callers rendering a
|
|
607
|
+
* name from it must apply the same `forwardVerified === true` gate that
|
|
608
|
+
* `AddressDisplay` applies (INV-52).
|
|
609
|
+
*
|
|
610
|
+
* @param address - The blockchain address to reverse-resolve
|
|
611
|
+
* @param networkId - Optional network identifier scoping the lookup
|
|
612
|
+
* @returns The current best-known record for the address
|
|
613
|
+
*
|
|
614
|
+
* @example
|
|
615
|
+
* ```tsx
|
|
616
|
+
* function EnsBadge({ address }: { address: string }) {
|
|
617
|
+
* const { record } = useAddressName(address);
|
|
618
|
+
* return record?.forwardVerified ? <Badge>{record.name}</Badge> : null;
|
|
619
|
+
* }
|
|
620
|
+
* ```
|
|
621
|
+
*/
|
|
622
|
+
function useAddressName(address, networkId) {
|
|
623
|
+
return { record: react.useContext(AddressNameContext)?.resolveAddressName(address, networkId) };
|
|
624
|
+
}
|
|
625
|
+
|
|
417
626
|
//#endregion
|
|
418
627
|
//#region src/components/ui/alert.tsx
|
|
419
628
|
const alertVariants = (0, class_variance_authority.cva)("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", {
|
|
@@ -2551,6 +2760,55 @@ function WizardLayout(props) {
|
|
|
2551
2760
|
});
|
|
2552
2761
|
}
|
|
2553
2762
|
|
|
2763
|
+
//#endregion
|
|
2764
|
+
//#region src/components/fields/address-suggestion/AddressSuggestionList.tsx
|
|
2765
|
+
/**
|
|
2766
|
+
* Presentational ARIA listbox for address suggestions (SF-3, D9).
|
|
2767
|
+
*
|
|
2768
|
+
* Extracted from `AddressField`'s inline dropdown markup into a reusable
|
|
2769
|
+
* presentational listbox so the base `AddressField` renders a consistent
|
|
2770
|
+
* suggestion structure (INV-72). Purely presentational and capability-free: it
|
|
2771
|
+
* owns no state and no resolution — the consuming field supplies the resolved
|
|
2772
|
+
* list, the highlighted index, and the select / hover callbacks.
|
|
2773
|
+
*
|
|
2774
|
+
* @see INV-72 (byte-identical suggestion structure across both fields)
|
|
2775
|
+
* @see INV-94 (`role="listbox"` / `role="option"`, `aria-selected`, `onMouseDown`-preventDefault select)
|
|
2776
|
+
*/
|
|
2777
|
+
/**
|
|
2778
|
+
* Render the suggestion dropdown. Returns `null` when there are no suggestions
|
|
2779
|
+
* (the consuming field also gates on `hasSuggestions`, so this is a safety net).
|
|
2780
|
+
*
|
|
2781
|
+
* @param props - {@link AddressSuggestionListProps}.
|
|
2782
|
+
*/
|
|
2783
|
+
function AddressSuggestionList({ id, suggestions, highlightedIndex, onSelect, onHighlight }) {
|
|
2784
|
+
if (suggestions.length === 0) return null;
|
|
2785
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2786
|
+
id: `${id}-suggestions`,
|
|
2787
|
+
className: (0, _openzeppelin_ui_utils.cn)("absolute z-50 mt-1 w-full rounded-md border border-border bg-popover shadow-md", "max-h-48 overflow-auto"),
|
|
2788
|
+
role: "listbox",
|
|
2789
|
+
children: suggestions.map((s, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2790
|
+
id: `${id}-suggestion-${i}`,
|
|
2791
|
+
type: "button",
|
|
2792
|
+
role: "option",
|
|
2793
|
+
"aria-selected": i === highlightedIndex,
|
|
2794
|
+
className: (0, _openzeppelin_ui_utils.cn)("flex w-full flex-col px-3 py-2 text-left text-sm", "hover:bg-selected/10", i === highlightedIndex && "bg-selected/10"),
|
|
2795
|
+
onMouseDown: (e) => {
|
|
2796
|
+
e.preventDefault();
|
|
2797
|
+
onSelect(s);
|
|
2798
|
+
},
|
|
2799
|
+
onMouseEnter: () => onHighlight(i),
|
|
2800
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2801
|
+
className: "font-medium",
|
|
2802
|
+
children: s.label
|
|
2803
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2804
|
+
className: "truncate font-mono text-xs text-muted-foreground",
|
|
2805
|
+
children: s.value
|
|
2806
|
+
})]
|
|
2807
|
+
}, `${s.value}-${s.description ?? i}`))
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
AddressSuggestionList.displayName = "AddressSuggestionList";
|
|
2811
|
+
|
|
2554
2812
|
//#endregion
|
|
2555
2813
|
//#region src/components/fields/address-suggestion/context.ts
|
|
2556
2814
|
/**
|
|
@@ -2560,6 +2818,372 @@ function WizardLayout(props) {
|
|
|
2560
2818
|
*/
|
|
2561
2819
|
const AddressSuggestionContext = (0, react.createContext)(null);
|
|
2562
2820
|
|
|
2821
|
+
//#endregion
|
|
2822
|
+
//#region src/components/fields/address-suggestion/useAddressSuggestionField.ts
|
|
2823
|
+
/**
|
|
2824
|
+
* Headless suggestion-dropdown behavior for address fields.
|
|
2825
|
+
*
|
|
2826
|
+
* Extracted from `AddressField`'s inline dropdown logic (SF-3, D9) into one
|
|
2827
|
+
* reusable headless hook the base `AddressField` consumes — no divergence, no
|
|
2828
|
+
* regression (INV-72). It owns:
|
|
2829
|
+
* - `AddressSuggestionContext` resolution (explicit `suggestions` prop overrides),
|
|
2830
|
+
* - the `MAX_SUGGESTIONS` (5) cap on context-resolved lists,
|
|
2831
|
+
* - the 200 ms suggestion debounce,
|
|
2832
|
+
* - keyboard highlight state with wrapping ArrowUp / ArrowDown navigation,
|
|
2833
|
+
* - click-outside-to-close.
|
|
2834
|
+
*
|
|
2835
|
+
* It is capability-free and chain-agnostic — nothing about ENS or any chain
|
|
2836
|
+
* leaks into `@openzeppelin/ui-components`. The consuming field owns the RHF
|
|
2837
|
+
* value write on selection and the Enter / Escape input handlers; this hook
|
|
2838
|
+
* exposes the state and primitives they need.
|
|
2839
|
+
*
|
|
2840
|
+
* Distinct from the pre-existing convenience hook `useAddressSuggestions(query,
|
|
2841
|
+
* networkId)`, which resolves a raw suggestion list from context with no
|
|
2842
|
+
* debounce / cap / keyboard state — that hook is unchanged and still exported.
|
|
2843
|
+
*
|
|
2844
|
+
* @see INV-72 (suggestion-dropdown behavior preserved with zero regression; SF-5 depends)
|
|
2845
|
+
* @see INV-94 (legacy a11y + suggestion keyboard contract preserved verbatim)
|
|
2846
|
+
*/
|
|
2847
|
+
/** Suggestion debounce window (ms). Matches the legacy `AddressField`. */
|
|
2848
|
+
const DEBOUNCE_MS = 200;
|
|
2849
|
+
/** Cap on context-resolved suggestions. Explicit arrays are rendered as-is. */
|
|
2850
|
+
const MAX_SUGGESTIONS = 5;
|
|
2851
|
+
/**
|
|
2852
|
+
* Headless address-suggestion dropdown behavior. See the module docstring for
|
|
2853
|
+
* the full behavior contract.
|
|
2854
|
+
*
|
|
2855
|
+
* @param args - {@link UseAddressSuggestionFieldArgs}.
|
|
2856
|
+
* @returns {@link UseAddressSuggestionFieldResult}.
|
|
2857
|
+
*/
|
|
2858
|
+
function useAddressSuggestionField({ inputValue, suggestions: suggestionsProp, networkId }) {
|
|
2859
|
+
const contextResolver = (0, react.useContext)(AddressSuggestionContext);
|
|
2860
|
+
const containerRef = (0, react.useRef)(null);
|
|
2861
|
+
const [debouncedQuery, setDebouncedQuery] = (0, react.useState)("");
|
|
2862
|
+
const [showSuggestions, setShowSuggestions] = (0, react.useState)(false);
|
|
2863
|
+
const [highlightedIndex, setHighlightedIndex] = (0, react.useState)(-1);
|
|
2864
|
+
(0, react.useEffect)(() => {
|
|
2865
|
+
if (!inputValue.trim()) {
|
|
2866
|
+
setDebouncedQuery("");
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
const timer = setTimeout(() => setDebouncedQuery(inputValue), DEBOUNCE_MS);
|
|
2870
|
+
return () => clearTimeout(timer);
|
|
2871
|
+
}, [inputValue]);
|
|
2872
|
+
const suggestionsDisabled = suggestionsProp === false;
|
|
2873
|
+
const resolvedSuggestions = (0, react.useMemo)(() => {
|
|
2874
|
+
if (suggestionsDisabled) return [];
|
|
2875
|
+
if (Array.isArray(suggestionsProp)) return suggestionsProp;
|
|
2876
|
+
if (!contextResolver || !debouncedQuery.trim()) return [];
|
|
2877
|
+
return contextResolver.resolveSuggestions(debouncedQuery, networkId).slice(0, MAX_SUGGESTIONS);
|
|
2878
|
+
}, [
|
|
2879
|
+
suggestionsDisabled,
|
|
2880
|
+
suggestionsProp,
|
|
2881
|
+
contextResolver,
|
|
2882
|
+
debouncedQuery,
|
|
2883
|
+
networkId
|
|
2884
|
+
]);
|
|
2885
|
+
const hasSuggestions = showSuggestions && resolvedSuggestions.length > 0;
|
|
2886
|
+
(0, react.useEffect)(() => {
|
|
2887
|
+
let active = true;
|
|
2888
|
+
const handleClickOutside = (e) => {
|
|
2889
|
+
if (active && containerRef.current && !containerRef.current.contains(e.target)) setShowSuggestions(false);
|
|
2890
|
+
};
|
|
2891
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
2892
|
+
return () => {
|
|
2893
|
+
active = false;
|
|
2894
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
2895
|
+
};
|
|
2896
|
+
}, []);
|
|
2897
|
+
const onContainerKeyDown = (0, react.useCallback)((e) => {
|
|
2898
|
+
if (!hasSuggestions) return;
|
|
2899
|
+
if (e.key === "ArrowDown") {
|
|
2900
|
+
e.preventDefault();
|
|
2901
|
+
setHighlightedIndex((prev) => prev < resolvedSuggestions.length - 1 ? prev + 1 : 0);
|
|
2902
|
+
} else if (e.key === "ArrowUp") {
|
|
2903
|
+
e.preventDefault();
|
|
2904
|
+
setHighlightedIndex((prev) => prev > 0 ? prev - 1 : resolvedSuggestions.length - 1);
|
|
2905
|
+
}
|
|
2906
|
+
}, [hasSuggestions, resolvedSuggestions.length]);
|
|
2907
|
+
const onInputChange = (0, react.useCallback)((value) => {
|
|
2908
|
+
setShowSuggestions(value.length > 0);
|
|
2909
|
+
setHighlightedIndex(-1);
|
|
2910
|
+
}, []);
|
|
2911
|
+
return {
|
|
2912
|
+
containerRef,
|
|
2913
|
+
resolvedSuggestions,
|
|
2914
|
+
hasSuggestions,
|
|
2915
|
+
highlightedIndex,
|
|
2916
|
+
suggestionsDisabled,
|
|
2917
|
+
setHighlightedIndex,
|
|
2918
|
+
closeSuggestions: (0, react.useCallback)(() => {
|
|
2919
|
+
setShowSuggestions(false);
|
|
2920
|
+
setHighlightedIndex(-1);
|
|
2921
|
+
}, []),
|
|
2922
|
+
onInputChange,
|
|
2923
|
+
onContainerKeyDown
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
//#endregion
|
|
2928
|
+
//#region src/components/fields/name-resolution/useInjectedNameResolution.ts
|
|
2929
|
+
/**
|
|
2930
|
+
* Injected name-resolution machine (SF-3).
|
|
2931
|
+
*
|
|
2932
|
+
* A thin, capability-free async machine that `AddressField` consumes: given a
|
|
2933
|
+
* typed input and the injected `resolveName`, it debounces, dispatches, tracks
|
|
2934
|
+
* status, and applies the out-of-order name-drop guard (INV-117). It is the
|
|
2935
|
+
* component-boundary analogue of SF-2's `useResolveName`, with all hard
|
|
2936
|
+
* mechanics (cache, dedupe, retries, UNSUPPORTED synthesis) deliberately left
|
|
2937
|
+
* behind the injected function — this hook does ONLY debounce + status
|
|
2938
|
+
* derivation + the funds guards.
|
|
2939
|
+
*
|
|
2940
|
+
* The machine holds no resolved-hex cache of its own (INV-85): its single
|
|
2941
|
+
* `settled` record is valid only while it matches the current debounced input,
|
|
2942
|
+
* the current resolver function identity, and the current retry attempt — any
|
|
2943
|
+
* mismatch derives `loading`, never a stale `resolved`.
|
|
2944
|
+
*
|
|
2945
|
+
* Resolver-identity-churn backstop (INV-123/124/125). The dispatch effect keys
|
|
2946
|
+
* on the injected `resolveName` function identity, so an unstable (non-memoized)
|
|
2947
|
+
* integrator resolver re-fires it on every render. To keep that from becoming an
|
|
2948
|
+
* unbounded RPC loop (rate-ban + cost + funds-path DoS), the machine caps
|
|
2949
|
+
* dispatches per resolution intent — the pair (normalized debounced input × retry
|
|
2950
|
+
* attempt) — at {@link MAX_DISPATCHES_PER_INTENT}, regardless of render count and
|
|
2951
|
+
* regardless of how many times `resolveName` identity changes under that intent.
|
|
2952
|
+
* The budget resets only on intent change (a new typed name, or `retry()`) or when
|
|
2953
|
+
* the field leaves the active name-resolution path (clear / disable). It does
|
|
2954
|
+
* *not* reset on `settled.source` mismatch: that would let two settling sources
|
|
2955
|
+
* flap and refill the counter (settling churn), silently defeating INV-123. A
|
|
2956
|
+
* genuine network swap still re-dispatches within the shared intent budget
|
|
2957
|
+
* (INV-119 / acceptance #5) because a single identity change costs one unit.
|
|
2958
|
+
* Clear→retype of the same name gets a fresh budget via the leave-path reset, so
|
|
2959
|
+
* it cannot accumulate toward the cap across cycles and then strand a later swap.
|
|
2960
|
+
* A churning identity that exhausts the budget emits a one-shot, development-only
|
|
2961
|
+
* warning (INV-124) and degrades to the safe gated state (bounded RPC, RHF value
|
|
2962
|
+
* `''`, submit gated, never a wrong hex, never a throw — INV-125). This backstop
|
|
2963
|
+
* is entirely internal: no signature, param, or return-type change; `AddressField`
|
|
2964
|
+
* consumes the machine unchanged.
|
|
2965
|
+
*/
|
|
2966
|
+
/**
|
|
2967
|
+
* Default debounce window (ms) for typed names — mirrors SF-2's
|
|
2968
|
+
* `forwardDebounceMs` default so the field and the bare hook feel identical.
|
|
2969
|
+
*/
|
|
2970
|
+
const NAME_RESOLUTION_DEBOUNCE_MS = 300;
|
|
2971
|
+
/**
|
|
2972
|
+
* Cap on resolver dispatches per resolution intent — the pair (normalized
|
|
2973
|
+
* debounced input × retry attempt) — regardless of how many times the field
|
|
2974
|
+
* re-renders or how many times `resolveName` identity changes under that intent
|
|
2975
|
+
* (INV-123). Chosen to sit comfortably above the dispatches a single displayed
|
|
2976
|
+
* input legitimately incurs (1 initial call + any human-initiated genuine network
|
|
2977
|
+
* swaps of that same name, each ≤1 unit — INV-119) and far below per-render
|
|
2978
|
+
* identity churn (hundreds/sec): a user does not switch networks eight times
|
|
2979
|
+
* while one unedited name sits in the field, whereas churn blows past eight
|
|
2980
|
+
* within milliseconds. The budget resets when the intent changes or when the
|
|
2981
|
+
* field leaves the name-resolution path (clear / disable) — never on
|
|
2982
|
+
* `settled.source` mismatch, so settling-source oscillation cannot refill it.
|
|
2983
|
+
*/
|
|
2984
|
+
const MAX_DISPATCHES_PER_INTENT = 8;
|
|
2985
|
+
/**
|
|
2986
|
+
* Debounce + dispatch + status derivation over an injected `resolveName`.
|
|
2987
|
+
*
|
|
2988
|
+
* Guarantees:
|
|
2989
|
+
* - INV-83: no call unless `enabled` (name-candidate + injected method), and
|
|
2990
|
+
* never for a debounced copy that lags the current normalized input.
|
|
2991
|
+
* - INV-117: a settled result whose requested name ≠ the current debounced
|
|
2992
|
+
* input is discarded (last-write-wins at the component boundary).
|
|
2993
|
+
* - INV-87: a rejecting resolver (contract violation) is mapped to a typed
|
|
2994
|
+
* `ADAPTER_ERROR` — nothing throws into the render tree.
|
|
2995
|
+
* - INV-123: resolver dispatches are bounded per resolution intent (normalized
|
|
2996
|
+
* debounced input × retry attempt) at {@link MAX_DISPATCHES_PER_INTENT},
|
|
2997
|
+
* regardless of render count or resolver-identity oscillation; the budget
|
|
2998
|
+
* resets on intent change or when leaving the name-resolution path — never on
|
|
2999
|
+
* `settled.source` mismatch; `retry()` never consumes an existing budget.
|
|
3000
|
+
* - INV-124: an identity-churning resolver that exhausts the budget triggers a
|
|
3001
|
+
* one-shot, development-only `logger.warn` (silent in production).
|
|
3002
|
+
* - INV-125: under sustained churn the field stays in the safe gated state — the
|
|
3003
|
+
* perpetually-changing identity keeps failing the `settled.source` check, so
|
|
3004
|
+
* the machine derives `loading`, never `resolved`; combined with INV-87 it
|
|
3005
|
+
* never throws and never writes a hex.
|
|
3006
|
+
* - No `setState` after unmount: every dispatch is cancelled on cleanup.
|
|
3007
|
+
*
|
|
3008
|
+
* @param params - {@link UseInjectedNameResolutionParams}
|
|
3009
|
+
* @returns The current {@link InjectedNameResolutionResult}.
|
|
3010
|
+
*/
|
|
3011
|
+
function useInjectedNameResolution({ input, enabled, resolveName, debounceMs = NAME_RESOLUTION_DEBOUNCE_MS }) {
|
|
3012
|
+
const normalized = input.trim().toLowerCase();
|
|
3013
|
+
const [debounced, setDebounced] = (0, react.useState)(normalized);
|
|
3014
|
+
const seededRef = (0, react.useRef)(false);
|
|
3015
|
+
(0, react.useEffect)(() => {
|
|
3016
|
+
if (!seededRef.current) {
|
|
3017
|
+
seededRef.current = true;
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
if (debounceMs <= 0) {
|
|
3021
|
+
setDebounced(normalized);
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
const timer = setTimeout(() => setDebounced(normalized), debounceMs);
|
|
3025
|
+
return () => clearTimeout(timer);
|
|
3026
|
+
}, [normalized, debounceMs]);
|
|
3027
|
+
const [attempt, setAttempt] = (0, react.useState)(0);
|
|
3028
|
+
const [settled, setSettled] = (0, react.useState)(null);
|
|
3029
|
+
const currentTargetRef = (0, react.useRef)("");
|
|
3030
|
+
currentTargetRef.current = enabled && resolveName ? debounced : "";
|
|
3031
|
+
const intentKeyRef = (0, react.useRef)("");
|
|
3032
|
+
const dispatchCountRef = (0, react.useRef)(0);
|
|
3033
|
+
const churnWarnedRef = (0, react.useRef)(false);
|
|
3034
|
+
const warnOnceOnChurn = (0, react.useCallback)(() => {
|
|
3035
|
+
if (process.env.NODE_ENV === "production" || churnWarnedRef.current) return;
|
|
3036
|
+
churnWarnedRef.current = true;
|
|
3037
|
+
_openzeppelin_ui_utils.logger.warn("AddressField", "The injected name resolver changes identity on every render, so inline name resolution was bounded to prevent an unbounded RPC loop. Memoize the resolver (e.g. wrap it in useMemo, or use useRuntimeNameResolver from @openzeppelin/ui-react) so its function identity is stable across renders. See the ens-address-input integration guide.");
|
|
3038
|
+
}, []);
|
|
3039
|
+
(0, react.useEffect)(() => {
|
|
3040
|
+
if (!enabled || !resolveName || debounced === "") {
|
|
3041
|
+
intentKeyRef.current = "";
|
|
3042
|
+
dispatchCountRef.current = 0;
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
if (debounced !== normalized) return;
|
|
3046
|
+
const intentKey = `${debounced}\u0000${attempt}`;
|
|
3047
|
+
if (intentKeyRef.current !== intentKey) {
|
|
3048
|
+
intentKeyRef.current = intentKey;
|
|
3049
|
+
dispatchCountRef.current = 0;
|
|
3050
|
+
}
|
|
3051
|
+
if (dispatchCountRef.current >= MAX_DISPATCHES_PER_INTENT) {
|
|
3052
|
+
warnOnceOnChurn();
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
dispatchCountRef.current += 1;
|
|
3056
|
+
let cancelled = false;
|
|
3057
|
+
const requestedName = debounced;
|
|
3058
|
+
const requestedAttempt = attempt;
|
|
3059
|
+
const source = resolveName;
|
|
3060
|
+
const settle = (result) => {
|
|
3061
|
+
if (cancelled) return;
|
|
3062
|
+
if (requestedName !== currentTargetRef.current) return;
|
|
3063
|
+
setSettled({
|
|
3064
|
+
name: requestedName,
|
|
3065
|
+
result,
|
|
3066
|
+
source,
|
|
3067
|
+
attempt: requestedAttempt
|
|
3068
|
+
});
|
|
3069
|
+
};
|
|
3070
|
+
source(requestedName).then(settle, (cause) => {
|
|
3071
|
+
settle({
|
|
3072
|
+
ok: false,
|
|
3073
|
+
error: {
|
|
3074
|
+
code: "ADAPTER_ERROR",
|
|
3075
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
3076
|
+
cause
|
|
3077
|
+
}
|
|
3078
|
+
});
|
|
3079
|
+
});
|
|
3080
|
+
return () => {
|
|
3081
|
+
cancelled = true;
|
|
3082
|
+
};
|
|
3083
|
+
}, [
|
|
3084
|
+
enabled,
|
|
3085
|
+
resolveName,
|
|
3086
|
+
debounced,
|
|
3087
|
+
normalized,
|
|
3088
|
+
attempt,
|
|
3089
|
+
warnOnceOnChurn
|
|
3090
|
+
]);
|
|
3091
|
+
const retry = (0, react.useCallback)(() => {
|
|
3092
|
+
setAttempt((current) => current + 1);
|
|
3093
|
+
}, []);
|
|
3094
|
+
if (!enabled || !resolveName || normalized === "") return { status: "idle" };
|
|
3095
|
+
if (debounced !== normalized) return {
|
|
3096
|
+
status: "debouncing",
|
|
3097
|
+
name: normalized
|
|
3098
|
+
};
|
|
3099
|
+
if (settled !== null && settled.name === debounced && settled.source === resolveName && settled.attempt === attempt) {
|
|
3100
|
+
if (settled.result.ok) return {
|
|
3101
|
+
status: "resolved",
|
|
3102
|
+
name: settled.name,
|
|
3103
|
+
data: settled.result.value
|
|
3104
|
+
};
|
|
3105
|
+
return {
|
|
3106
|
+
status: "error",
|
|
3107
|
+
name: settled.name,
|
|
3108
|
+
error: settled.result.error,
|
|
3109
|
+
retry
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
return {
|
|
3113
|
+
status: "loading",
|
|
3114
|
+
name: normalized
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
//#endregion
|
|
3119
|
+
//#region src/components/fields/name-resolution/context.ts
|
|
3120
|
+
/**
|
|
3121
|
+
* Process-global key for the context object. Uses `Symbol.for` so all duplicated
|
|
3122
|
+
* module instances share ONE React context — the same cross-bundle-singleton
|
|
3123
|
+
* pattern as `@openzeppelin/ui-react`'s `NameResolutionContext` /
|
|
3124
|
+
* `WalletStateContext` (bundler pre-bundling / npm-installed consumers).
|
|
3125
|
+
*/
|
|
3126
|
+
const CONTEXT_KEY = Symbol.for("@openzeppelin/ui-components/NameResolverContext");
|
|
3127
|
+
function getOrCreateSharedContext() {
|
|
3128
|
+
const globalScope = globalThis;
|
|
3129
|
+
if (!globalScope[CONTEXT_KEY]) globalScope[CONTEXT_KEY] = (0, react.createContext)(null);
|
|
3130
|
+
return globalScope[CONTEXT_KEY];
|
|
3131
|
+
}
|
|
3132
|
+
/**
|
|
3133
|
+
* @internal Shared context instance consumed by both AddressField and
|
|
3134
|
+
* NameResolverProvider (INV-118). Kept in its own file so component files
|
|
3135
|
+
* export only components (required by React Fast Refresh).
|
|
3136
|
+
*
|
|
3137
|
+
* `null` means no provider is mounted — every ENS branch in `AddressField` is
|
|
3138
|
+
* then dead code and the field is byte-identical to its pre-ENS behavior
|
|
3139
|
+
* (INV-82).
|
|
3140
|
+
*/
|
|
3141
|
+
const NameResolverContext = getOrCreateSharedContext();
|
|
3142
|
+
|
|
3143
|
+
//#endregion
|
|
3144
|
+
//#region src/components/fields/name-resolution/useNameResolver.ts
|
|
3145
|
+
/**
|
|
3146
|
+
* Read the injected resolver context. Returns `null` when no
|
|
3147
|
+
* `NameResolverProvider` is mounted — the consuming field must then treat
|
|
3148
|
+
* every ENS branch as dead code (INV-82).
|
|
3149
|
+
*
|
|
3150
|
+
* @returns The extended context value, or `null` when no provider is mounted.
|
|
3151
|
+
*/
|
|
3152
|
+
function useNameResolver() {
|
|
3153
|
+
return (0, react.useContext)(NameResolverContext);
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
//#endregion
|
|
3157
|
+
//#region src/components/fields/name-resolution/useResolvingAnnouncerCopy.ts
|
|
3158
|
+
/** Default escalation threshold for long-latency resolves (INV-129/130). */
|
|
3159
|
+
const RESOLVING_ANNOUNCER_ESCALATE_AFTER_MS = 3e3;
|
|
3160
|
+
const PHASE_1_COPY = "Resolving…";
|
|
3161
|
+
const PHASE_2_COPY = "Still resolving…";
|
|
3162
|
+
/**
|
|
3163
|
+
* Returns the announcer string for debouncing/loading arms.
|
|
3164
|
+
* Phase 1: "Resolving…" (SF-3 default, unchanged for first N ms).
|
|
3165
|
+
* Phase 2: "Still resolving…" after threshold — reduces frozen perception
|
|
3166
|
+
* during CCIP-scale latency without naming gateway/CCIP/v2.
|
|
3167
|
+
*/
|
|
3168
|
+
function useResolvingAnnouncerCopy({ isPending, escalateAfterMs = RESOLVING_ANNOUNCER_ESCALATE_AFTER_MS }) {
|
|
3169
|
+
const [escalated, setEscalated] = (0, react.useState)(false);
|
|
3170
|
+
(0, react.useEffect)(() => {
|
|
3171
|
+
if (!isPending) {
|
|
3172
|
+
setEscalated(false);
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
setEscalated(false);
|
|
3176
|
+
const timeoutId = window.setTimeout(() => {
|
|
3177
|
+
setEscalated(true);
|
|
3178
|
+
}, escalateAfterMs);
|
|
3179
|
+
return () => {
|
|
3180
|
+
window.clearTimeout(timeoutId);
|
|
3181
|
+
};
|
|
3182
|
+
}, [isPending, escalateAfterMs]);
|
|
3183
|
+
if (!isPending) return PHASE_1_COPY;
|
|
3184
|
+
return escalated ? PHASE_2_COPY : PHASE_1_COPY;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
2563
3187
|
//#endregion
|
|
2564
3188
|
//#region src/components/fields/utils/accessibility.ts
|
|
2565
3189
|
/**
|
|
@@ -2751,8 +3375,26 @@ function getWidthClasses(width) {
|
|
|
2751
3375
|
|
|
2752
3376
|
//#endregion
|
|
2753
3377
|
//#region src/components/fields/AddressField.tsx
|
|
2754
|
-
|
|
2755
|
-
|
|
3378
|
+
/**
|
|
3379
|
+
* Non-user-facing gate string returned by the sync validator while a typed name
|
|
3380
|
+
* is resolving, so `formState.isValid` is `false` even for an optional field
|
|
3381
|
+
* (INV-84). The visible message comes from the aria-live region, never this string.
|
|
3382
|
+
*/
|
|
3383
|
+
const NAME_RESOLUTION_PENDING_GATE = "__ens_name_resolution_pending__";
|
|
3384
|
+
/** Codes for which a `retry()` affordance is offered — transient only (INV-90). */
|
|
3385
|
+
const TRANSIENT_ERROR_CODES = new Set([
|
|
3386
|
+
"RESOLUTION_TIMEOUT",
|
|
3387
|
+
"EXTERNAL_GATEWAY_ERROR",
|
|
3388
|
+
"ADAPTER_ERROR"
|
|
3389
|
+
]);
|
|
3390
|
+
/**
|
|
3391
|
+
* Normalize a typed name for the resolved-write match guard: trim then lowercase,
|
|
3392
|
+
* mirroring the SF-2 engine (whose echoed `result.name` is already normalized).
|
|
3393
|
+
* Comparing the raw typed value would spuriously fail on case/whitespace (INV-79).
|
|
3394
|
+
*/
|
|
3395
|
+
function normalizeResolvedName(value) {
|
|
3396
|
+
return value.trim().toLowerCase();
|
|
3397
|
+
}
|
|
2756
3398
|
/**
|
|
2757
3399
|
* Address input field component specifically designed for blockchain addresses via React Hook Form integration.
|
|
2758
3400
|
*
|
|
@@ -2780,19 +3422,36 @@ const MAX_SUGGESTIONS = 5;
|
|
|
2780
3422
|
* Pass `suggestions={false}` to opt out when a provider is mounted.
|
|
2781
3423
|
*
|
|
2782
3424
|
* The suggestion dropdown includes built-in debouncing, keyboard navigation (Arrow keys,
|
|
2783
|
-
* Enter, Escape), click-outside dismissal, and ARIA listbox semantics
|
|
3425
|
+
* Enter, Escape), click-outside dismissal, and ARIA listbox semantics — all provided by
|
|
3426
|
+
* the shared headless `useAddressSuggestionField` hook + `AddressSuggestionList`.
|
|
3427
|
+
*
|
|
3428
|
+
* **Inline name resolution (SF-3, opt-in via context).** When a
|
|
3429
|
+
* `NameResolverProvider` is mounted, the field also accepts a name (e.g.
|
|
3430
|
+
* `alice.eth`): the name is resolved inline through the injected `resolveName`
|
|
3431
|
+
* and the RHF form value becomes the resolved hex — never the name, never a
|
|
3432
|
+
* silently-coerced value. The correctness spine is a shadow-state model:
|
|
3433
|
+
* the `<input>` always shows the typed string (`inputValue`, INV-69); the RHF
|
|
3434
|
+
* value is `''` for every unresolved name state so submit stays gated with no
|
|
3435
|
+
* async validator (INV-75); the single name→hex write fires only on
|
|
3436
|
+
* `resolved` + normalized name-match (INV-79/80). With **no** provider mounted
|
|
3437
|
+
* every resolution branch below is dead code and the field is byte-identical
|
|
3438
|
+
* to its pre-ENS behavior (INV-82 — the LOCKED backward-compat guarantee).
|
|
3439
|
+
* The component stays capability-free: it never reads a runtime or wallet
|
|
3440
|
+
* state; everything arrives through the injected context (INV-118).
|
|
2784
3441
|
*/
|
|
2785
|
-
function AddressField({ id, label, placeholder, helperText, control, name, width = "full", validation, addressing, readOnly, suggestions: suggestionsProp, onSuggestionSelect }) {
|
|
3442
|
+
function AddressField({ id, label, placeholder, helperText, control, name, width = "full", validation, addressing, readOnly, suggestions: suggestionsProp, onSuggestionSelect, onResolvedNameChange, showCrossNetworkFallbackDisclaimer = true }) {
|
|
2786
3443
|
const isRequired = !!validation?.required;
|
|
2787
3444
|
const errorId = `${id}-error`;
|
|
2788
3445
|
const descriptionId = `${id}-description`;
|
|
2789
|
-
const
|
|
2790
|
-
const
|
|
3446
|
+
const resolutionRegionId = `${id}-resolution`;
|
|
3447
|
+
const resolver = useNameResolver();
|
|
3448
|
+
const resolveName = resolver?.resolveName;
|
|
3449
|
+
const isValidName = resolver?.isValidName;
|
|
3450
|
+
const activeNetworkId = resolver?.activeNetworkId;
|
|
3451
|
+
const activeNetworkName = resolver?.activeNetworkName;
|
|
3452
|
+
const resolveNetworkLabel = resolver?.resolveNetworkLabel;
|
|
2791
3453
|
const lastSetValueRef = (0, react.useRef)("");
|
|
2792
3454
|
const [inputValue, setInputValue] = (0, react.useState)("");
|
|
2793
|
-
const [debouncedQuery, setDebouncedQuery] = (0, react.useState)("");
|
|
2794
|
-
const [showSuggestions, setShowSuggestions] = (0, react.useState)(false);
|
|
2795
|
-
const [highlightedIndex, setHighlightedIndex] = (0, react.useState)(-1);
|
|
2796
3455
|
const watchedFieldValue = (0, react_hook_form.useWatch)({
|
|
2797
3456
|
control,
|
|
2798
3457
|
name
|
|
@@ -2804,173 +3463,277 @@ function AddressField({ id, label, placeholder, helperText, control, name, width
|
|
|
2804
3463
|
setInputValue(currentFieldValue);
|
|
2805
3464
|
}
|
|
2806
3465
|
}, [watchedFieldValue]);
|
|
3466
|
+
const isValidAddress = (0, react.useMemo)(() => addressing ? (v) => addressing.isValidAddress(v) : void 0, [addressing]);
|
|
3467
|
+
const classification = (0, react.useMemo)(() => (0, _openzeppelin_ui_utils.classifyAddressInput)(inputValue, {
|
|
3468
|
+
isValidAddress,
|
|
3469
|
+
isValidName
|
|
3470
|
+
}), [
|
|
3471
|
+
inputValue,
|
|
3472
|
+
isValidAddress,
|
|
3473
|
+
isValidName
|
|
3474
|
+
]);
|
|
3475
|
+
const resolverActive = resolver !== null && classification === "name-candidate";
|
|
3476
|
+
const nameEnabled = resolverActive && resolveName != null;
|
|
3477
|
+
const forwardUnsupported = resolverActive && resolveName == null;
|
|
3478
|
+
const resolution = useInjectedNameResolution({
|
|
3479
|
+
input: inputValue,
|
|
3480
|
+
enabled: nameEnabled,
|
|
3481
|
+
resolveName
|
|
3482
|
+
});
|
|
3483
|
+
const chainScopeBlocked = resolution.status === "resolved" && (0, _openzeppelin_ui_utils.isChainScopeMismatch)(resolution.data.provenance, activeNetworkId);
|
|
3484
|
+
const resolvingAnnouncerCopy = useResolvingAnnouncerCopy({ isPending: !forwardUnsupported && (resolution.status === "debouncing" || resolution.status === "loading") });
|
|
3485
|
+
const resolverPresentRef = (0, react.useRef)(false);
|
|
3486
|
+
const classificationRef = (0, react.useRef)("empty");
|
|
3487
|
+
const statusRef = (0, react.useRef)("idle");
|
|
3488
|
+
const resolvedNameRef = (0, react.useRef)(void 0);
|
|
3489
|
+
const chainScopeBlockedRef = (0, react.useRef)(false);
|
|
3490
|
+
const validationRef = (0, react.useRef)(validation);
|
|
3491
|
+
const addressingRef = (0, react.useRef)(addressing);
|
|
3492
|
+
resolverPresentRef.current = resolver !== null;
|
|
3493
|
+
classificationRef.current = classification;
|
|
3494
|
+
statusRef.current = resolution.status;
|
|
3495
|
+
resolvedNameRef.current = resolution.status === "resolved" ? resolution.name : void 0;
|
|
3496
|
+
chainScopeBlockedRef.current = chainScopeBlocked;
|
|
3497
|
+
validationRef.current = validation;
|
|
3498
|
+
addressingRef.current = addressing;
|
|
3499
|
+
const { field, fieldState } = (0, react_hook_form.useController)({
|
|
3500
|
+
control,
|
|
3501
|
+
name,
|
|
3502
|
+
rules: { validate: (0, react.useCallback)((value) => {
|
|
3503
|
+
if (resolverPresentRef.current && classificationRef.current === "name-candidate" && (statusRef.current !== "resolved" || chainScopeBlockedRef.current)) return NAME_RESOLUTION_PENDING_GATE;
|
|
3504
|
+
if (value === void 0 || value === null || value === "") return validationRef.current?.required ? "This field is required" : true;
|
|
3505
|
+
const standardValidationResult = require_ErrorMessage.validateField(value, validationRef.current);
|
|
3506
|
+
if (standardValidationResult !== true) return standardValidationResult;
|
|
3507
|
+
if (addressingRef.current && typeof value === "string") {
|
|
3508
|
+
if (!addressingRef.current.isValidAddress(value)) return "Invalid address format for the selected chain";
|
|
3509
|
+
}
|
|
3510
|
+
return true;
|
|
3511
|
+
}, []) },
|
|
3512
|
+
disabled: readOnly
|
|
3513
|
+
});
|
|
3514
|
+
const fieldOnChange = field.onChange;
|
|
3515
|
+
const gateFormState = (0, react_hook_form.useFormState)({
|
|
3516
|
+
control,
|
|
3517
|
+
disabled: resolver === null
|
|
3518
|
+
});
|
|
3519
|
+
const observedIsValid = resolver !== null && gateFormState.isValid;
|
|
3520
|
+
const pendingNameGate = resolver !== null && classification === "name-candidate" && (resolution.status !== "resolved" || chainScopeBlocked);
|
|
2807
3521
|
(0, react.useEffect)(() => {
|
|
2808
|
-
if (
|
|
2809
|
-
|
|
2810
|
-
|
|
3522
|
+
if (pendingNameGate && observedIsValid) control.setError(name, {
|
|
3523
|
+
type: "validate",
|
|
3524
|
+
message: NAME_RESOLUTION_PENDING_GATE
|
|
3525
|
+
});
|
|
3526
|
+
}, [
|
|
3527
|
+
pendingNameGate,
|
|
3528
|
+
observedIsValid,
|
|
3529
|
+
control,
|
|
3530
|
+
name
|
|
3531
|
+
]);
|
|
3532
|
+
const resolvedAddress = resolution.status === "resolved" ? resolution.data.address : void 0;
|
|
3533
|
+
const resolvedName = resolution.status === "resolved" ? resolution.name : void 0;
|
|
3534
|
+
const isResolvedNameMatch = resolvedName !== void 0 && normalizeResolvedName(inputValue) === resolvedName;
|
|
3535
|
+
(0, react.useEffect)(() => {
|
|
3536
|
+
if (isResolvedNameMatch && resolvedAddress !== void 0 && !chainScopeBlocked) {
|
|
3537
|
+
lastSetValueRef.current = resolvedAddress;
|
|
3538
|
+
fieldOnChange(resolvedAddress);
|
|
2811
3539
|
}
|
|
2812
|
-
const timer = setTimeout(() => setDebouncedQuery(inputValue), DEBOUNCE_MS);
|
|
2813
|
-
return () => clearTimeout(timer);
|
|
2814
|
-
}, [inputValue]);
|
|
2815
|
-
const suggestionsDisabled = suggestionsProp === false;
|
|
2816
|
-
const resolvedSuggestions = (0, react.useMemo)(() => {
|
|
2817
|
-
if (suggestionsDisabled) return [];
|
|
2818
|
-
if (Array.isArray(suggestionsProp)) return suggestionsProp;
|
|
2819
|
-
if (!contextResolver || !debouncedQuery.trim()) return [];
|
|
2820
|
-
return contextResolver.resolveSuggestions(debouncedQuery).slice(0, MAX_SUGGESTIONS);
|
|
2821
3540
|
}, [
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
3541
|
+
isResolvedNameMatch,
|
|
3542
|
+
resolvedAddress,
|
|
3543
|
+
chainScopeBlocked,
|
|
3544
|
+
fieldOnChange
|
|
2826
3545
|
]);
|
|
2827
|
-
const
|
|
3546
|
+
const matchedName = isResolvedNameMatch ? resolvedName : void 0;
|
|
2828
3547
|
(0, react.useEffect)(() => {
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
3548
|
+
onResolvedNameChange?.(matchedName);
|
|
3549
|
+
}, [matchedName, onResolvedNameChange]);
|
|
3550
|
+
const { containerRef, resolvedSuggestions, hasSuggestions, highlightedIndex, suggestionsDisabled, setHighlightedIndex, closeSuggestions, onInputChange, onContainerKeyDown } = useAddressSuggestionField({
|
|
3551
|
+
inputValue,
|
|
3552
|
+
suggestions: suggestionsProp
|
|
3553
|
+
});
|
|
3554
|
+
/**
|
|
3555
|
+
* The single site for the typed/raw write (the name→hex write lives in the
|
|
3556
|
+
* effect above). With no resolver this is the legacy write, verbatim (INV-82);
|
|
3557
|
+
* with a resolver, only the `name-candidate` classification diverges:
|
|
3558
|
+
* - hex / malformed / empty → the raw value (legacy sync validate applies)
|
|
3559
|
+
* - name-candidate → `''` — gates submit and synchronously invalidates any
|
|
3560
|
+
* prior resolved hex before re-resolution (INV-75 / INV-81)
|
|
3561
|
+
*/
|
|
3562
|
+
const commitTypedValue = (0, react.useCallback)((value) => {
|
|
3563
|
+
setInputValue(value);
|
|
3564
|
+
if (!resolverPresentRef.current) {
|
|
3565
|
+
lastSetValueRef.current = value;
|
|
3566
|
+
fieldOnChange(value);
|
|
3567
|
+
return;
|
|
3568
|
+
}
|
|
3569
|
+
const c = (0, _openzeppelin_ui_utils.classifyAddressInput)(value, {
|
|
3570
|
+
isValidAddress,
|
|
3571
|
+
isValidName
|
|
3572
|
+
});
|
|
3573
|
+
classificationRef.current = c;
|
|
3574
|
+
if (c === "name-candidate") {
|
|
3575
|
+
const normalized = normalizeResolvedName(value);
|
|
3576
|
+
if (statusRef.current === "resolved" && resolvedNameRef.current === normalized && !chainScopeBlockedRef.current) return;
|
|
3577
|
+
statusRef.current = "debouncing";
|
|
3578
|
+
lastSetValueRef.current = "";
|
|
3579
|
+
fieldOnChange("");
|
|
3580
|
+
} else {
|
|
3581
|
+
lastSetValueRef.current = value;
|
|
3582
|
+
fieldOnChange(value);
|
|
3583
|
+
}
|
|
3584
|
+
}, [
|
|
3585
|
+
fieldOnChange,
|
|
3586
|
+
isValidAddress,
|
|
3587
|
+
isValidName
|
|
3588
|
+
]);
|
|
3589
|
+
const handleInputChange = (e) => {
|
|
3590
|
+
commitTypedValue(e.target.value);
|
|
3591
|
+
onInputChange(e.target.value);
|
|
3592
|
+
};
|
|
3593
|
+
const applySuggestion = (suggestion) => {
|
|
3594
|
+
commitTypedValue(suggestion.value);
|
|
3595
|
+
onSuggestionSelect?.(suggestion);
|
|
3596
|
+
closeSuggestions();
|
|
3597
|
+
};
|
|
3598
|
+
const handleKeyDown = (e) => {
|
|
3599
|
+
if (hasSuggestions && e.key === "Enter" && highlightedIndex >= 0) {
|
|
2845
3600
|
e.preventDefault();
|
|
2846
|
-
|
|
3601
|
+
applySuggestion(resolvedSuggestions[highlightedIndex]);
|
|
3602
|
+
return;
|
|
2847
3603
|
}
|
|
2848
|
-
|
|
3604
|
+
if (e.key === "Escape") {
|
|
3605
|
+
if (hasSuggestions) {
|
|
3606
|
+
closeSuggestions();
|
|
3607
|
+
return;
|
|
3608
|
+
}
|
|
3609
|
+
handleEscapeKey(commitTypedValue, inputValue)(e);
|
|
3610
|
+
}
|
|
3611
|
+
};
|
|
3612
|
+
const hasError = !!fieldState.error;
|
|
3613
|
+
const isPendingGate = fieldState.error?.message === NAME_RESOLUTION_PENDING_GATE;
|
|
3614
|
+
const hasRealError = hasError && !isPendingGate;
|
|
3615
|
+
const shouldShowError = hasRealError && fieldState.isTouched;
|
|
3616
|
+
const validationClasses = require_ErrorMessage.getValidationStateClasses(hasRealError ? fieldState.error : void 0, isPendingGate ? false : fieldState.isTouched);
|
|
3617
|
+
const patternErrorMessage = validation?.pattern && typeof validation.pattern === "object" && "message" in validation.pattern ? validation.pattern.message : void 0;
|
|
3618
|
+
const accessibilityProps = getAccessibilityProps({
|
|
3619
|
+
id,
|
|
3620
|
+
hasError: hasRealError,
|
|
3621
|
+
isRequired,
|
|
3622
|
+
hasHelperText: !!helperText
|
|
3623
|
+
});
|
|
3624
|
+
const renderOutcome = () => {
|
|
3625
|
+
if (forwardUnsupported) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3626
|
+
role: "alert",
|
|
3627
|
+
className: "text-destructive text-sm",
|
|
3628
|
+
children: (0, _openzeppelin_ui_utils.nameResolutionMessageForCode)("UNSUPPORTED_NETWORK")
|
|
3629
|
+
});
|
|
3630
|
+
switch (resolution.status) {
|
|
3631
|
+
case "idle": return null;
|
|
3632
|
+
case "debouncing":
|
|
3633
|
+
case "loading": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3634
|
+
className: "text-muted-foreground text-sm",
|
|
3635
|
+
children: resolvingAnnouncerCopy
|
|
3636
|
+
});
|
|
3637
|
+
case "resolved":
|
|
3638
|
+
if (chainScopeBlocked) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3639
|
+
role: "alert",
|
|
3640
|
+
className: "text-destructive text-sm",
|
|
3641
|
+
children: (0, _openzeppelin_ui_utils.nameResolutionChainScopeMismatchMessage)({ activeNetworkName })
|
|
3642
|
+
});
|
|
3643
|
+
const fallbackNetworks = showCrossNetworkFallbackDisclaimer && (0, _openzeppelin_ui_utils.isCrossNetworkFallback)(resolution.data.provenance) ? (0, _openzeppelin_ui_utils.getFallbackNetworks)(resolution.data.provenance) : void 0;
|
|
3644
|
+
const fallbackMessage = fallbackNetworks && (0, _openzeppelin_ui_utils.nameResolutionCrossNetworkFallbackMessage)(fallbackNetworks, (0, _openzeppelin_ui_utils.crossNetworkFallbackMessageNames)(fallbackNetworks, resolveNetworkLabel));
|
|
3645
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
3646
|
+
className: "text-sm",
|
|
3647
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: ["Resolved to ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
|
|
3648
|
+
className: "font-mono",
|
|
3649
|
+
children: resolution.data.address
|
|
3650
|
+
})] }), fallbackMessage ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3651
|
+
className: "mt-1 block text-muted-foreground text-xs",
|
|
3652
|
+
role: "note",
|
|
3653
|
+
children: fallbackMessage
|
|
3654
|
+
}) : null]
|
|
3655
|
+
});
|
|
3656
|
+
case "error": {
|
|
3657
|
+
const networkName = resolution.error.code === "UNSUPPORTED_NETWORK" ? resolution.error.networkId || void 0 : void 0;
|
|
3658
|
+
const message = (0, _openzeppelin_ui_utils.nameResolutionMessageForCode)(resolution.error.code, { networkName });
|
|
3659
|
+
const isTransient = TRANSIENT_ERROR_CODES.has(resolution.error.code);
|
|
3660
|
+
const retry = resolution.retry;
|
|
3661
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
3662
|
+
role: "alert",
|
|
3663
|
+
className: "text-destructive text-sm",
|
|
3664
|
+
children: [message, isTransient ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3665
|
+
type: "button",
|
|
3666
|
+
className: "ml-2 underline",
|
|
3667
|
+
onClick: retry,
|
|
3668
|
+
children: "Retry"
|
|
3669
|
+
}) : null]
|
|
3670
|
+
});
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
2849
3674
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2850
3675
|
className: `flex flex-col gap-2 ${width === "full" ? "w-full" : width === "half" ? "w-1/2" : "w-1/3"}`,
|
|
2851
|
-
children: [
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
]
|
|
2861
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_hook_form.Controller, {
|
|
2862
|
-
control,
|
|
2863
|
-
name,
|
|
2864
|
-
rules: { validate: (value) => {
|
|
2865
|
-
if (value === void 0 || value === null || value === "") return validation?.required ? "This field is required" : true;
|
|
2866
|
-
const standardValidationResult = require_ErrorMessage.validateField(value, validation);
|
|
2867
|
-
if (standardValidationResult !== true) return standardValidationResult;
|
|
2868
|
-
if (addressing && typeof value === "string") {
|
|
2869
|
-
if (!addressing.isValidAddress(value)) return "Invalid address format for the selected chain";
|
|
2870
|
-
}
|
|
2871
|
-
return true;
|
|
2872
|
-
} },
|
|
2873
|
-
disabled: readOnly,
|
|
2874
|
-
render: ({ fieldState: { error, isTouched }, field }) => {
|
|
2875
|
-
const hasError = !!error;
|
|
2876
|
-
const shouldShowError = hasError && isTouched;
|
|
2877
|
-
const validationClasses = require_ErrorMessage.getValidationStateClasses(error, isTouched);
|
|
2878
|
-
const patternErrorMessage = validation?.pattern && typeof validation.pattern === "object" && "message" in validation.pattern ? validation.pattern.message : void 0;
|
|
2879
|
-
const handleInputChange = (e) => {
|
|
2880
|
-
const value = e.target.value;
|
|
2881
|
-
field.onChange(value);
|
|
2882
|
-
lastSetValueRef.current = value;
|
|
2883
|
-
setInputValue(value);
|
|
2884
|
-
setShowSuggestions(value.length > 0);
|
|
2885
|
-
setHighlightedIndex(-1);
|
|
2886
|
-
};
|
|
2887
|
-
const applySuggestion = (suggestion) => {
|
|
2888
|
-
field.onChange(suggestion.value);
|
|
2889
|
-
onSuggestionSelect?.(suggestion);
|
|
2890
|
-
lastSetValueRef.current = suggestion.value;
|
|
2891
|
-
setInputValue(suggestion.value);
|
|
2892
|
-
setShowSuggestions(false);
|
|
2893
|
-
setHighlightedIndex(-1);
|
|
2894
|
-
};
|
|
2895
|
-
const handleKeyDown = (e) => {
|
|
2896
|
-
if (hasSuggestions && e.key === "Enter" && highlightedIndex >= 0) {
|
|
2897
|
-
e.preventDefault();
|
|
2898
|
-
applySuggestion(resolvedSuggestions[highlightedIndex]);
|
|
2899
|
-
return;
|
|
2900
|
-
}
|
|
2901
|
-
if (e.key === "Escape") {
|
|
2902
|
-
if (hasSuggestions) {
|
|
2903
|
-
setShowSuggestions(false);
|
|
2904
|
-
return;
|
|
2905
|
-
}
|
|
2906
|
-
handleEscapeKey(field.onChange, field.value)(e);
|
|
2907
|
-
}
|
|
2908
|
-
};
|
|
2909
|
-
const accessibilityProps = getAccessibilityProps({
|
|
2910
|
-
id,
|
|
2911
|
-
hasError,
|
|
2912
|
-
isRequired,
|
|
2913
|
-
hasHelperText: !!helperText
|
|
2914
|
-
});
|
|
2915
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
2916
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2917
|
-
ref: containerRef,
|
|
2918
|
-
className: "relative",
|
|
2919
|
-
onKeyDown: handleSuggestionKeyDown,
|
|
2920
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
|
|
2921
|
-
...field,
|
|
2922
|
-
id,
|
|
2923
|
-
placeholder: placeholder || "0x...",
|
|
2924
|
-
className: validationClasses,
|
|
2925
|
-
onChange: handleInputChange,
|
|
2926
|
-
onKeyDown: handleKeyDown,
|
|
2927
|
-
"data-slot": "input",
|
|
2928
|
-
value: field.value ?? "",
|
|
2929
|
-
...accessibilityProps,
|
|
2930
|
-
"aria-describedby": `${helperText ? descriptionId : ""} ${hasError ? errorId : ""}`,
|
|
2931
|
-
"aria-expanded": hasSuggestions,
|
|
2932
|
-
"aria-autocomplete": suggestionsDisabled ? void 0 : "list",
|
|
2933
|
-
"aria-controls": hasSuggestions ? `${id}-suggestions` : void 0,
|
|
2934
|
-
"aria-activedescendant": hasSuggestions && highlightedIndex >= 0 ? `${id}-suggestion-${highlightedIndex}` : void 0,
|
|
2935
|
-
disabled: readOnly
|
|
2936
|
-
}), hasSuggestions && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2937
|
-
id: `${id}-suggestions`,
|
|
2938
|
-
className: (0, _openzeppelin_ui_utils.cn)("absolute z-50 mt-1 w-full rounded-md border border-border bg-popover shadow-md", "max-h-48 overflow-auto"),
|
|
2939
|
-
role: "listbox",
|
|
2940
|
-
children: resolvedSuggestions.map((s, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2941
|
-
id: `${id}-suggestion-${i}`,
|
|
2942
|
-
type: "button",
|
|
2943
|
-
role: "option",
|
|
2944
|
-
"aria-selected": i === highlightedIndex,
|
|
2945
|
-
className: (0, _openzeppelin_ui_utils.cn)("flex w-full flex-col px-3 py-2 text-left text-sm", "hover:bg-selected/10", i === highlightedIndex && "bg-selected/10"),
|
|
2946
|
-
onMouseDown: (e) => {
|
|
2947
|
-
e.preventDefault();
|
|
2948
|
-
applySuggestion(s);
|
|
2949
|
-
},
|
|
2950
|
-
onMouseEnter: () => setHighlightedIndex(i),
|
|
2951
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2952
|
-
className: "font-medium",
|
|
2953
|
-
children: s.label
|
|
2954
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2955
|
-
className: "truncate font-mono text-xs text-muted-foreground",
|
|
2956
|
-
children: s.value
|
|
2957
|
-
})]
|
|
2958
|
-
}, `${s.value}-${s.description ?? i}`))
|
|
2959
|
-
})]
|
|
2960
|
-
}),
|
|
2961
|
-
helperText && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2962
|
-
id: descriptionId,
|
|
2963
|
-
className: "text-muted-foreground text-sm",
|
|
2964
|
-
children: helperText
|
|
2965
|
-
}),
|
|
2966
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ErrorMessage.ErrorMessage, {
|
|
2967
|
-
error,
|
|
2968
|
-
id: errorId,
|
|
2969
|
-
message: shouldShowError ? error?.message || patternErrorMessage : void 0
|
|
3676
|
+
children: [
|
|
3677
|
+
label && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Label, {
|
|
3678
|
+
htmlFor: id,
|
|
3679
|
+
children: [
|
|
3680
|
+
label,
|
|
3681
|
+
" ",
|
|
3682
|
+
isRequired && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3683
|
+
className: "text-destructive",
|
|
3684
|
+
children: "*"
|
|
2970
3685
|
})
|
|
2971
|
-
]
|
|
2972
|
-
}
|
|
2973
|
-
|
|
3686
|
+
]
|
|
3687
|
+
}),
|
|
3688
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3689
|
+
ref: containerRef,
|
|
3690
|
+
className: "relative",
|
|
3691
|
+
onKeyDown: onContainerKeyDown,
|
|
3692
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
|
|
3693
|
+
...field,
|
|
3694
|
+
id,
|
|
3695
|
+
placeholder: placeholder || (resolver !== null ? "0x... or name" : "0x..."),
|
|
3696
|
+
className: validationClasses,
|
|
3697
|
+
onChange: handleInputChange,
|
|
3698
|
+
onKeyDown: handleKeyDown,
|
|
3699
|
+
"data-slot": "input",
|
|
3700
|
+
value: inputValue,
|
|
3701
|
+
...accessibilityProps,
|
|
3702
|
+
"aria-describedby": resolver === null ? `${helperText ? descriptionId : ""} ${hasRealError ? errorId : ""}` : [
|
|
3703
|
+
helperText ? descriptionId : "",
|
|
3704
|
+
hasRealError ? errorId : "",
|
|
3705
|
+
resolutionRegionId
|
|
3706
|
+
].filter(Boolean).join(" ") || void 0,
|
|
3707
|
+
"aria-expanded": hasSuggestions,
|
|
3708
|
+
"aria-autocomplete": suggestionsDisabled ? void 0 : "list",
|
|
3709
|
+
"aria-controls": hasSuggestions ? `${id}-suggestions` : void 0,
|
|
3710
|
+
"aria-activedescendant": hasSuggestions && highlightedIndex >= 0 ? `${id}-suggestion-${highlightedIndex}` : void 0,
|
|
3711
|
+
disabled: readOnly
|
|
3712
|
+
}), hasSuggestions && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressSuggestionList, {
|
|
3713
|
+
id,
|
|
3714
|
+
suggestions: resolvedSuggestions,
|
|
3715
|
+
highlightedIndex,
|
|
3716
|
+
onSelect: applySuggestion,
|
|
3717
|
+
onHighlight: setHighlightedIndex
|
|
3718
|
+
})]
|
|
3719
|
+
}),
|
|
3720
|
+
helperText && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3721
|
+
id: descriptionId,
|
|
3722
|
+
className: "text-muted-foreground text-sm",
|
|
3723
|
+
children: helperText
|
|
3724
|
+
}),
|
|
3725
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ErrorMessage.ErrorMessage, {
|
|
3726
|
+
error: hasRealError ? fieldState.error : void 0,
|
|
3727
|
+
id: errorId,
|
|
3728
|
+
message: shouldShowError ? fieldState.error?.message || patternErrorMessage : void 0
|
|
3729
|
+
}),
|
|
3730
|
+
resolver !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3731
|
+
id: resolutionRegionId,
|
|
3732
|
+
"aria-live": "polite",
|
|
3733
|
+
className: "min-h-5",
|
|
3734
|
+
children: renderOutcome()
|
|
3735
|
+
})
|
|
3736
|
+
]
|
|
2974
3737
|
});
|
|
2975
3738
|
}
|
|
2976
3739
|
AddressField.displayName = "AddressField";
|
|
@@ -3510,6 +4273,84 @@ function useAddressSuggestions(query, networkId) {
|
|
|
3510
4273
|
]) };
|
|
3511
4274
|
}
|
|
3512
4275
|
|
|
4276
|
+
//#endregion
|
|
4277
|
+
//#region src/components/fields/name-resolution/name-resolver-context.tsx
|
|
4278
|
+
/**
|
|
4279
|
+
* Name Resolver Context (SF-3)
|
|
4280
|
+
*
|
|
4281
|
+
* Provides a React context for forward name resolution (name → address).
|
|
4282
|
+
* When a `NameResolverProvider` is mounted, every `AddressField` in the
|
|
4283
|
+
* subtree resolves typed names inline through the injected `resolveName`
|
|
4284
|
+
* and submits the resolved hex — never the name (SC-004).
|
|
4285
|
+
*
|
|
4286
|
+
* The provider is deliberately **dumb** (INV-118): it memoizes the injected
|
|
4287
|
+
* functions into the context value and nothing else — no hook state, no
|
|
4288
|
+
* capability, no chain dependency. The smart runtime wiring lives upstream in
|
|
4289
|
+
* `@openzeppelin/ui-react` (`useRuntimeNameResolver`), which an app or the
|
|
4290
|
+
* renderer mounts ambiently:
|
|
4291
|
+
*
|
|
4292
|
+
* @example
|
|
4293
|
+
* ```tsx
|
|
4294
|
+
* import { NameResolverProvider } from '@openzeppelin/ui-components';
|
|
4295
|
+
* import { useRuntimeNameResolver } from '@openzeppelin/ui-react';
|
|
4296
|
+
*
|
|
4297
|
+
* function FormRoot() {
|
|
4298
|
+
* const resolver = useRuntimeNameResolver();
|
|
4299
|
+
* return (
|
|
4300
|
+
* <NameResolverProvider {...resolver}>
|
|
4301
|
+
* <MyForm />
|
|
4302
|
+
* </NameResolverProvider>
|
|
4303
|
+
* );
|
|
4304
|
+
* }
|
|
4305
|
+
* ```
|
|
4306
|
+
*/
|
|
4307
|
+
/**
|
|
4308
|
+
* Provides forward name resolution to all `AddressField` instances in the
|
|
4309
|
+
* subtree (zero call-site wiring, SC-001). Both functions are optional: an
|
|
4310
|
+
* absent `resolveName` means forward resolution is unsupported and a typed
|
|
4311
|
+
* name surfaces `UNSUPPORTED_NETWORK` (INV-119 / SC-006).
|
|
4312
|
+
*
|
|
4313
|
+
* ## Stable resolver identity (integrator contract)
|
|
4314
|
+
*
|
|
4315
|
+
* The injected resolver **must be referentially stable across renders** —
|
|
4316
|
+
* memoize it (e.g. `useMemo`) or use `useRuntimeNameResolver` from
|
|
4317
|
+
* `@openzeppelin/ui-react`, which is stable by construction. A resolver whose
|
|
4318
|
+
* function identity changes on every render (e.g. a fresh inline function) is a
|
|
4319
|
+
* misconfiguration: the field keys inline resolution on that identity, so a
|
|
4320
|
+
* churning identity would otherwise re-dispatch on every render — an unbounded
|
|
4321
|
+
* RPC loop on a funds path.
|
|
4322
|
+
*
|
|
4323
|
+
* The base component **detects and withstands** this (it is not merely a caveat):
|
|
4324
|
+
* dispatch is bounded per resolution intent so a churning resolver can never drive
|
|
4325
|
+
* an unbounded loop (INV-123), a one-shot development-only warning names the
|
|
4326
|
+
* misconfiguration (INV-124), and the field degrades to a **safe gated state**
|
|
4327
|
+
* (empty value, submit blocked — never a wrong address, never a throw; INV-125)
|
|
4328
|
+
* until the resolver is memoized. A **genuine** resolver/network swap — a single
|
|
4329
|
+
* identity change for a given input — is still honored and re-resolves within
|
|
4330
|
+
* budget (INV-119). The bound is a backstop; the contract is a stable identity.
|
|
4331
|
+
*
|
|
4332
|
+
* @param props - Injected resolver functions and children
|
|
4333
|
+
*/
|
|
4334
|
+
function NameResolverProvider({ children, isValidName, resolveName, activeNetworkId, activeNetworkName, resolveNetworkLabel }) {
|
|
4335
|
+
const value = react.useMemo(() => ({
|
|
4336
|
+
isValidName,
|
|
4337
|
+
resolveName,
|
|
4338
|
+
activeNetworkId,
|
|
4339
|
+
activeNetworkName,
|
|
4340
|
+
resolveNetworkLabel
|
|
4341
|
+
}), [
|
|
4342
|
+
isValidName,
|
|
4343
|
+
resolveName,
|
|
4344
|
+
activeNetworkId,
|
|
4345
|
+
activeNetworkName,
|
|
4346
|
+
resolveNetworkLabel
|
|
4347
|
+
]);
|
|
4348
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NameResolverContext.Provider, {
|
|
4349
|
+
value,
|
|
4350
|
+
children
|
|
4351
|
+
});
|
|
4352
|
+
}
|
|
4353
|
+
|
|
3513
4354
|
//#endregion
|
|
3514
4355
|
//#region src/components/fields/AmountField.tsx
|
|
3515
4356
|
/**
|
|
@@ -6238,7 +7079,7 @@ SelectGroupedField.displayName = "SelectGroupedField";
|
|
|
6238
7079
|
* - Full accessibility support with ARIA attributes
|
|
6239
7080
|
* - Keyboard navigation
|
|
6240
7081
|
*/
|
|
6241
|
-
function TextField({ id, label, placeholder, helperText, control, name, width = "full", validation, readOnly }) {
|
|
7082
|
+
function TextField({ id, label, placeholder, helperText, control, name, width = "full", validation, readOnly, onUserEdit }) {
|
|
6242
7083
|
const isRequired = !!validation?.required;
|
|
6243
7084
|
const errorId = `${id}-error`;
|
|
6244
7085
|
const descriptionId = `${id}-description`;
|
|
@@ -6274,9 +7115,13 @@ function TextField({ id, label, placeholder, helperText, control, name, width =
|
|
|
6274
7115
|
const handleInputChange = (e) => {
|
|
6275
7116
|
const value = e.target.value;
|
|
6276
7117
|
field.onChange(value);
|
|
7118
|
+
onUserEdit?.(value);
|
|
6277
7119
|
};
|
|
6278
7120
|
const handleKeyDown = (e) => {
|
|
6279
|
-
if (e.key === "Escape") handleEscapeKey(
|
|
7121
|
+
if (e.key === "Escape") handleEscapeKey((value) => {
|
|
7122
|
+
field.onChange(value);
|
|
7123
|
+
onUserEdit?.(value);
|
|
7124
|
+
}, field.value)(e);
|
|
6280
7125
|
};
|
|
6281
7126
|
const accessibilityProps = getAccessibilityProps({
|
|
6282
7127
|
id,
|
|
@@ -6817,6 +7662,8 @@ exports.AddressDisplay = AddressDisplay;
|
|
|
6817
7662
|
exports.AddressField = AddressField;
|
|
6818
7663
|
exports.AddressLabelProvider = AddressLabelProvider;
|
|
6819
7664
|
exports.AddressListField = AddressListField;
|
|
7665
|
+
exports.AddressNameProvider = AddressNameProvider;
|
|
7666
|
+
exports.AddressSuggestionList = AddressSuggestionList;
|
|
6820
7667
|
exports.AddressSuggestionProvider = AddressSuggestionProvider;
|
|
6821
7668
|
exports.Alert = Alert;
|
|
6822
7669
|
exports.AlertDescription = AlertDescription;
|
|
@@ -6891,6 +7738,9 @@ exports.LoadingButton = LoadingButton;
|
|
|
6891
7738
|
exports.MapEntryRow = MapEntryRow;
|
|
6892
7739
|
exports.MapField = MapField;
|
|
6893
7740
|
exports.MidnightIcon = MidnightIcon;
|
|
7741
|
+
exports.NAME_RESOLUTION_DEBOUNCE_MS = NAME_RESOLUTION_DEBOUNCE_MS;
|
|
7742
|
+
exports.NameResolverContext = NameResolverContext;
|
|
7743
|
+
exports.NameResolverProvider = NameResolverProvider;
|
|
6894
7744
|
exports.NetworkAvailabilityNotice = NetworkAvailabilityNotice;
|
|
6895
7745
|
exports.NetworkErrorNotificationProvider = NetworkErrorNotificationProvider;
|
|
6896
7746
|
exports.NetworkIcon = NetworkIcon;
|
|
@@ -6965,9 +7815,13 @@ exports.hasFieldError = require_ErrorMessage.hasFieldError;
|
|
|
6965
7815
|
exports.isDuplicateMapKey = require_ErrorMessage.isDuplicateMapKey;
|
|
6966
7816
|
exports.resolveAddressListFieldLabels = resolveAddressListFieldLabels;
|
|
6967
7817
|
exports.useAddressLabel = useAddressLabel;
|
|
7818
|
+
exports.useAddressName = useAddressName;
|
|
7819
|
+
exports.useAddressSuggestionField = useAddressSuggestionField;
|
|
6968
7820
|
exports.useAddressSuggestions = useAddressSuggestions;
|
|
6969
7821
|
exports.useDuplicateKeyIndexes = useDuplicateKeyIndexes;
|
|
7822
|
+
exports.useInjectedNameResolution = useInjectedNameResolution;
|
|
6970
7823
|
exports.useMapFieldSync = useMapFieldSync;
|
|
7824
|
+
exports.useNameResolver = useNameResolver;
|
|
6971
7825
|
exports.useNetworkErrorAwareAdapter = useNetworkErrorAwareAdapter;
|
|
6972
7826
|
exports.useNetworkErrorReporter = useNetworkErrorReporter;
|
|
6973
7827
|
exports.useNetworkErrors = useNetworkErrors;
|