Skip to content

Commit

Permalink
feat(avatar): avatar 컴포넌트 구현 (#45)
Browse files Browse the repository at this point in the history
* feat(avatar): implement test

* feat(avatar): scaffold package

* feat(avatar): implement component

* feat(avatar): add avatar story
  • Loading branch information
kimdaeyeobbb authored Jan 9, 2025
1 parent d476230 commit f527341
Show file tree
Hide file tree
Showing 14 changed files with 452 additions and 0 deletions.
16 changes: 16 additions & 0 deletions packages/avatar/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { StorybookConfig } from '@storybook/react-vite';

export default {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@chromatic-com/storybook',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
} satisfies StorybookConfig;
5 changes: 5 additions & 0 deletions packages/avatar/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Preview } from '@storybook/react';

export default {
tags: ['autodocs'],
} satisfies Preview;
1 change: 1 addition & 0 deletions packages/avatar/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '*.module.css';
69 changes: 69 additions & 0 deletions packages/avatar/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"name": "@sipe-team/avatar",
"description": "Avatar component for Sipe Design System",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/sipe-team/3-2_side"
},
"type": "module",
"exports": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:storybook": "storybook build",
"dev:storybook": "storybook dev -p 6006",
"lint": "biome lint .",
"test": "vitest",
"typecheck": "tsc",
"prepack": "pnpm run build"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"@sipe-team/typography": "workspace:^",
"@sipe-team/tokens": "workspace:*",
"clsx": "^2.1.1"
},
"devDependencies": {
"@faker-js/faker": "^9.2.0",
"@storybook/addon-essentials": "catalog:",
"@storybook/addon-interactions": "catalog:",
"@storybook/addon-links": "catalog:",
"@storybook/blocks": "catalog:",
"@storybook/react": "catalog:",
"@storybook/react-vite": "catalog:",
"@storybook/test": "catalog:",
"@types/react": "^18.3.12",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"happy-dom": "catalog:",
"react": "^18.3.1",
"storybook": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"peerDependencies": {
"react": ">= 18"
},
"publishConfig": {
"access": "public",
"registry": "https://npm.pkg.github.com",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
},
"sideEffects": false
}
22 changes: 22 additions & 0 deletions packages/avatar/src/Avatar.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.avatar {
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: var(--avatar-shape);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #e2e8f0;
}

.image {
width: 100%;
height: 100%;
object-fit: cover;
}

.fallback {
font-size: 0.8rem;
color: #2d3748;
text-align: center;
}
45 changes: 45 additions & 0 deletions packages/avatar/src/Avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Avatar } from "./Avatar";
import { faker } from "@faker-js/faker";

const meta = {
title: "Avatar",
component: Avatar,
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Avatar>;
export default meta;

type Story = StoryObj<typeof meta>;

const testImage = faker.image.avatar();

export const Basic: Story = {
args: {
src: "https://randomuser.me/api/portraits/men/1.jpg",
alt: "대체 텍스트",
},
};

export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: "1rem" }}>
<Avatar size="xs" src={testImage} alt="XSmall" />
<Avatar size="sm" src={testImage} alt="small" />
<Avatar size="md" src={testImage} alt="medium" />
<Avatar size="lg" src={testImage} alt="large" />
<Avatar size="xl" src={testImage} alt="XLarge" />
</div>
),
};

export const Shapes: Story = {
render: () => (
<div style={{ display: "flex", gap: "1rem" }}>
<Avatar shape="circle" src={testImage} alt="원형" />
<Avatar shape="rounded" src={testImage} alt="둥근 사각형" />
<Avatar shape="square" src={testImage} alt="사각형" />
</div>
),
};
74 changes: 74 additions & 0 deletions packages/avatar/src/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { faker } from "@faker-js/faker";
import { render, screen } from "@testing-library/react";
import { expect, test, describe, it } from "vitest";
import { Avatar } from "./Avatar";
import type { AvatarShape, AvatarSize } from "./Avatar";

const testImage = faker.image.avatar();

test("Avatar 컴포넌트가 주입받은 이미지 주소를 src 속성으로 설정한다.", () => {
render(<Avatar src={testImage} alt="대체 텍스트" />);

const img = screen.getByRole("img");
expect(img).toHaveAttribute("src", testImage);
});

test("이미지가 없을 경우 대체 텍스트를 표시한다.", () => {
render(<Avatar alt="대체 텍스트" />);

expect(screen.getByText("대체 텍스트")).toBeInTheDocument();
});

test("이미지 로드 실패 시 fallback을 표시한다.", () => {
render(
<Avatar
src="broken-link"
fallback="https://randomuser.me/api/portraits/women/1.jpg"
/>
);

const img = screen.getByRole("img");
img.dispatchEvent(new Event("error"));

expect(img).toHaveAttribute(
"src",
"https://randomuser.me/api/portraits/women/1.jpg"
);
});

