@yuno-payments/dashboard-design-system 0.0.2 → 0.0.4-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuno-payments/dashboard-design-system",
3
- "version": "0.0.2",
3
+ "version": "0.0.4-beta.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -18,8 +18,8 @@
18
18
  "class-variance-authority": "^0.7.1",
19
19
  "clsx": "^2.1.1",
20
20
  "lucide-react": "^0.542.0",
21
- "react": "^19.1.1",
22
- "react-dom": "^19.1.1",
21
+ "react": "^18.2.0",
22
+ "react-dom": "^18.2.0",
23
23
  "tailwind-merge": "^3.3.1",
24
24
  "tailwindcss": "^4.1.12"
25
25
  },
@@ -32,10 +32,10 @@
32
32
  "@storybook/addon-vitest": "^9.1.3",
33
33
  "@storybook/react-vite": "^9.1.3",
34
34
  "@testing-library/jest-dom": "^6.8.0",
35
- "@testing-library/react": "^16.3.0",
35
+ "@testing-library/react": "^13.4.0",
36
36
  "@types/node": "^24.3.0",
37
- "@types/react": "^19.1.10",
38
- "@types/react-dom": "^19.1.7",
37
+ "@types/react": "^18.2.79",
38
+ "@types/react-dom": "^18.2.25",
39
39
  "@vitejs/plugin-react": "^5.0.0",
40
40
  "@vitest/browser": "^3.2.4",
41
41
  "@vitest/coverage-v8": "^3.2.4",
package/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Button } from "@/components/ui/button"
1
+ import { Button } from "@/components/atoms/button"
2
2
 
