Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
617247a
feat: 공통 inputBox 컴포넌트 구현
jaeu5325 Jan 28, 2026
d4c516a
feat: 코드 컨벤션에 맞춰 수정
jaeu5325 Feb 2, 2026
1baf502
feat: defaultValue 값 오류 수정
jaeu5325 Feb 3, 2026
dd8a233
Update src/shared/ui/inputBox/inputBox.tsx
jaeu5325 Feb 18, 2026
1f1df74
feat/tabs코드오류수정
jaeu5325 Feb 25, 2026
41b0ade
feat : test
jaeu5325 Feb 25, 2026
2c0becf
feat/dev 내용 pull
jaeu5325 Mar 10, 2026
e7a88db
feat/shared-tabs 코드컨벤션 수정
jaeu5325 Mar 11, 2026
57fb44d
feat: tabs수정사항 반영
jaeu5325 Mar 17, 2026
46a1ed1
feat/수정사항 반영
jaeu5325 Mar 21, 2026
16212bd
Merge branch 'dev' of https://github.com/ajou-industry-matching/aim-f…
jaeu5325 Mar 28, 2026
3d9e882
feat: lists & tables 구현 v1
jaeu5325 Mar 28, 2026
407b599
feat: avatars 구현 v1
jaeu5325 Mar 28, 2026
d8a72fd
feat: lists & tables 구현 v2
jaeu5325 Mar 28, 2026
ab8ea0e
Merge branch 'feat/shared-list' of https://github.com/ajou-industry-m…
jaeu5325 Mar 28, 2026
fad55fa
feat: avatars 구현 v2
jaeu5325 Mar 28, 2026
13f1912
feat: emptyStates 구현 v1
jaeu5325 Mar 28, 2026
65a4240
feat: lists & tables 아이콘 수정
jaeu5325 Mar 28, 2026
75c65a2
Merge branch 'feat/shared-list' of https://github.com/ajou-industry-m…
jaeu5325 Mar 28, 2026
120165a
feat: avatars 아이콘 수정
jaeu5325 Mar 28, 2026
b35110c
Merge branch 'feat/shared-avatars' of https://github.com/ajou-industr…
jaeu5325 Mar 28, 2026
fe19606
Update src/shared/ui/lists/lists.stories.tsx
jaeu5325 Mar 28, 2026
f0eb700
Update Storybook import path for emptyStates
jaeu5325 Mar 28, 2026
0b0a672
feat: emptyStates 피드백반영 v1
jaeu5325 Mar 30, 2026
5ec4a0c
feat: emptyStates 피드백반영 v2
jaeu5325 Mar 30, 2026
3934d3e
chore: empty-states.stories.tsx 병합 충돌 해결
jaeu5325 Mar 30, 2026
006b1dd
feat: avatars 수정사항반영
jaeu5325 Mar 31, 2026
519684b
Merge branch 'feat/shared-avatars' of https://github.com/ajou-industr…
jaeu5325 Mar 31, 2026
55039d4
feat: lists 수정사항반영
jaeu5325 Mar 31, 2026
a3b93c9
Merge branch 'feat/shared-list' of https://github.com/ajou-industry-m…
jaeu5325 Mar 31, 2026
d6b6021
Merge branch 'feat/shared-avatars' of https://github.com/ajou-industr…
jaeu5325 Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 107 additions & 0 deletions src/shared/ui/avatars/avatars.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Avatar, AvatarGroup, type AvatarProps } from "./avatars";

const meta = {
title: "Shared/UI/Avatars",
parameters: {
layout: "padded",
componentSubtitle: "유저 프로필 및 다중 유저를 표시하는 아바타 컴포넌트",
},
tags: ["autodocs"],
} satisfies Meta;

export default meta;

// ==========================================
// Avatar Stories
// ==========================================
export const AvatarSizes: StoryObj = {
render: () => (
<div className="flex items-end gap-4">
<Avatar size="xs" name="A" />
<Avatar size="sm" name="B" />
<Avatar size="md" name="C" />
<Avatar size="lg" name="D" />
<Avatar size="xl" name="E" />
<Avatar size="2xl" name="F" />
<Avatar size="3xl" name="G" />
</div>
),
parameters: {
docs: { description: { story: "XS(24px) 부터 3XL(128px) 까지 지원합니다. (기본값: MD 40px)" } },
},
};

