Skip to content

[Feat] #90 - 공통 Progress Indicators 컴포넌트 추가#91

Merged
sebeeeen merged 34 commits into
devfrom
feat/shared-progress
Apr 6, 2026
Merged

[Feat] #90 - 공통 Progress Indicators 컴포넌트 추가#91
sebeeeen merged 34 commits into
devfrom
feat/shared-progress

Conversation

@jaeu5325
Copy link
Copy Markdown
Collaborator

@jaeu5325 jaeu5325 commented Apr 4, 2026

🔎 What is this PR?

공통 inputBox 컴포넌트를 구현하고 Storybook 문서화를 완료


📝 Changes


📸 Screenshots (선택)

스크린샷 2026-04-04 오후 10 24 56 스크린샷 2026-04-04 오후 10 25 16 스크린샷 2026-04-04 오후 10 25 27

📚 Background / Context (선택)


✔ Checklist

  • 코드는 로컬에서 정상적으로 빌드됩니다 (pnpm build)
  • ESLint / Prettier 통과 (pnpm lint)
  • 네이밍/레이어 컨벤션 준수 (camelCase/PascalCase, is·has 불린 접두사, alias 계층 규칙)
  • 관련 문서/주석 반영 (필요 시)
  • 주요 로직에 테스트 또는 검증 완료

🙏 Request

fixes #90

Summary by CodeRabbit

  • New Features

    • Avatar component with multiple sizes, status indicators, and badge support
    • EmptyState component with six predefined variants (no content, no results, error, access denied, coming soon, no notifications)
    • ListItem and Table components with interactive selection and multi-state support
    • Progress indicators: linear progress bar, circular progress, spinner, and skeleton loaders
    • Six new UI icons: Inbox, User, File Text, Bell, Alert Circle, Clock
    • Striped and shimmer animation effects for visual feedback
  • Documentation

    • Added comprehensive Storybook stories for all new UI components

jaeu5325 and others added 30 commits January 28, 2026 14:28
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
isAllChecked 계산식의 빈 배열 케이스를 보정

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@jaeu5325 jaeu5325 requested a review from sebeeeen April 4, 2026 13:27
@jaeu5325 jaeu5325 self-assigned this Apr 4, 2026
@jaeu5325 jaeu5325 added the enhancement New feature or request label Apr 4, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 4, 2026

Warning

Rate limit exceeded

@sebeeeen has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 12 minutes and 23 seconds before requesting another review.

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 12 minutes and 23 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 69c9c66d-e682-4a82-a4d8-875e9ec0039d

📥 Commits

Reviewing files that changed from the base of the PR and between 467f112 and 4c95c61.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • next.config.ts
  • src/shared/ui/avatars/avatars.tsx
  • src/shared/ui/lists/lists.tsx
  • src/shared/ui/progress/progress.stories.tsx
  • tailwind.config.js
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive suite of new UI components (Avatar, AvatarGroup, EmptyState, ListItem, Table, ProgressBar, CircularProgress, Spinner, Skeleton) along with Storybook documentation, icon exports, and supporting Tailwind CSS animations for progress bars and shimmer effects.

Changes

Cohort / File(s) Summary
CSS & Tailwind Configuration
src/index.css, tailwind.config.js
Added Tailwind @layer utilities CSS rules for .bg-stripes, .animate-progress-stripes, and .animate-shimmer with corresponding @keyframes animations, plus theme extensions in Tailwind config for background images and animation keyframes.
Icon Exports
src/shared/ui/icons/index.tsx
Added six new SVG icon component exports: InboxIcon, UserSolidIcon, FileTextIcon, BellIcon, AlertCircleIcon, and ClockIcon, each accepting IconProps and rendering 24×24 scalable SVG elements.
Avatar Components
src/shared/ui/avatars/avatars.tsx, avatars.stories.tsx
Implemented Avatar (renders image, initials, or default icon with optional status/badge overlays) and AvatarGroup (renders multiple avatars with negative spacing and overflow count), plus full Storybook coverage for sizes, variants, status states, and grouping scenarios.
Empty State Components
src/shared/ui/empty_states/empty-states.tsx, empty-states.stories.tsx
Created reusable EmptyState component with variant mapping (no-content, no-results, no-notifications, error, access-denied, coming-soon) and optional primary/secondary action buttons, with comprehensive Storybook stories demonstrating each variant and customization options.
List & Table Components
src/shared/ui/lists/lists.tsx, lists.stories.tsx
Implemented ListItem (row with optional image, text, metadata, checkbox, and icon) and Table (header with optional select-all checkbox, rows with individual checkboxes/click handlers, and empty state display), plus interactive Storybook stories demonstrating selection and state management.
Progress Indicator Components
src/shared/ui/progress/progress.tsx, progress.stories.tsx
Added ProgressBar (horizontal with striping/animation options and label positioning), CircularProgress (SVG-based with optional percentage label), Spinner (animated loading indicator), and Skeleton (shimmer placeholder), with Storybook coverage for all size variants and configurations.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • sebeeeen
  • kimsman06

Poem