describe("Avatar 컴포넌트", () => {
const sizes: { size: AvatarSize; expectedSize: string }[] = [
{ size: "xs", expectedSize: "24px" },
{ size: "sm", expectedSize: "32px" },
{ size: "md", expectedSize: "40px" },
{ size: "lg", expectedSize: "70px" },
{ size: "xl", expectedSize: "96px" },
];

const shapes: { shape: AvatarShape; expectedRadius: string }[] = [
{ shape: "circle", expectedRadius: "50%" },
{ shape: "rounded", expectedRadius: "4px" },
{ shape: "square", expectedRadius: "0px" },
];

it.each(sizes)(
"size가 $size일때 $expectedSize x $expectedSize 크기로 렌더링 된다.",
({ size, expectedSize }) => {
render(<Avatar src={testImage} size={size} />);
const container = screen.getByRole("img").parentElement;
expect(container).toHaveStyle({
width: expectedSize,
height: expectedSize,
});
}
);

it.each(shapes)(
"shape가 $shape일때 borderRadius는 $expectedRadius로 나타난다.",
({ shape, expectedRadius }) => {
render(<Avatar src={testImage} shape={shape} />);
const container = screen.getByRole("img").parentElement;
expect(container).toHaveStyle({ borderRadius: expectedRadius });
}
);
});
111 changes: 111 additions & 0 deletions packages/avatar/src/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Slot } from "@radix-ui/react-slot";
import { clsx as cx } from "clsx";
import {
type CSSProperties,
type ComponentProps,
type ForwardedRef,
forwardRef,
} from "react";
import styles from "./Avatar.module.css";

/**
+ * Avatar 컴포넌트의 크기 옵션
+ * @type {AvatarSize}
+ * - xs: 24px
+ * - sm: 32px
+ * - md: 40px (기본값)
+ * - lg: 70px
+ * - xl: 96px
+ */
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";

/**
+ * Avatar 컴포넌트의 모양 옵션
+ * @type {AvatarShape}
+ * - circle: 원형 (50% border-radius)
+ * - rounded: 둥근 모서리 (4px border-radius)
+ * - square: 정사각형 (0px border-radius)
+ */
export type AvatarShape = "circle" | "rounded" | "square";

export interface AvatarProps extends ComponentProps<"div"> {
asChild?: boolean;
src?: string;
alt?: string;
size?: AvatarSize;
shape?: AvatarShape;
fallback?: string;
}

export const Avatar = forwardRef(function Avatar(
{
asChild,
className,
src,
alt,
size = "md",
shape = "circle",
fallback,
...props
}: AvatarProps,
ref: ForwardedRef<any>
) {
const Component = asChild ? Slot : "div";

const style = {
...props.style,
width: getAvatarSize(size),
height: getAvatarSize(size),
borderRadius: getAvatarShape(shape),
} as CSSProperties;

return (
<Component
className={cx(styles.avatar, className)}
ref={ref}
style={style}
{...props}
>
{src ? (
<img
src={src}
alt={alt}
onError={(e) => {
if (fallback) e.currentTarget.src = fallback;
}}
className={styles.image}
/>
) : (
<span className={styles.fallback}>{alt || fallback}</span>
)}
</Component>
);
});

function getAvatarSize(size: AvatarSize) {
switch (size) {
case "xs":
return "24px";
case "sm":
return "32px";
case "md":
return "40px";
case "lg":
return "70px";
case "xl":
return "96px";
default:
return "40px";
}
}

function getAvatarShape(shape: AvatarShape) {
switch (shape) {
case "rounded":
return "4px";
case "square":
return "0px";
default:
return "50%";
}
}
1 change: 1 addition & 0 deletions packages/avatar/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Avatar.tsx';
3 changes: 3 additions & 0 deletions packages/avatar/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
8 changes: 8 additions & 0 deletions packages/avatar/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.ts'],
clean: true,
dts: true,
format: ['esm', 'cjs'],
});
26 changes: 26 additions & 0 deletions packages/avatar/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
// 테스트와 관련한 설정
test: {
// 테스트를 실행할 환경
// default: 'node'
// 브라우저 환경에서 테스트를 희망시 - 'jsdom' 또는 'happy-dom'으로 설정
environment: 'happy-dom',

// 글로벌 API를 사용할지 여부를 선택
// ex) describe, it, expect 등
globals: true,

// 테스트 실행 환경에 필요한 스크립트를 불러올 수 있음
// ex) 모듈 mokcing, matcher extend 등
setupFiles: './vitest.setup.ts',
passWithNoTests: true,
watch: false,
css: true,
},

// 환경별로 설정해주어야하는 추가 기능을 플러그인으로 주입 가능
// ex) vite-tsconfig-paths
plugins: [],
});
4 changes: 4 additions & 0 deletions packages/avatar/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// vitest에서 기본적으로 제공하는 matcher 외에도 DOM 환경에서 유용하게 사용가능한 다양한 matcher를 제공
// ex) expect(foo).toBeInTheDocument();
// 얘가 없으면 Avatar.test.tsx에서 error가 발생함
import '@testing-library/jest-dom';
Loading

0 comments on commit f527341

Please sign in to comment.