This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import AppIcon from './index'
// Mock emoji-mart initialization
jest.mock('emoji-mart', () => ({
init: jest.fn(),
}))
// Mock emoji data
jest.mock('@emoji-mart/data', () => ({}))
// Mock the ahooks useHover hook
jest.mock('ahooks', () => ({
useHover: jest.fn(() => false),
}))
describe('AppIcon', () => {
beforeEach(() => {
// Mock custom element
if (!customElements.get('em-emoji')) {
customElements.define('em-emoji', class extends HTMLElement {
constructor() {
super()
}
// Mock basic functionality
connectedCallback() {
this.innerHTML = '🤖'
}
})
}
// Reset mocks
require('ahooks').useHover.mockReset().mockReturnValue(false)
})
it('renders default emoji when no icon or image is provided', () => {
render(<AppIcon />)
const emojiElement = document.querySelector('em-emoji')
expect(emojiElement).toBeInTheDocument()
expect(emojiElement?.getAttribute('id')).toBe('🤖')
})
it('renders with custom emoji when icon is provided', () => {
render(<AppIcon icon='smile' />)
const emojiElement = document.querySelector('em-emoji')
expect(emojiElement).toBeInTheDocument()
expect(emojiElement?.getAttribute('id')).toBe('smile')
})
it('renders image when iconType is image and imageUrl is provided', () => {
render(<AppIcon iconType='image' imageUrl='test-image.jpg' />)
const imgElement = screen.getByAltText('app icon')
expect(imgElement).toBeInTheDocument()
expect(imgElement).toHaveAttribute('src', 'test-image.jpg')
})
it('renders innerIcon when provided', () => {
render(<AppIcon innerIcon={<div data-testid='inner-icon'>Custom Icon</div>} />)
const innerIcon = screen.getByTestId('inner-icon')
expect(innerIcon).toBeInTheDocument()
})
it('applies size classes correctly', () => {
const { container: xsContainer } = render(<AppIcon size='xs' />)
expect(xsContainer.firstChild).toHaveClass('w-4 h-4 rounded-[4px]')
const { container: tinyContainer } = render(<AppIcon size='tiny' />)
expect(tinyContainer.firstChild).toHaveClass('w-6 h-6 rounded-md')
const { container: smallContainer } = render(<AppIcon size='small' />)
expect(smallContainer.firstChild).toHaveClass('w-8 h-8 rounded-lg')
const { container: mediumContainer } = render(<AppIcon size='medium' />)
expect(mediumContainer.firstChild).toHaveClass('w-9 h-9 rounded-[10px]')
const { container: largeContainer } = render(<AppIcon size='large' />)
expect(largeContainer.firstChild).toHaveClass('w-10 h-10 rounded-[10px]')
const { container: xlContainer } = render(<AppIcon size='xl' />)
expect(xlContainer.firstChild).toHaveClass('w-12 h-12 rounded-xl')
const { container: xxlContainer } = render(<AppIcon size='xxl' />)
expect(xxlContainer.firstChild).toHaveClass('w-14 h-14 rounded-2xl')
})
it('applies rounded class when rounded=true', () => {
const { container } = render(<AppIcon rounded />)
expect(container.firstChild).toHaveClass('rounded-full')
})
it('applies custom background color', () => {
const { container } = render(<AppIcon background='#FF5500' />)
expect(container.firstChild).toHaveStyle('background: #FF5500')
})
it('uses default background color when no background is provided for non-image icons', () => {
const { container } = render(<AppIcon />)
expect(container.firstChild).toHaveStyle('background: #FFEAD5')
})
it('does not apply background style for image icons', () => {
const { container } = render(<AppIcon iconType='image' imageUrl='test.jpg' background='#FF5500' />)
// Should not have the background style from the prop
expect(container.firstChild).not.toHaveStyle('background: #FF5500')
})
it('calls onClick handler when clicked', () => {
const handleClick = jest.fn()
const { container } = render(<AppIcon onClick={handleClick} />)
fireEvent.click(container.firstChild!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('applies custom className', () => {
const { container } = render(<AppIcon className='custom-class' />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('does not display edit icon when showEditIcon=false', () => {
render(<AppIcon />)
const editIcon = screen.queryByRole('svg')
expect(editIcon).not.toBeInTheDocument()
})
it('displays edit icon when showEditIcon=true and hovering', () => {
// Mock the useHover hook to return true for this test
require('ahooks').useHover.mockReturnValue(true)
render(<AppIcon showEditIcon />)
const editIcon = document.querySelector('svg')
expect(editIcon).toBeInTheDocument()
})
it('does not display edit icon when showEditIcon=true but not hovering', () => {
// useHover returns false by default from our mock setup
render(<AppIcon showEditIcon />)
const editIcon = document.querySelector('svg')
expect(editIcon).not.toBeInTheDocument()
})
it('handles conditional isValidImageIcon check correctly', () => {
// Case 1: Valid image icon
const { rerender } = render(
<AppIcon iconType='image' imageUrl='test.jpg' />,
)
expect(screen.getByAltText('app icon')).toBeInTheDocument()
// Case 2: Invalid - missing image URL
rerender(<AppIcon iconType='image' imageUrl={null} />)
expect(screen.queryByAltText('app icon')).not.toBeInTheDocument()
// Case 3: Invalid - wrong icon type
rerender(<AppIcon iconType='emoji' imageUrl='test.jpg' />)
expect(screen.queryByAltText('app icon')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { ComponentProps } from 'react'
import AppIcon from '.'
const meta = {
title: 'Base/General/AppIcon',
component: AppIcon,
parameters: {
docs: {
description: {
component: 'Reusable avatar for applications and workflows. Supports emoji or uploaded imagery, rounded mode, edit overlays, and multiple sizes.',
},
},
},
tags: ['autodocs'],
args: {
icon: '🧭',
background: '#FFEAD5',
size: 'medium',
rounded: false,
},
} satisfies Meta<typeof AppIcon>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: args => (
<div className="flex items-center gap-4">
<AppIcon {...args} />
<AppIcon {...args} rounded icon="🧠" background="#E0F2FE" />
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<AppIcon icon="🧭" background="#FFEAD5" />
<AppIcon icon="🧠" background="#E0F2FE" rounded />
`.trim(),
},
},
},
}
export const Sizes: Story = {
render: (args) => {
const sizes: Array<ComponentProps<typeof AppIcon>['size']> = ['xs', 'tiny', 'small', 'medium', 'large', 'xl', 'xxl']
return (
<div className="flex flex-wrap items-end gap-4">
{sizes.map(size => (
<div key={size} className="flex flex-col items-center gap-2">
<AppIcon {...args} size={size} icon="🚀" background="#E5DEFF" />
<span className="text-xs uppercase text-text-tertiary">{size}</span>
</div>
))}
</div>
)
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
{(['xs','tiny','small','medium','large','xl','xxl'] as const).map(size => (
<AppIcon key={size} size={size} icon="🚀" background="#E5DEFF" />
))}
`.trim(),
},
},
},
}
export const WithEditOverlay: Story = {
render: args => (
<div className="flex items-center gap-4">
<AppIcon
{...args}
icon="🛠️"
background="#E7F5FF"
showEditIcon
/>
<AppIcon
{...args}
iconType="image"
background={undefined}
imageUrl="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='80' height='80' rx='16' fill='%23CBD5F5'/><text x='50%' y='54%' dominant-baseline='middle' text-anchor='middle' font-size='30' font-family='Arial' fill='%231f2937'>AI</text></svg>"
showEditIcon
/>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<AppIcon icon="🛠️" background="#E7F5FF" showEditIcon />
<AppIcon
iconType="image"
imageUrl="data:image/svg+xml;utf8,&lt;svg ...&gt;"
showEditIcon
/>
`.trim(),
},
},
},
}

View File

@@ -0,0 +1,131 @@
'use client'
import React from 'react'
import { type FC, useRef } from 'react'
import { init } from 'emoji-mart'
import data from '@emoji-mart/data'
import { cva } from 'class-variance-authority'
import type { AppIconType } from '@/types/app'
import classNames from '@/utils/classnames'
import { useHover } from 'ahooks'
import { RiEditLine } from '@remixicon/react'
init({ data })
export type AppIconProps = {
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' | 'xl' | 'xxl'
rounded?: boolean
iconType?: AppIconType | null
icon?: string
background?: string | null
imageUrl?: string | null
className?: string
innerIcon?: React.ReactNode
coverElement?: React.ReactNode
showEditIcon?: boolean
onClick?: () => void
}
const appIconVariants = cva(
'flex items-center justify-center relative grow-0 shrink-0 overflow-hidden leading-none border-[0.5px] border-divider-regular',
{
variants: {
size: {
xs: 'w-4 h-4 text-xs rounded-[4px]',
tiny: 'w-6 h-6 text-base rounded-md',
small: 'w-8 h-8 text-xl rounded-lg',
medium: 'w-9 h-9 text-[22px] rounded-[10px]',
large: 'w-10 h-10 text-[24px] rounded-[10px]',
xl: 'w-12 h-12 text-[28px] rounded-xl',
xxl: 'w-14 h-14 text-[32px] rounded-2xl',
},
rounded: {
true: 'rounded-full',
},
},
defaultVariants: {
size: 'medium',
rounded: false,
},
})
const EditIconWrapperVariants = cva(
'absolute left-0 top-0 z-10 flex items-center justify-center bg-background-overlay-alt',
{
variants: {
size: {
xs: 'w-4 h-4 rounded-[4px]',
tiny: 'w-6 h-6 rounded-md',
small: 'w-8 h-8 rounded-lg',
medium: 'w-9 h-9 rounded-[10px]',
large: 'w-10 h-10 rounded-[10px]',
xl: 'w-12 h-12 rounded-xl',
xxl: 'w-14 h-14 rounded-2xl',
},
rounded: {
true: 'rounded-full',
},
},
defaultVariants: {
size: 'medium',
rounded: false,
},
})
const EditIconVariants = cva(
'text-text-primary-on-surface',
{
variants: {
size: {
xs: 'size-3',
tiny: 'size-3.5',
small: 'size-5',
medium: 'size-[22px]',
large: 'size-6',
xl: 'size-7',
xxl: 'size-8',
},
},
defaultVariants: {
size: 'medium',
},
})
const AppIcon: FC<AppIconProps> = ({
size = 'medium',
rounded = false,
iconType,
icon,
background,
imageUrl,
className,
innerIcon,
coverElement,
onClick,
showEditIcon = false,
}) => {
const isValidImageIcon = iconType === 'image' && imageUrl
const Icon = (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />
const wrapperRef = useRef<HTMLSpanElement>(null)
const isHovering = useHover(wrapperRef)
return (
<span
ref={wrapperRef}
className={classNames(appIconVariants({ size, rounded }), className)}
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
onClick={onClick}
>
{
isValidImageIcon
? <img src={imageUrl} className='h-full w-full' alt='app icon' />
: (innerIcon || Icon)
}
{
showEditIcon && isHovering && (
<div className={EditIconWrapperVariants({ size, rounded })}>
<RiEditLine className={EditIconVariants({ size })} />
</div>
)
}
{coverElement}
</span>
)
}
export default React.memo(AppIcon)