[Feat] #74 - 공통 Empty States 컴포넌트 추가#76
Conversation
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…y-matching/aim-frontend into feat/shared-empty_states
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 6 minutes and 7 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThe PR adds multiple new shared UI components including Avatar, AvatarGroup, ListItem, Table, and EmptyState, along with six new icon components (InboxIcon, UserSolidIcon, FileTextIcon, BellIcon, AlertCircleIcon, ClockIcon). Comprehensive Storybook stories document each component with various configurations and interactive examples showcasing sizing, variants, states, and customization options. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
isAllChecked 계산식의 빈 배열 케이스를 보정 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (5)
src/shared/ui/lists/lists.tsx (3)
144-145: Consider adding keyboard accessibility for interactive list items.The
ListItemuses adivwithonClickfor interaction, but lacks keyboard support (role,tabIndex,onKeyDown). Screen reader users and keyboard-only users cannot interact with these items.♿ Proposed accessibility improvement
return ( <div className={getListItemClasses(isActive, isDisabled)} onClick={handleItemClick} + role="button" + tabIndex={isDisabled ? -1 : 0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleItemClick(); + } + }} + aria-disabled={isDisabled} >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/shared/ui/lists/lists.tsx` around lines 144 - 145, The ListItem currently renders an interactive div without keyboard accessibility; update the ListItem component (the element using getListItemClasses(isActive, isDisabled) and handleItemClick) to be keyboard-focusable and screen-reader friendly by adding role="button", tabIndex={isDisabled ? -1 : 0}, and an onKeyDown handler that triggers handleItemClick when Enter or Space is pressed (and ignores input when isDisabled is true); ensure focus/disabled behavior matches existing click behavior and that ARIA state (e.g., aria-pressed or aria-disabled) is applied as appropriate.
284-289: Same keyboard accessibility concern for table rows.Table rows with
onClickshould also support keyboard navigation for accessibility compliance.♿ Proposed accessibility improvement
return ( <div key={row.id} className={getTableRowClasses(isSelected, isDisabled)} onClick={handleRowClick} + role="row" + tabIndex={isDisabled ? -1 : 0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleRowClick(); + } + }} + aria-disabled={isDisabled} >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/shared/ui/lists/lists.tsx` around lines 284 - 289, The table row currently uses an onClick handler (handleRowClick) but lacks keyboard accessibility; update the rendered div (the element with key={row.id} and className={getTableRowClasses(isSelected, isDisabled)}) to be keyboard-focusable and activatable: add tabIndex={isDisabled ? -1 : 0}, a suitable role (e.g., role="row" or "button" depending on semantics), and an onKeyDown handler that calls the same activation logic as handleRowClick when Enter or Space is pressed while ignoring input when isDisabled is true; ensure focus/disabled styling and ARIA states (aria-selected/aria-disabled) mirror the visual state returned by getTableRowClasses.
260-267: Consider reusing theEmptyStatecomponent.The Table's empty state rendering is implemented inline. For consistency across the design system, consider using the shared
EmptyStatecomponent from this PR, potentially with a minimal variant or custom styling.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/shared/ui/lists/lists.tsx` around lines 260 - 267, Replace the inline empty-state div in Lists (the conditional that checks isEmpty || data.length === 0) with the shared EmptyState component: import EmptyState into src/shared/ui/lists/lists.tsx, remove the custom div that renders InboxIcon and the text, and render <EmptyState> (or the minimal/variant prop) passing the InboxIcon (or an icon prop) and the message "데이터가 없습니다" so styling and behavior are consistent across the app; ensure the conditional still uses isEmpty/data and that the new component receives any necessary className or size props to match the previous layout.src/shared/ui/lists/lists.stories.tsx (1)
159-172: Clarify the interaction pattern between row click and checkbox.In
InteractiveTable,handleRowCheck(lines 159-161) toggles individual row selection (multi-select), whilehandleRowClick(lines 169-172) clears all selections and selects only the clicked row (single-select). These conflicting behaviors in the same demo may confuse users exploring the component.Consider adding a comment explaining this is demonstrating two different selection modes, or unify the behavior within this story.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/shared/ui/lists/lists.stories.tsx` around lines 159 - 172, The story mixes two conflicting selection modes: handleRowCheck implements multi-select while handleRowClick enforces single-select; update the story to either unify behavior or clearly document both modes. Option A: make handleRowClick align with multi-select (toggle the clicked row like handleRowCheck does) so clicking and checkbox behave consistently; Option B: keep both behaviors but add a clear comment at the top of the story explaining that handleRowCheck/handleCheckAll demonstrate checkbox multi-select and handleRowClick demonstrates a separate single-select interaction. Reference the functions handleRowCheck, handleCheckAll and handleRowClick when making the change or adding the comment.src/shared/ui/empty_states/emptyStates.tsx (1)
121-127: The.filter(Boolean)is unnecessary here.All values in the array are guaranteed to be non-empty strings, so filtering for truthy values has no effect.
♻️ Suggested simplification
const containerClasses = [ containerBaseClasses, hasBackground ? "bg-[color:var(--color-gray-50,`#F9F9F9`)] rounded-lg" : "bg-transparent", className, - ] - .filter(Boolean) - .join(" "); + ].join(" ");🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/shared/ui/empty_states/emptyStates.tsx` around lines 121 - 127, The array used to build containerClasses unnecessarily calls .filter(Boolean) even though containerBaseClasses, hasBackground branch, and className are always non-empty strings; remove the .filter(Boolean) call and directly join the array created from containerBaseClasses, the hasBackground conditional string, and className (used in the containerClasses declaration) to simplify the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/shared/ui/avatars/avatars.tsx`:
- Around line 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.
- Around line 164-166: Clamp and integer-normalize maxVisible before using it:
ensure maxVisible is floored and constrained to the range [0, avatars.length]
and then compute visibleAvatars and hiddenCount from that clamped value; update
references to visibleAvatars, hiddenCount, avatars, and maxVisible so slicing
and subtraction use the sanitized/clampedMaxVisible to avoid negative or
fractional results.
- Around line 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.
In `@src/shared/ui/empty_states/emptyStates.stories.tsx`:
- Around line 1-2: Change the import source for Storybook types: replace
importing Meta and StoryObj from "@storybook/react-vite" with importing them
from "@storybook/react" in the file that references the Meta and StoryObj types
(used alongside the EmptyState component/EmptyState story definitions), so
TypeScript uses the Storybook 10 CSF3-supported types.
---
Nitpick comments:
In `@src/shared/ui/empty_states/emptyStates.tsx`:
- Around line 121-127: The array used to build containerClasses unnecessarily
calls .filter(Boolean) even though containerBaseClasses, hasBackground branch,
and className are always non-empty strings; remove the .filter(Boolean) call and
directly join the array created from containerBaseClasses, the hasBackground
conditional string, and className (used in the containerClasses declaration) to
simplify the code.
In `@src/shared/ui/lists/lists.stories.tsx`:
- Around line 159-172: The story mixes two conflicting selection modes:
handleRowCheck implements multi-select while handleRowClick enforces
single-select; update the story to either unify behavior or clearly document
both modes. Option A: make handleRowClick align with multi-select (toggle the
clicked row like handleRowCheck does) so clicking and checkbox behave
consistently; Option B: keep both behaviors but add a clear comment at the top
of the story explaining that handleRowCheck/handleCheckAll demonstrate checkbox
multi-select and handleRowClick demonstrates a separate single-select
interaction. Reference the functions handleRowCheck, handleCheckAll and
handleRowClick when making the change or adding the comment.
In `@src/shared/ui/lists/lists.tsx`:
- Around line 144-145: The ListItem currently renders an interactive div without
keyboard accessibility; update the ListItem component (the element using
getListItemClasses(isActive, isDisabled) and handleItemClick) to be
keyboard-focusable and screen-reader friendly by adding role="button",
tabIndex={isDisabled ? -1 : 0}, and an onKeyDown handler that triggers
handleItemClick when Enter or Space is pressed (and ignores input when
isDisabled is true); ensure focus/disabled behavior matches existing click
behavior and that ARIA state (e.g., aria-pressed or aria-disabled) is applied as
appropriate.
- Around line 284-289: The table row currently uses an onClick handler
(handleRowClick) but lacks keyboard accessibility; update the rendered div (the
element with key={row.id} and className={getTableRowClasses(isSelected,
isDisabled)}) to be keyboard-focusable and activatable: add tabIndex={isDisabled
? -1 : 0}, a suitable role (e.g., role="row" or "button" depending on
semantics), and an onKeyDown handler that calls the same activation logic as
handleRowClick when Enter or Space is pressed while ignoring input when
isDisabled is true; ensure focus/disabled styling and ARIA states
(aria-selected/aria-disabled) mirror the visual state returned by
getTableRowClasses.
- Around line 260-267: Replace the inline empty-state div in Lists (the
conditional that checks isEmpty || data.length === 0) with the shared EmptyState
component: import EmptyState into src/shared/ui/lists/lists.tsx, remove the
custom div that renders InboxIcon and the text, and render <EmptyState> (or the
minimal/variant prop) passing the InboxIcon (or an icon prop) and the message
"데이터가 없습니다" so styling and behavior are consistent across the app; ensure the
conditional still uses isEmpty/data and that the new component receives any
necessary className or size props to match the previous layout.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a3836179-d3bf-4e75-b208-20cc16656455
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (7)
src/shared/ui/avatars/avatars.stories.tsxsrc/shared/ui/avatars/avatars.tsxsrc/shared/ui/empty_states/emptyStates.stories.tsxsrc/shared/ui/empty_states/emptyStates.tsxsrc/shared/ui/icons/index.tsxsrc/shared/ui/lists/lists.stories.tsxsrc/shared/ui/lists/lists.tsx
| const getInitial = (name: string): string => { | ||
| return name.charAt(0).toUpperCase(); | ||
| }; |
There was a problem hiding this comment.
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.
| return ( | ||
| <div className={avatarClasses} onClick={onClick}> | ||
| {/* 1. 이미지 우선 렌더링 */} |
There was a problem hiding this comment.
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.
line 1, 2 수정
| // Button/Primary/Large 스타일 모방 (실제 Button 컴포넌트가 있다면 대체 가능) | ||
| const primaryBtnClasses = | ||
| "inline-flex items-center justify-center h-[48px] px-6 rounded-lg bg-[color:var(--color-primary-800,#004A9C)] text-white font-medium text-[16px] transition-colors hover:bg-[color:var(--color-primary-900,#003875)] active:bg-[color:var(--color-primary-950,#002B5E)]"; | ||
| // Button/Ghost 스타일 모방 | ||
| const secondaryBtnClasses = | ||
| "inline-flex items-center justify-center h-[48px] px-6 rounded-lg bg-transparent text-[color:var(--color-gray-600,#666666)] font-medium text-[16px] transition-colors hover:bg-[color:var(--color-gray-100,#F2F2F2)] mt-[12px]"; |
There was a problem hiding this comment.
현재 EmptyState 안에서 버튼 스타일을 따로 만들고 있는데
이미 shared/ui에 공용 Button 컴포넌트가 있어 이를 재사용해주시면 감사하겠습니다~
이렇게 하면 버튼 스타일을 한 곳에서 관리할 수 있어서 유지보수도 더 쉬워질 것 같습니다!
파일명도 컨벤션에서 정한 케밥 케이스와 맞춰 정리 부탁드립니다! |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/shared/ui/empty_states/empty-states.stories.tsx`:
- Around line 38-85: The story handlers for ErrorState and AccessDenied are
using destructive browser APIs (window.location.reload and history.back) which
can disrupt Storybook; update the onClick handlers in the
ErrorState.args.primaryAction and AccessDenied.args.primaryAction to
non-destructive, story-safe alternatives (e.g., show an alert or console.log)
while keeping the labels the same so the Story definitions (ErrorState,
AccessDenied) remain unchanged and interactive without navigating away.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b6cedd18-ac47-4310-9674-cbd6f9f3afd2
📒 Files selected for processing (2)
src/shared/ui/empty_states/empty-states.stories.tsxsrc/shared/ui/empty_states/empty-states.tsx
| export const NoContent: Story = { | ||
| args: { | ||
| variant: "no-content", | ||
| primaryAction: { | ||
| label: "첫 게시물 작성하기", | ||
| onClick: () => alert("게시물 작성 모달 오픈!"), | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const NoResults: Story = { | ||
| args: { | ||
| variant: "no-results", | ||
| primaryAction: { | ||
| label: "검색어 초기화", | ||
| onClick: () => alert("검색어가 초기화되었습니다."), | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const NoNotifications: Story = { | ||
| args: { | ||
| variant: "no-notifications", | ||
| }, | ||
| }; | ||
|
|
||
| export const ErrorState: Story = { | ||
| args: { | ||
| variant: "error", | ||
| primaryAction: { | ||
| label: "다시 시도", | ||
| onClick: () => window.location.reload(), | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const AccessDenied: Story = { | ||
| args: { | ||
| variant: "access-denied", | ||
| primaryAction: { | ||
| label: "이전 페이지로", | ||
| onClick: () => history.back(), | ||
| }, | ||
| secondaryAction: { | ||
| label: "홈으로 이동", | ||
| onClick: () => alert("홈으로 이동합니다."), | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Avoid destructive browser side effects in stories.
Line 69 (window.location.reload()) and Line 79 (history.back()) can kick users out of Storybook (or reset docs) when clicking demo buttons. Keep story handlers non-destructive so docs stay usable.
Proposed story-safe handler changes
export const NoContent: Story = {
args: {
variant: "no-content",
primaryAction: {
label: "첫 게시물 작성하기",
- onClick: () => alert("게시물 작성 모달 오픈!"),
+ onClick: () => {},
},
},
};
@@
export const NoResults: Story = {
args: {
variant: "no-results",
primaryAction: {
label: "검색어 초기화",
- onClick: () => alert("검색어가 초기화되었습니다."),
+ onClick: () => {},
},
},
};
@@
export const ErrorState: Story = {
args: {
variant: "error",
primaryAction: {
label: "다시 시도",
- onClick: () => window.location.reload(),
+ onClick: () => {},
},
},
};
@@
export const AccessDenied: Story = {
args: {
variant: "access-denied",
primaryAction: {
label: "이전 페이지로",
- onClick: () => history.back(),
+ onClick: () => {},
},
secondaryAction: {
label: "홈으로 이동",
- onClick: () => alert("홈으로 이동합니다."),
+ onClick: () => {},
},
},
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const NoContent: Story = { | |
| args: { | |
| variant: "no-content", | |
| primaryAction: { | |
| label: "첫 게시물 작성하기", | |
| onClick: () => alert("게시물 작성 모달 오픈!"), | |
| }, | |
| }, | |
| }; | |
| export const NoResults: Story = { | |
| args: { | |
| variant: "no-results", | |
| primaryAction: { | |
| label: "검색어 초기화", | |
| onClick: () => alert("검색어가 초기화되었습니다."), | |
| }, | |
| }, | |
| }; | |
| export const NoNotifications: Story = { | |
| args: { | |
| variant: "no-notifications", | |
| }, | |
| }; | |
| export const ErrorState: Story = { | |
| args: { | |
| variant: "error", | |
| primaryAction: { | |
| label: "다시 시도", | |
| onClick: () => window.location.reload(), | |
| }, | |
| }, | |
| }; | |
| export const AccessDenied: Story = { | |
| args: { | |
| variant: "access-denied", | |
| primaryAction: { | |
| label: "이전 페이지로", | |
| onClick: () => history.back(), | |
| }, | |
| secondaryAction: { | |
| label: "홈으로 이동", | |
| onClick: () => alert("홈으로 이동합니다."), | |
| }, | |
| }, | |
| export const NoContent: Story = { | |
| args: { | |
| variant: "no-content", | |
| primaryAction: { | |
| label: "첫 게시물 작성하기", | |
| onClick: () => {}, | |
| }, | |
| }, | |
| }; | |
| export const NoResults: Story = { | |
| args: { | |
| variant: "no-results", | |
| primaryAction: { | |
| label: "검색어 초기화", | |
| onClick: () => {}, | |
| }, | |
| }, | |
| }; | |
| export const NoNotifications: Story = { | |
| args: { | |
| variant: "no-notifications", | |
| }, | |
| }; | |
| export const ErrorState: Story = { | |
| args: { | |
| variant: "error", | |
| primaryAction: { | |
| label: "다시 시도", | |
| onClick: () => {}, | |
| }, | |
| }, | |
| }; | |
| export const AccessDenied: Story = { | |
| args: { | |
| variant: "access-denied", | |
| primaryAction: { | |
| label: "이전 페이지로", | |
| onClick: () => {}, | |
| }, | |
| secondaryAction: { | |
| label: "홈으로 이동", | |
| onClick: () => {}, | |
| }, | |
| }, | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/shared/ui/empty_states/empty-states.stories.tsx` around lines 38 - 85,
The story handlers for ErrorState and AccessDenied are using destructive browser
APIs (window.location.reload and history.back) which can disrupt Storybook;
update the onClick handlers in the ErrorState.args.primaryAction and
AccessDenied.args.primaryAction to non-destructive, story-safe alternatives
(e.g., show an alert or console.log) while keeping the labels the same so the
Story definitions (ErrorState, AccessDenied) remain unchanged and interactive
without navigating away.
🔎 What is this PR?
📝 Changes
📸 Screenshots (선택)
📚 Background / Context (선택)
✔ Checklist
pnpm build)pnpm lint)🙏 Request
Summary by CodeRabbit
New Features
Tests