diff --git a/src/components/atoms/Avatar/Avatar.stories.tsx b/src/components/atoms/Avatar/Avatar.stories.tsx new file mode 100644 index 0000000..8c28dd0 --- /dev/null +++ b/src/components/atoms/Avatar/Avatar.stories.tsx @@ -0,0 +1,61 @@ +import type {Meta, StoryObj} from "@storybook/react-vite"; +import {Avatar} from "./Avatar"; + +const meta = { + title: "components/atoms/Avatar", + component: Avatar, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * Default avatar with initials fallback + */ +export const Default: Story = { + args: { + fallback: "AM", + alt: "Arnab Mandal", + size: "md", + }, +}; + +/** + * Avatar with real image + */ +export const WithImage: Story = { + args: { + src: "https://i.pravatar.cc/150?img=5", + fallback: "AM", + alt: "User profile", + size: "md", + }, +}; + +/** + * Broken image → fallback test + */ +export const BrokenImage: Story = { + args: { + src: "https://example.com/invalid-image.jpg", + fallback: "NA", + alt: "Broken avatar", + size: "md", + }, +}; + +/** + * All sizes preview + */ +export const Sizes: Story = { + render: () => ( +
+ + + + + +
+ ), +}; diff --git a/src/components/atoms/Avatar/Avatar.tsx b/src/components/atoms/Avatar/Avatar.tsx new file mode 100644 index 0000000..44cfa46 --- /dev/null +++ b/src/components/atoms/Avatar/Avatar.tsx @@ -0,0 +1,96 @@ +import {useEffect, useState} from "react"; +import type {AvatarProps, AvatarSize} from "./AvatarProps"; + +const sizeMap: Record = { + xs: 24, + sm: 32, + md: 40, + lg: 56, + xl: 72, +}; + +/** + * Renders an Avatar component. + * + * Displays a user profile image when `src` is provided. + * Falls back to initials or a placeholder when the image is missing or fails to load. + * + * @param {Readonly} props - Avatar properties + * @returns {React.ReactNode} Rendered avatar + * + * @example + * + * + * @example + * + * + * @example + * + * + * @example + * + */ +export function Avatar(props: Readonly) { + const { + src, + alt = "Avatar", + size = "md", + fallback, + className, + } = props; + + const [hasError, setHasError] = useState(false); + + // Reset error state when image source changes + useEffect(() => { + setHasError(false); + }, [src]); + + const pixelSize = sizeMap[size]; + const showImage = Boolean(src) && !hasError; + + // IMAGE RENDER + if (showImage) { + return ( + {alt} { + // prevent infinite error loop + e.currentTarget.onerror = null; + setHasError(true); + }} + style={{ + borderRadius: "50%", + objectFit: "cover", + }} + /> + ); + } + + // FALLBACK RENDER + return ( +
+ {fallback?.slice(0, 2).toUpperCase() ?? "?"} +
+ ); +} diff --git a/src/components/atoms/Avatar/AvatarProps.ts b/src/components/atoms/Avatar/AvatarProps.ts new file mode 100644 index 0000000..ffccec9 --- /dev/null +++ b/src/components/atoms/Avatar/AvatarProps.ts @@ -0,0 +1,9 @@ +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; + +export interface AvatarProps { + src?: string; + alt?: string; + size?: AvatarSize; + fallback?: string; // initials OR single char OR icon text + className?: string; +}