From 65868349d66485e5601165750afce6034b3fbb68 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 9 Sep 2026 10:05:32 -0600 Subject: [PATCH 1/9] feat(ui): upload, change, and remove the profile picture in place The profile picture row now owns a hidden file input. With no picture set it shows an Upload button that opens the file picker; once one is set it shows an action menu with Change avatar and Remove avatar. `onEditProfilePicture` is replaced by `onProfilePictureChange(file)` and `onRemoveProfilePicture()`. Validation stays out of the view. `pen` was the registry's only fill-based glyph; it is now stroked, matching every other icon in the set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cn886fa1NupHSgMQWyWPve --- .changeset/spicy-avatars-change.md | 2 + .../src/stories/fixtures/user-page.ts | 3 +- .../src/stories/user-page.stories.tsx | 3 +- .../user-profile-account-section.stories.tsx | 6 ++- .../user-profile-profile-panel.stories.tsx | 3 +- packages/ui/src/mosaic/icons/registry.tsx | 16 ++++--- .../user-profile-profile-panel.view.test.tsx | 32 ++++++++++--- .../user-profile-account-section.view.tsx | 48 +++++++++++++++++-- .../user-profile/user-profile-action-menu.tsx | 8 ++++ .../user-profile-profile-panel.view.tsx | 6 ++- 10 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 .changeset/spicy-avatars-change.md diff --git a/.changeset/spicy-avatars-change.md b/.changeset/spicy-avatars-change.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/spicy-avatars-change.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/fixtures/user-page.ts b/packages/swingset/src/stories/fixtures/user-page.ts index eb1a0c93d4e..7afed7d7385 100644 --- a/packages/swingset/src/stories/fixtures/user-page.ts +++ b/packages/swingset/src/stories/fixtures/user-page.ts @@ -82,7 +82,8 @@ export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) }, ]), onDeleteAccount: () => Promise.resolve(), - onEditProfilePicture: () => undefined, + onProfilePictureChange: () => undefined, + onRemoveProfilePicture: () => undefined, onManageEmail: () => undefined, onManagePhone: () => undefined, onNameChange: () => undefined, diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx index 30d2ce1555d..ca725a6cf11 100644 --- a/packages/swingset/src/stories/user-page.stories.tsx +++ b/packages/swingset/src/stories/user-page.stories.tsx @@ -123,7 +123,8 @@ export function Default() { }, ]), onDeleteAccount: () => Promise.resolve(), - onEditProfilePicture: () => undefined, + onProfilePictureChange: () => undefined, + onRemoveProfilePicture: () => undefined, onManageEmail: () => undefined, onManagePhone: () => undefined, onNameChange: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 23da0290b27..43eebfa9016 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -30,12 +30,13 @@ function AccountSection({ allowMultipleAccounts }: { allowMultipleAccounts: bool const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); + const [imageUrl, setImageUrl] = useState('https://avatars.githubusercontent.com/u/51144033?v=4'); return ( undefined} onManageEmail={() => undefined} onManagePhone={() => undefined} + onProfilePictureChange={file => setImageUrl(URL.createObjectURL(file))} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + onRemoveProfilePicture={() => setImageUrl(undefined)} onNameChange={() => undefined} onUsernameChange={() => undefined} /> diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 1912b2c907d..1eabf42eb16 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -79,10 +79,11 @@ export function Default(_args: Record) { } onConnectAccount={() => undefined} onDeleteAccount={() => Promise.resolve()} - onEditProfilePicture={() => undefined} onManageEmail={() => undefined} onManagePhone={() => undefined} + onProfilePictureChange={() => undefined} onRemoveConnectedAccount={() => undefined} + onRemoveProfilePicture={() => undefined} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onConnectWeb3Wallet={() => undefined} diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 6e4fdde4792..ae97eb8839a 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -272,12 +272,16 @@ const ArrowRightTop = glyph( ); const Pen = glyph( - , + <> + + + , ); const LogOut = glyph( diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index b5e4bcd46e7..a791166236c 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -30,7 +30,7 @@ function renderView(overrides: Partial = {}) { describe('UserProfileProfilePanelView', () => { it('composes the profile content without profile navigation', () => { - renderView({ onEditProfilePicture: vi.fn(), onNameChange: vi.fn(), onUsernameChange: vi.fn() }); + renderView({ onProfilePictureChange: vi.fn(), onNameChange: vi.fn(), onUsernameChange: vi.fn() }); expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); expect(screen.getByRole('region', { name: 'Account' })).toContainElement( @@ -59,14 +59,34 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); }); - it('edits the profile picture when Upload is clicked', async () => { - const onEditProfilePicture = vi.fn(); + it('uploads the picked file when no profile picture is set', async () => { + const onProfilePictureChange = vi.fn(); const user = userEvent.setup(); - renderView({ onEditProfilePicture }); + const { container } = renderView({ onProfilePictureChange, onRemoveProfilePicture: vi.fn() }); - await user.click(screen.getByRole('button', { name: 'Upload' })); + expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); - expect(onEditProfilePicture).toHaveBeenCalledOnce(); + const input = container.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); + await user.upload(input as HTMLInputElement, file); + + expect(onProfilePictureChange).toHaveBeenCalledWith(file); + }); + + it('offers change and remove in a menu once a profile picture is set', async () => { + const onProfilePictureChange = vi.fn(); + const onRemoveProfilePicture = vi.fn(); + const user = userEvent.setup(); + renderView({ imageUrl: 'https://example.com/avatar.png', onProfilePictureChange, onRemoveProfilePicture }); + + expect(screen.queryByRole('button', { name: 'Upload' })).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); + + expect(screen.getByRole('menuitem', { name: 'Change avatar' })).toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); + + expect(onRemoveProfilePicture).toHaveBeenCalledOnce(); }); it('breaks out both contact types when multiple accounts are allowed', () => { diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 1e27884859d..3744af1e395 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -1,4 +1,5 @@ import * as stylex from '@stylexjs/stylex'; +import { useRef } from 'react'; import { Avatar } from '../components/avatar'; import { Badge } from '../components/badge'; @@ -9,6 +10,8 @@ import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { styles } from './user-profile-profile-panel.styles'; +const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; + export interface UserProfileEmail { id: string; value: string; @@ -32,7 +35,8 @@ export interface UserProfileAccountSectionViewProps { username: string; emails: UserProfileEmail[]; phones: UserProfilePhone[]; - onEditProfilePicture?: () => void; + onProfilePictureChange?: (file: File) => void; + onRemoveProfilePicture?: () => void; onNameChange?: (value: string) => void; onUsernameChange?: (value: string) => void; onAddEmail?: () => void; @@ -54,7 +58,8 @@ export function UserProfileAccountSectionView({ username, emails, phones, - onEditProfilePicture, + onProfilePictureChange, + onRemoveProfilePicture, onNameChange, onUsernameChange, onAddEmail, @@ -74,11 +79,39 @@ export function UserProfileAccountSectionView({ .join('') .slice(0, 2) .toUpperCase(); + const fileInputRef = useRef(null); + const openFilePicker = () => fileInputRef.current?.click(); + const pictureActions: UserProfileMenuAction[] = []; + + if (imageUrl && onProfilePictureChange) { + pictureActions.push({ label: 'Change avatar', icon: 'pen', onClick: openFilePicker }); + } + + if (imageUrl && onRemoveProfilePicture) { + pictureActions.push({ label: 'Remove avatar', icon: 'close', onClick: onRemoveProfilePicture }); + } + const updateName = onNameChange ? () => onNameChange(name) : undefined; const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined; return (
+ {onProfilePictureChange ? ( + { + const file = event.currentTarget.files?.[0]; + // Clear the input so re-picking the same file still fires a change event. + event.currentTarget.value = ''; + if (file) { + onProfilePictureChange(file); + } + }} + /> + ) : null} Profile @@ -97,13 +130,20 @@ export function UserProfileAccountSectionView({ Profile picture Recommend size 1:1, up to 10MB. - {onEditProfilePicture ? ( + {pictureActions.length > 0 ? ( + + + + ) : !imageUrl && onProfilePictureChange ? ( diff --git a/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx index 009c35ccfce..288bd38c2ba 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx @@ -1,8 +1,11 @@ +import { Icon } from '../components/icon'; import { Menu } from '../components/menu'; +import type { IconName } from '../icons/registry'; export interface UserProfileMenuAction { label: string; color?: 'neutral' | 'negative'; + icon?: IconName; onClick: () => void; } @@ -22,6 +25,11 @@ export function UserProfileActionMenu({ label, actions }: { label: string; actio label={action.label} onClick={action.onClick} > + {action.icon ? ( + + + + ) : null} {action.label} ))} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 9d2dca6376e..8d36777ccc0 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -41,7 +41,8 @@ export function UserProfileProfilePanelView({ phones = [], connectedAccounts = [], web3Wallets = [], - onEditProfilePicture, + onProfilePictureChange, + onRemoveProfilePicture, onNameChange, onUsernameChange, onAddEmail, @@ -81,11 +82,12 @@ export function UserProfileProfilePanelView({ username={username} onAddEmail={onAddEmail} onAddPhone={onAddPhone} - onEditProfilePicture={onEditProfilePicture} onManageEmail={onManageEmail} onManagePhone={onManagePhone} + onProfilePictureChange={onProfilePictureChange} onRemoveEmail={onRemoveEmail} onRemovePhone={onRemovePhone} + onRemoveProfilePicture={onRemoveProfilePicture} onSetPrimaryEmail={onSetPrimaryEmail} onSetPrimaryPhone={onSetPrimaryPhone} onVerifyEmail={onVerifyEmail} From 4e5e1f1e8546f8ca7661995739e58822397ef5aa Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 9 Sep 2026 11:50:46 -0600 Subject: [PATCH 2/9] refactor(ui): key the profile picture row on hasImage and route its strings through messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clerk's image service always returns a URL — a generated initials avatar when the user uploaded nothing — so branching on `imageUrl` meant the row never offered Upload and offered Remove on an avatar there was nothing to remove. The row now takes `hasImage`, supplied from `user.hasImage`. Every string the section renders moves into `user-profile-account-section.messages.ts`, matching the delete section and user button. Email and phone keep separate keys rather than sharing a templated noun. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cn886fa1NupHSgMQWyWPve --- .../src/stories/fixtures/user-page.ts | 1 + .../src/stories/user-page.stories.tsx | 1 + .../user-profile-account-section.stories.tsx | 1 + .../user-profile-profile-panel.stories.tsx | 1 + .../user-profile-profile-panel.view.test.tsx | 19 ++++- .../user-profile-account-section.messages.ts | 60 +++++++++++++++ .../user-profile-account-section.view.tsx | 76 ++++++++++--------- .../user-profile-profile-panel.view.tsx | 2 + 8 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts diff --git a/packages/swingset/src/stories/fixtures/user-page.ts b/packages/swingset/src/stories/fixtures/user-page.ts index 7afed7d7385..e1bdb423b17 100644 --- a/packages/swingset/src/stories/fixtures/user-page.ts +++ b/packages/swingset/src/stories/fixtures/user-page.ts @@ -66,6 +66,7 @@ export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) const panels: UserPageViewProps['panels'] = { account: { allowMultipleAccounts: true, + hasImage: true, imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', name: 'Preston Booth', username: 'prestonxyz', diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx index ca725a6cf11..21bdf4ec965 100644 --- a/packages/swingset/src/stories/user-page.stories.tsx +++ b/packages/swingset/src/stories/user-page.stories.tsx @@ -103,6 +103,7 @@ export function Default() { const panels: UserPageViewProps['panels'] = { account: { allowMultipleAccounts: true, + hasImage: true, imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', name: 'Preston Booth', username: 'prestonxyz', diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 43eebfa9016..2e1c81c4009 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -36,6 +36,7 @@ function AccountSection({ allowMultipleAccounts }: { allowMultipleAccounts: bool ) { connected: false, }, ]} + hasImage imageUrl={profileImageUrl} name='Preston Booth' phones={phones} diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index a791166236c..b700713d9ce 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -74,11 +74,28 @@ describe('UserProfileProfilePanelView', () => { expect(onProfilePictureChange).toHaveBeenCalledWith(file); }); + it('offers Upload while the avatar is only a generated default', () => { + renderView({ + hasImage: false, + imageUrl: 'https://img.clerk.com/generated-default.png', + onProfilePictureChange: vi.fn(), + onRemoveProfilePicture: vi.fn(), + }); + + expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); + }); + it('offers change and remove in a menu once a profile picture is set', async () => { const onProfilePictureChange = vi.fn(); const onRemoveProfilePicture = vi.fn(); const user = userEvent.setup(); - renderView({ imageUrl: 'https://example.com/avatar.png', onProfilePictureChange, onRemoveProfilePicture }); + renderView({ + hasImage: true, + imageUrl: 'https://example.com/avatar.png', + onProfilePictureChange, + onRemoveProfilePicture, + }); expect(screen.queryByRole('button', { name: 'Upload' })).toBeNull(); await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts new file mode 100644 index 00000000000..2e251a1a0e8 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts @@ -0,0 +1,60 @@ +/** + * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so + * localizing this component is a matter of registering the namespace and swapping the reads for + * `useMessages('userProfileAccountSection', userProfileAccountSectionBase)`, not of hunting the + * literals down first. + * + * A parameterized message is its template, the way `params()` takes it. `fill` below resolves them + * until that layer lands. + * + * Email and phone keep separate keys rather than sharing one templated string: a locale that + * inflects around the noun cannot build either from the other. + */ +export const userProfileAccountSectionBase = { + sectionLabel: 'Account', + sectionTitle: 'Profile', + picture: { + label: 'Profile picture', + description: 'Recommend size 1:1, up to 10MB.', + upload: 'Upload', + manage: 'Manage profile picture', + change: 'Change avatar', + remove: 'Remove avatar', + }, + name: { + label: 'Name', + edit: 'Edit name', + }, + username: { + label: 'Username', + edit: 'Edit username', + }, + primary: 'Primary', + add: 'Add', + manage: 'Manage', + setPrimary: 'Set as primary', + completeVerification: 'Complete verification', + /** Names the action menu on one contact row, read as e.g. "Manage item1@clerk.dev". */ + manageValue: 'Manage {value}', + email: { + label: 'Email', + empty: 'No email addresses added', + update: 'Update email', + add: 'Add email', + verify: 'Verify', + remove: 'Remove email', + }, + phone: { + label: 'Phone', + empty: 'No phone numbers added', + update: 'Update phone number', + add: 'Add phone number', + verify: 'Verify phone number', + remove: 'Remove phone number', + }, +}; + +/** Substitutes `{name}`-style placeholders. Replaced by the localization layer's own formatter. */ +export function fill(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 3744af1e395..84a9658c511 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -6,6 +6,7 @@ import { Badge } from '../components/badge'; import { Button } from '../components/button'; import { Icon } from '../components/icon'; import { Section } from '../components/section'; +import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { styles } from './user-profile-profile-panel.styles'; @@ -31,6 +32,12 @@ export interface UserProfilePhone { export interface UserProfileAccountSectionViewProps { allowMultipleAccounts?: boolean; imageUrl?: string; + /** + * Whether `imageUrl` is a picture the user uploaded. Clerk's image service always returns a URL — + * a generated initials avatar when none was uploaded — so the row cannot tell the two apart from + * `imageUrl` alone. Supplied from `user.hasImage`. + */ + hasImage?: boolean; name: string; username: string; emails: UserProfileEmail[]; @@ -54,6 +61,7 @@ export interface UserProfileAccountSectionViewProps { export function UserProfileAccountSectionView({ allowMultipleAccounts = false, imageUrl, + hasImage = false, name, username, emails, @@ -83,12 +91,12 @@ export function UserProfileAccountSectionView({ const openFilePicker = () => fileInputRef.current?.click(); const pictureActions: UserProfileMenuAction[] = []; - if (imageUrl && onProfilePictureChange) { - pictureActions.push({ label: 'Change avatar', icon: 'pen', onClick: openFilePicker }); + if (hasImage && onProfilePictureChange) { + pictureActions.push({ label: m.picture.change, icon: 'pen', onClick: openFilePicker }); } - if (imageUrl && onRemoveProfilePicture) { - pictureActions.push({ label: 'Remove avatar', icon: 'close', onClick: onRemoveProfilePicture }); + if (hasImage && onRemoveProfilePicture) { + pictureActions.push({ label: m.picture.remove, icon: 'close', onClick: onRemoveProfilePicture }); } const updateName = onNameChange ? () => onNameChange(name) : undefined; @@ -112,8 +120,8 @@ export function UserProfileAccountSectionView({ }} /> ) : null} - - Profile + + {m.sectionTitle} @@ -127,17 +135,17 @@ export function UserProfileAccountSectionView({ - Profile picture - Recommend size 1:1, up to 10MB. + {m.picture.label} + {m.picture.description} {pictureActions.length > 0 ? ( - ) : !imageUrl && onProfilePictureChange ? ( + ) : !hasImage && onProfilePictureChange ? ( ) : null} @@ -154,7 +162,7 @@ export function UserProfileAccountSectionView({ - Name + {m.name.label} {name} {updateName ? ( @@ -165,7 +173,7 @@ export function UserProfileAccountSectionView({ variant='outline' onClick={updateName} > - Edit name + {m.name.edit} ) : null} @@ -174,7 +182,7 @@ export function UserProfileAccountSectionView({ - Username + {m.username.label} {username} {updateUsername ? ( @@ -185,7 +193,7 @@ export function UserProfileAccountSectionView({ variant='outline' onClick={updateUsername} > - Edit username + {m.username.edit} ) : null} @@ -195,7 +203,7 @@ export function UserProfileAccountSectionView({ @@ -204,7 +212,7 @@ export function UserProfileAccountSectionView({ @@ -215,7 +223,7 @@ export function UserProfileAccountSectionView({ onManage(item.id) : undefined) : onAdd; - const emptyDescription = kind === 'email' ? 'No email addresses added' : 'No phone numbers added'; - const actionLabel = item - ? kind === 'email' - ? 'Update email' - : 'Update phone number' - : kind === 'email' - ? 'Add email' - : 'Add phone number'; + const emptyDescription = m[kind].empty; + const actionLabel = item ? m[kind].update : m[kind].add; return ( @@ -280,7 +282,7 @@ function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectio {item ? ( {item.value} - {item.isDefault ? Primary : null} + {item.isDefault ? {m.primary} : null} ) : ( {emptyDescription} @@ -304,7 +306,7 @@ function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectio } function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimary, onRemove }: ContactSectionProps) { - const emptyDescription = kind === 'email' ? 'No email addresses added' : 'No phone numbers added'; + const emptyDescription = m[kind].empty; return ( @@ -315,7 +317,7 @@ function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimar {onAdd ? ( ) : null} @@ -345,23 +347,23 @@ function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimar if (item.isVerified === false && onVerify) { actions.push({ - label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number', + label: item.isDefault ? m.completeVerification : m[kind].verify, onClick: () => onVerify(item.id), }); } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { - actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) }); + actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); } if (onRemove && item.canRemove !== false) { actions.push({ - label: kind === 'email' ? 'Remove email' : 'Remove phone number', + label: m[kind].remove, color: 'negative', onClick: () => onRemove(item.id), }); } if (!hasExplicitActions && onManage) { - actions.push({ label: 'Manage', onClick: () => onManage(item.id) }); + actions.push({ label: m.manage, onClick: () => onManage(item.id) }); } return ( @@ -369,14 +371,14 @@ function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimar {item.value} - {item.isDefault ? Primary : null} + {item.isDefault ? {m.primary} : null} {actions.length > 0 ? ( ) : null} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 8d36777ccc0..5fc388dd825 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -35,6 +35,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect export function UserProfileProfilePanelView({ allowMultipleAccounts, imageUrl, + hasImage, name = '', username = '', emails = [], @@ -76,6 +77,7 @@ export function UserProfileProfilePanelView({ Date: Wed, 9 Sep 2026 12:15:26 -0600 Subject: [PATCH 3/9] refactor(ui): pick the profile picture with the FileUpload primitive The row hand-rolled a hidden input, the click-to-open, and the value reset that lets the same file be picked twice. `FileUpload.Root` already owns all three, and its README names avatar pickers as the case it is for. Going through the primitive also buys the size check the row's own description advertises: `maxSize` turns away anything past 10MB and reports it through the new `onProfilePictureReject`. The row renders no error of its own, so a consumer that wants the user told has to surface those. The picture row's actions move into `ProfilePictureActions`, which sits inside `FileUpload.Root` so a menu item can open the picker through `useFileUpload` rather than a `FileUpload.Trigger` button. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cn886fa1NupHSgMQWyWPve --- .../user-profile-profile-panel.view.test.tsx | 13 ++ .../user-profile-account-section.view.tsx | 134 +++++++++++------- .../user-profile-profile-panel.view.tsx | 2 + 3 files changed, 99 insertions(+), 50 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index b700713d9ce..3f72684abc9 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -74,6 +74,19 @@ describe('UserProfileProfilePanelView', () => { expect(onProfilePictureChange).toHaveBeenCalledWith(file); }); + it('turns away a file past the size the row advertises', async () => { + const onProfilePictureChange = vi.fn(); + const onProfilePictureReject = vi.fn(); + const user = userEvent.setup(); + const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); + + const oversized = new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' }); + await user.upload(container.querySelector('input[type="file"]') as HTMLInputElement, oversized); + + expect(onProfilePictureChange).not.toHaveBeenCalled(); + expect(onProfilePictureReject).toHaveBeenCalledWith([{ file: oversized, reason: 'size' }]); + }); + it('offers Upload while the avatar is only a generated default', () => { renderView({ hasImage: false, diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 84a9658c511..98957c0e90c 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -1,5 +1,6 @@ +import type { FileRejection } from '@clerk/headless/file-upload'; +import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { useRef } from 'react'; import { Avatar } from '../components/avatar'; import { Badge } from '../components/badge'; @@ -12,6 +13,8 @@ import { UserProfileActionMenu } from './user-profile-action-menu'; import { styles } from './user-profile-profile-panel.styles'; const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; +/** Matches the limit the row's own description advertises. */ +const PROFILE_PICTURE_MAX_BYTES = 10 * 1000 * 1000; export interface UserProfileEmail { id: string; @@ -43,6 +46,11 @@ export interface UserProfileAccountSectionViewProps { emails: UserProfileEmail[]; phones: UserProfilePhone[]; onProfilePictureChange?: (file: File) => void; + /** + * Called with the files the picker turned away for type or size. The row renders no error of its + * own, so a consumer that wants the user told has to surface these. + */ + onProfilePictureReject?: (rejections: FileRejection[]) => void; onRemoveProfilePicture?: () => void; onNameChange?: (value: string) => void; onUsernameChange?: (value: string) => void; @@ -67,6 +75,7 @@ export function UserProfileAccountSectionView({ emails, phones, onProfilePictureChange, + onProfilePictureReject, onRemoveProfilePicture, onNameChange, onUsernameChange, @@ -87,39 +96,22 @@ export function UserProfileAccountSectionView({ .join('') .slice(0, 2) .toUpperCase(); - const fileInputRef = useRef(null); - const openFilePicker = () => fileInputRef.current?.click(); - const pictureActions: UserProfileMenuAction[] = []; - - if (hasImage && onProfilePictureChange) { - pictureActions.push({ label: m.picture.change, icon: 'pen', onClick: openFilePicker }); - } - - if (hasImage && onRemoveProfilePicture) { - pictureActions.push({ label: m.picture.remove, icon: 'close', onClick: onRemoveProfilePicture }); - } - const updateName = onNameChange ? () => onNameChange(name) : undefined; const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined; return ( -
- {onProfilePictureChange ? ( - { - const file = event.currentTarget.files?.[0]; - // Clear the input so re-picking the same file still fires a change event. - event.currentTarget.value = ''; - if (file) { - onProfilePictureChange(file); - } - }} - /> - ) : null} + } + onReject={onProfilePictureReject} + onValueChange={files => { + const file = files[0]; + if (file) { + onProfilePictureChange?.(file); + } + }} + > {m.sectionTitle} @@ -138,25 +130,11 @@ export function UserProfileAccountSectionView({ {m.picture.label} {m.picture.description} - {pictureActions.length > 0 ? ( - - - - ) : !hasImage && onProfilePictureChange ? ( - - - - ) : null} + @@ -243,10 +221,66 @@ export function UserProfileAccountSectionView({ onVerify={onVerifyPhone} /> ) : null} -
+ ); } +/** + * Sits inside `FileUpload.Root` so it can open the picker from a menu item, which is a plain + * callback rather than a `FileUpload.Trigger` button. + */ +function ProfilePictureActions({ + hasImage, + canChange, + onRemove, +}: { + hasImage: boolean; + canChange: boolean; + onRemove?: () => void; +}) { + const { openFilePicker } = FileUpload.useFileUpload(); + const actions: UserProfileMenuAction[] = []; + + if (hasImage && canChange) { + actions.push({ label: m.picture.change, icon: 'pen', onClick: openFilePicker }); + } + + if (hasImage && onRemove) { + actions.push({ label: m.picture.remove, icon: 'close', onClick: onRemove }); + } + + if (actions.length > 0) { + return ( + + + + ); + } + + if (!hasImage && canChange) { + return ( + + + } + > + {m.picture.upload} + + + ); + } + + return null; +} + interface ContactSectionProps { kind: 'email' | 'phone'; label: string; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 5fc388dd825..ad999ed3d02 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -43,6 +43,7 @@ export function UserProfileProfilePanelView({ connectedAccounts = [], web3Wallets = [], onProfilePictureChange, + onProfilePictureReject, onRemoveProfilePicture, onNameChange, onUsernameChange, @@ -87,6 +88,7 @@ export function UserProfileProfilePanelView({ onManageEmail={onManageEmail} onManagePhone={onManagePhone} onProfilePictureChange={onProfilePictureChange} + onProfilePictureReject={onProfilePictureReject} onRemoveEmail={onRemoveEmail} onRemovePhone={onRemovePhone} onRemoveProfilePicture={onRemoveProfilePicture} From a788ef6b62d650be8ee331d52dc37a7372fb018d Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 9 Sep 2026 12:58:15 -0600 Subject: [PATCH 4/9] feat(ui): tell the user why the profile picture was turned away The row enforced a 10MB limit and the four types legacy accepts, but rendered nothing when a file failed either check, so a rejected pick looked like a no-op. It now renders the reason under the picture's description. `Field.Error` tolerates a missing field context, so the row borrows its icon and negative tone standalone rather than pretending to be a form control, and carries `role='alert'` in place of the `aria-describedby` a real field would wire up. The wording matches the `avatar_file_size_exceeded` and `avatar_file_type_invalid` strings legacy already ships. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cn886fa1NupHSgMQWyWPve --- .../user-profile-profile-panel.view.test.tsx | 14 ++++++++++++++ .../user-profile-account-section.messages.ts | 6 ++++++ .../user-profile-account-section.view.tsx | 16 ++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 3f72684abc9..328812f019a 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -85,6 +85,20 @@ describe('UserProfileProfilePanelView', () => { expect(onProfilePictureChange).not.toHaveBeenCalled(); expect(onProfilePictureReject).toHaveBeenCalledWith([{ file: oversized, reason: 'size' }]); + expect(screen.getByRole('alert')).toHaveTextContent('File size exceeds the maximum limit of 10MB.'); + expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toBeInTheDocument(); + }); + + it('clears the rejection once an acceptable file is picked', async () => { + const user = userEvent.setup(); + const { container } = renderView({ onProfilePictureChange: vi.fn() }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' })); + expect(screen.getByRole('alert')).toBeInTheDocument(); + + await user.upload(input, new File(['small'], 'small.png', { type: 'image/png' })); + expect(screen.queryByRole('alert')).toBeNull(); }); it('offers Upload while the avatar is only a generated default', () => { diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts index 2e251a1a0e8..d8dfca184c3 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts @@ -20,6 +20,12 @@ export const userProfileAccountSectionBase = { manage: 'Manage profile picture', change: 'Change avatar', remove: 'Remove avatar', + /** Shown under the description when the picker turns a file away. Keyed by rejection reason. */ + errors: { + accept: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', + size: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + overflow: 'Only one file can be uploaded at a time.', + }, }, name: { label: 'Name', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 98957c0e90c..b0f540cd0e2 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -1,10 +1,12 @@ -import type { FileRejection } from '@clerk/headless/file-upload'; +import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; +import { useState } from 'react'; import { Avatar } from '../components/avatar'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; +import { Field } from '../components/field'; import { Icon } from '../components/icon'; import { Section } from '../components/section'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; @@ -96,6 +98,7 @@ export function UserProfileAccountSectionView({ .join('') .slice(0, 2) .toUpperCase(); + const [rejection, setRejection] = useState(null); const updateName = onNameChange ? () => onNameChange(name) : undefined; const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined; @@ -104,10 +107,14 @@ export function UserProfileAccountSectionView({ accept={PROFILE_PICTURE_MIME_TYPES} maxSize={PROFILE_PICTURE_MAX_BYTES} render={
} - onReject={onProfilePictureReject} + onReject={rejections => { + setRejection(rejections[0]?.reason ?? null); + onProfilePictureReject?.(rejections); + }} onValueChange={files => { const file = files[0]; if (file) { + setRejection(null); onProfilePictureChange?.(file); } }} @@ -129,6 +136,11 @@ export function UserProfileAccountSectionView({ {m.picture.label} {m.picture.description} + {rejection ? ( + // Standalone: `Field.Error` tolerates a missing field context, so the row borrows + // its icon and tone without pretending to be a form control. + {m.picture.errors[rejection]} + ) : null} Date: Wed, 9 Sep 2026 13:15:23 -0600 Subject: [PATCH 5/9] feat(ui): add Section.Error and render the picture rejection through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Field.Error` inside `Section.Content` grew the content column, which pushed the avatar and the actions off the centre line the row shares whenever a message appeared. `Section.Error` mirrors it — same alert-circle glyph, negative tone, and xs type — but sits as a sibling of `Section.Item` under the row, above a divider in the section's own border colour. The item stays one centred line either way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cn886fa1NupHSgMQWyWPve --- .../src/stories/fixtures/user-page.ts | 9 +-- .../src/stories/user-page.stories.tsx | 9 +-- .../user-profile-profile-panel.stories.tsx | 9 +-- .../components/section/section.styles.ts | 19 +++++++ .../src/mosaic/components/section/section.tsx | 56 ++++++++++++++++++- .../user-profile-account-section.view.tsx | 7 +-- 6 files changed, 90 insertions(+), 19 deletions(-) diff --git a/packages/swingset/src/stories/fixtures/user-page.ts b/packages/swingset/src/stories/fixtures/user-page.ts index e1bdb423b17..353a955373e 100644 --- a/packages/swingset/src/stories/fixtures/user-page.ts +++ b/packages/swingset/src/stories/fixtures/user-page.ts @@ -19,6 +19,7 @@ export interface UserPageFixtureOptions { */ export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) { const [activePanel, setActivePanel] = useState('account'); + const [imageUrl, setImageUrl] = useState('https://avatars.githubusercontent.com/u/51144033?v=4'); const [emails, setEmails] = useState([ { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, { id: 'email_2', value: 'preston.booth@gmail.com', isVerified: true }, @@ -66,8 +67,8 @@ export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) const panels: UserPageViewProps['panels'] = { account: { allowMultipleAccounts: true, - hasImage: true, - imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', + hasImage: Boolean(imageUrl), + imageUrl, name: 'Preston Booth', username: 'prestonxyz', emails, @@ -83,8 +84,8 @@ export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) }, ]), onDeleteAccount: () => Promise.resolve(), - onProfilePictureChange: () => undefined, - onRemoveProfilePicture: () => undefined, + onProfilePictureChange: (file: File) => setImageUrl(URL.createObjectURL(file)), + onRemoveProfilePicture: () => setImageUrl(undefined), onManageEmail: () => undefined, onManagePhone: () => undefined, onNameChange: () => undefined, diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx index 21bdf4ec965..cfaa5b7c27c 100644 --- a/packages/swingset/src/stories/user-page.stories.tsx +++ b/packages/swingset/src/stories/user-page.stories.tsx @@ -94,6 +94,7 @@ export function Default() { const [apiKeys, setAPIKeys] = useState(initialAPIKeys); const [apiKeysPageSize, setAPIKeysPageSize] = useState(10); const [searchValue, setSearchValue] = useState(''); + const [imageUrl, setImageUrl] = useState('https://avatars.githubusercontent.com/u/51144033?v=4'); const [selectedIds, setSelectedIds] = useState([]); const visibleAPIKeys = useMemo( () => apiKeys.filter(apiKey => apiKey.name.toLowerCase().includes(searchValue.toLowerCase())), @@ -103,8 +104,8 @@ export function Default() { const panels: UserPageViewProps['panels'] = { account: { allowMultipleAccounts: true, - hasImage: true, - imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', + hasImage: Boolean(imageUrl), + imageUrl, name: 'Preston Booth', username: 'prestonxyz', emails, @@ -124,8 +125,8 @@ export function Default() { }, ]), onDeleteAccount: () => Promise.resolve(), - onProfilePictureChange: () => undefined, - onRemoveProfilePicture: () => undefined, + onProfilePictureChange: (file: File) => setImageUrl(URL.createObjectURL(file)), + onRemoveProfilePicture: () => setImageUrl(undefined), onManageEmail: () => undefined, onManagePhone: () => undefined, onNameChange: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 61f350ff2e6..ceed4e2f3b8 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -26,6 +26,7 @@ export function Default(_args: Record) { const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); + const [imageUrl, setImageUrl] = useState(profileImageUrl); return ( ) { connected: false, }, ]} - hasImage - imageUrl={profileImageUrl} + hasImage={Boolean(imageUrl)} + imageUrl={imageUrl} name='Preston Booth' phones={phones} username='prestonxyz' @@ -82,9 +83,9 @@ export function Default(_args: Record) { onDeleteAccount={() => Promise.resolve()} onManageEmail={() => undefined} onManagePhone={() => undefined} - onProfilePictureChange={() => undefined} + onProfilePictureChange={file => setImageUrl(URL.createObjectURL(file))} onRemoveConnectedAccount={() => undefined} - onRemoveProfilePicture={() => undefined} + onRemoveProfilePicture={() => setImageUrl(undefined)} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onConnectWeb3Wallet={() => undefined} diff --git a/packages/ui/src/mosaic/components/section/section.styles.ts b/packages/ui/src/mosaic/components/section/section.styles.ts index 57991bbc1c5..e690323a1fb 100644 --- a/packages/ui/src/mosaic/components/section/section.styles.ts +++ b/packages/ui/src/mosaic/components/section/section.styles.ts @@ -128,5 +128,24 @@ export const styles = stylex.create({ flexShrink: 0, justifyContent: 'flex-end', }, + // Sits under the row's item rather than inside its content, so a message never shifts the + // media and actions off the centre line they share. + error: { + margin: 0, + gap: space['1'], + alignItems: 'flex-start', + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + color: colorVars['--cl-color-negative'], + display: 'flex', + paddingBlockStart: space['2'], + textWrap: 'pretty', + width: '100%', + }, + errorIcon: { + flexShrink: 0, + height: '1lh', + }, }); /* eslint-enable @stylexjs/no-lookahead-selectors */ diff --git a/packages/ui/src/mosaic/components/section/section.tsx b/packages/ui/src/mosaic/components/section/section.tsx index 90eac350ad0..20b107b8353 100644 --- a/packages/ui/src/mosaic/components/section/section.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -6,8 +6,10 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; +import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; import type { HeadingProps } from '../heading'; import { Heading } from '../heading'; +import { Icon } from '../icon'; import { sectionItemsMarker } from './section.markers.stylex'; import { styles } from './section.styles'; @@ -23,6 +25,7 @@ export type SectionContentProps = MosaicComponentProps<'div'>; export type SectionLabelProps = MosaicComponentProps<'div'>; export type SectionDescriptionProps = MosaicComponentProps<'div'>; export type SectionActionsProps = MosaicComponentProps<'div'>; +export type SectionErrorProps = MosaicComponentProps<'p'>; const mediaSizes = { sm: styles.mediaSm, @@ -250,8 +253,59 @@ const Actions = React.forwardRef(function S }); }); +/** + * A row-level message, mirroring `Field.Error` for a row that holds no form control. Place it as a + * sibling of `Section.Item` inside `Section.Row`, not inside `Section.Content`: the item stays a + * single centred line, so the media and actions hold their position whether or not it is showing. + * Carries `role='alert'` for the announcement a `Field.Root` would otherwise wire up. + */ +const SectionError = React.forwardRef(function SectionError( + { render, className, style, children, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('section-error'), + stylex.props(reset.base, typographyStyles.base, typographySizes.xs, styles.error), + className, + style, + ), + role: 'alert', + ...rest, + children: ( + <> +