refactor(web): simplify avatar composition api

This commit is contained in:
yyh
2026-03-25 13:53:54 +08:00
parent 1a2baa431e
commit beeb07bc70
4 changed files with 67 additions and 35 deletions

View File

@ -1,8 +1,9 @@
import type { ImageLoadingStatus } from '@base-ui/react/avatar'
import type * as React from 'react'
import { Avatar as BaseAvatar } from '@base-ui/react/avatar'
import { cn } from '@/utils/classnames'
export const avatarSizeClasses = {
const avatarSizeClasses = {
'xxs': { root: 'size-4', text: 'text-[7px]' },
'xs': { root: 'size-5', text: 'text-[8px]' },
'sm': { root: 'size-6', text: 'text-[10px]' },
@ -15,8 +16,6 @@ export const avatarSizeClasses = {
export type AvatarSize = keyof typeof avatarSizeClasses
export const getAvatarSizeClassNames = (size: AvatarSize) => avatarSizeClasses[size]
export type AvatarProps = {
name: string
avatar: string | null
@ -25,15 +24,61 @@ export type AvatarProps = {
onLoadingStatusChange?: (status: ImageLoadingStatus) => void
}
export const AvatarRoot = BaseAvatar.Root
export const AvatarImage = BaseAvatar.Image
export const AvatarFallback = BaseAvatar.Fallback
export type AvatarRootProps = React.ComponentPropsWithRef<typeof BaseAvatar.Root> & {
size?: AvatarSize
}
export const avatarPartClassNames = {
root: 'relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-primary-600',
image: 'absolute inset-0 size-full object-cover',
fallback: 'flex size-full items-center justify-center font-medium text-white',
} as const
export function AvatarRoot({
size = 'md',
className,
...props
}: AvatarRootProps) {
return (
<BaseAvatar.Root
className={cn(
'relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-primary-600',
avatarSizeClasses[size].root,
className,
)}
{...props}
/>
)
}
export type AvatarImageProps = React.ComponentPropsWithRef<typeof BaseAvatar.Image>
export function AvatarImage({
className,
...props
}: AvatarImageProps) {
return (
<BaseAvatar.Image
className={cn('absolute inset-0 size-full object-cover', className)}
{...props}
/>
)
}
export type AvatarFallbackProps = React.ComponentPropsWithRef<typeof BaseAvatar.Fallback> & {
size?: AvatarSize
}
export function AvatarFallback({
size = 'md',
className,
...props
}: AvatarFallbackProps) {
return (
<BaseAvatar.Fallback
className={cn(
'flex size-full items-center justify-center font-medium text-white',
avatarSizeClasses[size].text,
className,
)}
{...props}
/>
)
}
export const Avatar = ({
name,
@ -42,19 +87,16 @@ export const Avatar = ({
className,
onLoadingStatusChange,
}: AvatarProps) => {
const sizeClassNames = getAvatarSizeClassNames(size)
return (
<AvatarRoot className={cn(avatarPartClassNames.root, sizeClassNames.root, className)}>
<AvatarRoot size={size} className={className}>
{avatar && (
<AvatarImage
src={avatar}
alt={name}
className={avatarPartClassNames.image}
onLoadingStatusChange={onLoadingStatusChange}
/>
)}
<AvatarFallback className={cn(avatarPartClassNames.fallback, sizeClassNames.text)}>
<AvatarFallback size={size}>
{name?.[0]?.toLocaleUpperCase()}
</AvatarFallback>
</AvatarRoot>

View File

@ -1,10 +1,9 @@
import type { FC } from 'react'
import type { AvatarSize } from '@/app/components/base/avatar'
import { memo } from 'react'
import { AvatarFallback, AvatarImage, avatarPartClassNames, AvatarRoot, getAvatarSizeClassNames } from '@/app/components/base/avatar'
import { AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/avatar'
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
import { useAppContext } from '@/context/app-context'
import { cn } from '@/utils/classnames'
type User = {
id: string
@ -12,7 +11,6 @@ type User = {
avatar_url?: string | null
}
/** Map legacy pixel size to Avatar token (closest; ties favor smaller px, e.g. 28 -> sm). */
function numericPxToAvatarSize(px: number): AvatarSize {
const candidates: { px: number, size: AvatarSize }[] = [
{ px: 16, size: 'xxs' },
@ -62,7 +60,6 @@ export const UserAvatarList: FC<UserAvatarListProps> = memo(({
const currentUserId = userProfile?.id
const avatarSize = numericPxToAvatarSize(size)
const avatarSizeClassNames = getAvatarSizeClassNames(avatarSize)
return (
<div className={`flex items-center -space-x-1 ${className}`}>
@ -75,16 +72,15 @@ export const UserAvatarList: FC<UserAvatarListProps> = memo(({
className="relative"
style={{ zIndex: visibleUsers.length - index }}
>
<AvatarRoot className={cn(avatarPartClassNames.root, avatarSizeClassNames.root, 'ring-2 ring-components-panel-bg')}>
<AvatarRoot size={avatarSize} className="ring-2 ring-components-panel-bg">
{user.avatar_url && (
<AvatarImage
src={user.avatar_url}
alt={user.name}
className={avatarPartClassNames.image}
/>
)}
<AvatarFallback
className={cn(avatarPartClassNames.fallback, avatarSizeClassNames.text)}
size={avatarSize}
style={userColor ? { backgroundColor: userColor } : undefined}
>
{user.name?.[0]?.toLocaleUpperCase()}

View File

@ -6,7 +6,7 @@ import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircl
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow, useViewport } from 'reactflow'
import { Avatar, AvatarFallback, AvatarImage, avatarPartClassNames, AvatarRoot, getAvatarSizeClassNames } from '@/app/components/base/avatar'
import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/avatar'
import Divider from '@/app/components/base/divider'
import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
@ -19,8 +19,6 @@ import { cn } from '@/utils/classnames'
import { useStore } from '../store'
import { MentionInput } from './mention-input'
const threadAvatarSizeClassNames = getAvatarSizeClassNames('sm')
type CommentThreadProps = {
comment: WorkflowCommentDetail
loading?: boolean
@ -122,16 +120,15 @@ const ThreadMessage: FC<{
return (
<div className={cn('flex gap-3 pt-1', className)}>
<div className="shrink-0">
<AvatarRoot className={cn(avatarPartClassNames.root, threadAvatarSizeClassNames.root, 'h-8 w-8 rounded-full')}>
<AvatarRoot size="sm" className="h-8 w-8 rounded-full">
{avatarUrl && (
<AvatarImage
src={avatarUrl}
alt={authorName}
className={avatarPartClassNames.image}
/>
)}
<AvatarFallback
className={cn(avatarPartClassNames.fallback, threadAvatarSizeClassNames.text)}
size="sm"
style={userColor ? { backgroundColor: userColor } : undefined}
>
{authorName?.[0]?.toLocaleUpperCase()}

View File

@ -4,7 +4,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow } from 'reactflow'
import { AvatarFallback, AvatarImage, avatarPartClassNames, AvatarRoot, getAvatarSizeClassNames } from '@/app/components/base/avatar'
import { AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/avatar'
import Divider from '@/app/components/base/divider'
import {
PortalToFollowElem,
@ -51,8 +51,6 @@ const useAvatarUrls = (users: OnlineUser[]) => {
return avatarUrls
}
const onlineUserAvatarSizeClassNames = getAvatarSizeClassNames('sm')
type OnlineUserAvatarProps = {
name: string
avatar: string | null
@ -66,16 +64,15 @@ const OnlineUserAvatar = ({
className,
fallbackColor,
}: OnlineUserAvatarProps) => (
<AvatarRoot className={cn(avatarPartClassNames.root, onlineUserAvatarSizeClassNames.root, className)}>
<AvatarRoot size="sm" className={className}>
{avatar && (
<AvatarImage
src={avatar}
alt={name}
className={avatarPartClassNames.image}
/>
)}
<AvatarFallback
className={cn(avatarPartClassNames.fallback, onlineUserAvatarSizeClassNames.text)}
size="sm"
style={fallbackColor ? { backgroundColor: fallbackColor } : undefined}
>
{name?.[0]?.toLocaleUpperCase()}