3
3
  function App() {
4
4
  return (
@@ -0,0 +1,423 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { User, Settings, Star } from 'lucide-react'
3
+ import { Avatar } from './index'
4
+
5
+ const meta: Meta<typeof Avatar> = {
6
+ title: 'Atoms/Avatar',
7
+ component: Avatar,
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ tags: ['autodocs'],
12
+ argTypes: {
13
+ src: {
14
+ control: 'text',
15
+ description: 'Image source URL for the avatar',
16
+ },
17
+ alt: {
18
+ control: 'text',
19
+ description: 'Alternative text for the avatar image',
20
+ },
21
+ name: {
22
+ control: 'text',
23
+ description: 'Name to generate initials from when image is not available',
24
+ },
25
+ fallback: {
26
+ control: 'text',
27
+ description: 'Custom fallback content when image and name are not available',
28
+ },
29
+ size: {
30
+ control: 'select',
31
+ options: ['sm', 'md', 'lg'],
32
+ description: 'Size variant of the avatar',
33
+ },
34
+ className: {
35
+ control: 'text',
36
+ description: 'Additional CSS classes',
37
+ },
38
+ },
39
+ }
40
+
41
+ export default meta
42
+ type Story = StoryObj<typeof meta>
43
+
44
+ export const WithImage: Story = {
45
+ args: {
46
+ src: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face',
47
+ alt: 'John Doe',
48
+ size: 'md',
49
+ },
50
+ }
51
+
52
+ export const WithInitials: Story = {
53
+ args: {
54
+ name: 'John Doe',
55
+ size: 'md',
56
+ },
57
+ }
58
+
59
+ export const WithSingleInitial: Story = {
60
+ args: {
61
+ name: 'John',
62
+ size: 'md',
63
+ },
64
+ }
65
+
66
+ export const WithCustomFallback: Story = {
67
+ args: {
68
+ fallback: 'AB',
69
+ size: 'md',
70
+ },
71
+ }
72
+
73
+ export const WithIconFallback: Story = {
74
+ args: {
75
+ fallback: <Settings className="size-1/2" />,
76
+ size: 'md',
77
+ },
78
+ }
79
+
80
+ export const DefaultFallback: Story = {
81
+ args: {
82
+ size: 'md',
83
+ },
84
+ }
85
+
86
+ export const ImageWithFallback: Story = {
87
+ args: {
88
+ src: 'invalid-image-url',
89
+ name: 'John Doe',
90
+ size: 'md',
91
+ },
92
+ }
93
+
94
+ export const AllSizes: Story = {
95
+ render: () => (
96
+ <div className="flex items-center gap-4">
97
+ <div className="flex flex-col items-center gap-2">
98
+ <Avatar size="sm" name="John Doe" />
99
+ <span className="text-xs text-muted-foreground">Small (24px)</span>
100
+ </div>
101
+ <div className="flex flex-col items-center gap-2">
102
+ <Avatar size="md" name="John Doe" />
103
+ <span className="text-xs text-muted-foreground">Medium (32px)</span>
104
+ </div>
105
+ <div className="flex flex-col items-center gap-2">
106
+ <Avatar size="lg" name="John Doe" />
107
+ <span className="text-xs text-muted-foreground">Large (40px)</span>
108
+ </div>
109
+ </div>
110
+ ),
111
+ parameters: {
112
+ docs: {
113
+ description: {
114
+ story: 'Avatar component in different sizes: small (24px), medium (32px), and large (40px).',
115
+ },
116
+ },
117
+ },
118
+ }
119
+
120
+ export const WithImages: Story = {
121
+ render: () => (
122
+ <div className="flex items-center gap-4">
123
+ <Avatar
124
+ size="sm"
125
+ src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face"
126
+ alt="John Doe"
127
+ />
128
+ <Avatar
129
+ size="md"
130
+ src="https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face"
131
+ alt="Jane Smith"
132
+ />
133
+ <Avatar
134
+ size="lg"
135
+ src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face"
136
+ alt="Mike Johnson"
137
+ />
138
+ </div>
139
+ ),
140
+ parameters: {
141
+ docs: {
142
+ description: {
143
+ story: 'Avatar components with different images in various sizes.',
144
+ },
145
+ },
146
+ },
147
+ }
148
+
149
+ export const WithInitialsVariations: Story = {
150
+ render: () => (
151
+ <div className="flex items-center gap-4">
152
+ <div className="flex flex-col items-center gap-2">
153
+ <Avatar name="John Doe" />
154
+ <span className="text-xs text-muted-foreground">John Doe</span>
155
+ </div>
156
+ <div className="flex flex-col items-center gap-2">
157
+ <Avatar name="Alice Wonderland" />
158
+ <span className="text-xs text-muted-foreground">Alice Wonderland</span>
159
+ </div>
160
+ <div className="flex flex-col items-center gap-2">
161
+ <Avatar name="Bob" />
162
+ <span className="text-xs text-muted-foreground">Bob</span>
163
+ </div>
164
+ <div className="flex flex-col items-center gap-2">
165
+ <Avatar name="María José García" />
166
+ <span className="text-xs text-muted-foreground">María José García</span>
167
+ </div>
168
+ </div>
169
+ ),
170
+ parameters: {
171
+ docs: {
172
+ description: {
173
+ story: 'Avatar components showing how initials are generated from different name formats.',
174
+ },
175
+ },
176
+ },
177
+ }
178
+
179
+ export const FallbackTypes: Story = {
180
+ render: () => (
181
+ <div className="flex items-center gap-4">
182
+ <div className="flex flex-col items-center gap-2">
183
+ <Avatar name="John Doe" />
184
+ <span className="text-xs text-muted-foreground">Name Initials</span>
185
+ </div>
186
+ <div className="flex flex-col items-center gap-2">
187
+ <Avatar fallback="AB" />
188
+ <span className="text-xs text-muted-foreground">Custom Text</span>
189
+ </div>
190
+ <div className="flex flex-col items-center gap-2">
191
+ <Avatar fallback={<Star className="size-1/2" />} />
192
+ <span className="text-xs text-muted-foreground">Custom Icon</span>
193
+ </div>
194
+ <div className="flex flex-col items-center gap-2">
195
+ <Avatar />
196
+ <span className="text-xs text-muted-foreground">Default Icon</span>
197
+ </div>
198
+ </div>
199
+ ),
200
+ parameters: {
201
+ docs: {
202
+ description: {
203
+ story: 'Different fallback types when image is not available: name initials, custom text, custom icon, and default icon.',
204
+ },
205
+ },
206
+ },
207
+ }
208
+
209
+ export const InteractiveExample: Story = {
210
+ render: () => (
211
+ <div className="space-y-6">
212
+ <div>
213
+ <h3 className="text-lg font-semibold mb-4">Image Loading with Fallback</h3>
214
+ <div className="space-y-2">
215
+ <div className="flex items-center gap-4">
216
+ <Avatar
217
+ src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face"
218
+ name="John Doe"
219
+ alt="Valid image"
220
+ />
221
+ <span className="text-sm text-muted-foreground">Valid image URL</span>
222
+ </div>
223
+ <div className="flex items-center gap-4">
224
+ <Avatar
225
+ src="invalid-image-url"
226
+ name="John Doe"
227
+ alt="Invalid image"
228
+ />
229
+ <span className="text-sm text-muted-foreground">Invalid image URL (falls back to initials)</span>
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
+ <div>
235
+ <h3 className="text-lg font-semibold mb-4">Name Processing Examples</h3>
236
+ <div className="space-y-2">
237
+ <div className="flex items-center gap-4">
238
+ <Avatar name="John Doe" />
239
+ <span className="text-sm text-muted-foreground">"John Doe" → JD</span>
240
+ </div>
241
+ <div className="flex items-center gap-4">
242
+ <Avatar name=" Alice Wonderland " />
243
+ <span className="text-sm text-muted-foreground">" Alice Wonderland " → AW (handles extra spaces)</span>
244
+ </div>
245
+ <div className="flex items-center gap-4">
246
+ <Avatar name="J0hn D03!" />
247
+ <span className="text-sm text-muted-foreground">"J0hn D03!" → JD (removes numbers and special chars)</span>
248
+ </div>
249
+ <div className="flex items-center gap-4">
250
+ <Avatar name="Bob" />
251
+ <span className="text-sm text-muted-foreground">"Bob" → B (single name)</span>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ </div>
256
+ ),
257
+ parameters: {
258
+ docs: {
259
+ description: {
260
+ story: 'Interactive examples showing image loading behavior and name processing logic.',
261
+ },
262
+ },
263
+ },
264
+ }
265
+
266
+ export const Docs: Story = {
267
+ render: () => (
268
+ <div className="max-w-4xl mx-auto space-y-8">
269
+ <div>
270
+ <h1 className="text-3xl font-bold mb-4">Avatar Component</h1>
271
+ <p className="text-lg text-muted-foreground mb-6">
272
+ A flexible avatar component that displays user profile images, initials, or fallback icons.
273
+ Supports automatic fallback hierarchy: image → initials → custom fallback → default icon.
274
+ </p>
275
+ </div>
276
+
277
+ <div>
278
+ <h2 className="text-2xl font-semibold mb-4">Features</h2>
279
+ <ul className="list-disc list-inside space-y-2 text-muted-foreground">
280
+ <li>Multiple content types: images, initials, icons, and custom fallbacks</li>
281
+ <li>Automatic fallback hierarchy when image loading fails</li>
282
+ <li>Smart initial generation from names with sanitization</li>
283
+ <li>Three size variants: small (24px), medium (32px), large (40px)</li>
284
+ <li>Accessible with proper alt text and ARIA attributes</li>
285
+ <li>Consistent styling with Shadcn design tokens</li>
286
+ <li>TypeScript support with full type safety</li>
287
+ </ul>
288
+ </div>
289
+
290
+ <div>
291
+ <h2 className="text-2xl font-semibold mb-4">Usage Guidelines</h2>
292
+ <div className="space-y-4">
293
+ <div>
294
+ <h3 className="text-lg font-medium mb-2">When to Use</h3>
295
+ <ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
296
+ <li>User profile representations in headers, cards, or lists</li>
297
+ <li>Author attribution in comments, posts, or articles</li>
298
+ <li>Team member displays in organizational charts</li>
299
+ <li>Contact lists and user directories</li>
300
+ </ul>
301
+ </div>
302
+
303
+ <div>
304
+ <h3 className="text-lg font-medium mb-2">Best Practices</h3>
305
+ <ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
306
+ <li>Always provide meaningful alt text for images</li>
307
+ <li>Use consistent sizing within the same context</li>
308
+ <li>Provide names for initial generation when possible</li>
309
+ <li>Consider loading states for dynamic image sources</li>
310
+ <li>Use appropriate sizes: sm for compact lists, md for general use, lg for profiles</li>
311
+ </ul>
312
+ </div>
313
+ </div>
314
+ </div>
315
+
316
+ <div>
317
+ <h2 className="text-2xl font-semibold mb-4">Accessibility</h2>
318
+ <div className="space-y-2 text-sm text-muted-foreground">
319
+ <p>The Avatar component follows accessibility best practices:</p>
320
+ <ul className="list-disc list-inside space-y-1 ml-4">
321
+ <li>Images include proper alt text (from alt prop, name prop, or default)</li>
322
+ <li>Fallback content is properly announced by screen readers</li>
323
+ <li>Color contrast meets WCAG guidelines for text initials</li>
324
+ <li>Component supports keyboard navigation when interactive</li>
325
+ </ul>
326
+ </div>
327
+ </div>
328
+
329
+ <div>
330
+ <h2 className="text-2xl font-semibold mb-4">Examples</h2>
331
+ <div className="space-y-6">
332
+ <div>
333
+ <h3 className="text-base font-medium mb-2">Sizes</h3>
334
+ <div className="flex items-center gap-4">
335
+ <Avatar size="sm" name="Small Avatar" />
336
+ <Avatar size="md" name="Medium Avatar" />
337
+ <Avatar size="lg" name="Large Avatar" />
338
+ </div>
339
+ </div>
340
+
341
+ <div>
342
+ <h3 className="text-base font-medium mb-2">Content Types</h3>
343
+ <div className="flex items-center gap-4">
344
+ <Avatar
345
+ src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face"
346
+ alt="Profile Image"
347
+ />
348
+ <Avatar name="John Doe" />
349
+ <Avatar fallback={<User className="size-1/2" />} />
350
+ <Avatar />
351
+ </div>
352
+ </div>
353
+ </div>
354
+ </div>
355
+
356
+ <div>
357
+ <h2 className="text-2xl font-semibold mb-4">API Reference</h2>
358
+ <div className="space-y-4">
359
+ <div>
360
+ <h3 className="text-base font-medium mb-2">Props</h3>
361
+ <div className="overflow-x-auto">
362
+ <table className="w-full text-sm border-collapse border border-border">
363
+ <thead>
364
+ <tr className="border-b border-border">
365
+ <th className="text-left p-2 border-r border-border">Prop</th>
366
+ <th className="text-left p-2 border-r border-border">Type</th>
367
+ <th className="text-left p-2 border-r border-border">Default</th>
368
+ <th className="text-left p-2">Description</th>
369
+ </tr>
370
+ </thead>
371
+ <tbody className="text-muted-foreground">
372
+ <tr className="border-b border-border">
373
+ <td className="p-2 border-r border-border font-mono">src</td>
374
+ <td className="p-2 border-r border-border">string</td>
375
+ <td className="p-2 border-r border-border">-</td>
376
+ <td className="p-2">Image source URL</td>
377
+ </tr>
378
+ <tr className="border-b border-border">
379
+ <td className="p-2 border-r border-border font-mono">alt</td>
380
+ <td className="p-2 border-r border-border">string</td>
381
+ <td className="p-2 border-r border-border">-</td>
382
+ <td className="p-2">Alternative text for image</td>
383
+ </tr>
384
+ <tr className="border-b border-border">
385
+ <td className="p-2 border-r border-border font-mono">name</td>
386
+ <td className="p-2 border-r border-border">string</td>
387
+ <td className="p-2 border-r border-border">-</td>
388
+ <td className="p-2">Name to generate initials from</td>
389
+ </tr>
390
+ <tr className="border-b border-border">
391
+ <td className="p-2 border-r border-border font-mono">fallback</td>
392
+ <td className="p-2 border-r border-border">ReactNode</td>
393
+ <td className="p-2 border-r border-border">-</td>
394
+ <td className="p-2">Custom fallback content</td>
395
+ </tr>
396
+ <tr className="border-b border-border">
397
+ <td className="p-2 border-r border-border font-mono">size</td>
398
+ <td className="p-2 border-r border-border">'sm' | 'md' | 'lg'</td>
399
+ <td className="p-2 border-r border-border">'md'</td>
400
+ <td className="p-2">Size variant</td>
401
+ </tr>
402
+ <tr>
403
+ <td className="p-2 border-r border-border font-mono">className</td>
404
+ <td className="p-2 border-r border-border">string</td>
405
+ <td className="p-2 border-r border-border">-</td>
406
+ <td className="p-2">Additional CSS classes</td>
407
+ </tr>
408
+ </tbody>
409
+ </table>
410
+ </div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ </div>
415
+ ),
416
+ parameters: {
417
+ docs: {
418
+ description: {
419
+ story: 'Complete documentation and usage guidelines for the Avatar component.',
420
+ },
421
+ },
422
+ },
423
+ }
@@ -0,0 +1,226 @@
1
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import '@testing-library/jest-dom'
4
+ import { Avatar } from './index'
5
+ import { stringAvatar } from '../../../lib/string-avatar'
6
+
7
+ const mockFn = () => vi.fn()
8
+
9
+ describe('Avatar', () => {
10
+ describe('Image Avatar', () => {
11
+ it('renders with image when src is provided', () => {
12
+ render(<Avatar src="https://example.com/avatar.jpg" alt="User Avatar" />)
13
+
14
+ const image = screen.getByTestId('avatar-image')
15
+ expect(image).toBeInTheDocument()
16
+ expect(image).toHaveAttribute('src', 'https://example.com/avatar.jpg')
17
+ expect(image).toHaveAttribute('alt', 'User Avatar')
18
+ })
19
+
20
+ it('falls back to initials when image fails to load', async () => {
21
+ render(<Avatar src="invalid-url" name="John Doe" />)
22
+
23
+ const image = screen.getByTestId('avatar-image')
24
+ fireEvent.error(image)
25
+
26
+ await waitFor(() => {
27
+ expect(screen.getByTestId('avatar-fallback')).toBeInTheDocument()
28
+ expect(screen.getByText('JD')).toBeInTheDocument()
29
+ })
30
+ })
31
+
32
+ it('uses name as alt text when alt is not provided', () => {
33
+ render(<Avatar src="https://example.com/avatar.jpg" name="John Doe" />)
34
+
35
+ const image = screen.getByTestId('avatar-image')
36
+ expect(image).toHaveAttribute('alt', 'John Doe')
37
+ })
38
+
39
+ it('uses default alt text when neither alt nor name is provided', () => {
40
+ render(<Avatar src="https://example.com/avatar.jpg" />)
41
+
42
+ const image = screen.getByTestId('avatar-image')
43
+ expect(image).toHaveAttribute('alt', 'Avatar')
44
+ })
45
+ })
46
+
47
+ describe('Initials Avatar', () => {
48
+ it('renders initials when name is provided and no image', () => {
49
+ render(<Avatar name="John Doe" />)
50
+
51
+ expect(screen.getByTestId('avatar-fallback')).toBeInTheDocument()
52
+ expect(screen.getByText('JD')).toBeInTheDocument()
53
+ })
54
+
55
+ it('renders single initial for single name', () => {
56
+ render(<Avatar name="John" />)
57
+
58
+ expect(screen.getByText('J')).toBeInTheDocument()
59
+ })
60
+
61
+ it('handles names with special characters', () => {
62
+ render(<Avatar name="J0hn D03!" />)
63
+
64
+ expect(screen.getByText('JD')).toBeInTheDocument()
65
+ })
66
+
67
+ it('handles names with extra spaces', () => {
68
+ render(<Avatar name=" John Doe " />)
69
+
70
+ expect(screen.getByText('JD')).toBeInTheDocument()
71
+ })
72
+
73
+ it('handles empty or invalid names gracefully', () => {
74
+ render(<Avatar name="" />)
75
+
76
+ expect(screen.getByTestId('avatar-fallback')).toBeInTheDocument()
77
+ expect(screen.queryByText('JD')).not.toBeInTheDocument()
78
+ })
79
+ })
80
+
81
+ describe('Fallback Avatar', () => {
82
+ it('renders custom fallback when provided', () => {
83
+ render(<Avatar fallback="AB" />)
84
+
85
+ expect(screen.getByTestId('avatar-fallback')).toBeInTheDocument()
86
+ expect(screen.getByText('AB')).toBeInTheDocument()
87
+ })
88
+
89
+ it('renders icon fallback when no name or custom fallback', () => {
90
+ render(<Avatar />)
91
+
92
+ expect(screen.getByTestId('avatar-fallback')).toBeInTheDocument()
93
+ const fallback = screen.getByTestId('avatar-fallback')
94
+ expect(fallback.querySelector('svg')).toBeInTheDocument()
95
+ })
96
+
97
+ it('prioritizes custom fallback over name initials', () => {
98
+ render(<Avatar name="John Doe" fallback="Custom" />)
99
+
100
+ expect(screen.getByText('Custom')).toBeInTheDocument()
101
+ expect(screen.queryByText('JD')).not.toBeInTheDocument()
102
+ })
103
+ })
104
+
105
+ describe('Size Variants', () => {
106
+ it('renders small size correctly', () => {
107
+ render(<Avatar size="sm" name="John Doe" />)
108
+
109
+ const avatar = screen.getByTestId('avatar')
110
+ expect(avatar).toHaveClass('size-6')
111
+ })
112
+
113
+ it('renders medium size correctly (default)', () => {
114
+ render(<Avatar name="John Doe" />)
115
+
116
+ const avatar = screen.getByTestId('avatar')
117
+ expect(avatar).toHaveClass('size-8')
118
+ })
119
+
120
+ it('renders large size correctly', () => {
121
+ render(<Avatar size="lg" name="John Doe" />)
122
+
123
+ const avatar = screen.getByTestId('avatar')
124
+ expect(avatar).toHaveClass('size-10')
125
+ })
126
+ })
127
+
128
+ describe('Accessibility', () => {
129
+ it('has proper accessibility attributes', () => {
130
+ render(<Avatar name="John Doe" />)
131
+
132
+ const avatar = screen.getByTestId('avatar')
133
+ expect(avatar).toBeInTheDocument()
134
+ })
135
+
136
+ it('supports custom className', () => {
137
+ render(<Avatar className="custom-class" name="John Doe" />)
138
+
139
+ const avatar = screen.getByTestId('avatar')
140
+ expect(avatar).toHaveClass('custom-class')
141
+ })
142
+
143
+ it('forwards ref correctly', () => {
144
+ const ref = vi.fn()
145
+ render(<Avatar ref={ref} name="John Doe" />)
146
+
147
+ expect(ref).toHaveBeenCalled()
148
+ })
149
+
150
+ it('supports custom props', () => {
151
+ const onClick = mockFn()
152
+ render(<Avatar onClick={onClick} name="John Doe" />)
153
+
154
+ const avatar = screen.getByTestId('avatar')
155
+ fireEvent.click(avatar)
156
+ expect(onClick).toHaveBeenCalled()
157
+ })
158
+ })
159
+
160
+ describe('Image Loading States', () => {
161
+ it('resets error state when src changes', async () => {
162
+ const { rerender } = render(<Avatar src="invalid-url" name="John Doe" />)
163
+
164
+ const image = screen.getByTestId('avatar-image')
165
+ fireEvent.error(image)
166
+
167
+ await waitFor(() => {
168
+ expect(screen.getByTestId('avatar-fallback')).toBeInTheDocument()
169
+ })
170
+
171
+ rerender(<Avatar src="https://example.com/new-avatar.jpg" name="John Doe" />)
172
+
173
+ expect(screen.getByTestId('avatar-image')).toBeInTheDocument()
174
+ expect(screen.getByTestId('avatar-image')).toHaveAttribute('src', 'https://example.com/new-avatar.jpg')
175
+ })
176
+
177
+ it('handles image load event', () => {
178
+ render(<Avatar src="https://example.com/avatar.jpg" name="John Doe" />)
179
+
180
+ const image = screen.getByTestId('avatar-image')
181
+ fireEvent.load(image)
182
+
183
+ expect(image).toBeInTheDocument()
184
+ })
185
+ })
186
+ })
187
+
188
+ describe('stringAvatar utility', () => {
189
+ it('should return initials from a two-word name', () => {
190
+ expect(stringAvatar('John Doe')).toBe('JD')
191
+ expect(stringAvatar('Alice Wonderland')).toBe('AW')
192
+ })
193
+
194
+ it('should handle single-word names', () => {
195
+ expect(stringAvatar('John')).toBe('J')
196
+ expect(stringAvatar('Alice')).toBe('A')
197
+ })
198
+
199
+ it('should sanitize names by removing special characters and numbers', () => {
200
+ expect(stringAvatar('J0hn D03!')).toBe('JD')
201
+ expect(stringAvatar('A!ice123 W#0nderland')).toBe('AW')
202
+ })
203
+
204
+ it('should handle names with extra spaces', () => {
205
+ expect(stringAvatar(' John Doe ')).toBe('JD')
206
+ expect(stringAvatar(' Alice Wonderland ')).toBe('AW')
207
+ })
208
+
209
+ it('should handle empty or invalid names gracefully', () => {
210
+ expect(stringAvatar('')).toBeUndefined()
211
+ expect(stringAvatar(' ')).toBeUndefined()
212
+ expect(stringAvatar('123')).toBeUndefined()
213
+ expect(stringAvatar('!@#')).toBeUndefined()
214
+ })
215
+
216
+ it('should handle names with multiple words', () => {
217
+ expect(stringAvatar('John Michael Doe')).toBe('JD')
218
+ expect(stringAvatar('Alice Mary Jane Wonderland')).toBe('AW')
219
+ })
220
+
221
+ it('should handle names with mixed case', () => {
222
+ expect(stringAvatar('john doe')).toBe('JD')
223
+ expect(stringAvatar('ALICE WONDERLAND')).toBe('AW')
224
+ expect(stringAvatar('JoHn DoE')).toBe('JD')
225
+ })
226
+ })
@@ -0,0 +1,118 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { User } from "lucide-react"
4
+ import { cn } from "../../../lib/utils"
5
+ import { stringAvatar } from "../../../lib/string-avatar"
6
+
7
+ const avatarVariants = cva(
8
+ "relative flex shrink-0 overflow-hidden rounded-full",
9
+ {
10
+ variants: {
11
+ size: {
12
+ sm: "size-6",
13
+ md: "size-8",
14
+ lg: "size-10",
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ size: "md",
19
+ },
20
+ }
21
+ )
22
+
23
+ const avatarImageVariants = cva(
24
+ "aspect-square h-full w-full object-cover"
25
+ )
26
+
27
+ const avatarFallbackVariants = cva(
28
+ "flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground font-medium uppercase",
29
+ {
30
+ variants: {
31
+ size: {
32
+ sm: "text-xs",
33
+ md: "text-sm",
34
+ lg: "text-base",
35
+ },
36
+ },
37
+ defaultVariants: {
38
+ size: "md",
39
+ },
40
+ }
41
+ )
42
+
43
+
44
+ export interface AvatarProps
45
+ extends React.HTMLAttributes<HTMLDivElement>,
46
+ VariantProps<typeof avatarVariants> {
47
+ src?: string
48
+ alt?: string
49
+ name?: string
50
+ fallback?: React.ReactNode
51
+ }
52
+
53
+ const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
54
+ (
55
+ {
56
+ className,
57
+ size,
58
+ src,
59
+ alt,
60
+ name,
61
+ fallback,
62
+ ...props
63
+ },
64
+ ref
65
+ ) => {
66
+ const [imageError, setImageError] = React.useState(false)
67
+
68
+ React.useEffect(() => {
69
+ setImageError(false)
70
+ }, [src])
71
+
72
+ const handleImageError = () => {
73
+ setImageError(true)
74
+ }
75
+
76
+ const showImage = src && !imageError
77
+ const initials = name ? stringAvatar(name) : undefined
78
+
79
+ let displayFallback: React.ReactNode = fallback
80
+ if (!displayFallback && initials) {
81
+ displayFallback = initials
82
+ }
83
+ if (!displayFallback) {
84
+ displayFallback = <User className="size-1/2" />
85
+ }
86
+
87
+ return (
88
+ <div
89
+ ref={ref}
90
+ className={cn(avatarVariants({ size, className }))}
91
+ data-testid="avatar"
92
+ {...props}
93
+ >
94
+ {showImage && (
95
+ <img
96
+ src={src}
97
+ alt={alt || name || "Avatar"}
98
+ className={cn(avatarImageVariants())}
99
+ onError={handleImageError}
100
+ data-testid="avatar-image"
101
+ />
102
+ )}
103
+ {!showImage && (
104
+ <div
105
+ className={cn(avatarFallbackVariants({ size }))}
106
+ data-testid="avatar-fallback"
107
+ >
108
+ {displayFallback}
109
+ </div>
110
+ )}
111
+ </div>
112
+ )
113
+ }
114
+ )
115
+
116
+ Avatar.displayName = "Avatar"
117
+
118
+ export { Avatar }
@@ -97,7 +97,7 @@ export interface BoxProps
97
97
  extends React.HTMLAttributes<HTMLDivElement>,
98
98
  VariantProps<typeof boxVariants> {
99
99
  asChild?: boolean
100
- as?: keyof JSX.IntrinsicElements
100
+ as?: React.ElementType
101
101
  }
102
102
 
103
103
  export const Box = React.forwardRef<HTMLDivElement, BoxProps>(
@@ -1,5 +1,4 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
- import * as React from 'react'
3
2
  import { Skeleton } from './index'
4
3
 
5
4
  const meta: Meta<typeof Skeleton> = {
@@ -1,3 +1,4 @@
1
+ export { Avatar } from './atoms/avatar'
1
2
  export { Button } from './atoms/button'
2
3
  export { Input } from './atoms/input'
3
4
  export { Checkbox } from './atoms/checkbox'
@@ -393,7 +393,7 @@ const Dropdown = React.forwardRef<HTMLDivElement, DropdownProps>(
393
393
  newSelected = isSelected ? valueArray : []
394
394
  } else {
395
395
  if (isSelected) {
396
- newSelected = [...new Set([...prev, ...valueArray])]
396
+ newSelected = Array.from(new Set([...prev, ...valueArray]))
397
397
  } else {
398
398
  newSelected = prev.filter(item => !valueArray.includes(item))
399
399
  }
@@ -0,0 +1,23 @@
1
+ export const stringAvatar = (name: string): string | undefined => {
2
+ const sanitizedName = name
3
+ .replace(/[^\w\s]/gi, '')
4
+ .replace(/\d+/g, '')
5
+ .replace(/\s+/g, ' ')
6
+ .trim()
7
+
8
+ if (!sanitizedName) {
9
+ return undefined
10
+ }
11
+
12
+ const words = sanitizedName.split(' ').filter(word => word.length > 0)
13
+
14
+ if (words.length === 0) {
15
+ return undefined
16
+ }
17
+
18
+ if (words.length === 1) {
19
+ return words[0].charAt(0).toUpperCase()
20
+ }
21
+
22
+ return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase()
23
+ }