🐰 Hop along, new components arrive!
Avatars dance, lists spring to life,
Progress bars stripe and shimmer bright,
Empty states guide through design's height—
A shared library, cohesive and right!

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title describes adding Progress Indicators components (공통 Progress Indicators 컴포넌트 추가), but the actual changes include Avatar, EmptyState, List/Table, and Progress components—significantly broader than the title suggests. Update the title to accurately reflect all major components added, or split the PR into focused changes. Consider a title like '[Feat] #90 - 공통 UI 컴포넌트 추가 (Progress, Avatar, EmptyState, Lists)' or separate into component-specific PRs.
Description check ⚠️ Warning The PR description claims implementation of a shared inputBox component, but the actual changes include Progress Indicators, Avatar, EmptyState, Lists/Tables, and icons—completely mismatched with what was implemented. Rewrite the description to accurately list all implemented components: Progress (ProgressBar, CircularProgress, Spinner, Skeleton), Avatar, AvatarGroup, EmptyState, ListItem, Table, and new icon exports. Fill in the empty Changes section.
Out of Scope Changes check ⚠️ Warning The PR includes multiple out-of-scope components not mentioned in issue #90: Avatar (avatars.tsx/stories.tsx), AvatarGroup, EmptyState (empty-states.tsx/stories.tsx), ListItem and Table (lists.tsx/stories.tsx), and new icons. Only Progress Indicators were requested. Either update the linked issue to include all components being added, or create separate PRs for Avatar, EmptyState, and Lists/Tables components to maintain focused, reviewable changes aligned with issue scope.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #90 requires Progress Indicators components (ProgressBar, CircularProgress, Spinner, Skeleton) with Storybook documentation and Tailwind styling. The PR successfully implements all required progress components, animations (stripes, shimmer), and comprehensive stories.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shared-progress

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (4)
src/shared/ui/progress/progress.tsx (2)

174-189: Consider adding aria-hidden to skeleton loaders.

Skeletons are visual placeholders and shouldn't be announced by screen readers. Adding aria-hidden="true" prevents confusion.