export const AvatarVariants: StoryObj = {
render: () => (
<div className="flex gap-6">
<Avatar size="xl" src="https://picsum.photos/200/200" name="김아주" />
<Avatar size="xl" name="김아주" />
<Avatar size="xl" /> {/* src, name 둘 다 없으면 기본 아이콘 */}
</div>
),
parameters: {
docs: {
description: {
story: "이미지, 이니셜 텍스트, 기본 아이콘 순으로 렌더링 우선순위를 가집니다.",
},
},
},
};

export const AvatarStatusDisplay: StoryObj = {
render: () => (
<div className="flex gap-6">
<Avatar size="lg" src="https://picsum.photos/100/100" status="online" />
<Avatar size="lg" name="A" status="away" />
<Avatar size="lg" status="busy" />
<Avatar size="lg" src="https://picsum.photos/101/101" status="offline" />
</div>
),
};

export const AvatarWithBadge: StoryObj<typeof Avatar> = {
render: (args) => <Avatar {...args} />,
args: {
size: "lg",
src: "https://picsum.photos/102/102",
badge: (
<span className="flex items-center justify-center w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full border-2 border-white">
3
</span>
),
},
};

// ==========================================
// Avatar Group Stories
// ==========================================
const mockAvatars: AvatarProps[] = [
{ src: "https://picsum.photos/200/200?random=1", name: "User 1" },
{ src: "https://picsum.photos/200/200?random=2", name: "User 2" },
{ name: "Choi", status: "online" },
{ src: "https://picsum.photos/200/200?random=4", name: "User 4" },
{ src: "https://picsum.photos/200/200?random=5", name: "User 5" },
{ name: "Park" },
{ src: "https://picsum.photos/200/200?random=7", name: "User 7" },
];

export const GroupDefault: StoryObj<typeof AvatarGroup> = {
render: (args) => <AvatarGroup {...args} />,
args: {
avatars: mockAvatars,
maxVisible: 4,
size: "md",
},
};

export const GroupSizes: StoryObj = {
render: () => (
<div className="flex flex-col gap-6">
<AvatarGroup avatars={mockAvatars} size="sm" maxVisible={3} />
<AvatarGroup avatars={mockAvatars} size="md" maxVisible={5} />
<AvatarGroup avatars={mockAvatars} size="xl" maxVisible={4} />
</div>
),
};
191 changes: 191 additions & 0 deletions src/shared/ui/avatars/avatars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React from "react";
// 1. 공통 아이콘 Import 추가
import { UserSolidIcon } from "../icons";

// ----------------------------------------------------------------------
// 타입 정의
// ----------------------------------------------------------------------
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl";
export type AvatarStatus = "online" | "away" | "busy" | "offline";

export type AvatarProps = {
src?: string; // 이미지 URL
name?: string; // 이름 (이미지 없을 시 이니셜 추출용)
size?: AvatarSize;
status?: AvatarStatus;
badge?: React.ReactNode; // 코너 뱃지 (숫자, 닷 등)
hasBorder?: boolean; // 하얀색 테두리 여부
isGrouped?: boolean; // 그룹 내 배치 여부 (테두리 두께 3px 및 z-index 조정을 위함)
className?: string;
onClick?: () => void;
};

export type AvatarGroupProps = {
avatars: AvatarProps[];
maxVisible?: number; // 최대 표시 개수
size?: AvatarSize; // 그룹 전체 일괄 사이즈
className?: string;
};

// ----------------------------------------------------------------------
// 스타일 토큰 (상수)
// ----------------------------------------------------------------------
const avatarBaseClasses =
"relative inline-flex items-center justify-center rounded-full bg-[color:var(--color-primary-800,#004A9C)] text-white font-bold shadow-[0_2px_8px_rgba(0,0,0,0.08)] flex-shrink-0 select-none";

