Skip to content

Commit be3ee6f

Browse files
committed
avatar added to docs site
1 parent f4c4928 commit be3ee6f

File tree

5 files changed

+571
-2
lines changed

5 files changed

+571
-2
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import React, { useState, useEffect, useRef } from 'react';
2+
import { cn } from '../../../lib/utils';
3+
import { avatarClassNames, avatarImageClassNames, avatarFallbackClassNames } from './styles';
4+
5+
/**
6+
* Avatar component that displays a user avatar with fallback support
7+
*/
8+
interface AvatarProps {
9+
className?: string;
10+
children?: React.ReactNode;
11+
style?: React.CSSProperties;
12+
size?: 'sm' | 'md' | 'lg'; // predefined sizes
13+
mode?: 'light' | 'dark'; // theme mode
14+
}
15+
16+
const Avatar: React.FC<AvatarProps> = ({
17+
className = '',
18+
children,
19+
style,
20+
size = 'md',
21+
mode = 'light',
22+
}) => {
23+
// Process children to ensure text is wrapped
24+
const renderChildren = () => {
25+
if (children == null) {
26+
return null;
27+
}
28+
29+
// If children is a string, wrap it in a span
30+
if (typeof children === 'string') {
31+
return <span>{children}</span>;
32+
}
33+
34+
// If children is a number, convert to string and wrap in span
35+
if (typeof children === 'number') {
36+
return <span>{children.toString()}</span>;
37+
}
38+
39+
// If children is a boolean, convert to string and wrap in span
40+
if (typeof children === 'boolean') {
41+
return <span>{children.toString()}</span>;
42+
}
43+
44+
// If children is an array, process each item
45+
if (Array.isArray(children)) {
46+
return React.Children.map(children, child => {
47+
if (typeof child === 'string' || typeof child === 'number' || typeof child === 'boolean') {
48+
return <span>{String(child)}</span>;
49+
}
50+
return child;
51+
});
52+
}
53+
54+
// Otherwise, return as is
55+
return children;
56+
};
57+
58+
// Safe rendering function
59+
const safeRender = () => {
60+
try {
61+
return renderChildren();
62+
} catch (error) {
63+
console.error('Error rendering Avatar children:', error);
64+
return null;
65+
}
66+
};
67+
68+
return (
69+
<div
70+
style={style}
71+
className={cn(
72+
avatarClassNames.base,
73+
avatarClassNames.size[size],
74+
mode === 'dark' ? 'dark' : '',
75+
className
76+
)}
77+
>
78+
{safeRender()}
79+
</div>
80+
);
81+
};
82+
83+
/**
84+
* AvatarImage component that displays the avatar image
85+
*/
86+
interface AvatarImageProps {
87+
className?: string;
88+
source: string;
89+
alt?: string;
90+
onLoadingStatusChange?: (isLoading: boolean) => void;
91+
onError?: () => void;
92+
mode?: 'light' | 'dark'; // theme mode
93+
}
94+
95+
const AvatarImage: React.FC<AvatarImageProps> = ({
96+
className = '',
97+
source,
98+
alt,
99+
onLoadingStatusChange,
100+
onError,
101+
mode = 'light',
102+
}) => {
103+
const [hasError, setHasError] = useState(false);
104+
const [isLoading, setIsLoading] = useState(true);
105+
const [imageLoaded, setImageLoaded] = useState(false);
106+
107+
// Fix infinite loop by using a ref to track changes
108+
const initialLoadRef = React.useRef(true);
109+
const sourceRef = React.useRef(source);
110+
111+
// Clean up when component unmounts
112+
useEffect(() => {
113+
// Set initial loading state
114+
if (onLoadingStatusChange && initialLoadRef.current) {
115+
onLoadingStatusChange(true);
116+
}
117+
}, []);
118+
119+
// Only reset loading state on mount and when source genuinely changes
120+
useEffect(() => {
121+
// Check if this is a genuine source change
122+
const isSourceChange = initialLoadRef.current || source !== sourceRef.current;
123+
124+
if (isSourceChange) {
125+
// Reset component state
126+
setIsLoading(true);
127+
setHasError(false);
128+
setImageLoaded(false);
129+
130+
// Notify loading status change
131+
if (onLoadingStatusChange) {
132+
onLoadingStatusChange(true);
133+
}
134+
135+
// Update the source ref
136+
sourceRef.current = source;
137+
initialLoadRef.current = false;
138+
}
139+
}, [source]);
140+
141+
const handleError = () => {
142+
setHasError(true);
143+
setIsLoading(false);
144+
setImageLoaded(false);
145+
146+
if (onLoadingStatusChange) {
147+
setTimeout(() => onLoadingStatusChange(false), 0);
148+
}
149+
150+
if (onError) {
151+
setTimeout(() => onError(), 0);
152+
}
153+
};
154+
155+
const handleLoad = () => {
156+
setIsLoading(false);
157+
setImageLoaded(true);
158+
159+
if (onLoadingStatusChange) {
160+
setTimeout(() => onLoadingStatusChange(false), 0);
161+
}
162+
};
163+
164+
if (hasError && !imageLoaded) {
165+
return null;
166+
}
167+
168+
return (
169+
// eslint-disable-next-line @next/next/no-img-element
170+
<img
171+
className={cn(avatarImageClassNames.base, mode === 'dark' ? 'dark' : '', className)}
172+
src={source}
173+
alt={alt}
174+
onError={handleError}
175+
onLoad={handleLoad}
176+
/>
177+
);
178+
};
179+
180+
/**
181+
* AvatarFallback component that is displayed when the avatar image fails to load
182+
*/
183+
interface AvatarFallbackProps {
184+
className?: string;
185+
delayMs?: number;
186+
children?: React.ReactNode;
187+
isImageLoading?: boolean;
188+
hasImageError?: boolean;
189+
standalone?: boolean;
190+
mode?: 'light' | 'dark'; // theme mode
191+
}
192+
193+
const AvatarFallback: React.FC<AvatarFallbackProps> = ({
194+
className = '',
195+
delayMs = 0,
196+
children,
197+
isImageLoading = false,
198+
hasImageError = false,
199+
standalone = false,
200+
mode = 'light',
201+
}) => {
202+
const [shouldShow, setShouldShow] = useState(delayMs === 0);
203+
const isLoadingRef = useRef(isImageLoading);
204+
205+
// When isImageLoading changes, update our ref
206+
useEffect(() => {
207+
isLoadingRef.current = isImageLoading;
208+
}, [isImageLoading]);
209+
210+
React.useEffect(() => {
211+
if (delayMs > 0) {
212+
const timer = setTimeout(() => {
213+
setShouldShow(true);
214+
}, delayMs);
215+
216+
return () => clearTimeout(timer);
217+
}
218+
return undefined;
219+
}, [delayMs]);
220+
221+
const shouldDisplayFallback = shouldShow && (standalone || isImageLoading || hasImageError);
222+
223+
if (!shouldDisplayFallback) {
224+
return null;
225+
}
226+
227+
// Process children to ensure text is wrapped
228+
const renderContent = () => {
229+
// If children is null or undefined, return null
230+
if (children == null) {
231+
return null;
232+
}
233+
234+
// If children is a string, wrap it in a span
235+
if (typeof children === 'string') {
236+
return <span className={avatarFallbackClassNames.text}>{children}</span>;
237+
}
238+
239+
// If children is a number, convert to string and wrap in span
240+
if (typeof children === 'number') {
241+
return <span className={avatarFallbackClassNames.text}>{children.toString()}</span>;
242+
}
243+
244+
// If children is a boolean, convert to string and wrap in span
245+
if (typeof children === 'boolean') {
246+
return <span className={avatarFallbackClassNames.text}>{children.toString()}</span>;
247+
}
248+
249+
// If children is an array, process each child
250+
if (Array.isArray(children)) {
251+
return React.Children.map(children, child => {
252+
if (typeof child === 'string' || typeof child === 'number' || typeof child === 'boolean') {
253+
return <span className={avatarFallbackClassNames.text}>{String(child)}</span>;
254+
}
255+
return child;
256+
});
257+
}
258+
259+
// If it's already a valid React element, return it
260+
if (React.isValidElement(children)) {
261+
return children;
262+
}
263+
264+
// For any other case, wrap it in a span to be safe
265+
return <span className={avatarFallbackClassNames.text}>{String(children)}</span>;
266+
};
267+
268+
// Safe rendering function
269+
const safeRender = () => {
270+
try {
271+
return renderContent();
272+
} catch (error) {
273+
console.error('Error rendering AvatarFallback children:', error);
274+
return null;
275+
}
276+
};
277+
278+
return (
279+
<div className={cn(avatarFallbackClassNames.base, mode === 'dark' ? 'dark' : '', className)}>
280+
{safeRender()}
281+
</div>
282+
);
283+
};
284+
285+
export { Avatar, AvatarImage, AvatarFallback };
286+
export default Avatar;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export const avatarClassNames = {
2+
base: 'relative shrink-0 overflow-hidden rounded-full',
3+
4+
size: {
5+
sm: 'h-6 w-6', // 24px
6+
md: 'h-10 w-10', // 40px
7+
lg: 'h-16 w-16', // 64px
8+
},
9+
};
10+
11+
export const avatarImageClassNames = {
12+
base: 'aspect-square h-full w-full absolute top-0 left-0 right-0 bottom-0',
13+
};
14+
15+
export const avatarFallbackClassNames = {
16+
base: 'absolute inset-0 flex items-center justify-center rounded-full bg-muted dark:bg-gray-800',
17+
text: 'text-sm font-medium dark:text-gray-200',
18+
};

docs/pages/components/_meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"accordion": "Accordion",
33
"alert": "Alert",
44
"button": "Button",
5-
"alert-dialog": "AlertDialog"
5+
"alert-dialog": "Alert Dialog",
6+
"avatar": "Avatar"
67
}

docs/pages/components/alert-dialog.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ The AlertDialog component is used to show important alerts that interrupt the us
7979

8080
## Variants
8181

82-
AlertDialog actions can use different ButtonWithMode variants to indicate different levels of severity.
82+
AlertDialog actions can use different [Button](https://nativecn.xyz/components/button) variants to indicate different levels of severity.
8383

8484
<ComponentPreview
8585
title="AlertDialog Variants"

0 commit comments

Comments
 (0)