♻️ Proposed fix
   return (
     <div
+      aria-hidden="true"
       className={`relative overflow-hidden bg-[color:var(--color-gray-200,`#E5E5E5`)] ${shapeClasses[shape]} ${className}`}
     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/progress/progress.tsx` around lines 174 - 189, The Skeleton
component renders visual-only placeholders that may be announced by screen
readers; update the outer wrapper in the Skeleton functional component to
include aria-hidden="true" so assistive tech ignores it (modify the div returned
by export const Skeleton to add aria-hidden="true"); ensure this attribute is
applied regardless of shape/className so all skeleton variants are hidden from
AT.

152-164: Add accessibility attributes to Spinner.

The spinner should indicate its purpose to screen readers, especially when used as a loading indicator.

♿ Proposed fix to add accessibility
   return (
     <div
+      role="status"
+      aria-label="Loading"
       className={`rounded-full animate-spin border-[color:var(--color-gray-200,`#E5E5E5`)] border-t-[color:var(--color-primary-800,`#004A9C`)] ${sizeClasses[size]} ${className}`}
-    />
+    >
+      <span className="sr-only">Loading...</span>
+    </div>
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/progress/progress.tsx` around lines 152 - 164, The Spinner
component currently lacks accessibility semantics; update the Spinner function
to include role="status" on the root div and provide screen-reader text (e.g., a
visually-hidden <span> with "Loading…" or an aria-label="Loading") so assistive
tech knows it's a loading indicator; modify the JSX returned by Spinner to
include role="status" and either an inner visually-hidden span with "Loading…"
(use your project's sr-only class) or set aria-label/aria-live appropriately,
keeping existing sizeClasses and className handling intact.
src/shared/ui/avatars/avatars.tsx (2)

126-131: Add error handling for broken image URLs.

If the image fails to load (404, network error), the browser will show a broken image icon instead of gracefully falling back to initials or the default icon.

♻️ Proposed fix to handle image load errors
+import React, { useState } from "react";
-import React from "react";

Then in the component:

 export const Avatar = ({
   ...
 }: AvatarProps): React.ReactElement => {
+  const [imgError, setImgError] = useState(false);
   const isClickable = !!onClick;
   ...
   return (
     <div className={avatarClasses} onClick={onClick}>
-      {src ? (
+      {src && !imgError ? (
         <img
           src={src}
           alt={name || "User avatar"}
           className="w-full h-full object-cover rounded-full"
+          onError={() => setImgError(true)}
         />
       ) : name ? (
🤖 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 126 - 131, The image tag
currently shows a broken icon on load failure — add local state (e.g.,
hasImageError via useState) in the avatars component (avatars.tsx / Avatar or
Avatars component) and change the render condition to use src && !hasImageError;
attach an onError handler to the <img> (onError={() => setHasImageError(true)})
so when loading fails you fall back to rendering initials or the default icon
(use the existing name prop for initials); also reset hasImageError if src prop
changes to support new images.

182-188: Consider scaling the +N indicator text size with avatar size.

The +N indicator uses a fixed text-[14px] regardless of the avatar size prop. For xs or sm avatars, this text may overflow; for 2xl or 3xl, it may appear too small.

♻️ Proposed fix to use dynamic text sizing

Add a size-to-text mapping similar to avatarTextSizeClasses:

+const overflowTextSizeClasses: Record<AvatarSize, string> = {
+  xs: "text-[8px]",
+  sm: "text-[10px]",
+  md: "text-[12px]",
+  lg: "text-[14px]",
+  xl: "text-[18px]",
+  "2xl": "text-[24px]",
+  "3xl": "text-[32px]",
+};

Then update the +N indicator:

-        <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`}
+        <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 ${overflowTextSizeClasses[size]} border-[3px] border-white box-content shadow-[0_2px_8px_rgba(0,0,0,0.08)] flex-shrink-0 z-0`}
🤖 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 182 - 188, The +N indicator
currently uses a fixed "text-[14px]" which can overflow or look small for
different avatar sizes; replace the hardcoded size by deriving a text class from
the existing avatarTextSizeClasses mapping (the same mapping used for avatar
label sizing) and apply that class to the +{hiddenCount} div. Locate the
+{hiddenCount} block in the avatars component (where avatarSizeClasses[size] is
used) and swap the fixed text-[14px] for the dynamic class from
avatarTextSizeClasses[size], ensuring the mapping covers all size keys (xs, sm,
md, lg, xl, 2xl, 3xl) and falls back to a sensible default.
🤖 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 123-125: The avatar div in avatars.tsx becomes interactive when
onClick is provided but lacks keyboard accessibility; update the returned
element (the div using avatarClasses and onClick) to be keyboard-focusable and
operable: when onClick exists add tabIndex={0}, role="button" and an onKeyDown
handler that calls the same onClick for Enter and Space keys (or convert to a
semantic <button> if styling allows), and ensure aria-label or aria-labelledby
is set to describe the avatar for screen readers.

In `@src/shared/ui/empty_states/empty-states.stories.tsx`:
- Around line 38-86: The story handlers in NoContent, NoResults, ErrorState, and
AccessDenied perform side-effects (alert, window.location.reload, history.back)
which must be side-effect free; replace those onClick implementations in the
primaryAction/secondaryAction args with non-destructive stubs (e.g., Storybook
action logger or simple no-op/console.log) so clicking in docs only demonstrates
the component without blocking, reloading, or navigating the preview; update the
onClick values for primaryAction in NoContent, NoResults, ErrorState and both
primaryAction/secondaryAction in AccessDenied accordingly.

In `@src/shared/ui/lists/lists.stories.tsx`:
- Around line 32-40: The story ListItemWithImage uses a remote image URL in its
args (args.imageUrl) which makes Storybook nondeterministic; replace the remote
picsum URL with a checked-in static asset or a data URI and update the story
args accordingly (modify ListItemWithImage.render/args to point to the local
asset or data URI) so the ListItem story uses a deterministic, versioned image.

In `@src/shared/ui/lists/lists.tsx`:
- Around line 241-267: The current list rendering uses only divs
(tableContainerClasses, tableHeaderRowClasses, columns mapping,
hasCheckbox/isAllChecked/onCheckAll) which breaks semantic table relationships
and accessibility; convert the header and body to real table semantics (use
<table>, <thead>, <tbody>, <tr>, <th>, <td> or at minimum add appropriate
role="table"/"rowgroup"/"row"/"columnheader"/"cell" attributes) so screen
readers can associate headers with cells, and ensure each checkbox (the
select-all checkbox and per-row checkboxes rendered alongside rows) has an
accessible name via aria-label or aria-labelledby and proper aria-checked
handling; update column header rendering (where columns.map produces header
cells) to be columnheader/th elements (or role="columnheader") and link them to
cells so assistive tech can determine column relationships.
- Around line 145-166: The interactive area using getListItemClasses with
handleItemClick must be keyboard-accessible and the checkbox must be labeled:
make the wrapper element that currently has onClick behave like a button by
adding tabIndex={0}, role="button", and a keyDown handler that invokes
handleItemClick on Enter/Space; ensure isDisabled prevents focus and activation.
For the checkbox (hasCheckbox, onCheck, id, isChecked, isDisabled,
handleCheckClick) provide an accessible label—either wrap the input in a <label>
that includes visible or visually-hidden text describing the item (e.g., using
the list item title or id) or add aria-label/aria-labelledby referencing the
item text—so screen readers know what the checkbox toggles, and keep the
existing e.stopPropagation() to avoid bubbling.
- Around line 91-112: getListItemClasses and getTableRowClasses currently always
add interactive classes (cursor-pointer, hover, group) even when no click
handler exists; update each function to accept an additional boolean flag
indicating whether a click handler is provided (e.g., hasOnClick for
getListItemClasses and hasOnRowClick for getTableRowClasses) and only append the
interactive classes when isDisabled is false AND the new handler flag is true;
keep the active/selected styles unchanged but remove cursor-pointer/hover/group
from the non-interactive branch so read-only items/rows no longer appear
clickable.

In `@src/shared/ui/progress/progress.tsx`:
- Around line 87-142: The CircularProgress component lacks ARIA attributes;
update the component (CircularProgress) to add accessibility attributes on the
wrapper (the outer div returned): set role="progressbar", aria-valuemin="0",
aria-valuemax="100", and aria-valuenow={clampedValue}; also accept an optional
ariaLabel prop (or default to a sensible string like "Progress") and add it as
aria-label or use aria-labelledby if a visible label exists (respecting
showLabel) so screen readers get a descriptive name for the progress indicator.
- Around line 16-75: The ProgressBar component lacks ARIA semantics; update the
ProgressBar JSX so the track or fill element exposes role="progressbar" with
aria-valuemin="0", aria-valuemax="100" and aria-valuenow={clampedValue} and
include either an aria-label prop or aria-labelledby that references the top
label when labelPosition === "top" (generate a stable id if needed); ensure the
accessible name reflects the purpose (e.g., "진행률" or a passed-in label) and keep
these attributes on the element that represents the progress value (refer to
ProgressBar, clampedValue, labelPosition, and fillClasses).

---

Nitpick comments:
In `@src/shared/ui/avatars/avatars.tsx`:
- Around line 126-131: The image tag currently shows a broken icon on load
failure — add local state (e.g., hasImageError via useState) in the avatars
component (avatars.tsx / Avatar or Avatars component) and change the render
condition to use src && !hasImageError; attach an onError handler to the <img>
(onError={() => setHasImageError(true)}) so when loading fails you fall back to
rendering initials or the default icon (use the existing name prop for
initials); also reset hasImageError if src prop changes to support new images.
- Around line 182-188: The +N indicator currently uses a fixed "text-[14px]"
which can overflow or look small for different avatar sizes; replace the
hardcoded size by deriving a text class from the existing avatarTextSizeClasses
mapping (the same mapping used for avatar label sizing) and apply that class to
the +{hiddenCount} div. Locate the +{hiddenCount} block in the avatars component
(where avatarSizeClasses[size] is used) and swap the fixed text-[14px] for the
dynamic class from avatarTextSizeClasses[size], ensuring the mapping covers all
size keys (xs, sm, md, lg, xl, 2xl, 3xl) and falls back to a sensible default.

In `@src/shared/ui/progress/progress.tsx`:
- Around line 174-189: The Skeleton component renders visual-only placeholders
that may be announced by screen readers; update the outer wrapper in the
Skeleton functional component to include aria-hidden="true" so assistive tech
ignores it (modify the div returned by export const Skeleton to add
aria-hidden="true"); ensure this attribute is applied regardless of
shape/className so all skeleton variants are hidden from AT.
- Around line 152-164: The Spinner component currently lacks accessibility
semantics; update the Spinner function to include role="status" on the root div
and provide screen-reader text (e.g., a visually-hidden <span> with "Loading…"
or an aria-label="Loading") so assistive tech knows it's a loading indicator;
modify the JSX returned by Spinner to include role="status" and either an inner
visually-hidden span with "Loading…" (use your project's sr-only class) or set
aria-label/aria-live appropriately, keeping existing sizeClasses and className
handling intact.
🪄 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: 54d2e72d-c40d-4146-944a-c8ca71a9e838

📥 Commits

Reviewing files that changed from the base of the PR and between 37630fd and 467f112.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • src/index.css
  • src/shared/ui/avatars/avatars.stories.tsx
  • src/shared/ui/avatars/avatars.tsx
  • src/shared/ui/empty_states/empty-states.stories.tsx
  • src/shared/ui/empty_states/empty-states.tsx
  • src/shared/ui/icons/index.tsx
  • src/shared/ui/lists/lists.stories.tsx
  • src/shared/ui/lists/lists.tsx
  • src/shared/ui/progress/progress.stories.tsx
  • src/shared/ui/progress/progress.tsx
  • tailwind.config.js

Comment on lines +123 to +125
return (
<div className={avatarClasses} onClick={onClick}>
{/* 1. 이미지 우선 렌더링 */}
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

Clickable avatar lacks keyboard accessibility.

When onClick is provided, the avatar becomes interactive but is not keyboard-accessible. Users navigating with a keyboard cannot focus or activate it.

♿ Proposed fix to add keyboard accessibility
-  return (
-    <div className={avatarClasses} onClick={onClick}>
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (onClick && (e.key === "Enter" || e.key === " ")) {
+      e.preventDefault();
+      onClick();
+    }
+  };
+
+  return (
+    <div
+      className={avatarClasses}
+      onClick={onClick}
+      onKeyDown={handleKeyDown}
+      tabIndex={isClickable ? 0 : undefined}
+      role={isClickable ? "button" : 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 div in
avatars.tsx becomes interactive when onClick is provided but lacks keyboard
accessibility; update the returned element (the div using avatarClasses and
onClick) to be keyboard-focusable and operable: when onClick exists add
tabIndex={0}, role="button" and an onKeyDown handler that calls the same onClick
for Enter and Space keys (or convert to a semantic <button> if styling allows),
and ensure aria-label or aria-labelledby is set to describe the avatar for
screen readers.

Comment on lines +38 to +86
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("홈으로 이동합니다."),
},
},
};
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

Keep story actions side-effect free.

These handlers currently call alert, window.location.reload(), and history.back(). Clicking the docs will block, reload, or navigate the preview instead of just demonstrating the component.

🤖 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 - 86,
The story handlers in NoContent, NoResults, ErrorState, and AccessDenied perform
side-effects (alert, window.location.reload, history.back) which must be
side-effect free; replace those onClick implementations in the
primaryAction/secondaryAction args with non-destructive stubs (e.g., Storybook
action logger or simple no-op/console.log) so clicking in docs only demonstrates
the component without blocking, reloading, or navigating the preview; update the
onClick values for primaryAction in NoContent, NoResults, ErrorState and both
primaryAction/secondaryAction in AccessDenied accordingly.

Comment on lines +32 to +40
export const ListItemWithImage: StoryObj<typeof ListItem> = {
render: (args) => <ListItem {...args} />,
args: {
id: "item-2",
title: "프로필 정보 업데이트",
description: "새로운 이미지와 프로필 상세 정보를 업데이트 하세요.",
imageUrl: "https://picsum.photos/100/100", // 더미 이미지
hasArrow: true,
},
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

Avoid remote assets in Storybook.

https://picsum.photos/... makes this story nondeterministic and can fail in offline or CI environments. A checked-in asset or data URI will keep the visual docs stable.

🤖 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 32 - 40, The story
ListItemWithImage uses a remote image URL in its args (args.imageUrl) which
makes Storybook nondeterministic; replace the remote picsum URL with a
checked-in static asset or a data URI and update the story args accordingly
(modify ListItemWithImage.render/args to point to the local asset or data URI)
so the ListItem story uses a deterministic, versioned image.

Comment on lines +91 to +112
const getListItemClasses = (isActive: boolean, isDisabled: boolean): string => {
if (isDisabled) {
return [listItemBaseClasses, "bg-white opacity-50 cursor-not-allowed"].join(" ");
}

const stateClasses = isActive
? "bg-[color:var(--color-primary-50,#F0F6FD)] border-[color:var(--color-primary-200,#B3D1F7)]"
: "bg-white hover:bg-[color:var(--color-gray-50,#F9F9F9)] cursor-pointer group";

return [listItemBaseClasses, stateClasses].join(" ");
};

const getTableRowClasses = (isSelected: boolean, isDisabled: boolean): string => {
if (isDisabled) {
return [tableRowBaseClasses, "bg-white opacity-50 cursor-not-allowed"].join(" ");
}

const stateClasses = isSelected
? "bg-[color:var(--color-primary-50,#F0F6FD)] border-[color:var(--color-primary-200,#B3D1F7)]"
: "bg-white hover:bg-[color:var(--color-gray-50,#F9F9F9)] cursor-pointer";

return [tableRowBaseClasses, stateClasses].join(" ");
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

Only show interactive affordances when a handler exists.

onClick and onRowClick are optional, but the class builders always add cursor-pointer, hover styles, and group. Read-only items and rows therefore look clickable even when nothing happens.

Also applies to: 145-145, 295-296

🤖 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 91 - 112, getListItemClasses and
getTableRowClasses currently always add interactive classes (cursor-pointer,
hover, group) even when no click handler exists; update each function to accept
an additional boolean flag indicating whether a click handler is provided (e.g.,
hasOnClick for getListItemClasses and hasOnRowClick for getTableRowClasses) and
only append the interactive classes when isDisabled is false AND the new handler
flag is true; keep the active/selected styles unchanged but remove
cursor-pointer/hover/group from the non-interactive branch so read-only
items/rows no longer appear clickable.

Comment on lines +145 to +166
<div className={getListItemClasses(isActive, isDisabled)} onClick={handleItemClick}>
{/* 체크박스 */}
{hasCheckbox && (
<div
className="flex items-center justify-center w-[20px] h-[20px] flex-shrink-0"
onClick={handleCheckClick}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => {
if (onCheck) {
onCheck(id, !isChecked);
}
}}
onClick={(e) => {
// 부모 <li> 나 <div>에 걸려있는 onClick 이벤트로 버블링되는 것 방지=
e.stopPropagation();
}}
disabled={isDisabled}
className="w-4 h-4 cursor-pointer accent-[color:var(--color-primary-800,#004A9C)]"
/>
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 the interactive ListItem accessible.

When onClick is passed, the action lives on a plain div, so keyboard users can't focus or activate it. The checkbox is also unlabeled, so screen readers don't know what it toggles.

🤖 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 145 - 166, The interactive area
using getListItemClasses with handleItemClick must be keyboard-accessible and
the checkbox must be labeled: make the wrapper element that currently has
onClick behave like a button by adding tabIndex={0}, role="button", and a
keyDown handler that invokes handleItemClick on Enter/Space; ensure isDisabled
prevents focus and activation. For the checkbox (hasCheckbox, onCheck, id,
isChecked, isDisabled, handleCheckClick) provide an accessible label—either wrap
the input in a <label> that includes visible or visually-hidden text describing
the item (e.g., using the list item title or id) or add
aria-label/aria-labelledby referencing the item text—so screen readers know what
the checkbox toggles, and keep the existing e.stopPropagation() to avoid
bubbling.

Comment on lines +241 to +267
<div className={tableContainerClasses}>
{/* Table Header */}
<div className={tableHeaderRowClasses}>
{hasCheckbox && (
<div className={`${columnWidthClasses.checkbox} flex items-center justify-center`}>
<input
type="checkbox"
checked={isAllChecked}
onChange={(e) => onCheckAll && onCheckAll(e.target.checked)}
className="w-4 h-4 cursor-pointer accent-[color:var(--color-primary-800,#004A9C)]"
/>
</div>
)}
{columns.map((col) => (
<div
key={col.id}
className={`flex items-center ${columnWidthClasses[col.width || "fill"]} ${
columnAlignClasses[col.align || "left"]
}`}
>
<span className="text-[14px] font-semibold leading-[20px] text-[color:var(--color-gray-800,#333333)] truncate">
{col.label}
</span>
</div>
))}
</div>

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

Expose real table semantics here.

Headers, rows, and cells are all rendered as divs, so assistive tech loses the column/header relationships entirely. The select-all and row checkboxes also need accessible names.

Also applies to: 293-317

🤖 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 241 - 267, The current list
rendering uses only divs (tableContainerClasses, tableHeaderRowClasses, columns
mapping, hasCheckbox/isAllChecked/onCheckAll) which breaks semantic table
relationships and accessibility; convert the header and body to real table
semantics (use <table>, <thead>, <tbody>, <tr>, <th>, <td> or at minimum add
appropriate role="table"/"rowgroup"/"row"/"columnheader"/"cell" attributes) so
screen readers can associate headers with cells, and ensure each checkbox (the
select-all checkbox and per-row checkboxes rendered alongside rows) has an
accessible name via aria-label or aria-labelledby and proper aria-checked
handling; update column header rendering (where columns.map produces header
cells) to be columnheader/th elements (or role="columnheader") and link them to
cells so assistive tech can determine column relationships.

Comment on lines +16 to +75
export const ProgressBar = ({
value,
variant = "primary",
size = "medium",
hasStripes = false,
isAnimated = false,
labelPosition = "none",
className = "",
}: ProgressBarProps) => {
const clampedValue = Math.min(100, Math.max(0, value));

const variantClasses = {
primary: "bg-[color:var(--color-primary-800,#004A9C)]",
success: "bg-[color:var(--color-success-500,#10A259)]",
warning: "bg-[color:var(--color-warning-500,#F59E0B)]",
error: "bg-[color:var(--color-error-500,#EF4444)]",
};

const sizeClasses = {
thin: "h-[4px] rounded-[2px]",
medium: "h-[8px] rounded-[4px]",
thick: "h-[12px] rounded-[6px]",
};

const fillClasses = [
"h-full rounded-full transition-all duration-300 ease-in-out relative overflow-hidden",
variantClasses[variant],
hasStripes ? "bg-stripes bg-[length:1rem_1rem]" : "", // 👈 bg-[length:1rem_1rem] 추가
isAnimated && hasStripes ? "animate-progress-stripes" : "",
]
.filter(Boolean)
.join(" ");

return (
<div className={`w-full flex flex-col gap-1 ${className}`}>
{/* Top Label */}
{labelPosition === "top" && (
<div className="flex justify-between items-center text-[12px] font-medium text-[color:var(--color-gray-600,#666666)] mb-1">
<span>진행률</span>
<span>{clampedValue}%</span>
</div>
)}

{/* Track */}
<div
className={`w-full bg-[color:var(--color-gray-200,#E5E5E5)] overflow-hidden ${sizeClasses[size]}`}
>
{/* Fill */}
<div className={fillClasses} style={{ width: `${clampedValue}%` }}>
{/* Inside Label */}
{labelPosition === "inside" && size === "thick" && (
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-white">
{clampedValue}%
</div>
)}
</div>
</div>
</div>
);
};
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

Add ARIA attributes for screen reader accessibility.

The ProgressBar lacks semantic accessibility attributes. Screen readers won't announce the progress value or purpose.

♿ Proposed fix to add ARIA attributes
       {/* Track */}
       <div
-        className={`w-full bg-[color:var(--color-gray-200,`#E5E5E5`)] overflow-hidden ${sizeClasses[size]}`}
+        className={`w-full bg-[color:var(--color-gray-200,`#E5E5E5`)] overflow-hidden ${sizeClasses[size]}`}
+        role="progressbar"
+        aria-valuenow={clampedValue}
+        aria-valuemin={0}
+        aria-valuemax={100}
+        aria-label="Progress"
       >
📝 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.

Suggested change
export const ProgressBar = ({
value,
variant = "primary",
size = "medium",
hasStripes = false,
isAnimated = false,
labelPosition = "none",
className = "",
}: ProgressBarProps) => {
const clampedValue = Math.min(100, Math.max(0, value));
const variantClasses = {
primary: "bg-[color:var(--color-primary-800,#004A9C)]",
success: "bg-[color:var(--color-success-500,#10A259)]",
warning: "bg-[color:var(--color-warning-500,#F59E0B)]",
error: "bg-[color:var(--color-error-500,#EF4444)]",
};
const sizeClasses = {
thin: "h-[4px] rounded-[2px]",
medium: "h-[8px] rounded-[4px]",
thick: "h-[12px] rounded-[6px]",
};
const fillClasses = [
"h-full rounded-full transition-all duration-300 ease-in-out relative overflow-hidden",
variantClasses[variant],
hasStripes ? "bg-stripes bg-[length:1rem_1rem]" : "", // 👈 bg-[length:1rem_1rem] 추가
isAnimated && hasStripes ? "animate-progress-stripes" : "",
]
.filter(Boolean)
.join(" ");
return (
<div className={`w-full flex flex-col gap-1 ${className}`}>
{/* Top Label */}
{labelPosition === "top" && (
<div className="flex justify-between items-center text-[12px] font-medium text-[color:var(--color-gray-600,#666666)] mb-1">
<span>진행률</span>
<span>{clampedValue}%</span>
</div>
)}
{/* Track */}
<div
className={`w-full bg-[color:var(--color-gray-200,#E5E5E5)] overflow-hidden ${sizeClasses[size]}`}
>
{/* Fill */}
<div className={fillClasses} style={{ width: `${clampedValue}%` }}>
{/* Inside Label */}
{labelPosition === "inside" && size === "thick" && (
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-white">
{clampedValue}%
</div>
)}
</div>
</div>
</div>
);
};
export const ProgressBar = ({
value,
variant = "primary",
size = "medium",
hasStripes = false,
isAnimated = false,
labelPosition = "none",
className = "",
}: ProgressBarProps) => {
const clampedValue = Math.min(100, Math.max(0, value));
const variantClasses = {
primary: "bg-[color:var(--color-primary-800,`#004A9C`)]",
success: "bg-[color:var(--color-success-500,`#10A259`)]",
warning: "bg-[color:var(--color-warning-500,`#F59E0B`)]",
error: "bg-[color:var(--color-error-500,`#EF4444`)]",
};
const sizeClasses = {
thin: "h-[4px] rounded-[2px]",
medium: "h-[8px] rounded-[4px]",
thick: "h-[12px] rounded-[6px]",
};
const fillClasses = [
"h-full rounded-full transition-all duration-300 ease-in-out relative overflow-hidden",
variantClasses[variant],
hasStripes ? "bg-stripes bg-[length:1rem_1rem]" : "", // 👈 bg-[length:1rem_1rem] 추가
isAnimated && hasStripes ? "animate-progress-stripes" : "",
]
.filter(Boolean)
.join(" ");
return (
<div className={`w-full flex flex-col gap-1 ${className}`}>
{/* Top Label */}
{labelPosition === "top" && (
<div className="flex justify-between items-center text-[12px] font-medium text-[color:var(--color-gray-600,`#666666`)] mb-1">
<span>진행률</span>
<span>{clampedValue}%</span>
</div>
)}
{/* Track */}
<div
className={`w-full bg-[color:var(--color-gray-200,`#E5E5E5`)] overflow-hidden ${sizeClasses[size]}`}
role="progressbar"
aria-valuenow={clampedValue}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Progress"
>
{/* Fill */}
<div className={fillClasses} style={{ width: `${clampedValue}%` }}>
{/* Inside Label */}
{labelPosition === "inside" && size === "thick" && (
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-white">
{clampedValue}%
</div>
)}
</div>
</div>
</div>
);
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/progress/progress.tsx` around lines 16 - 75, The ProgressBar
component lacks ARIA semantics; update the ProgressBar JSX so the track or fill
element exposes role="progressbar" with aria-valuemin="0", aria-valuemax="100"
and aria-valuenow={clampedValue} and include either an aria-label prop or
aria-labelledby that references the top label when labelPosition === "top"
(generate a stable id if needed); ensure the accessible name reflects the
purpose (e.g., "진행률" or a passed-in label) and keep these attributes on the
element that represents the progress value (refer to ProgressBar, clampedValue,
labelPosition, and fillClasses).

Comment on lines +87 to +142
export const CircularProgress = ({
value,
size = "medium",
showLabel = true,
className = "",
}: CircularProgressProps) => {
const clampedValue = Math.min(100, Math.max(0, value));

const sizeConfig = {
small: { svgSize: 32, strokeWidth: 4, fontSize: "text-[12px]" },
medium: { svgSize: 64, strokeWidth: 6, fontSize: "text-[16px]" },
large: { svgSize: 96, strokeWidth: 8, fontSize: "text-[20px]" },
};

const { svgSize, strokeWidth, fontSize } = sizeConfig[size];
const radius = (svgSize - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const dashoffset = circumference - (clampedValue / 100) * circumference;

return (
<div className={`relative inline-flex items-center justify-center ${className}`}>
<svg width={svgSize} height={svgSize} className="-rotate-90">
{/* Background Circle */}
<circle
cx={svgSize / 2}
cy={svgSize / 2}
r={radius}
fill="none"
stroke="var(--color-gray-200, #E5E5E5)"
strokeWidth={strokeWidth}
/>
{/* Progress Circle */}
<circle
cx={svgSize / 2}
cy={svgSize / 2}
r={radius}
fill="none"
stroke="var(--color-primary-800, #004A9C)"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashoffset}
className="transition-all duration-300 ease-in-out"
/>
</svg>
{/* Center Label */}
{showLabel && (
<span
className={`absolute font-bold text-[color:var(--color-gray-900,#1A1A1A)] ${fontSize}`}
>
{clampedValue}%
</span>
)}
</div>
);
};
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

Add ARIA attributes to CircularProgress for accessibility.

Similar to ProgressBar, the CircularProgress should have ARIA attributes for screen reader support.

♿ Proposed fix to add ARIA attributes
-    <div className={`relative inline-flex items-center justify-center ${className}`}>
+    <div
+      className={`relative inline-flex items-center justify-center ${className}`}
+      role="progressbar"
+      aria-valuenow={clampedValue}
+      aria-valuemin={0}
+      aria-valuemax={100}
+    >
📝 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.

Suggested change
export const CircularProgress = ({
value,
size = "medium",
showLabel = true,
className = "",
}: CircularProgressProps) => {
const clampedValue = Math.min(100, Math.max(0, value));
const sizeConfig = {
small: { svgSize: 32, strokeWidth: 4, fontSize: "text-[12px]" },
medium: { svgSize: 64, strokeWidth: 6, fontSize: "text-[16px]" },
large: { svgSize: 96, strokeWidth: 8, fontSize: "text-[20px]" },
};
const { svgSize, strokeWidth, fontSize } = sizeConfig[size];
const radius = (svgSize - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const dashoffset = circumference - (clampedValue / 100) * circumference;
return (
<div className={`relative inline-flex items-center justify-center ${className}`}>
<svg width={svgSize} height={svgSize} className="-rotate-90">
{/* Background Circle */}
<circle
cx={svgSize / 2}
cy={svgSize / 2}
r={radius}
fill="none"
stroke="var(--color-gray-200, #E5E5E5)"
strokeWidth={strokeWidth}
/>
{/* Progress Circle */}
<circle
cx={svgSize / 2}
cy={svgSize / 2}
r={radius}
fill="none"
stroke="var(--color-primary-800, #004A9C)"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashoffset}
className="transition-all duration-300 ease-in-out"
/>
</svg>
{/* Center Label */}
{showLabel && (
<span
className={`absolute font-bold text-[color:var(--color-gray-900,#1A1A1A)] ${fontSize}`}
>
{clampedValue}%
</span>
)}
</div>
);
};
export const CircularProgress = ({
value,
size = "medium",
showLabel = true,
className = "",
}: CircularProgressProps) => {
const clampedValue = Math.min(100, Math.max(0, value));
const sizeConfig = {
small: { svgSize: 32, strokeWidth: 4, fontSize: "text-[12px]" },
medium: { svgSize: 64, strokeWidth: 6, fontSize: "text-[16px]" },
large: { svgSize: 96, strokeWidth: 8, fontSize: "text-[20px]" },
};
const { svgSize, strokeWidth, fontSize } = sizeConfig[size];
const radius = (svgSize - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const dashoffset = circumference - (clampedValue / 100) * circumference;
return (
<div
className={`relative inline-flex items-center justify-center ${className}`}
role="progressbar"
aria-valuenow={clampedValue}
aria-valuemin={0}
aria-valuemax={100}
>
<svg width={svgSize} height={svgSize} className="-rotate-90">
{/* Background Circle */}
<circle
cx={svgSize / 2}
cy={svgSize / 2}
r={radius}
fill="none"
stroke="var(--color-gray-200, `#E5E5E5`)"
strokeWidth={strokeWidth}
/>
{/* Progress Circle */}
<circle
cx={svgSize / 2}
cy={svgSize / 2}
r={radius}
fill="none"
stroke="var(--color-primary-800, `#004A9C`)"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashoffset}
className="transition-all duration-300 ease-in-out"
/>
</svg>
{/* Center Label */}
{showLabel && (
<span
className={`absolute font-bold text-[color:var(--color-gray-900,`#1A1A1A`)] ${fontSize}`}
>
{clampedValue}%
</span>
)}
</div>
);
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/progress/progress.tsx` around lines 87 - 142, The
CircularProgress component lacks ARIA attributes; update the component
(CircularProgress) to add accessibility attributes on the wrapper (the outer div
returned): set role="progressbar", aria-valuemin="0", aria-valuemax="100", and
aria-valuenow={clampedValue}; also accept an optional ariaLabel prop (or default
to a sensible string like "Progress") and add it as aria-label or use
aria-labelledby if a visible label exists (respecting showLabel) so screen
readers get a descriptive name for the progress indicator.

sebeeeen added 2 commits April 6, 2026 22:25
- icons/index.tsx: dev의 20x20 아이콘 시스템으로 통일 (UserSolidIcon, FileTextAltIcon, BellIcon 등)
- tailwind.config.js: dev content 경로 사용 + progress 애니메이션 theme 유지
- avatars/empty-states/lists stories: @storybook/nextjs import로 변경
- empty-states.tsx: FileTextIcon → FileTextAltIcon (dev 네이밍)
- lists.stories.tsx: isAllChecked 한 줄 포매팅 적용
- progress.stories.tsx: @storybook/nextjs import로 변경
- avatars.tsx: img → next/image (fill 방식)
- lists.tsx: img → next/image (width/height 고정)
- next.config.ts: images.unoptimized 추가 (static export 대응)
@sebeeeen sebeeeen merged commit c342616 into dev Apr 6, 2026
1 check passed
@sebeeeen sebeeeen deleted the feat/shared-progress branch May 25, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Progress Indicators 컴포넌트 구현

2 participants