@openzeppelin/ui-components 3.3.0 → 3.4.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 +1006 -191
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +417 -15
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +423 -21
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +988 -194
- 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.4.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,30 @@ 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, 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 effectiveTruncate = truncateProp !== void 0 ? truncateProp : truncateWhenLabeled ? Boolean(effectiveLabel) : true;
|
|
206
291
|
const contextEditHandler = react.useCallback(() => {
|
|
207
292
|
resolver?.onEditLabel?.(address, networkId);
|
|
208
293
|
}, [
|
|
@@ -276,6 +361,17 @@ function AddressDisplay({ address, truncate: truncateProp, truncateWhenLabeled =
|
|
|
276
361
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Pencil, { className: "h-3.5 w-3.5" })
|
|
277
362
|
})
|
|
278
363
|
] });
|
|
364
|
+
const ensAvatarUrl = ensName !== void 0 && resolvedLabel === void 0 ? record?.avatarUrl : void 0;
|
|
365
|
+
const effectiveAvatar = avatar ?? (ensAvatarUrl ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressAvatar, {
|
|
366
|
+
src: ensAvatarUrl,
|
|
367
|
+
variant: avatarVariant
|
|
368
|
+
}) : void 0);
|
|
369
|
+
const isFillAvatar = avatarVariant === "fill" && Boolean(effectiveAvatar);
|
|
370
|
+
const fillAvatarChipClassName = isFillAvatar && isChip ? "overflow-hidden" : void 0;
|
|
371
|
+
const avatarSlot = effectiveAvatar ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
372
|
+
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"),
|
|
373
|
+
children: effectiveAvatar
|
|
374
|
+
}) : null;
|
|
279
375
|
const shouldShowTooltip = showTooltip && effectiveTruncate;
|
|
280
376
|
const wrapWithTooltip = (content) => {
|
|
281
377
|
if (!shouldShowTooltip) return content;
|
|
@@ -290,33 +386,43 @@ function AddressDisplay({ address, truncate: truncateProp, truncateWhenLabeled =
|
|
|
290
386
|
})] })
|
|
291
387
|
});
|
|
292
388
|
};
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
onPointerEnter: handlePointerEnter,
|
|
296
|
-
onPointerLeave: handlePointerLeave,
|
|
297
|
-
onClick: handleUntruncateClick,
|
|
298
|
-
...props,
|
|
299
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
389
|
+
if (effectiveLabel) {
|
|
390
|
+
const labelColumn = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
300
391
|
className: "truncate font-sans font-medium text-slate-900 leading-snug",
|
|
301
|
-
children:
|
|
392
|
+
children: effectiveLabel
|
|
302
393
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
303
394
|
className: "flex min-w-0 items-center font-mono text-[10px] text-slate-400 leading-snug",
|
|
304
395
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
305
396
|
className: addressTextClassName,
|
|
306
397
|
children: displayAddress
|
|
307
398
|
}), actionButtons]
|
|
308
|
-
})]
|
|
309
|
-
|
|
399
|
+
})] });
|
|
400
|
+
return wrapWithTooltip(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
401
|
+
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),
|
|
402
|
+
onPointerEnter: handlePointerEnter,
|
|
403
|
+
onPointerLeave: handlePointerLeave,
|
|
404
|
+
onClick: handleUntruncateClick,
|
|
405
|
+
...props,
|
|
406
|
+
children: effectiveAvatar ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [avatarSlot, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
407
|
+
className: "inline-flex min-w-0 flex-col",
|
|
408
|
+
children: labelColumn
|
|
409
|
+
})] }) : labelColumn
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
310
412
|
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),
|
|
413
|
+
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
414
|
onPointerEnter: handlePointerEnter,
|
|
313
415
|
onPointerLeave: handlePointerLeave,
|
|
314
416
|
onClick: handleUntruncateClick,
|
|
315
417
|
...props,
|
|
316
|
-
children: [
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
418
|
+
children: [
|
|
419
|
+
avatarSlot,
|
|
420
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
421
|
+
className: addressTextClassName,
|
|
422
|
+
children: displayAddress
|
|
423
|
+
}),
|
|
424
|
+
actionButtons
|
|
425
|
+
]
|
|
320
426
|
}));
|
|
321
427
|
}
|
|
322
428
|
|
|
@@ -414,6 +520,79 @@ function useAddressLabel(address, networkId) {
|
|
|
414
520
|
};
|
|
415
521
|
}
|
|
416
522
|
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region src/components/ui/address-display/address-name-context.tsx
|
|
525
|
+
/**
|
|
526
|
+
* Address Name Context
|
|
527
|
+
*
|
|
528
|
+
* Provides a React context for surfacing already-resolved reverse name
|
|
529
|
+
* records (`ResolvedName`) for blockchain addresses. When an
|
|
530
|
+
* `AddressNameProvider` is mounted, every `AddressDisplay` in the subtree
|
|
531
|
+
* reads its record synchronously — no call-site changes — and renders the
|
|
532
|
+
* name/avatar only when the record is forward-verified (INV-52).
|
|
533
|
+
*
|
|
534
|
+
* Structural mirror of `AddressLabelProvider` (INV-122): synchronous,
|
|
535
|
+
* value-only, no-op by default. The resolver implementation lives in the
|
|
536
|
+
* react/renderer layer, which bridges async resolution state into this
|
|
537
|
+
* synchronous read; this file stays capability-free (INV-121).
|
|
538
|
+
*
|
|
539
|
+
* @example
|
|
540
|
+
* ```tsx
|
|
541
|
+
* import { AddressNameProvider } from '@openzeppelin/ui-components';
|
|
542
|
+
*
|
|
543
|
+
* <AddressNameProvider resolveAddressName={readFromResolutionCache}>
|
|
544
|
+
* <TxHistory /> {// rows are plain <AddressDisplay/> — verified names fill in }
|
|
545
|
+
* </AddressNameProvider>
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
/**
|
|
549
|
+
* Provides reverse-name resolution to all `AddressDisplay` instances in the
|
|
550
|
+
* subtree. `resolveAddressName` MUST be synchronous — it is called during
|
|
551
|
+
* render (INV-122). A resolver that returns `undefined` for an address is
|
|
552
|
+
* behaviorally identical to mounting no provider (INV-54).
|
|
553
|
+
*
|
|
554
|
+
* @param props - Resolver function and children
|
|
555
|
+
*/
|
|
556
|
+
function AddressNameProvider({ children, resolveAddressName }) {
|
|
557
|
+
const value = react.useMemo(() => ({ resolveAddressName }), [resolveAddressName]);
|
|
558
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressNameContext.Provider, {
|
|
559
|
+
value,
|
|
560
|
+
children
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/components/ui/address-display/use-address-name.ts
|
|
566
|
+
/**
|
|
567
|
+
* Convenience hook for reading a reverse name record from the nearest
|
|
568
|
+
* `AddressNameProvider`.
|
|
569
|
+
*
|
|
570
|
+
* Kept in its own file so that `address-name-context.tsx` exports only
|
|
571
|
+
* components (required by React Fast Refresh) — mirrors `use-address-label.ts`.
|
|
572
|
+
*/
|
|
573
|
+
/**
|
|
574
|
+
* Reads the `AddressNameContext` for one address, synchronously (INV-122).
|
|
575
|
+
* Returns `{ record: undefined }` when no provider is mounted — never throws,
|
|
576
|
+
* never suspends. Note the record is returned verbatim: callers rendering a
|
|
577
|
+
* name from it must apply the same `forwardVerified === true` gate that
|
|
578
|
+
* `AddressDisplay` applies (INV-52).
|
|
579
|
+
*
|
|
580
|
+
* @param address - The blockchain address to reverse-resolve
|
|
581
|
+
* @param networkId - Optional network identifier scoping the lookup
|
|
582
|
+
* @returns The current best-known record for the address
|
|
583
|
+
*
|
|
584
|
+
* @example
|
|
585
|
+
* ```tsx
|
|
586
|
+
* function EnsBadge({ address }: { address: string }) {
|
|
587
|
+
* const { record } = useAddressName(address);
|
|
588
|
+
* return record?.forwardVerified ? <Badge>{record.name}</Badge> : null;
|
|
589
|
+
* }
|
|
590
|
+
* ```
|
|
591
|
+
*/
|
|
592
|
+
function useAddressName(address, networkId) {
|
|
593
|
+
return { record: react.useContext(AddressNameContext)?.resolveAddressName(address, networkId) };
|
|
594
|
+
}
|
|
595
|
+
|
|
417
596
|
//#endregion
|
|
418
597
|
//#region src/components/ui/alert.tsx
|
|
419
598
|
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 +2730,55 @@ function WizardLayout(props) {
|
|
|
2551
2730
|
});
|
|
2552
2731
|
}
|
|
2553
2732
|
|
|
2733
|
+
//#endregion
|
|
2734
|
+
//#region src/components/fields/address-suggestion/AddressSuggestionList.tsx
|
|
2735
|
+
/**
|
|
2736
|
+
* Presentational ARIA listbox for address suggestions (SF-3, D9).
|
|
2737
|
+
*
|
|
2738
|
+
* Extracted from `AddressField`'s inline dropdown markup into a reusable
|
|
2739
|
+
* presentational listbox so the base `AddressField` renders a consistent
|
|
2740
|
+
* suggestion structure (INV-72). Purely presentational and capability-free: it
|
|
2741
|
+
* owns no state and no resolution — the consuming field supplies the resolved
|
|
2742
|
+
* list, the highlighted index, and the select / hover callbacks.
|
|
2743
|
+
*
|
|
2744
|
+
* @see INV-72 (byte-identical suggestion structure across both fields)
|
|
2745
|
+
* @see INV-94 (`role="listbox"` / `role="option"`, `aria-selected`, `onMouseDown`-preventDefault select)
|
|
2746
|
+
*/
|
|
2747
|
+
/**
|
|
2748
|
+
* Render the suggestion dropdown. Returns `null` when there are no suggestions
|
|
2749
|
+
* (the consuming field also gates on `hasSuggestions`, so this is a safety net).
|
|
2750
|
+
*
|
|
2751
|
+
* @param props - {@link AddressSuggestionListProps}.
|
|
2752
|
+
*/
|
|
2753
|
+
function AddressSuggestionList({ id, suggestions, highlightedIndex, onSelect, onHighlight }) {
|
|
2754
|
+
if (suggestions.length === 0) return null;
|
|
2755
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2756
|
+
id: `${id}-suggestions`,
|
|
2757
|
+
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"),
|
|
2758
|
+
role: "listbox",
|
|
2759
|
+
children: suggestions.map((s, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2760
|
+
id: `${id}-suggestion-${i}`,
|
|
2761
|
+
type: "button",
|
|
2762
|
+
role: "option",
|
|
2763
|
+
"aria-selected": i === highlightedIndex,
|
|
2764
|
+
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"),
|
|
2765
|
+
onMouseDown: (e) => {
|
|
2766
|
+
e.preventDefault();
|
|
2767
|
+
onSelect(s);
|
|
2768
|
+
},
|
|
2769
|
+
onMouseEnter: () => onHighlight(i),
|
|
2770
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2771
|
+
className: "font-medium",
|
|
2772
|
+
children: s.label
|
|
2773
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2774
|
+
className: "truncate font-mono text-xs text-muted-foreground",
|
|
2775
|
+
children: s.value
|
|
2776
|
+
})]
|
|
2777
|
+
}, `${s.value}-${s.description ?? i}`))
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
AddressSuggestionList.displayName = "AddressSuggestionList";
|
|
2781
|
+
|
|
2554
2782
|
//#endregion
|
|
2555
2783
|
//#region src/components/fields/address-suggestion/context.ts
|
|
2556
2784
|
/**
|
|
@@ -2560,6 +2788,372 @@ function WizardLayout(props) {
|
|
|
2560
2788
|
*/
|
|
2561
2789
|
const AddressSuggestionContext = (0, react.createContext)(null);
|
|
2562
2790
|
|
|
2791
|
+
//#endregion
|
|
2792
|
+
//#region src/components/fields/address-suggestion/useAddressSuggestionField.ts
|
|
2793
|
+
/**
|
|
2794
|
+
* Headless suggestion-dropdown behavior for address fields.
|
|
2795
|
+
*
|
|
2796
|
+
* Extracted from `AddressField`'s inline dropdown logic (SF-3, D9) into one
|
|
2797
|
+
* reusable headless hook the base `AddressField` consumes — no divergence, no
|
|
2798
|
+
* regression (INV-72). It owns:
|
|
2799
|
+
* - `AddressSuggestionContext` resolution (explicit `suggestions` prop overrides),
|
|
2800
|
+
* - the `MAX_SUGGESTIONS` (5) cap on context-resolved lists,
|
|
2801
|
+
* - the 200 ms suggestion debounce,
|
|
2802
|
+
* - keyboard highlight state with wrapping ArrowUp / ArrowDown navigation,
|
|
2803
|
+
* - click-outside-to-close.
|
|
2804
|
+
*
|
|
2805
|
+
* It is capability-free and chain-agnostic — nothing about ENS or any chain
|
|
2806
|
+
* leaks into `@openzeppelin/ui-components`. The consuming field owns the RHF
|
|
2807
|
+
* value write on selection and the Enter / Escape input handlers; this hook
|
|
2808
|
+
* exposes the state and primitives they need.
|
|
2809
|
+
*
|
|
2810
|
+
* Distinct from the pre-existing convenience hook `useAddressSuggestions(query,
|
|
2811
|
+
* networkId)`, which resolves a raw suggestion list from context with no
|
|
2812
|
+
* debounce / cap / keyboard state — that hook is unchanged and still exported.
|
|
2813
|
+
*
|
|
2814
|
+
* @see INV-72 (suggestion-dropdown behavior preserved with zero regression; SF-5 depends)
|
|
2815
|
+
* @see INV-94 (legacy a11y + suggestion keyboard contract preserved verbatim)
|
|
2816
|
+
*/
|
|
2817
|
+
/** Suggestion debounce window (ms). Matches the legacy `AddressField`. */
|
|
2818
|
+
const DEBOUNCE_MS = 200;
|
|
2819
|
+
/** Cap on context-resolved suggestions. Explicit arrays are rendered as-is. */
|
|
2820
|
+
const MAX_SUGGESTIONS = 5;
|
|
2821
|
+
/**
|
|
2822
|
+
* Headless address-suggestion dropdown behavior. See the module docstring for
|
|
2823
|
+
* the full behavior contract.
|
|
2824
|
+
*
|
|
2825
|
+
* @param args - {@link UseAddressSuggestionFieldArgs}.
|
|
2826
|
+
* @returns {@link UseAddressSuggestionFieldResult}.
|
|
2827
|
+
*/
|
|
2828
|
+
function useAddressSuggestionField({ inputValue, suggestions: suggestionsProp, networkId }) {
|
|
2829
|
+
const contextResolver = (0, react.useContext)(AddressSuggestionContext);
|
|
2830
|
+
const containerRef = (0, react.useRef)(null);
|
|
2831
|
+
const [debouncedQuery, setDebouncedQuery] = (0, react.useState)("");
|
|
2832
|
+
const [showSuggestions, setShowSuggestions] = (0, react.useState)(false);
|
|
2833
|
+
const [highlightedIndex, setHighlightedIndex] = (0, react.useState)(-1);
|
|
2834
|
+
(0, react.useEffect)(() => {
|
|
2835
|
+
if (!inputValue.trim()) {
|
|
2836
|
+
setDebouncedQuery("");
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
const timer = setTimeout(() => setDebouncedQuery(inputValue), DEBOUNCE_MS);
|
|
2840
|
+
return () => clearTimeout(timer);
|
|
2841
|
+
}, [inputValue]);
|
|
2842
|
+
const suggestionsDisabled = suggestionsProp === false;
|
|
2843
|
+
const resolvedSuggestions = (0, react.useMemo)(() => {
|
|
2844
|
+
if (suggestionsDisabled) return [];
|
|
2845
|
+
if (Array.isArray(suggestionsProp)) return suggestionsProp;
|
|
2846
|
+
if (!contextResolver || !debouncedQuery.trim()) return [];
|
|
2847
|
+
return contextResolver.resolveSuggestions(debouncedQuery, networkId).slice(0, MAX_SUGGESTIONS);
|
|
2848
|
+
}, [
|
|
2849
|
+
suggestionsDisabled,
|
|
2850
|
+
suggestionsProp,
|
|
2851
|
+
contextResolver,
|
|
2852
|
+
debouncedQuery,
|
|
2853
|
+
networkId
|
|
2854
|
+
]);
|
|
2855
|
+
const hasSuggestions = showSuggestions && resolvedSuggestions.length > 0;
|
|
2856
|
+
(0, react.useEffect)(() => {
|
|
2857
|
+
let active = true;
|
|
2858
|
+
const handleClickOutside = (e) => {
|
|
2859
|
+
if (active && containerRef.current && !containerRef.current.contains(e.target)) setShowSuggestions(false);
|
|
2860
|
+
};
|
|
2861
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
2862
|
+
return () => {
|
|
2863
|
+
active = false;
|
|
2864
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
2865
|
+
};
|
|
2866
|
+
}, []);
|
|
2867
|
+
const onContainerKeyDown = (0, react.useCallback)((e) => {
|
|
2868
|
+
if (!hasSuggestions) return;
|
|
2869
|
+
if (e.key === "ArrowDown") {
|
|
2870
|
+
e.preventDefault();
|
|
2871
|
+
setHighlightedIndex((prev) => prev < resolvedSuggestions.length - 1 ? prev + 1 : 0);
|
|
2872
|
+
} else if (e.key === "ArrowUp") {
|
|
2873
|
+
e.preventDefault();
|
|
2874
|
+
setHighlightedIndex((prev) => prev > 0 ? prev - 1 : resolvedSuggestions.length - 1);
|
|
2875
|
+
}
|
|
2876
|
+
}, [hasSuggestions, resolvedSuggestions.length]);
|
|
2877
|
+
const onInputChange = (0, react.useCallback)((value) => {
|
|
2878
|
+
setShowSuggestions(value.length > 0);
|
|
2879
|
+
setHighlightedIndex(-1);
|
|
2880
|
+
}, []);
|
|
2881
|
+
return {
|
|
2882
|
+
containerRef,
|
|
2883
|
+
resolvedSuggestions,
|
|
2884
|
+
hasSuggestions,
|
|
2885
|
+
highlightedIndex,
|
|
2886
|
+
suggestionsDisabled,
|
|
2887
|
+
setHighlightedIndex,
|
|
2888
|
+
closeSuggestions: (0, react.useCallback)(() => {
|
|
2889
|
+
setShowSuggestions(false);
|
|
2890
|
+
setHighlightedIndex(-1);
|
|
2891
|
+
}, []),
|
|
2892
|
+
onInputChange,
|
|
2893
|
+
onContainerKeyDown
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
//#endregion
|
|
2898
|
+
//#region src/components/fields/name-resolution/useInjectedNameResolution.ts
|
|
2899
|
+
/**
|
|
2900
|
+
* Injected name-resolution machine (SF-3).
|
|
2901
|
+
*
|
|
2902
|
+
* A thin, capability-free async machine that `AddressField` consumes: given a
|
|
2903
|
+
* typed input and the injected `resolveName`, it debounces, dispatches, tracks
|
|
2904
|
+
* status, and applies the out-of-order name-drop guard (INV-117). It is the
|
|
2905
|
+
* component-boundary analogue of SF-2's `useResolveName`, with all hard
|
|
2906
|
+
* mechanics (cache, dedupe, retries, UNSUPPORTED synthesis) deliberately left
|
|
2907
|
+
* behind the injected function — this hook does ONLY debounce + status
|
|
2908
|
+
* derivation + the funds guards.
|
|
2909
|
+
*
|
|
2910
|
+
* The machine holds no resolved-hex cache of its own (INV-85): its single
|
|
2911
|
+
* `settled` record is valid only while it matches the current debounced input,
|
|
2912
|
+
* the current resolver function identity, and the current retry attempt — any
|
|
2913
|
+
* mismatch derives `loading`, never a stale `resolved`.
|
|
2914
|
+
*
|
|
2915
|
+
* Resolver-identity-churn backstop (INV-123/124/125). The dispatch effect keys
|
|
2916
|
+
* on the injected `resolveName` function identity, so an unstable (non-memoized)
|
|
2917
|
+
* integrator resolver re-fires it on every render. To keep that from becoming an
|
|
2918
|
+
* unbounded RPC loop (rate-ban + cost + funds-path DoS), the machine caps
|
|
2919
|
+
* dispatches per resolution intent — the pair (normalized debounced input × retry
|
|
2920
|
+
* attempt) — at {@link MAX_DISPATCHES_PER_INTENT}, regardless of render count and
|
|
2921
|
+
* regardless of how many times `resolveName` identity changes under that intent.
|
|
2922
|
+
* The budget resets only on intent change (a new typed name, or `retry()`) or when
|
|
2923
|
+
* the field leaves the active name-resolution path (clear / disable). It does
|
|
2924
|
+
* *not* reset on `settled.source` mismatch: that would let two settling sources
|
|
2925
|
+
* flap and refill the counter (settling churn), silently defeating INV-123. A
|
|
2926
|
+
* genuine network swap still re-dispatches within the shared intent budget
|
|
2927
|
+
* (INV-119 / acceptance #5) because a single identity change costs one unit.
|
|
2928
|
+
* Clear→retype of the same name gets a fresh budget via the leave-path reset, so
|
|
2929
|
+
* it cannot accumulate toward the cap across cycles and then strand a later swap.
|
|
2930
|
+
* A churning identity that exhausts the budget emits a one-shot, development-only
|
|
2931
|
+
* warning (INV-124) and degrades to the safe gated state (bounded RPC, RHF value
|
|
2932
|
+
* `''`, submit gated, never a wrong hex, never a throw — INV-125). This backstop
|
|
2933
|
+
* is entirely internal: no signature, param, or return-type change; `AddressField`
|
|
2934
|
+
* consumes the machine unchanged.
|
|
2935
|
+
*/
|
|
2936
|
+
/**
|
|
2937
|
+
* Default debounce window (ms) for typed names — mirrors SF-2's
|
|
2938
|
+
* `forwardDebounceMs` default so the field and the bare hook feel identical.
|
|
2939
|
+
*/
|
|
2940
|
+
const NAME_RESOLUTION_DEBOUNCE_MS = 300;
|
|
2941
|
+
/**
|
|
2942
|
+
* Cap on resolver dispatches per resolution intent — the pair (normalized
|
|
2943
|
+
* debounced input × retry attempt) — regardless of how many times the field
|
|
2944
|
+
* re-renders or how many times `resolveName` identity changes under that intent
|
|
2945
|
+
* (INV-123). Chosen to sit comfortably above the dispatches a single displayed
|
|
2946
|
+
* input legitimately incurs (1 initial call + any human-initiated genuine network
|
|
2947
|
+
* swaps of that same name, each ≤1 unit — INV-119) and far below per-render
|
|
2948
|
+
* identity churn (hundreds/sec): a user does not switch networks eight times
|
|
2949
|
+
* while one unedited name sits in the field, whereas churn blows past eight
|
|
2950
|
+
* within milliseconds. The budget resets when the intent changes or when the
|
|
2951
|
+
* field leaves the name-resolution path (clear / disable) — never on
|
|
2952
|
+
* `settled.source` mismatch, so settling-source oscillation cannot refill it.
|
|
2953
|
+
*/
|
|
2954
|
+
const MAX_DISPATCHES_PER_INTENT = 8;
|
|
2955
|
+
/**
|
|
2956
|
+
* Debounce + dispatch + status derivation over an injected `resolveName`.
|
|
2957
|
+
*
|
|
2958
|
+
* Guarantees:
|
|
2959
|
+
* - INV-83: no call unless `enabled` (name-candidate + injected method), and
|
|
2960
|
+
* never for a debounced copy that lags the current normalized input.
|
|
2961
|
+
* - INV-117: a settled result whose requested name ≠ the current debounced
|
|
2962
|
+
* input is discarded (last-write-wins at the component boundary).
|
|
2963
|
+
* - INV-87: a rejecting resolver (contract violation) is mapped to a typed
|
|
2964
|
+
* `ADAPTER_ERROR` — nothing throws into the render tree.
|
|
2965
|
+
* - INV-123: resolver dispatches are bounded per resolution intent (normalized
|
|
2966
|
+
* debounced input × retry attempt) at {@link MAX_DISPATCHES_PER_INTENT},
|
|
2967
|
+
* regardless of render count or resolver-identity oscillation; the budget
|
|
2968
|
+
* resets on intent change or when leaving the name-resolution path — never on
|
|
2969
|
+
* `settled.source` mismatch; `retry()` never consumes an existing budget.
|
|
2970
|
+
* - INV-124: an identity-churning resolver that exhausts the budget triggers a
|
|
2971
|
+
* one-shot, development-only `logger.warn` (silent in production).
|
|
2972
|
+
* - INV-125: under sustained churn the field stays in the safe gated state — the
|
|
2973
|
+
* perpetually-changing identity keeps failing the `settled.source` check, so
|
|
2974
|
+
* the machine derives `loading`, never `resolved`; combined with INV-87 it
|
|
2975
|
+
* never throws and never writes a hex.
|
|
2976
|
+
* - No `setState` after unmount: every dispatch is cancelled on cleanup.
|
|
2977
|
+
*
|
|
2978
|
+
* @param params - {@link UseInjectedNameResolutionParams}
|
|
2979
|
+
* @returns The current {@link InjectedNameResolutionResult}.
|
|
2980
|
+
*/
|
|
2981
|
+
function useInjectedNameResolution({ input, enabled, resolveName, debounceMs = NAME_RESOLUTION_DEBOUNCE_MS }) {
|
|
2982
|
+
const normalized = input.trim().toLowerCase();
|
|
2983
|
+
const [debounced, setDebounced] = (0, react.useState)(normalized);
|
|
2984
|
+
const seededRef = (0, react.useRef)(false);
|
|
2985
|
+
(0, react.useEffect)(() => {
|
|
2986
|
+
if (!seededRef.current) {
|
|
2987
|
+
seededRef.current = true;
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
if (debounceMs <= 0) {
|
|
2991
|
+
setDebounced(normalized);
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
const timer = setTimeout(() => setDebounced(normalized), debounceMs);
|
|
2995
|
+
return () => clearTimeout(timer);
|
|
2996
|
+
}, [normalized, debounceMs]);
|
|
2997
|
+
const [attempt, setAttempt] = (0, react.useState)(0);
|
|
2998
|
+
const [settled, setSettled] = (0, react.useState)(null);
|
|
2999
|
+
const currentTargetRef = (0, react.useRef)("");
|
|
3000
|
+
currentTargetRef.current = enabled && resolveName ? debounced : "";
|
|
3001
|
+
const intentKeyRef = (0, react.useRef)("");
|
|
3002
|
+
const dispatchCountRef = (0, react.useRef)(0);
|
|
3003
|
+
const churnWarnedRef = (0, react.useRef)(false);
|
|
3004
|
+
const warnOnceOnChurn = (0, react.useCallback)(() => {
|
|
3005
|
+
if (process.env.NODE_ENV === "production" || churnWarnedRef.current) return;
|
|
3006
|
+
churnWarnedRef.current = true;
|
|
3007
|
+
_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.");
|
|
3008
|
+
}, []);
|
|
3009
|
+
(0, react.useEffect)(() => {
|
|
3010
|
+
if (!enabled || !resolveName || debounced === "") {
|
|
3011
|
+
intentKeyRef.current = "";
|
|
3012
|
+
dispatchCountRef.current = 0;
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
if (debounced !== normalized) return;
|
|
3016
|
+
const intentKey = `${debounced}\u0000${attempt}`;
|
|
3017
|
+
if (intentKeyRef.current !== intentKey) {
|
|
3018
|
+
intentKeyRef.current = intentKey;
|
|
3019
|
+
dispatchCountRef.current = 0;
|
|
3020
|
+
}
|
|
3021
|
+
if (dispatchCountRef.current >= MAX_DISPATCHES_PER_INTENT) {
|
|
3022
|
+
warnOnceOnChurn();
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
dispatchCountRef.current += 1;
|
|
3026
|
+
let cancelled = false;
|
|
3027
|
+
const requestedName = debounced;
|
|
3028
|
+
const requestedAttempt = attempt;
|
|
3029
|
+
const source = resolveName;
|
|
3030
|
+
const settle = (result) => {
|
|
3031
|
+
if (cancelled) return;
|
|
3032
|
+
if (requestedName !== currentTargetRef.current) return;
|
|
3033
|
+
setSettled({
|
|
3034
|
+
name: requestedName,
|
|
3035
|
+
result,
|
|
3036
|
+
source,
|
|
3037
|
+
attempt: requestedAttempt
|
|
3038
|
+
});
|
|
3039
|
+
};
|
|
3040
|
+
source(requestedName).then(settle, (cause) => {
|
|
3041
|
+
settle({
|
|
3042
|
+
ok: false,
|
|
3043
|
+
error: {
|
|
3044
|
+
code: "ADAPTER_ERROR",
|
|
3045
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
3046
|
+
cause
|
|
3047
|
+
}
|
|
3048
|
+
});
|
|
3049
|
+
});
|
|
3050
|
+
return () => {
|
|
3051
|
+
cancelled = true;
|
|
3052
|
+
};
|
|
3053
|
+
}, [
|
|
3054
|
+
enabled,
|
|
3055
|
+
resolveName,
|
|
3056
|
+
debounced,
|
|
3057
|
+
normalized,
|
|
3058
|
+
attempt,
|
|
3059
|
+
warnOnceOnChurn
|
|
3060
|
+
]);
|
|
3061
|
+
const retry = (0, react.useCallback)(() => {
|
|
3062
|
+
setAttempt((current) => current + 1);
|
|
3063
|
+
}, []);
|
|
3064
|
+
if (!enabled || !resolveName || normalized === "") return { status: "idle" };
|
|
3065
|
+
if (debounced !== normalized) return {
|
|
3066
|
+
status: "debouncing",
|
|
3067
|
+
name: normalized
|
|
3068
|
+
};
|
|
3069
|
+
if (settled !== null && settled.name === debounced && settled.source === resolveName && settled.attempt === attempt) {
|
|
3070
|
+
if (settled.result.ok) return {
|
|
3071
|
+
status: "resolved",
|
|
3072
|
+
name: settled.name,
|
|
3073
|
+
data: settled.result.value
|
|
3074
|
+
};
|
|
3075
|
+
return {
|
|
3076
|
+
status: "error",
|
|
3077
|
+
name: settled.name,
|
|
3078
|
+
error: settled.result.error,
|
|
3079
|
+
retry
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
return {
|
|
3083
|
+
status: "loading",
|
|
3084
|
+
name: normalized
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
//#endregion
|
|
3089
|
+
//#region src/components/fields/name-resolution/context.ts
|
|
3090
|
+
/**
|
|
3091
|
+
* Process-global key for the context object. Uses `Symbol.for` so all duplicated
|
|
3092
|
+
* module instances share ONE React context — the same cross-bundle-singleton
|
|
3093
|
+
* pattern as `@openzeppelin/ui-react`'s `NameResolutionContext` /
|
|
3094
|
+
* `WalletStateContext` (bundler pre-bundling / npm-installed consumers).
|
|
3095
|
+
*/
|
|
3096
|
+
const CONTEXT_KEY = Symbol.for("@openzeppelin/ui-components/NameResolverContext");
|
|
3097
|
+
function getOrCreateSharedContext() {
|
|
3098
|
+
const globalScope = globalThis;
|
|
3099
|
+
if (!globalScope[CONTEXT_KEY]) globalScope[CONTEXT_KEY] = (0, react.createContext)(null);
|
|
3100
|
+
return globalScope[CONTEXT_KEY];
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* @internal Shared context instance consumed by both AddressField and
|
|
3104
|
+
* NameResolverProvider (INV-118). Kept in its own file so component files
|
|
3105
|
+
* export only components (required by React Fast Refresh).
|
|
3106
|
+
*
|
|
3107
|
+
* `null` means no provider is mounted — every ENS branch in `AddressField` is
|
|
3108
|
+
* then dead code and the field is byte-identical to its pre-ENS behavior
|
|
3109
|
+
* (INV-82).
|
|
3110
|
+
*/
|
|
3111
|
+
const NameResolverContext = getOrCreateSharedContext();
|
|
3112
|
+
|
|
3113
|
+
//#endregion
|
|
3114
|
+
//#region src/components/fields/name-resolution/useNameResolver.ts
|
|
3115
|
+
/**
|
|
3116
|
+
* Read the injected resolver context. Returns `null` when no
|
|
3117
|
+
* `NameResolverProvider` is mounted — the consuming field must then treat
|
|
3118
|
+
* every ENS branch as dead code (INV-82).
|
|
3119
|
+
*
|
|
3120
|
+
* @returns The extended context value, or `null` when no provider is mounted.
|
|
3121
|
+
*/
|
|
3122
|
+
function useNameResolver() {
|
|
3123
|
+
return (0, react.useContext)(NameResolverContext);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
//#endregion
|
|
3127
|
+
//#region src/components/fields/name-resolution/useResolvingAnnouncerCopy.ts
|
|
3128
|
+
/** Default escalation threshold for long-latency resolves (INV-129/130). */
|
|
3129
|
+
const RESOLVING_ANNOUNCER_ESCALATE_AFTER_MS = 3e3;
|
|
3130
|
+
const PHASE_1_COPY = "Resolving…";
|
|
3131
|
+
const PHASE_2_COPY = "Still resolving…";
|
|
3132
|
+
/**
|
|
3133
|
+
* Returns the announcer string for debouncing/loading arms.
|
|
3134
|
+
* Phase 1: "Resolving…" (SF-3 default, unchanged for first N ms).
|
|
3135
|
+
* Phase 2: "Still resolving…" after threshold — reduces frozen perception
|
|
3136
|
+
* during CCIP-scale latency without naming gateway/CCIP/v2.
|
|
3137
|
+
*/
|
|
3138
|
+
function useResolvingAnnouncerCopy({ isPending, escalateAfterMs = RESOLVING_ANNOUNCER_ESCALATE_AFTER_MS }) {
|
|
3139
|
+
const [escalated, setEscalated] = (0, react.useState)(false);
|
|
3140
|
+
(0, react.useEffect)(() => {
|
|
3141
|
+
if (!isPending) {
|
|
3142
|
+
setEscalated(false);
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
3145
|
+
setEscalated(false);
|
|
3146
|
+
const timeoutId = window.setTimeout(() => {
|
|
3147
|
+
setEscalated(true);
|
|
3148
|
+
}, escalateAfterMs);
|
|
3149
|
+
return () => {
|
|
3150
|
+
window.clearTimeout(timeoutId);
|
|
3151
|
+
};
|
|
3152
|
+
}, [isPending, escalateAfterMs]);
|
|
3153
|
+
if (!isPending) return PHASE_1_COPY;
|
|
3154
|
+
return escalated ? PHASE_2_COPY : PHASE_1_COPY;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
2563
3157
|
//#endregion
|
|
2564
3158
|
//#region src/components/fields/utils/accessibility.ts
|
|
2565
3159
|
/**
|
|
@@ -2751,8 +3345,26 @@ function getWidthClasses(width) {
|
|
|
2751
3345
|
|
|
2752
3346
|
//#endregion
|
|
2753
3347
|
//#region src/components/fields/AddressField.tsx
|
|
2754
|
-
|
|
2755
|
-
|
|
3348
|
+
/**
|
|
3349
|
+
* Non-user-facing gate string returned by the sync validator while a typed name
|
|
3350
|
+
* is resolving, so `formState.isValid` is `false` even for an optional field
|
|
3351
|
+
* (INV-84). The visible message comes from the aria-live region, never this string.
|
|
3352
|
+
*/
|
|
3353
|
+
const NAME_RESOLUTION_PENDING_GATE = "__ens_name_resolution_pending__";
|
|
3354
|
+
/** Codes for which a `retry()` affordance is offered — transient only (INV-90). */
|
|
3355
|
+
const TRANSIENT_ERROR_CODES = new Set([
|
|
3356
|
+
"RESOLUTION_TIMEOUT",
|
|
3357
|
+
"EXTERNAL_GATEWAY_ERROR",
|
|
3358
|
+
"ADAPTER_ERROR"
|
|
3359
|
+
]);
|
|
3360
|
+
/**
|
|
3361
|
+
* Normalize a typed name for the resolved-write match guard: trim then lowercase,
|
|
3362
|
+
* mirroring the SF-2 engine (whose echoed `result.name` is already normalized).
|
|
3363
|
+
* Comparing the raw typed value would spuriously fail on case/whitespace (INV-79).
|
|
3364
|
+
*/
|
|
3365
|
+
function normalizeResolvedName(value) {
|
|
3366
|
+
return value.trim().toLowerCase();
|
|
3367
|
+
}
|
|
2756
3368
|
/**
|
|
2757
3369
|
* Address input field component specifically designed for blockchain addresses via React Hook Form integration.
|
|
2758
3370
|
*
|
|
@@ -2780,19 +3392,35 @@ const MAX_SUGGESTIONS = 5;
|
|
|
2780
3392
|
* Pass `suggestions={false}` to opt out when a provider is mounted.
|
|
2781
3393
|
*
|
|
2782
3394
|
* The suggestion dropdown includes built-in debouncing, keyboard navigation (Arrow keys,
|
|
2783
|
-
* Enter, Escape), click-outside dismissal, and ARIA listbox semantics
|
|
3395
|
+
* Enter, Escape), click-outside dismissal, and ARIA listbox semantics — all provided by
|
|
3396
|
+
* the shared headless `useAddressSuggestionField` hook + `AddressSuggestionList`.
|
|
3397
|
+
*
|
|
3398
|
+
* **Inline name resolution (SF-3, opt-in via context).** When a
|
|
3399
|
+
* `NameResolverProvider` is mounted, the field also accepts a name (e.g.
|
|
3400
|
+
* `alice.eth`): the name is resolved inline through the injected `resolveName`
|
|
3401
|
+
* and the RHF form value becomes the resolved hex — never the name, never a
|
|
3402
|
+
* silently-coerced value. The correctness spine is a shadow-state model:
|
|
3403
|
+
* the `<input>` always shows the typed string (`inputValue`, INV-69); the RHF
|
|
3404
|
+
* value is `''` for every unresolved name state so submit stays gated with no
|
|
3405
|
+
* async validator (INV-75); the single name→hex write fires only on
|
|
3406
|
+
* `resolved` + normalized name-match (INV-79/80). With **no** provider mounted
|
|
3407
|
+
* every resolution branch below is dead code and the field is byte-identical
|
|
3408
|
+
* to its pre-ENS behavior (INV-82 — the LOCKED backward-compat guarantee).
|
|
3409
|
+
* The component stays capability-free: it never reads a runtime or wallet
|
|
3410
|
+
* state; everything arrives through the injected context (INV-118).
|
|
2784
3411
|
*/
|
|
2785
|
-
function AddressField({ id, label, placeholder, helperText, control, name, width = "full", validation, addressing, readOnly, suggestions: suggestionsProp, onSuggestionSelect }) {
|
|
3412
|
+
function AddressField({ id, label, placeholder, helperText, control, name, width = "full", validation, addressing, readOnly, suggestions: suggestionsProp, onSuggestionSelect, onResolvedNameChange }) {
|
|
2786
3413
|
const isRequired = !!validation?.required;
|
|
2787
3414
|
const errorId = `${id}-error`;
|
|
2788
3415
|
const descriptionId = `${id}-description`;
|
|
2789
|
-
const
|
|
2790
|
-
const
|
|
3416
|
+
const resolutionRegionId = `${id}-resolution`;
|
|
3417
|
+
const resolver = useNameResolver();
|
|
3418
|
+
const resolveName = resolver?.resolveName;
|
|
3419
|
+
const isValidName = resolver?.isValidName;
|
|
3420
|
+
const activeNetworkId = resolver?.activeNetworkId;
|
|
3421
|
+
const activeNetworkName = resolver?.activeNetworkName;
|
|
2791
3422
|
const lastSetValueRef = (0, react.useRef)("");
|
|
2792
3423
|
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
3424
|
const watchedFieldValue = (0, react_hook_form.useWatch)({
|
|
2797
3425
|
control,
|
|
2798
3426
|
name
|
|
@@ -2804,173 +3432,271 @@ function AddressField({ id, label, placeholder, helperText, control, name, width
|
|
|
2804
3432
|
setInputValue(currentFieldValue);
|
|
2805
3433
|
}
|
|
2806
3434
|
}, [watchedFieldValue]);
|
|
3435
|
+
const isValidAddress = (0, react.useMemo)(() => addressing ? (v) => addressing.isValidAddress(v) : void 0, [addressing]);
|
|
3436
|
+
const classification = (0, react.useMemo)(() => (0, _openzeppelin_ui_utils.classifyAddressInput)(inputValue, {
|
|
3437
|
+
isValidAddress,
|
|
3438
|
+
isValidName
|
|
3439
|
+
}), [
|
|
3440
|
+
inputValue,
|
|
3441
|
+
isValidAddress,
|
|
3442
|
+
isValidName
|
|
3443
|
+
]);
|
|
3444
|
+
const resolverActive = resolver !== null && classification === "name-candidate";
|
|
3445
|
+
const nameEnabled = resolverActive && resolveName != null;
|
|
3446
|
+
const forwardUnsupported = resolverActive && resolveName == null;
|
|
3447
|
+
const resolution = useInjectedNameResolution({
|
|
3448
|
+
input: inputValue,
|
|
3449
|
+
enabled: nameEnabled,
|
|
3450
|
+
resolveName
|
|
3451
|
+
});
|
|
3452
|
+
const chainScopeBlocked = resolution.status === "resolved" && (0, _openzeppelin_ui_utils.isChainScopeMismatch)(resolution.data.provenance, activeNetworkId);
|
|
3453
|
+
const resolvingAnnouncerCopy = useResolvingAnnouncerCopy({ isPending: !forwardUnsupported && (resolution.status === "debouncing" || resolution.status === "loading") });
|
|
3454
|
+
const resolverPresentRef = (0, react.useRef)(false);
|
|
3455
|
+
const classificationRef = (0, react.useRef)("empty");
|
|
3456
|
+
const statusRef = (0, react.useRef)("idle");
|
|
3457
|
+
const resolvedNameRef = (0, react.useRef)(void 0);
|
|
3458
|
+
const chainScopeBlockedRef = (0, react.useRef)(false);
|
|
3459
|
+
const validationRef = (0, react.useRef)(validation);
|
|
3460
|
+
const addressingRef = (0, react.useRef)(addressing);
|
|
3461
|
+
resolverPresentRef.current = resolver !== null;
|
|
3462
|
+
classificationRef.current = classification;
|
|
3463
|
+
statusRef.current = resolution.status;
|
|
3464
|
+
resolvedNameRef.current = resolution.status === "resolved" ? resolution.name : void 0;
|
|
3465
|
+
chainScopeBlockedRef.current = chainScopeBlocked;
|
|
3466
|
+
validationRef.current = validation;
|
|
3467
|
+
addressingRef.current = addressing;
|
|
3468
|
+
const { field, fieldState } = (0, react_hook_form.useController)({
|
|
3469
|
+
control,
|
|
3470
|
+
name,
|
|
3471
|
+
rules: { validate: (0, react.useCallback)((value) => {
|
|
3472
|
+
if (resolverPresentRef.current && classificationRef.current === "name-candidate" && (statusRef.current !== "resolved" || chainScopeBlockedRef.current)) return NAME_RESOLUTION_PENDING_GATE;
|
|
3473
|
+
if (value === void 0 || value === null || value === "") return validationRef.current?.required ? "This field is required" : true;
|
|
3474
|
+
const standardValidationResult = require_ErrorMessage.validateField(value, validationRef.current);
|
|
3475
|
+
if (standardValidationResult !== true) return standardValidationResult;
|
|
3476
|
+
if (addressingRef.current && typeof value === "string") {
|
|
3477
|
+
if (!addressingRef.current.isValidAddress(value)) return "Invalid address format for the selected chain";
|
|
3478
|
+
}
|
|
3479
|
+
return true;
|
|
3480
|
+
}, []) },
|
|
3481
|
+
disabled: readOnly
|
|
3482
|
+
});
|
|
3483
|
+
const fieldOnChange = field.onChange;
|
|
3484
|
+
const gateFormState = (0, react_hook_form.useFormState)({
|
|
3485
|
+
control,
|
|
3486
|
+
disabled: resolver === null
|
|
3487
|
+
});
|
|
3488
|
+
const observedIsValid = resolver !== null && gateFormState.isValid;
|
|
3489
|
+
const pendingNameGate = resolver !== null && classification === "name-candidate" && (resolution.status !== "resolved" || chainScopeBlocked);
|
|
2807
3490
|
(0, react.useEffect)(() => {
|
|
2808
|
-
if (
|
|
2809
|
-
|
|
2810
|
-
|
|
3491
|
+
if (pendingNameGate && observedIsValid) control.setError(name, {
|
|
3492
|
+
type: "validate",
|
|
3493
|
+
message: NAME_RESOLUTION_PENDING_GATE
|
|
3494
|
+
});
|
|
3495
|
+
}, [
|
|
3496
|
+
pendingNameGate,
|
|
3497
|
+
observedIsValid,
|
|
3498
|
+
control,
|
|
3499
|
+
name
|
|
3500
|
+
]);
|
|
3501
|
+
const resolvedAddress = resolution.status === "resolved" ? resolution.data.address : void 0;
|
|
3502
|
+
const resolvedName = resolution.status === "resolved" ? resolution.name : void 0;
|
|
3503
|
+
const isResolvedNameMatch = resolvedName !== void 0 && normalizeResolvedName(inputValue) === resolvedName;
|
|
3504
|
+
(0, react.useEffect)(() => {
|
|
3505
|
+
if (isResolvedNameMatch && resolvedAddress !== void 0 && !chainScopeBlocked) {
|
|
3506
|
+
lastSetValueRef.current = resolvedAddress;
|
|
3507
|
+
fieldOnChange(resolvedAddress);
|
|
2811
3508
|
}
|
|
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
3509
|
}, [
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
3510
|
+
isResolvedNameMatch,
|
|
3511
|
+
resolvedAddress,
|
|
3512
|
+
chainScopeBlocked,
|
|
3513
|
+
fieldOnChange
|
|
2826
3514
|
]);
|
|
2827
|
-
const
|
|
3515
|
+
const matchedName = isResolvedNameMatch ? resolvedName : void 0;
|
|
2828
3516
|
(0, react.useEffect)(() => {
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
3517
|
+
onResolvedNameChange?.(matchedName);
|
|
3518
|
+
}, [matchedName, onResolvedNameChange]);
|
|
3519
|
+
const { containerRef, resolvedSuggestions, hasSuggestions, highlightedIndex, suggestionsDisabled, setHighlightedIndex, closeSuggestions, onInputChange, onContainerKeyDown } = useAddressSuggestionField({
|
|
3520
|
+
inputValue,
|
|
3521
|
+
suggestions: suggestionsProp
|
|
3522
|
+
});
|
|
3523
|
+
/**
|
|
3524
|
+
* The single site for the typed/raw write (the name→hex write lives in the
|
|
3525
|
+
* effect above). With no resolver this is the legacy write, verbatim (INV-82);
|
|
3526
|
+
* with a resolver, only the `name-candidate` classification diverges:
|
|
3527
|
+
* - hex / malformed / empty → the raw value (legacy sync validate applies)
|
|
3528
|
+
* - name-candidate → `''` — gates submit and synchronously invalidates any
|
|
3529
|
+
* prior resolved hex before re-resolution (INV-75 / INV-81)
|
|
3530
|
+
*/
|
|
3531
|
+
const commitTypedValue = (0, react.useCallback)((value) => {
|
|
3532
|
+
setInputValue(value);
|
|
3533
|
+
if (!resolverPresentRef.current) {
|
|
3534
|
+
lastSetValueRef.current = value;
|
|
3535
|
+
fieldOnChange(value);
|
|
3536
|
+
return;
|
|
3537
|
+
}
|
|
3538
|
+
const c = (0, _openzeppelin_ui_utils.classifyAddressInput)(value, {
|
|
3539
|
+
isValidAddress,
|
|
3540
|
+
isValidName
|
|
3541
|
+
});
|
|
3542
|
+
classificationRef.current = c;
|
|
3543
|
+
if (c === "name-candidate") {
|
|
3544
|
+
const normalized = normalizeResolvedName(value);
|
|
3545
|
+
if (statusRef.current === "resolved" && resolvedNameRef.current === normalized && !chainScopeBlockedRef.current) return;
|
|
3546
|
+
statusRef.current = "debouncing";
|
|
3547
|
+
lastSetValueRef.current = "";
|
|
3548
|
+
fieldOnChange("");
|
|
3549
|
+
} else {
|
|
3550
|
+
lastSetValueRef.current = value;
|
|
3551
|
+
fieldOnChange(value);
|
|
3552
|
+
}
|
|
3553
|
+
}, [
|
|
3554
|
+
fieldOnChange,
|
|
3555
|
+
isValidAddress,
|
|
3556
|
+
isValidName
|
|
3557
|
+
]);
|
|
3558
|
+
const handleInputChange = (e) => {
|
|
3559
|
+
commitTypedValue(e.target.value);
|
|
3560
|
+
onInputChange(e.target.value);
|
|
3561
|
+
};
|
|
3562
|
+
const applySuggestion = (suggestion) => {
|
|
3563
|
+
commitTypedValue(suggestion.value);
|
|
3564
|
+
onSuggestionSelect?.(suggestion);
|
|
3565
|
+
closeSuggestions();
|
|
3566
|
+
};
|
|
3567
|
+
const handleKeyDown = (e) => {
|
|
3568
|
+
if (hasSuggestions && e.key === "Enter" && highlightedIndex >= 0) {
|
|
2845
3569
|
e.preventDefault();
|
|
2846
|
-
|
|
3570
|
+
applySuggestion(resolvedSuggestions[highlightedIndex]);
|
|
3571
|
+
return;
|
|
2847
3572
|
}
|
|
2848
|
-
|
|
3573
|
+
if (e.key === "Escape") {
|
|
3574
|
+
if (hasSuggestions) {
|
|
3575
|
+
closeSuggestions();
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
handleEscapeKey(commitTypedValue, inputValue)(e);
|
|
3579
|
+
}
|
|
3580
|
+
};
|
|
3581
|
+
const hasError = !!fieldState.error;
|
|
3582
|
+
const isPendingGate = fieldState.error?.message === NAME_RESOLUTION_PENDING_GATE;
|
|
3583
|
+
const hasRealError = hasError && !isPendingGate;
|
|
3584
|
+
const shouldShowError = hasRealError && fieldState.isTouched;
|
|
3585
|
+
const validationClasses = require_ErrorMessage.getValidationStateClasses(hasRealError ? fieldState.error : void 0, isPendingGate ? false : fieldState.isTouched);
|
|
3586
|
+
const patternErrorMessage = validation?.pattern && typeof validation.pattern === "object" && "message" in validation.pattern ? validation.pattern.message : void 0;
|
|
3587
|
+
const accessibilityProps = getAccessibilityProps({
|
|
3588
|
+
id,
|
|
3589
|
+
hasError: hasRealError,
|
|
3590
|
+
isRequired,
|
|
3591
|
+
hasHelperText: !!helperText
|
|
3592
|
+
});
|
|
3593
|
+
const renderOutcome = () => {
|
|
3594
|
+
if (forwardUnsupported) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3595
|
+
role: "alert",
|
|
3596
|
+
className: "text-destructive text-sm",
|
|
3597
|
+
children: (0, _openzeppelin_ui_utils.nameResolutionMessageForCode)("UNSUPPORTED_NETWORK")
|
|
3598
|
+
});
|
|
3599
|
+
switch (resolution.status) {
|
|
3600
|
+
case "idle": return null;
|
|
3601
|
+
case "debouncing":
|
|
3602
|
+
case "loading": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3603
|
+
className: "text-muted-foreground text-sm",
|
|
3604
|
+
children: resolvingAnnouncerCopy
|
|
3605
|
+
});
|
|
3606
|
+
case "resolved":
|
|
3607
|
+
if (chainScopeBlocked) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3608
|
+
role: "alert",
|
|
3609
|
+
className: "text-destructive text-sm",
|
|
3610
|
+
children: (0, _openzeppelin_ui_utils.nameResolutionChainScopeMismatchMessage)({ activeNetworkName })
|
|
3611
|
+
});
|
|
3612
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
3613
|
+
className: "text-sm",
|
|
3614
|
+
children: ["Resolved to ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
|
|
3615
|
+
className: "font-mono",
|
|
3616
|
+
children: resolution.data.address
|
|
3617
|
+
})]
|
|
3618
|
+
});
|
|
3619
|
+
case "error": {
|
|
3620
|
+
const networkName = resolution.error.code === "UNSUPPORTED_NETWORK" ? resolution.error.networkId || void 0 : void 0;
|
|
3621
|
+
const message = (0, _openzeppelin_ui_utils.nameResolutionMessageForCode)(resolution.error.code, { networkName });
|
|
3622
|
+
const isTransient = TRANSIENT_ERROR_CODES.has(resolution.error.code);
|
|
3623
|
+
const retry = resolution.retry;
|
|
3624
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
3625
|
+
role: "alert",
|
|
3626
|
+
className: "text-destructive text-sm",
|
|
3627
|
+
children: [message, isTransient ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3628
|
+
type: "button",
|
|
3629
|
+
className: "ml-2 underline",
|
|
3630
|
+
onClick: retry,
|
|
3631
|
+
children: "Retry"
|
|
3632
|
+
}) : null]
|
|
3633
|
+
});
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
};
|
|
2849
3637
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2850
3638
|
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
|
|
3639
|
+
children: [
|
|
3640
|
+
label && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Label, {
|
|
3641
|
+
htmlFor: id,
|
|
3642
|
+
children: [
|
|
3643
|
+
label,
|
|
3644
|
+
" ",
|
|
3645
|
+
isRequired && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3646
|
+
className: "text-destructive",
|
|
3647
|
+
children: "*"
|
|
2970
3648
|
})
|
|
2971
|
-
]
|
|
2972
|
-
}
|
|
2973
|
-
|
|
3649
|
+
]
|
|
3650
|
+
}),
|
|
3651
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3652
|
+
ref: containerRef,
|
|
3653
|
+
className: "relative",
|
|
3654
|
+
onKeyDown: onContainerKeyDown,
|
|
3655
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
|
|
3656
|
+
...field,
|
|
3657
|
+
id,
|
|
3658
|
+
placeholder: placeholder || (resolver !== null ? "0x... or name" : "0x..."),
|
|
3659
|
+
className: validationClasses,
|
|
3660
|
+
onChange: handleInputChange,
|
|
3661
|
+
onKeyDown: handleKeyDown,
|
|
3662
|
+
"data-slot": "input",
|
|
3663
|
+
value: inputValue,
|
|
3664
|
+
...accessibilityProps,
|
|
3665
|
+
"aria-describedby": resolver === null ? `${helperText ? descriptionId : ""} ${hasRealError ? errorId : ""}` : [
|
|
3666
|
+
helperText ? descriptionId : "",
|
|
3667
|
+
hasRealError ? errorId : "",
|
|
3668
|
+
resolutionRegionId
|
|
3669
|
+
].filter(Boolean).join(" ") || void 0,
|
|
3670
|
+
"aria-expanded": hasSuggestions,
|
|
3671
|
+
"aria-autocomplete": suggestionsDisabled ? void 0 : "list",
|
|
3672
|
+
"aria-controls": hasSuggestions ? `${id}-suggestions` : void 0,
|
|
3673
|
+
"aria-activedescendant": hasSuggestions && highlightedIndex >= 0 ? `${id}-suggestion-${highlightedIndex}` : void 0,
|
|
3674
|
+
disabled: readOnly
|
|
3675
|
+
}), hasSuggestions && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddressSuggestionList, {
|
|
3676
|
+
id,
|
|
3677
|
+
suggestions: resolvedSuggestions,
|
|
3678
|
+
highlightedIndex,
|
|
3679
|
+
onSelect: applySuggestion,
|
|
3680
|
+
onHighlight: setHighlightedIndex
|
|
3681
|
+
})]
|
|
3682
|
+
}),
|
|
3683
|
+
helperText && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3684
|
+
id: descriptionId,
|
|
3685
|
+
className: "text-muted-foreground text-sm",
|
|
3686
|
+
children: helperText
|
|
3687
|
+
}),
|
|
3688
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_ErrorMessage.ErrorMessage, {
|
|
3689
|
+
error: hasRealError ? fieldState.error : void 0,
|
|
3690
|
+
id: errorId,
|
|
3691
|
+
message: shouldShowError ? fieldState.error?.message || patternErrorMessage : void 0
|
|
3692
|
+
}),
|
|
3693
|
+
resolver !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
3694
|
+
id: resolutionRegionId,
|
|
3695
|
+
"aria-live": "polite",
|
|
3696
|
+
className: "min-h-5",
|
|
3697
|
+
children: renderOutcome()
|
|
3698
|
+
})
|
|
3699
|
+
]
|
|
2974
3700
|
});
|
|
2975
3701
|
}
|
|
2976
3702
|
AddressField.displayName = "AddressField";
|
|
@@ -3510,6 +4236,82 @@ function useAddressSuggestions(query, networkId) {
|
|
|
3510
4236
|
]) };
|
|
3511
4237
|
}
|
|
3512
4238
|
|
|
4239
|
+
//#endregion
|
|
4240
|
+
//#region src/components/fields/name-resolution/name-resolver-context.tsx
|
|
4241
|
+
/**
|
|
4242
|
+
* Name Resolver Context (SF-3)
|
|
4243
|
+
*
|
|
4244
|
+
* Provides a React context for forward name resolution (name → address).
|
|
4245
|
+
* When a `NameResolverProvider` is mounted, every `AddressField` in the
|
|
4246
|
+
* subtree resolves typed names inline through the injected `resolveName`
|
|
4247
|
+
* and submits the resolved hex — never the name (SC-004).
|
|
4248
|
+
*
|
|
4249
|
+
* The provider is deliberately **dumb** (INV-118): it memoizes the injected
|
|
4250
|
+
* functions into the context value and nothing else — no hook state, no
|
|
4251
|
+
* capability, no chain dependency. The smart runtime wiring lives upstream in
|
|
4252
|
+
* `@openzeppelin/ui-react` (`useRuntimeNameResolver`), which an app or the
|
|
4253
|
+
* renderer mounts ambiently:
|
|
4254
|
+
*
|
|
4255
|
+
* @example
|
|
4256
|
+
* ```tsx
|
|
4257
|
+
* import { NameResolverProvider } from '@openzeppelin/ui-components';
|
|
4258
|
+
* import { useRuntimeNameResolver } from '@openzeppelin/ui-react';
|
|
4259
|
+
*
|
|
4260
|
+
* function FormRoot() {
|
|
4261
|
+
* const resolver = useRuntimeNameResolver();
|
|
4262
|
+
* return (
|
|
4263
|
+
* <NameResolverProvider {...resolver}>
|
|
4264
|
+
* <MyForm />
|
|
4265
|
+
* </NameResolverProvider>
|
|
4266
|
+
* );
|
|
4267
|
+
* }
|
|
4268
|
+
* ```
|
|
4269
|
+
*/
|
|
4270
|
+
/**
|
|
4271
|
+
* Provides forward name resolution to all `AddressField` instances in the
|
|
4272
|
+
* subtree (zero call-site wiring, SC-001). Both functions are optional: an
|
|
4273
|
+
* absent `resolveName` means forward resolution is unsupported and a typed
|
|
4274
|
+
* name surfaces `UNSUPPORTED_NETWORK` (INV-119 / SC-006).
|
|
4275
|
+
*
|
|
4276
|
+
* ## Stable resolver identity (integrator contract)
|
|
4277
|
+
*
|
|
4278
|
+
* The injected resolver **must be referentially stable across renders** —
|
|
4279
|
+
* memoize it (e.g. `useMemo`) or use `useRuntimeNameResolver` from
|
|
4280
|
+
* `@openzeppelin/ui-react`, which is stable by construction. A resolver whose
|
|
4281
|
+
* function identity changes on every render (e.g. a fresh inline function) is a
|
|
4282
|
+
* misconfiguration: the field keys inline resolution on that identity, so a
|
|
4283
|
+
* churning identity would otherwise re-dispatch on every render — an unbounded
|
|
4284
|
+
* RPC loop on a funds path.
|
|
4285
|
+
*
|
|
4286
|
+
* The base component **detects and withstands** this (it is not merely a caveat):
|
|
4287
|
+
* dispatch is bounded per resolution intent so a churning resolver can never drive
|
|
4288
|
+
* an unbounded loop (INV-123), a one-shot development-only warning names the
|
|
4289
|
+
* misconfiguration (INV-124), and the field degrades to a **safe gated state**
|
|
4290
|
+
* (empty value, submit blocked — never a wrong address, never a throw; INV-125)
|
|
4291
|
+
* until the resolver is memoized. A **genuine** resolver/network swap — a single
|
|
4292
|
+
* identity change for a given input — is still honored and re-resolves within
|
|
4293
|
+
* budget (INV-119). The bound is a backstop; the contract is a stable identity.
|
|
4294
|
+
*
|
|
4295
|
+
* @param props - Injected resolver functions and children
|
|
4296
|
+
*/
|
|
4297
|
+
function NameResolverProvider({ children, isValidName, resolveName, activeNetworkId, activeNetworkName }) {
|
|
4298
|
+
const value = react.useMemo(() => ({
|
|
4299
|
+
isValidName,
|
|
4300
|
+
resolveName,
|
|
4301
|
+
activeNetworkId,
|
|
4302
|
+
activeNetworkName
|
|
4303
|
+
}), [
|
|
4304
|
+
isValidName,
|
|
4305
|
+
resolveName,
|
|
4306
|
+
activeNetworkId,
|
|
4307
|
+
activeNetworkName
|
|
4308
|
+
]);
|
|
4309
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NameResolverContext.Provider, {
|
|
4310
|
+
value,
|
|
4311
|
+
children
|
|
4312
|
+
});
|
|
4313
|
+
}
|
|
4314
|
+
|
|
3513
4315
|
//#endregion
|
|
3514
4316
|
//#region src/components/fields/AmountField.tsx
|
|
3515
4317
|
/**
|
|
@@ -6238,7 +7040,7 @@ SelectGroupedField.displayName = "SelectGroupedField";
|
|
|
6238
7040
|
* - Full accessibility support with ARIA attributes
|
|
6239
7041
|
* - Keyboard navigation
|
|
6240
7042
|
*/
|
|
6241
|
-
function TextField({ id, label, placeholder, helperText, control, name, width = "full", validation, readOnly }) {
|
|
7043
|
+
function TextField({ id, label, placeholder, helperText, control, name, width = "full", validation, readOnly, onUserEdit }) {
|
|
6242
7044
|
const isRequired = !!validation?.required;
|
|
6243
7045
|
const errorId = `${id}-error`;
|
|
6244
7046
|
const descriptionId = `${id}-description`;
|
|
@@ -6274,9 +7076,13 @@ function TextField({ id, label, placeholder, helperText, control, name, width =
|
|
|
6274
7076
|
const handleInputChange = (e) => {
|
|
6275
7077
|
const value = e.target.value;
|
|
6276
7078
|
field.onChange(value);
|
|
7079
|
+
onUserEdit?.(value);
|
|
6277
7080
|
};
|
|
6278
7081
|
const handleKeyDown = (e) => {
|
|
6279
|
-
if (e.key === "Escape") handleEscapeKey(
|
|
7082
|
+
if (e.key === "Escape") handleEscapeKey((value) => {
|
|
7083
|
+
field.onChange(value);
|
|
7084
|
+
onUserEdit?.(value);
|
|
7085
|
+
}, field.value)(e);
|
|
6280
7086
|
};
|
|
6281
7087
|
const accessibilityProps = getAccessibilityProps({
|
|
6282
7088
|
id,
|
|
@@ -6817,6 +7623,8 @@ exports.AddressDisplay = AddressDisplay;
|
|
|
6817
7623
|
exports.AddressField = AddressField;
|
|
6818
7624
|
exports.AddressLabelProvider = AddressLabelProvider;
|
|
6819
7625
|
exports.AddressListField = AddressListField;
|
|
7626
|
+
exports.AddressNameProvider = AddressNameProvider;
|
|
7627
|
+
exports.AddressSuggestionList = AddressSuggestionList;
|
|
6820
7628
|
exports.AddressSuggestionProvider = AddressSuggestionProvider;
|
|
6821
7629
|
exports.Alert = Alert;
|
|
6822
7630
|
exports.AlertDescription = AlertDescription;
|
|
@@ -6891,6 +7699,9 @@ exports.LoadingButton = LoadingButton;
|
|
|
6891
7699
|
exports.MapEntryRow = MapEntryRow;
|
|
6892
7700
|
exports.MapField = MapField;
|
|
6893
7701
|
exports.MidnightIcon = MidnightIcon;
|
|
7702
|
+
exports.NAME_RESOLUTION_DEBOUNCE_MS = NAME_RESOLUTION_DEBOUNCE_MS;
|
|
7703
|
+
exports.NameResolverContext = NameResolverContext;
|
|
7704
|
+
exports.NameResolverProvider = NameResolverProvider;
|
|
6894
7705
|
exports.NetworkAvailabilityNotice = NetworkAvailabilityNotice;
|
|
6895
7706
|
exports.NetworkErrorNotificationProvider = NetworkErrorNotificationProvider;
|
|
6896
7707
|
exports.NetworkIcon = NetworkIcon;
|
|
@@ -6965,9 +7776,13 @@ exports.hasFieldError = require_ErrorMessage.hasFieldError;
|
|
|
6965
7776
|
exports.isDuplicateMapKey = require_ErrorMessage.isDuplicateMapKey;
|
|
6966
7777
|
exports.resolveAddressListFieldLabels = resolveAddressListFieldLabels;
|
|
6967
7778
|
exports.useAddressLabel = useAddressLabel;
|
|
7779
|
+
exports.useAddressName = useAddressName;
|
|
7780
|
+
exports.useAddressSuggestionField = useAddressSuggestionField;
|
|
6968
7781
|
exports.useAddressSuggestions = useAddressSuggestions;
|
|
6969
7782
|
exports.useDuplicateKeyIndexes = useDuplicateKeyIndexes;
|
|
7783
|
+
exports.useInjectedNameResolution = useInjectedNameResolution;
|
|
6970
7784
|
exports.useMapFieldSync = useMapFieldSync;
|
|
7785
|
+
exports.useNameResolver = useNameResolver;
|
|
6971
7786
|
exports.useNetworkErrorAwareAdapter = useNetworkErrorAwareAdapter;
|
|
6972
7787
|
exports.useNetworkErrorReporter = useNetworkErrorReporter;
|
|
6973
7788
|
exports.useNetworkErrors = useNetworkErrors;
|