@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/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.3.1";
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, and human-readable label.
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. No label (renders address only, identical to previous behavior)
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 suppress context-based resolution (e.g. when the
167
- * surrounding UI already shows a name, such as a contract selector).
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 effectiveTruncate = truncateProp !== void 0 ? truncateProp : truncateWhenLabeled ? Boolean(resolvedLabel) : true;
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 (resolvedLabel) return wrapWithTooltip(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
294
- className: (0, _openzeppelin_ui_utils.cn)("group inline-flex max-w-full min-w-0 flex-col", isChip && "rounded-md bg-slate-100 px-2 py-1", "text-xs text-slate-700", expandInteractionClassName, className),
295
- onPointerEnter: handlePointerEnter,
296
- onPointerLeave: handlePointerLeave,
297
- onClick: handleUntruncateClick,
298
- ...props,
299
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
300
- className: "truncate font-sans font-medium text-slate-900 leading-snug",
301
- children: resolvedLabel
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: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
317
- className: addressTextClassName,
318
- children: displayAddress
319
- }), actionButtons]
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
- const DEBOUNCE_MS = 200;
2755
- const MAX_SUGGESTIONS = 5;
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 contextResolver = (0, react.useContext)(AddressSuggestionContext);
2790
- const containerRef = (0, react.useRef)(null);
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 (!inputValue.trim()) {
2809
- setDebouncedQuery("");
2810
- return;
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
- suggestionsDisabled,
2823
- suggestionsProp,
2824
- contextResolver,
2825
- debouncedQuery
3541
+ isResolvedNameMatch,
3542
+ resolvedAddress,
3543
+ chainScopeBlocked,
3544
+ fieldOnChange
2826
3545
  ]);
2827
- const hasSuggestions = showSuggestions && resolvedSuggestions.length > 0;
3546
+ const matchedName = isResolvedNameMatch ? resolvedName : void 0;
2828
3547
  (0, react.useEffect)(() => {
2829
- let active = true;
2830
- const handleClickOutside = (e) => {
2831
- if (active && containerRef.current && !containerRef.current.contains(e.target)) setShowSuggestions(false);
2832
- };
2833
- document.addEventListener("mousedown", handleClickOutside);
2834
- return () => {
2835
- active = false;
2836
- document.removeEventListener("mousedown", handleClickOutside);
2837
- };
2838
- }, []);
2839
- const handleSuggestionKeyDown = (0, react.useCallback)((e) => {
2840
- if (!hasSuggestions) return;
2841
- if (e.key === "ArrowDown") {
2842
- e.preventDefault();
2843
- setHighlightedIndex((prev) => prev < resolvedSuggestions.length - 1 ? prev + 1 : 0);
2844
- } else if (e.key === "ArrowUp") {
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
- setHighlightedIndex((prev) => prev > 0 ? prev - 1 : resolvedSuggestions.length - 1);
3601
+ applySuggestion(resolvedSuggestions[highlightedIndex]);
3602
+ return;
2847
3603
  }
2848
- }, [hasSuggestions, resolvedSuggestions.length]);
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: [label && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Label, {
2852
- htmlFor: id,
2853
- children: [
2854
- label,
2855
- " ",
2856
- isRequired && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2857
- className: "text-destructive",
2858
- children: "*"
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(field.onChange, field.value)(e);
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;