From e6158f886f02f55a0bac98e2ea1bb691d7753d6a Mon Sep 17 00:00:00 2001
From: Aleksandr Pasevin
Date: Sun, 19 Jul 2026 00:35:13 +0100
Subject: [PATCH 1/2] feat(components): add AddressFieldWithResolvedPreview for
ENS preview UX
Extract the forward-resolution field + reverse ENS preview card into
reusable ui-components exports, wire ui-renderer Address Book to the
shared pattern, and update the examples app demos accordingly.
Co-authored-by: Cursor
---
.changeset/address-field-resolved-preview.md | 7 +
.changeset/renderer-resolved-preview.md | 5 +
.../src/components/AccountAliasDemo.tsx | 10 +-
.../src/components/AddressFieldDemo.tsx | 130 +++++++++++++++++-
.../src/components/ENSResolutionDemo.tsx | 107 +++++++++-----
.../src/components/ENSShowcaseDemo.tsx | 65 +++++----
.../MainnetL1FallbackOptInToggle.tsx | 12 +-
.../AddressFieldWithResolvedPreview.test.tsx | 81 +++++++++++
.../AddressFieldWithResolvedPreview.tsx | 65 +++++++++
.../fields/ResolvedAddressFieldPreview.tsx | 61 ++++++++
.../components/src/components/fields/index.ts | 2 +
.../AddressBookWidget/AddAliasDialog.tsx | 19 ++-
.../__tests__/AddAliasDialog.sf5.test.tsx | 17 ++-
...dAddressFieldPreviewWithNameResolution.tsx | 31 +++++
packages/renderer/src/components/index.ts | 1 +
15 files changed, 528 insertions(+), 85 deletions(-)
create mode 100644 .changeset/address-field-resolved-preview.md
create mode 100644 .changeset/renderer-resolved-preview.md
create mode 100644 packages/components/src/components/fields/AddressFieldWithResolvedPreview.test.tsx
create mode 100644 packages/components/src/components/fields/AddressFieldWithResolvedPreview.tsx
create mode 100644 packages/components/src/components/fields/ResolvedAddressFieldPreview.tsx
create mode 100644 packages/renderer/src/components/ResolvedAddressFieldPreviewWithNameResolution.tsx
diff --git a/.changeset/address-field-resolved-preview.md b/.changeset/address-field-resolved-preview.md
new file mode 100644
index 00000000..9257dd47
--- /dev/null
+++ b/.changeset/address-field-resolved-preview.md
@@ -0,0 +1,7 @@
+---
+'@openzeppelin/ui-components': minor
+---
+
+Add `AddressFieldWithResolvedPreview` and `ResolvedAddressFieldPreview` — a reusable composition for ENS forward resolution in `AddressField` plus a rich reverse-resolved preview card below the field.
+
+`AddressFieldWithResolvedPreview` always suppresses the forward "Resolved to `0x…`" success announcer and cross-network disclaimer (overridable), collapses the empty aria-live region, and accepts an optional `preview` slot for custom reverse-resolution bridges. `ResolvedAddressFieldPreview` is the presentational preview card; pass `resolvedName` for sync display or wrap with `AddressNameProvider` / `AddressNameResolutionProvider` for async reverse lookup.
diff --git a/.changeset/renderer-resolved-preview.md b/.changeset/renderer-resolved-preview.md
new file mode 100644
index 00000000..00a5b11a
--- /dev/null
+++ b/.changeset/renderer-resolved-preview.md
@@ -0,0 +1,5 @@
+---
+'@openzeppelin/ui-renderer': patch
+---
+
+Use `AddressFieldWithResolvedPreview` and `ResolvedAddressFieldPreviewWithNameResolution` from `@openzeppelin/ui-components` for the Address Book add-alias ENS preview. Removes the duplicated renderer-local preview component.
diff --git a/examples/basic-react-app/src/components/AccountAliasDemo.tsx b/examples/basic-react-app/src/components/AccountAliasDemo.tsx
index 95f4e94f..7616105e 100644
--- a/examples/basic-react-app/src/components/AccountAliasDemo.tsx
+++ b/examples/basic-react-app/src/components/AccountAliasDemo.tsx
@@ -191,13 +191,15 @@ function App() {
enableNameResolution)
- A single opt-in prop. When on, the Add-alias dialog becomes ENS-aware (type a name →
- it resolves to hex and auto-suggests the alias) and rows render the base{' '}
+ A single opt-in prop. When on, the Add-alias dialog uses{' '}
+ AddressFieldWithResolvedPreview (type
+ a name → it resolves to hex, shows a reverse ENS preview card, and auto-suggests the
+ alias) and rows render the base{' '}
AddressDisplay fed by the
reverse-name bridge. Requires an ambient{' '}
WalletStateProvider (present here).
- Names resolve against the active network — select Ethereum Mainnet in the
- header/Name Resolution demo for real names.
+ Names resolve against the active network — select Ethereum Mainnet in the header for
+ real names.
diff --git a/examples/basic-react-app/src/components/AddressFieldDemo.tsx b/examples/basic-react-app/src/components/AddressFieldDemo.tsx
index 6fc404c2..45ac06c2 100644
--- a/examples/basic-react-app/src/components/AddressFieldDemo.tsx
+++ b/examples/basic-react-app/src/components/AddressFieldDemo.tsx
@@ -1,10 +1,17 @@
import { useMemo, useState } from 'react';
-import { useForm } from 'react-hook-form';
-
-import { AddressField, Input, Label } from '@openzeppelin/ui-components';
+import { useForm, useWatch } from 'react-hook-form';
+
+import {
+ AddressField,
+ AddressFieldWithResolvedPreview,
+ Input,
+ Label,
+} from '@openzeppelin/ui-components';
+import { ResolvedAddressFieldPreviewWithNameResolution } from '@openzeppelin/ui-renderer';
import { classifyAddressInput } from '@openzeppelin/ui-utils';
import { useEcosystem } from '../context';
+import { CodeBlock } from './CodeBlock';
import { DemoSection } from './DemoSection';
import { EcosystemIndicator } from './EcosystemIndicator';
import { MainnetL1FallbackOptInToggle } from './MainnetL1FallbackOptInToggle';
@@ -55,7 +62,44 @@ function TransferForm({ adapter }) {
//
//
//
-// `;
+//
+//
+// For a rich ENS preview card below the field (reverse name + avatar) instead of
+// the mechanism-neutral "Resolved to 0x…" announcer, use AddressFieldWithResolvedPreview
+// — see the "Rich ENS preview" section below.`;
+
+const PREVIEW_USAGE = `import { useForm, useWatch } from 'react-hook-form';
+import { AddressFieldWithResolvedPreview } from '@openzeppelin/ui-components';
+import { ResolvedAddressFieldPreviewWithNameResolution } from '@openzeppelin/ui-renderer';
+
+function RecipientWithPreview({ adapter, networkId }) {
+ const { control } = useForm({ mode: 'onChange', defaultValues: { recipient: '' } });
+ const previewAddress = useWatch({ control, name: 'recipient' });
+
+ return (
+
+ }
+ />
+ );
+}
+
+// AddressFieldWithResolvedPreview suppresses the forward success announcer and
+// cross-network disclaimer by default — the preview card replaces them. Pass a
+// custom preview slot when reverse resolution needs a different runtime bridge.`;
/**
* Gallery demo for the base {@link AddressField}: a chain-agnostic address
@@ -91,6 +135,8 @@ export function AddressFieldDemo(): React.ReactElement {
+
+
NameResolverProvider mounted, the
same field also accepts a name — e.g. ENS: try{' '}
vitalik.eth. The form value is always the
- resolved address, never the name, and submit stays gated until resolution completes.
- Without the provider (or on networks without a resolver) the field behaves exactly as
- above. This app mounts that provider globally, fed by{' '}
+ resolved address, never the name, and submit stays gated until resolution completes. The
+ field surfaces a mechanism-neutral “Resolved to 0x…” announcer on success (see
+ the rich preview section below for the recommended card UX). Without the provider (or on
+ networks without a resolver) the field behaves exactly as above. This app mounts that
+ provider globally, fed by{' '}
useRuntimeNameResolver — so the field
follows the active network chosen from the header selector.
@@ -257,6 +305,74 @@ function NameResolutionSection(): React.ReactElement {
);
}
+// ----------------------------------------------------------------------------
+// Rich ENS preview (AddressFieldWithResolvedPreview)
+// ----------------------------------------------------------------------------
+
+function RichPreviewSection(): React.ReactElement {
+ const { capabilities, network } = useEcosystem();
+ const networkId = network?.id;
+ const networkName = network?.name ?? '…';
+
+ const { control } = useForm({
+ mode: 'onChange',
+ defaultValues: { recipient: '' },
+ });
+
+ const previewAddress = useWatch({ control, name: 'recipient' });
+
+ return (
+
+
+
Rich ENS preview
+
+ AddressFieldWithResolvedPreview composes
+ the base field with a reverse-resolved preview card below. It suppresses the redundant
+ forward “Resolved to 0x…” announcer — watch the same field name and pass the
+ value as previewAddress. Wire async reverse
+ lookup through{' '}
+
+ ResolvedAddressFieldPreviewWithNameResolution
+ {' '}
+ (renderer) or pass resolvedName directly to{' '}
+ ResolvedAddressFieldPreview.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
// ----------------------------------------------------------------------------
// classifyAddressInput sub-section (drives the field's branch)
// ----------------------------------------------------------------------------
diff --git a/examples/basic-react-app/src/components/ENSResolutionDemo.tsx b/examples/basic-react-app/src/components/ENSResolutionDemo.tsx
index 74e45644..49f9ad1d 100644
--- a/examples/basic-react-app/src/components/ENSResolutionDemo.tsx
+++ b/examples/basic-react-app/src/components/ENSResolutionDemo.tsx
@@ -2,13 +2,14 @@ import { ArrowRight } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import {
- AddressField,
+ AddressFieldWithResolvedPreview,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@openzeppelin/ui-components';
+import { ResolvedAddressFieldPreviewWithNameResolution } from '@openzeppelin/ui-renderer';
import type { NameResolutionErrorCode } from '@openzeppelin/ui-utils';
import { nameResolutionMessageForCode } from '@openzeppelin/ui-utils';
@@ -97,15 +98,51 @@ const resolveRuntime = useCallback(
{children}
;`;
-const DISCLAIMER_PROPS = `import { AddressField, AddressDisplay } from '@openzeppelin/ui-components';
+const PREVIEW_WIRING = `import { useForm, useWatch } from 'react-hook-form';
+import { AddressFieldWithResolvedPreview } from '@openzeppelin/ui-components';
+import { ResolvedAddressFieldPreviewWithNameResolution } from '@openzeppelin/ui-renderer';
+
+function TransferRecipientField({ control, addressing, networkId }) {
+ const previewAddress = useWatch({ control, name: 'recipient' });
+
+ return (
+
+ }
+ />
+ );
+}
+
+// Suppresses the forward "Resolved to 0x…" announcer by default — the preview
+// card replaces it. Cross-network fallback disclaimer appears on AddressDisplay
+// inside the card (amber triangle-alert) unless you pass resolvedName with custom UI.`;
+
+const DISCLAIMER_PROPS = `import {
+ AddressField,
+ AddressFieldWithResolvedPreview,
+ AddressDisplay,
+} from '@openzeppelin/ui-components';
-// Forward: muted note under the success template (default on).
-
+// Base field: muted note under the success template (default on).
+
-// Reverse: amber triangle-alert + tooltip after the verified name (default on).
+// Rich preview: announcers suppressed by default — disclaimer on the card instead.
+
+
+// Reverse-only: amber triangle-alert + tooltip after the verified name (default on).
`;
interface DemoProps {
@@ -138,7 +175,7 @@ export function ENSResolutionDemo({ onNavigate }: DemoProps): React.ReactElement
return (
Runtime opt-in (default off)
+
+
Rich preview field (recommended)
+
+
Disclaimer presentation (default on)
@@ -212,7 +253,6 @@ function LiveResolverWidget(): React.ReactElement {
// so watching it gives us the address to render. Gate on the active network's
// address validation so we only render a display for a settled hex.
const recipient = useWatch({ control, name: 'recipient' });
- const hasResolvedAddress = Boolean(recipient && capabilities?.isValidAddress(recipient));
return (
@@ -234,7 +274,7 @@ function LiveResolverWidget(): React.ReactElement {
{/* Forward: name → address (ambient app-wide resolver) */}
-
-
- {hasResolvedAddress && (
-
-
- Resolved address
-
-
-
- Reverse-resolved display for the hex above. Cross-network fallback provenance shows
- as an amber triangle-alert inline after the name (default on).
-
-
- )}
+ }
+ />
+
+ Reverse-resolved display for the hex above. Cross-network fallback provenance shows as
+ an amber triangle-alert inline after the name (default on).
+
{/* Reverse: address → name + avatar */}
@@ -333,8 +366,12 @@ function ZeroWiringNote(): React.ReactElement {
useRuntimeNameResolver (the app-wide active
network). Every dynamic-form address field becomes ENS-capable automatically — the only
requirement is an ambient WalletStateProvider
- . The live widget above rides that same ambient resolver, so it follows whichever network
- you select in the header. See the code snippet below.
+ . The live widget above uses{' '}
+ AddressFieldWithResolvedPreview with the
+ renderer's{' '}
+ ResolvedAddressFieldPreviewWithNameResolution{' '}
+ bridge — the same ambient resolver as dynamic forms, so it follows whichever network you
+ select in the header. See the code snippets below.
);
@@ -396,7 +433,7 @@ function ComponentLinks({ onNavigate }: DemoProps): React.ReactElement {
{
key: 'address-field',
title: 'AddressField',
- desc: 'The form field — name resolution is an opt-in section (Forms).',
+ desc: 'Base field + rich ENS preview section (Forms).',
},
{
key: 'address-display',
diff --git a/examples/basic-react-app/src/components/ENSShowcaseDemo.tsx b/examples/basic-react-app/src/components/ENSShowcaseDemo.tsx
index 820f0ea7..6086945d 100644
--- a/examples/basic-react-app/src/components/ENSShowcaseDemo.tsx
+++ b/examples/basic-react-app/src/components/ENSShowcaseDemo.tsx
@@ -2,19 +2,19 @@ import { useCallback, useLayoutEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import {
- AddressField,
+ AddressFieldWithResolvedPreview,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@openzeppelin/ui-components';
+import { ResolvedAddressFieldPreviewWithNameResolution } from '@openzeppelin/ui-renderer';
import { useEcosystem } from '../context';
import { getNetworkById } from '../core/ecosystemManager';
import { ENS_RESOLUTION_NETWORK_ID } from './NameResolutionNetworkHint';
import { NetworkRequirementHint } from './NetworkRequirementHint';
-import { ResolvedAddressDisplay } from './ResolvedAddressDisplay';
// ----------------------------------------------------------------------------
// Live-verified preset catalog (resolved through local adapter-evm + viem)
@@ -273,7 +273,6 @@ function LiveShowcaseForm({ seedName }: LiveShowcaseFormProps): React.ReactEleme
});
const recipient = useWatch({ control, name: 'recipient' });
- const hasResolvedAddress = Boolean(recipient && capabilities?.isValidAddress(recipient));
const networkName = network?.name ?? '…';
useProgrammaticNameInput(SHOWCASE_RECIPIENT_FIELD_ID, seedName, true, fieldSeedVersion);
@@ -289,9 +288,20 @@ function LiveShowcaseForm({ seedName }: LiveShowcaseFormProps): React.ReactEleme
? activePreset.expectedAddress
: undefined;
+ const explorerUrl = (() => {
+ if (!recipient || !capabilities) {
+ return undefined;
+ }
+ try {
+ return capabilities.getExplorerUrl(recipient) ?? undefined;
+ } catch {
+ return undefined;
+ }
+ })();
+
return (