Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/addresslistfield-toggle-flush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@openzeppelin/ui-components': patch
---

fix(components): keep the AddressListField entry-mode toggle flush to the input in both single and bulk modes

The toggle now sits as an attached tab on the input/textarea bottom-right corner (inset from the rounded corner) and no longer shifts when the resolution announcer or bulk preview text appears, nor overlaps the action button when the field is empty. Adds `announcerEndSlot` to `AddressField` and `helperEndSlot` to `TextAreaField` to pin trailing content beside the announcer/helper row, and lifts the input above the toggle so the focus ring is not covered.
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* @vitest-environment jsdom
*
* `announcerEndSlot` — resolution announcer row layout for AddressListField.
*/
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { useForm } from 'react-hook-form';

import { controlledResolver, renderAddressField } from './name-resolution/__tests__/helpers';
import { NameResolverProvider } from './name-resolution/name-resolver-context';

import { AddressField } from './AddressField';

beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});

describe('AddressField announcerEndSlot', () => {
it('renders the end slot pinned under the input with right inset when resolver is null', () => {
function Field(): React.ReactElement {
const { control } = useForm({ defaultValues: { draft: '' }, mode: 'onChange' });
return (
<AddressField
id="allow-list-entry"
name="draft"
label=""
control={control}
announcerEndSlot={
<button type="button" data-testid="mode-toggle">
Bulk paste
</button>
}
/>
);
}

render(<Field />);

const toggle = screen.getByTestId('mode-toggle');
expect(toggle).toBeTruthy();
expect(toggle.parentElement?.parentElement?.className).toContain('justify-between');
expect(toggle.parentElement?.parentElement?.className).toContain('pr-2.5');

const region = document.getElementById('allow-list-entry-resolution');
expect(region).not.toBeNull();
expect(region?.className).toContain('mt-4');
expect(region?.className).toContain('flex-1');
expect(region?.textContent).toBe('');
expect(region?.parentElement?.contains(toggle)).toBe(true);
expect(region).not.toBe(toggle.parentElement);
});

it('keeps min-h-5 on the standalone announcer when no end slot is provided', async () => {
const r = controlledResolver();
const h = renderAddressField({ resolver: { resolveName: r.resolveName } });
expect(h.region()?.className).toContain('min-h-5');
});

it('joins the announcer region id into aria-describedby when an end slot is present', () => {
function Field(): React.ReactElement {
const { control } = useForm({ defaultValues: { draft: '' }, mode: 'onChange' });
return (
<NameResolverProvider resolveName={vi.fn()}>
<AddressField
id="allow-list-entry"
name="draft"
label=""
control={control}
announcerEndSlot={<span data-testid="mode-toggle">Bulk paste</span>}
/>
</NameResolverProvider>
);
}

render(<Field />);

const input = screen.getByRole('textbox');
const describedBy = input.getAttribute('aria-describedby') ?? '';
expect(describedBy.split(/\s+/)).toContain('allow-list-entry-resolution');
});

it('keeps the end slot outside the announcer live region so it does not shift vertically', () => {
const r = controlledResolver();
function Field(): React.ReactElement {
const { control } = useForm({ defaultValues: { draft: '' }, mode: 'onChange' });
return (
<NameResolverProvider resolveName={r.resolveName}>
<AddressField
id="allow-list-entry"
name="draft"
label=""
control={control}
announcerEndSlot={<button type="button">Bulk paste</button>}
/>
</NameResolverProvider>
);
}

render(<Field />);

const toggle = screen.getByRole('button', { name: /bulk paste/i });
const region = document.getElementById('allow-list-entry-resolution');
expect(region?.contains(toggle)).toBe(false);
expect(region?.className).toContain('mt-4');
});
});
193 changes: 129 additions & 64 deletions packages/components/src/components/fields/AddressField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ export interface AddressFieldProps<TFieldValues extends FieldValues = FieldValue
* already presents the resolved address (e.g. a rich ENS preview card).
*/
showForwardResolutionSuccessAnnouncer?: boolean;

/**
* Optional trailing content on the resolution announcer row (e.g. an entry-mode
* toggle). When set, the announcer and slot share one flex row beneath the
* input — the slot stays right-aligned and the announcer fills the left.
* The row renders even when no resolver is mounted so the slot is always visible;
* without a resolver the announcer cell is empty and no `min-h-5` is reserved.
*/
announcerEndSlot?: React.ReactNode;
}

