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. +

+
+ + + + + +
+ + } + /> +
+ +
+

Usage

+ +
+
+ ); +} + // ---------------------------------------------------------------------------- // 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 (
- + } />