const avatarSizeClasses: Record<AvatarSize, string> = {
xs: "w-[24px] h-[24px]",
sm: "w-[32px] h-[32px]",
md: "w-[40px] h-[40px]",
lg: "w-[48px] h-[48px]",
xl: "w-[64px] h-[64px]",
"2xl": "w-[96px] h-[96px]",
"3xl": "w-[128px] h-[128px]",
};

// 비율에 맞춘 텍스트 사이즈
const avatarTextSizeClasses: Record<AvatarSize, string> = {
xs: "text-[10px]",
sm: "text-[13px]",
md: "text-[16px]",
lg: "text-[19px]",
xl: "text-[26px]",
"2xl": "text-[38px]",
"3xl": "text-[51px]",
};

// 비율에 맞춘 상태 표시기 사이즈
const statusSizeClasses: Record<AvatarSize, string> = {
xs: "w-[5px] h-[5px] border-[1px]",
sm: "w-[6px] h-[6px] border-[1.5px]",
md: "w-[8px] h-[8px] border-[2px]",
lg: "w-[10px] h-[10px] border-[2px]",
xl: "w-[13px] h-[13px] border-[2px]",
"2xl": "w-[19px] h-[19px] border-[3px]",
"3xl": "w-[26px] h-[26px] border-[4px]",
};

const statusColorClasses: Record<AvatarStatus, string> = {
online: "bg-[color:var(--color-success-500,#10A259)]",
away: "bg-[color:var(--color-warning-500,#F59E0B)]",
busy: "bg-[color:var(--color-error-500,#EF4444)]",
offline: "bg-[color:var(--color-gray-400,#999999)]",
};

// ----------------------------------------------------------------------
// 헬퍼 함수
// ----------------------------------------------------------------------
const getInitial = (name: string): string => {
return name.charAt(0).toUpperCase();
};
Comment on lines +78 to +80
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Trim name input before computing/displaying initials.

Whitespace-prefixed names can currently render an empty-looking initial.

✂️ Suggested fix
 const getInitial = (name: string): string => {
-  return name.charAt(0).toUpperCase();
+  return name.trim().charAt(0).toUpperCase();
 };
@@
 }: AvatarProps): React.ReactElement => {
+  const normalizedName = name?.trim();
   const isClickable = !!onClick;
@@
-          alt={name || "User avatar"}
+          alt={normalizedName || "User avatar"}
           className="w-full h-full object-cover rounded-full"
         />
       ) : /* 2. 이미지가 없으면 이름 이니셜 */
