-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] #74 - 공통 Empty States 컴포넌트 추가 #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
617247a
d4c516a
1baf502
dd8a233
1f1df74
41b0ade
2c0becf
e7a88db
57fb44d
46a1ed1
16212bd
3d9e882
407b599
d8a72fd
ab8ea0e
fad55fa
13f1912
65a4240
75c65a2
120165a
b35110c
fe19606
f0eb700
0b0a672
5ec4a0c
3934d3e
006b1dd
519684b
55039d4
a3b93c9
d6b6021
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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> | ||
| ), | ||
| }; |
| 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(); | ||
| }; | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make clickable avatars keyboard-accessible. When ♿ 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 |
||
| {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> | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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