/**
Expand Down Expand Up @@ -167,6 +176,7 @@ export function AddressField<TFieldValues extends FieldValues = FieldValues>({
onResolvedNameChange,
showCrossNetworkFallbackDisclaimer = true,
showForwardResolutionSuccessAnnouncer = true,
announcerEndSlot,
}: AddressFieldProps<TFieldValues>): React.ReactElement {
const isRequired = !!validation?.required;
const errorId = `${id}-error`;
Expand Down Expand Up @@ -544,6 +554,84 @@ export function AddressField<TFieldValues extends FieldValues = FieldValues>({
}
};

const hasAnnouncerEndSlot = announcerEndSlot != null;
const showResolutionAnnouncer = resolver !== null || hasAnnouncerEndSlot;
const includeResolutionInDescribedBy = resolver !== null || hasAnnouncerEndSlot;

const resolutionAnnouncerRow = showResolutionAnnouncer ? (
hasAnnouncerEndSlot ? (
// The end slot (mode toggle) is pinned flush to the input's bottom-right
// (an attached tab) while the announcer sits on a separate gapped row on the
// left. Both share one in-flow row so the toggle keeps its position when the
// announcer gets content (INV: no shift) and the row reserves height so it
// never overlaps content rendered below the field. The row cancels the parent
// `gap-2` (`-mt-2`) so the toggle can sit flush against the input's border.
<div className="-mt-2 flex items-start justify-between gap-2 pr-2.5">
<div id={resolutionRegionId} aria-live="polite" className="mt-4 min-w-0 flex-1 text-sm">
{resolver !== null ? renderOutcome() : null}
</div>
<div className="-mt-px shrink-0">{announcerEndSlot}</div>
</div>
) : (
<div id={resolutionRegionId} aria-live="polite" className="min-h-5">
{renderOutcome()}
</div>
)
) : null;

const inputBlock = (
// With a flush end slot the toggle overlaps the input's bottom border; lift
// the input so its focus ring is not covered by the toggle.
<div
ref={containerRef}
className={hasAnnouncerEndSlot ? 'relative z-10' : 'relative'}
onKeyDown={onContainerKeyDown}
>
<Input
{...field}
id={id}
placeholder={placeholder || (resolver !== null ? '0x... or name' : '0x...')}
className={validationClasses}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
data-slot="input"
// INV-69: the input always shows the typed string — never the resolved hex.
// Identical to binding `field.value` when no resolver is mounted (INV-82).
value={inputValue}
{...accessibilityProps}
// INV-92: preserve the legacy describedby wiring verbatim when no resolver
// is mounted; with a resolver, additively associate the dedicated
// resolution announcer (never overwriting error/description).
aria-describedby={
!includeResolutionInDescribedBy
? `${helperText ? descriptionId : ''} ${hasRealError ? errorId : ''}`
: [helperText ? descriptionId : '', hasRealError ? errorId : '', resolutionRegionId]
.filter(Boolean)
.join(' ') || undefined
}
aria-expanded={hasSuggestions}
aria-autocomplete={suggestionsDisabled ? undefined : 'list'}
aria-controls={hasSuggestions ? `${id}-suggestions` : undefined}
aria-activedescendant={
hasSuggestions && highlightedIndex >= 0
? `${id}-suggestion-${highlightedIndex}`
: undefined
}
disabled={readOnly}
/>

{hasSuggestions && (
<AddressSuggestionList
id={id}
suggestions={resolvedSuggestions}
highlightedIndex={highlightedIndex}
onSelect={applySuggestion}
onHighlight={setHighlightedIndex}
/>
)}
</div>
);

return (
<div
className={`flex flex-col gap-2 ${width === 'full' ? 'w-full' : width === 'half' ? 'w-1/2' : 'w-1/3'}`}
Expand All @@ -554,74 +642,51 @@ export function AddressField<TFieldValues extends FieldValues = FieldValues>({
</Label>
)}

<div ref={containerRef} className="relative" onKeyDown={onContainerKeyDown}>
<Input
{...field}
id={id}
placeholder={placeholder || (resolver !== null ? '0x... or name' : '0x...')}
className={validationClasses}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
data-slot="input"
// INV-69: the input always shows the typed string — never the resolved hex.
// Identical to binding `field.value` when no resolver is mounted (INV-82).
value={inputValue}
{...accessibilityProps}
// INV-92: preserve the legacy describedby wiring verbatim when no resolver
// is mounted; with a resolver, additively associate the dedicated
// resolution announcer (never overwriting error/description).
aria-describedby={
resolver === null
? `${helperText ? descriptionId : ''} ${hasRealError ? errorId : ''}`
: [helperText ? descriptionId : '', hasRealError ? errorId : '', resolutionRegionId]
.filter(Boolean)
.join(' ') || undefined
}
aria-expanded={hasSuggestions}
aria-autocomplete={suggestionsDisabled ? undefined : 'list'}
aria-controls={hasSuggestions ? `${id}-suggestions` : undefined}
aria-activedescendant={
hasSuggestions && highlightedIndex >= 0
? `${id}-suggestion-${highlightedIndex}`
: undefined
}
disabled={readOnly}
/>

{hasSuggestions && (
<AddressSuggestionList
id={id}
suggestions={resolvedSuggestions}
highlightedIndex={highlightedIndex}
onSelect={applySuggestion}
onHighlight={setHighlightedIndex}
{hasAnnouncerEndSlot ? (
<>
{inputBlock}
{resolutionAnnouncerRow}
</>
) : (
<>
{inputBlock}
{/* Display helper text */}
{helperText && (
<div id={descriptionId} className="text-muted-foreground text-sm">
{helperText}
</div>
)}

