refactor(web): restore avatar composition API

This commit is contained in:
yyh 2026-04-13 20:03:28 +08:00
parent d32bc1a364
commit a4a7d5c2fa
No known key found for this signature in database
6 changed files with 115 additions and 80 deletions

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { Avatar } from '..' import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '..'
describe('Avatar', () => { describe('Avatar', () => {
describe('Rendering', () => { describe('Rendering', () => {
@ -60,6 +60,23 @@ describe('Avatar', () => {
}) })
}) })
describe('Primitives', () => {
it('should support composed avatar usage through exported primitives', () => {
render(
<AvatarRoot size="sm" data-testid="avatar-root">
<AvatarImage src="https://example.com/avatar.jpg" alt="Jane Doe" />
<AvatarFallback size="sm" style={{ backgroundColor: 'rgb(1, 2, 3)' }}>
J
</AvatarFallback>
</AvatarRoot>,
)
expect(screen.getByTestId('avatar-root')).toHaveClass('size-6')
expect(screen.getByText('J')).toBeInTheDocument()
expect(screen.getByText('J')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' })
})
})
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle empty string name gracefully', () => { it('should handle empty string name gracefully', () => {
const { container } = render(<Avatar name="" avatar={null} />) const { container } = render(<Avatar name="" avatar={null} />)

View File

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Avatar } from '.' import { Avatar, AvatarFallback, AvatarRoot } from '.'
const meta = { const meta = {
title: 'Base/Data Display/Avatar', title: 'Base/Data Display/Avatar',
@ -84,3 +84,27 @@ export const AllFallbackSizes: Story = {
</div> </div>
), ),
} }
export const ComposedFallback: Story = {
render: () => (
<AvatarRoot size="xl">
<AvatarFallback size="xl" style={{ backgroundColor: '#2563eb' }}>
C
</AvatarFallback>
</AvatarRoot>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<AvatarRoot size="xl">
<AvatarFallback size="xl" style={{ backgroundColor: '#2563eb' }}>
C
</AvatarFallback>
</AvatarRoot>
`.trim(),
},
},
},
}

View File

@ -21,31 +21,18 @@ export type AvatarProps = {
avatar: string | null avatar: string | null
size?: AvatarSize size?: AvatarSize
className?: string className?: string
textClassName?: string
onError?: (hasError: boolean) => void
backgroundColor?: string
onLoadingStatusChange?: (status: ImageLoadingStatus) => void onLoadingStatusChange?: (status: ImageLoadingStatus) => void
} }
type AvatarRootProps = React.ComponentPropsWithRef<typeof BaseAvatar.Root> & { export type AvatarRootProps = React.ComponentPropsWithRef<typeof BaseAvatar.Root> & {
size?: AvatarSize size?: AvatarSize
hasAvatar?: boolean
backgroundColor?: string
} }
function AvatarRoot({ export function AvatarRoot({
size = 'md', size = 'md',
className, className,
hasAvatar = false,
backgroundColor,
style,
...props ...props
}: AvatarRootProps) { }: AvatarRootProps) {
const resolvedStyle: React.CSSProperties = {
...(backgroundColor && !hasAvatar ? { backgroundColor } : {}),
...style,
}
return ( return (
<BaseAvatar.Root <BaseAvatar.Root
className={cn( className={cn(
@ -53,45 +40,35 @@ function AvatarRoot({
avatarSizeClasses[size].root, avatarSizeClasses[size].root,
className, className,
)} )}
style={resolvedStyle}
{...props} {...props}
/> />
) )
} }
type AvatarFallbackProps = React.ComponentPropsWithRef<typeof BaseAvatar.Fallback> & { export type AvatarFallbackProps = React.ComponentPropsWithRef<typeof BaseAvatar.Fallback> & {
size?: AvatarSize size?: AvatarSize
textClassName?: string
} }
function AvatarFallback({ export function AvatarFallback({
size = 'md', size = 'md',
textClassName,
className, className,
style,
...props ...props
}: AvatarFallbackProps) { }: AvatarFallbackProps) {
const resolvedStyle: React.CSSProperties = {
...style,
}
return ( return (
<BaseAvatar.Fallback <BaseAvatar.Fallback
className={cn( className={cn(
'flex size-full items-center justify-center font-medium text-white', 'flex size-full items-center justify-center font-medium text-white',
avatarSizeClasses[size].text, avatarSizeClasses[size].text,
textClassName,
className, className,
)} )}
style={resolvedStyle}
{...props} {...props}
/> />
) )
} }
type AvatarImageProps = React.ComponentPropsWithRef<typeof BaseAvatar.Image> export type AvatarImageProps = React.ComponentPropsWithRef<typeof BaseAvatar.Image>
function AvatarImage({ export function AvatarImage({
className, className,
...props ...props
}: AvatarImageProps) { }: AvatarImageProps) {
@ -108,34 +85,18 @@ export const Avatar = ({
avatar, avatar,
size = 'md', size = 'md',
className, className,
textClassName,
onError,
backgroundColor,
onLoadingStatusChange, onLoadingStatusChange,
}: AvatarProps) => { }: AvatarProps) => {
const handleLoadingStatusChange = (status: ImageLoadingStatus) => {
onLoadingStatusChange?.(status)
if (status === 'error')
onError?.(true)
if (status === 'loaded')
onError?.(false)
}
return ( return (
<AvatarRoot <AvatarRoot size={size} className={className}>
size={size}
className={className}
backgroundColor={backgroundColor}
hasAvatar={Boolean(avatar)}
>
{avatar && ( {avatar && (
<AvatarImage <AvatarImage
src={avatar} src={avatar}
alt={name} alt={name}
onLoadingStatusChange={handleLoadingStatusChange} onLoadingStatusChange={onLoadingStatusChange}
/> />
)} )}
<AvatarFallback size={size} textClassName={textClassName}> <AvatarFallback size={size}>
{name?.[0]?.toLocaleUpperCase()} {name?.[0]?.toLocaleUpperCase()}
</AvatarFallback> </AvatarFallback>
</AvatarRoot> </AvatarRoot>

View File

@ -1,7 +1,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { AvatarSize } from '@/app/components/base/ui/avatar' import type { AvatarSize } from '@/app/components/base/ui/avatar'
import { memo } from 'react' import { memo } from 'react'
import { Avatar } from '@/app/components/base/ui/avatar' import { AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/ui/avatar'
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color' import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
@ -59,13 +59,20 @@ export const UserAvatarList: FC<UserAvatarListProps> = memo(({
className="relative" className="relative"
style={{ zIndex: visibleUsers.length - index }} style={{ zIndex: visibleUsers.length - index }}
> >
<Avatar <AvatarRoot size={size} className="ring-2 ring-components-panel-bg">
name={user.name} {user.avatar_url && (
avatar={user.avatar_url || null} <AvatarImage
size={size} src={user.avatar_url}
className="ring-2 ring-components-panel-bg" alt={user.name}
backgroundColor={userColor} />
/> )}
<AvatarFallback
size={size}
style={userColor ? { backgroundColor: userColor } : undefined}
>
{user.name?.[0]?.toLocaleUpperCase()}
</AvatarFallback>
</AvatarRoot>
</div> </div>
) )
}, },

View File

@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
import { useReactFlow, useViewport } from 'reactflow' import { useReactFlow, useViewport } from 'reactflow'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm' import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm'
import { Avatar } from '@/app/components/base/ui/avatar' import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/ui/avatar'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -124,13 +124,20 @@ const ThreadMessage: FC<{
return ( return (
<div className={cn('flex gap-3 pt-1', className)}> <div className={cn('flex gap-3 pt-1', className)}>
<div className="shrink-0"> <div className="shrink-0">
<Avatar <AvatarRoot size="sm" className={cn('h-8 w-8 rounded-full')}>
name={authorName} {avatarUrl && (
avatar={avatarUrl || null} <AvatarImage
size="sm" src={avatarUrl}
className={cn('h-8 w-8 rounded-full')} alt={authorName}
backgroundColor={userColor} />
/> )}
<AvatarFallback
size="sm"
style={userColor ? { backgroundColor: userColor } : undefined}
>
{authorName?.[0]?.toLocaleUpperCase()}
</AvatarFallback>
</AvatarRoot>
</div> </div>
<div className="min-w-0 flex-1 pb-4 text-text-primary last:pb-0"> <div className="min-w-0 flex-1 pb-4 text-text-primary last:pb-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">

View File

@ -4,7 +4,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useReactFlow } from 'reactflow' import { useReactFlow } from 'reactflow'
import { Avatar } from '@/app/components/base/ui/avatar' import { AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/ui/avatar'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@ -123,6 +123,8 @@ const OnlineUsers = () => {
{visibleUsers.map((user, index) => { {visibleUsers.map((user, index) => {
const isCurrentUser = user.user_id === currentUserId const isCurrentUser = user.user_id === currentUserId
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id) const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
const avatarUrl = getAvatarUrl(user)
const displayName = user.username || fallbackUsername
return ( return (
<Tooltip key={`${user.sid}-${index}`}> <Tooltip key={`${user.sid}-${index}`}>
<TooltipTrigger> <TooltipTrigger>
@ -135,13 +137,20 @@ const OnlineUsers = () => {
style={{ zIndex: visibleUsers.length - index }} style={{ zIndex: visibleUsers.length - index }}
onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)} onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)}
> >
<Avatar <AvatarRoot size="sm" className="ring-1 ring-components-panel-bg">
name={user.username || fallbackUsername} {avatarUrl && (
avatar={getAvatarUrl(user)} <AvatarImage
size="sm" src={avatarUrl}
className="ring-1 ring-components-panel-bg" alt={displayName}
backgroundColor={userColor} />
/> )}
<AvatarFallback
size="sm"
style={userColor ? { backgroundColor: userColor } : undefined}
>
{displayName?.[0]?.toLocaleUpperCase()}
</AvatarFallback>
</AvatarRoot>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
@ -190,6 +199,8 @@ const OnlineUsers = () => {
{onlineUsers.map((user) => { {onlineUsers.map((user) => {
const isCurrentUser = user.user_id === currentUserId const isCurrentUser = user.user_id === currentUserId
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id) const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
const avatarUrl = getAvatarUrl(user)
const displayName = user.username || fallbackUsername
return ( return (
<div <div
key={user.sid} key={user.sid}
@ -205,12 +216,20 @@ const OnlineUsers = () => {
}} }}
> >
<div className="relative"> <div className="relative">
<Avatar <AvatarRoot size="sm">
name={user.username || fallbackUsername} {avatarUrl && (
avatar={getAvatarUrl(user)} <AvatarImage
size="sm" src={avatarUrl}
backgroundColor={userColor} alt={displayName}
/> />
)}
<AvatarFallback
size="sm"
style={userColor ? { backgroundColor: userColor } : undefined}
>
{displayName?.[0]?.toLocaleUpperCase()}
</AvatarFallback>
</AvatarRoot>
</div> </div>
{renderDisplayName( {renderDisplayName(
user, user,