-      name ? (
-        <span className={`${avatarTextSizeClasses[size]} tracking-tight`}>{getInitial(name)}</span>
+      normalizedName ? (
+        <span className={`${avatarTextSizeClasses[size]} tracking-tight`}>{getInitial(normalizedName)}</span>
       ) : (

Also applies to: 133-135

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/avatars/avatars.tsx` around lines 78 - 80, Trim the input
before computing initials: update getInitial to call name = name.trim() before
using name.charAt(0).toUpperCase() and return a safe fallback (e.g., empty
string) when the trimmed name is empty; apply the same change to the other
initials helper used around lines 133-135 (e.g., getInitials or any function
that reads name parts) so all initials logic trims whitespace and handles empty
names gracefully.


const getAvatarClasses = (
size: AvatarSize,
hasBorder: boolean,
isGrouped: boolean,
className: string,
): string => {
const base = avatarBaseClasses;
const sizeClass = avatarSizeClasses[size];
const borderClass = isGrouped
? "border-[3px] border-white box-content"
: hasBorder
? "border-[2px] border-white box-content"
: "";

return [base, sizeClass, borderClass, className].filter(Boolean).join(" ");
};

// ----------------------------------------------------------------------
// 컴포넌트 정의
// ----------------------------------------------------------------------

// User Avatar Component
export const Avatar = ({
src,
name,
size = "md",
status,
badge,
hasBorder = false,
isGrouped = false,
className = "",
onClick,
}: AvatarProps): React.ReactElement => {
const isClickable = !!onClick;
const avatarClasses = getAvatarClasses(
size,
hasBorder,
isGrouped,
[className, isClickable ? "cursor-pointer" : ""].join(" "),
);

return (
<div className={avatarClasses} onClick={onClick}>
{/* 1. 이미지 우선 렌더링 */}
Comment on lines +123 to +125
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make clickable avatars keyboard-accessible.

When onClick is set, the wrapper is still a plain div, so keyboard users can’t reliably focus/activate it.

♿ Suggested fix
-    <div className={avatarClasses} onClick={onClick}>
+    <div
+      className={avatarClasses}
+      onClick={onClick}
+      role={isClickable ? "button" : undefined}
+      tabIndex={isClickable ? 0 : undefined}
+      aria-label={isClickable ? (name?.trim() ? `${name.trim()} avatar` : "User avatar") : undefined}
+      onKeyDown={
+        isClickable
+          ? (e) => {
+              if (e.key === "Enter" || e.key === " ") {
+                e.preventDefault();
+                onClick?.();
+              }
+            }
+          : undefined
+      }
+    >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/avatars/avatars.tsx` around lines 123 - 125, The avatar wrapper
currently renders a plain div with props avatarClasses and onClick, which
prevents keyboard users from focusing/activating it; update the wrapper in
avatars.tsx so when onClick is provided it becomes accessible by adding
role="button" and tabIndex={0}, and implement an onKeyDown handler on the same
element that calls the onClick callback when Enter or Space is pressed (also
prevent default for Space), while preserving the existing onClick behavior and
passing through any aria-label/alt text if available for screen readers.

{src ? (
<img
src={src}
alt={name || "User avatar"}
className="w-full h-full object-cover rounded-full"
/>
) : /* 2. 이미지가 없으면 이름 이니셜 */
name ? (
<span className={`${avatarTextSizeClasses[size]} tracking-tight`}>{getInitial(name)}</span>
) : (
/* 3. 이름도 없으면 기본 아이콘 */
<div className="w-full h-full bg-[color:var(--color-gray-200,#E5E5E5)] rounded-full flex items-center justify-center">
<UserSolidIcon className="w-[60%] h-[60%] text-[color:var(--color-gray-500,#808080)]" />
</div>
)}

{/* 상태 표시기 (우측 하단) */}
{status && (
<span
className={`absolute bottom-0 right-0 rounded-full border-white ${statusSizeClasses[size]} ${statusColorClasses[status]} translate-x-[10%] translate-y-[10%]`}
/>
)}

{/* 뱃지 (우측 상단 등, badge prop으로 통째로 받음) */}
{badge && (
<div className="absolute top-0 right-0 translate-x-[20%] -translate-y-[20%]">{badge}</div>
)}
</div>
);
};

// Avatar Group Component
export const AvatarGroup = ({
avatars,
maxVisible = 4,
size = "md",
className = "",
}: AvatarGroupProps): React.ReactElement => {
const safeMaxVisible = Math.max(0, maxVisible);
const visibleAvatars = avatars.slice(0, safeMaxVisible);
const hiddenCount = Math.max(0, avatars.length - safeMaxVisible);

return (
// Gap: -8px (Tailwind -space-x-2)
<div className={`flex flex-row items-center -space-x-2 ${className}`}>
{visibleAvatars.map((avatarProps, index) => (
<div
key={index}
// 왼쪽 아바타가 위로 오도록 z-index 감소 설정
style={{ zIndex: 100 - index }}
>
<Avatar {...avatarProps} size={size} isGrouped />
</div>
))}

{/* +N 표시기 */}
{hiddenCount > 0 && (
<div
className={`${avatarSizeClasses[size]} relative inline-flex items-center justify-center rounded-full bg-[color:var(--color-gray-200,#E5E5E5)] text-[color:var(--color-gray-600,#666666)] font-bold text-[14px] border-[3px] border-white box-content shadow-[0_2px_8px_rgba(0,0,0,0.08)] flex-shrink-0 z-0`}
>
+{hiddenCount}
</div>
)}
</div>
);
};
Loading