{/* Display error message — unchanged; the pending gate string is suppressed. */}
<ErrorMessage
error={hasRealError ? fieldState.error : undefined}
id={errorId}
message={shouldShowError ? fieldState.error?.message || patternErrorMessage : undefined}
/>
)}
</div>

{/* Display helper text */}
{helperText && (
<div id={descriptionId} className="text-muted-foreground text-sm">
{helperText}
</div>
{/* INV-92: dedicated aria-live announcer, distinct from the RHF error region;
absent entirely with no resolver and no end slot (INV-82). Kept mounted
while a resolver is present so live-region announcements are reliable.
INV-93: it never steals focus — announcement is via aria-live only. */}
{resolutionAnnouncerRow}
</>
)}

{/* Display error message — unchanged; the pending gate string is suppressed. */}
<ErrorMessage
error={hasRealError ? fieldState.error : undefined}
id={errorId}
message={shouldShowError ? fieldState.error?.message || patternErrorMessage : undefined}
/>

{/* INV-92: dedicated aria-live announcer, distinct from the RHF error region;
absent entirely with no resolver (INV-82). Kept mounted while a resolver is
present so live-region announcements are reliable. INV-93: it never steals
focus — announcement is via aria-live only. */}
{resolver !== null && (
<div id={resolutionRegionId} aria-live="polite" className="min-h-5">
{renderOutcome()}
</div>
)}
{hasAnnouncerEndSlot ? (
<>
{helperText && (
<div id={descriptionId} className="text-muted-foreground text-sm">
{helperText}
</div>
)}

<ErrorMessage
error={hasRealError ? fieldState.error : undefined}
id={errorId}
message={shouldShowError ? fieldState.error?.message || patternErrorMessage : undefined}
/>
</>
) : null}
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,18 @@ export function AddressListBulkEntry({
</div>

<div>
<div className="relative">
<TextAreaField
id={`address-list-bulk-${fieldId}`}
name="input"
label=""
placeholder={placeholder}
helperText={statusMessage}
control={control}
rows={4}
validation={{ required: false }}
readOnly={inputDisabled}
/>
</div>
{modeToggle ? <div className="flex justify-end pr-2">{modeToggle}</div> : null}
<TextAreaField
id={`address-list-bulk-${fieldId}`}
name="input"
label=""
placeholder={placeholder}
helperText={statusMessage}
control={control}
rows={4}
validation={{ required: false }}
readOnly={inputDisabled}
helperEndSlot={modeToggle}
/>
</div>

<div className="flex flex-wrap items-center justify-between gap-2">
Expand Down
Loading
Loading