Repository: tutti-tutti/tutti-client
Pull request #109: [✨ feat] 프로그레스 바 컴포넌트 제작 (#108)
Author: login: bbjbc
Labels: name: ✨ feat
description: 새로운 기능을 구현하거나 기존 기능을 확장합니다
Created: 2025-03-18T05:42:47Z | Merged: 2025-03-18T07:56:53Z

# 🚀 풀 리퀘스트 제안

## 📋 작업 내용

장바구니나 결제 단계 등 단계를 나타내는 프로그레스 바를 제작했어요.

## 🔧 변경 사항

- `StepProgressBar` 컴포넌트를 제작하고 스토리북을 작성했어요.
- `Icon` 컴포넌트에 `iconProps` 프로퍼티를 추가했어요. 아이콘의 크기가 변경될 때 이를 사용해 변경하면 돼요.
- 이전 PR에서 `Dropdown` 컴포넌트의 스타일을 일부 수정했어요. 

## 📸 스크린샷

스토리북으로 확인바랍니다.

## 📄 기타

추가적으로 전달하고 싶은 내용이나 특별한 요구 사항이 있으면 작성해 주세요.



Diff:
diff --git a/src/components/common/Dropdown.tsx b/src/components/common/Dropdown.tsx
index 384bc65f..1bf1f4f4 100644
--- a/src/components/common/Dropdown.tsx
+++ b/src/components/common/Dropdown.tsx
@@ -61,7 +61,7 @@ const Dropdown = ({
   return (
     <div className="relative w-full" ref={dropdownRef}>
       <div
-        className="border-border-primary py-sm px-lg bg-bg-primary flex cursor-pointer items-center justify-between rounded-sm border"
+        className="border-border-primary py-sm px-lg bg-bg-primary flex cursor-pointer items-center justify-between rounded-md border"
         onClick={toggleDropdown}
       >
         <div className="line-clamp-2 flex-1 overflow-hidden pr-2 break-words">
@@ -86,7 +86,7 @@ const Dropdown = ({
       </div>
 
       {isOpen && (
-        <div className="animate-dropdown bg-bg-tertiary gap-2xs border-border-secondary shadow-custom-effect mt-xs absolute z-10 flex max-h-60 w-full origin-top flex-col overflow-y-auto rounded-sm border transition-all duration-300">
+        <div className="animate-dropdown bg-bg-tertiary gap-2xs border-border-secondary shadow-custom-effect mt-xs absolute z-1 flex max-h-60 w-full origin-top flex-col overflow-y-auto rounded-sm border transition-all duration-300">
           {options.length > 0 &&
             options.map((option, index) => (
               <RadioOption
diff --git a/src/components/common/Icon.tsx b/src/components/common/Icon.tsx
index a740f426..76e79d6b 100644
--- a/src/components/common/Icon.tsx
+++ b/src/components/common/Icon.tsx
@@ -73,12 +73,13 @@ export const IconMap: Record<
 
 interface IconComponentProps extends React.SVGProps<SVGSVGElement> {
   iconName: IconType;
+  iconProps?: React.SVGProps<SVGSVGElement>;
 }
 
-const Icon = ({ iconName, ...props }: IconComponentProps) => {
+const Icon = ({ iconName, iconProps, ...props }: IconComponentProps) => {
   const IconComponent = IconMap[iconName];
 
-  return <IconComponent {...props} />;
+  return <IconComponent {...iconProps} {...props} />;
 };
 
 export default Icon;
diff --git a/src/components/common/StepProgressBar.tsx b/src/components/common/StepProgressBar.tsx
new file mode 100644
index 00000000..83541f08
--- /dev/null
+++ b/src/components/common/StepProgressBar.tsx
@@ -0,0 +1,67 @@
+import { Icon } from '@/components';
+import { cn } from '@/utils';
+
+type Step = string;
+
+interface StepProgressBarProps {
+  currentStep: Step;
+  steps: Step[];
+}
+
+const StepProgressBar = ({ currentStep, steps }: StepProgressBarProps) => {
+  return (
+    <div className="gap-sm flex w-full items-center justify-center">
+      {steps.map((step, index) => {
+        const isActive = currentStep === step;
+        const isLastStep = index === steps.length - 1;
+
+        return (
+          <div key={index} className="gap-sm flex items-center">
+            <div className="gap-sm flex items-center">
+              <div
+                className={cn(
+                  'p-xs flex items-center justify-center rounded-full border-[1.25px] md:block',
+                  isActive
+                    ? 'border-border-primaryInteraction'
+                    : 'border-border-disabled',
+                )}
+              >
+                <Icon
+                  iconName="check"
+                  iconProps={{
+                    color: isActive
+                      ? 'var(--color-icon-primaryInteraction)'
+                      : 'var(--color-icon-tertiary)',
+                  }}
+                />
+              </div>
+              <span
+                className={cn(
+                  'font-style-subHeading',
+                  isActive
+                    ? 'text-text-primaryInteraction'
+                    : 'text-text-disabled',
+                )}
+              >
+                {step}
+              </span>
+            </div>
+
+            {!isLastStep && (
+              <Icon
+                iconName="right"
+                color={
+                  isActive
+                    ? 'var(--color-icon-info)'
+                    : 'var(--color-icon-unchecked)'
+                }
+              />
+            )}
+          </div>
+        );
+      })}
+    </div>
+  );
+};
+
+export default StepProgressBar;
diff --git a/src/components/common/index.ts b/src/components/common/index.ts
index 9a55c270..094f586b 100644
--- a/src/components/common/index.ts
+++ b/src/components/common/index.ts
@@ -12,3 +12,4 @@ export { default as Logo } from './Logo';
 export { default as Dropdown } from './Dropdown';
 export { default as RadioOption } from './RadioOption';
 export { default as Switch } from './Switch';
+export { default as StepProgressBar } from './StepProgressBar';
diff --git a/src/stories/StepProgressBar.stories.ts b/src/stories/StepProgressBar.stories.ts
new file mode 100644
index 00000000..07d502b6
--- /dev/null
+++ b/src/stories/StepProgressBar.stories.ts
@@ -0,0 +1,60 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { StepProgressBar } from '@/components';
+
+const meta: Meta<typeof StepProgressBar> = {
+  title: 'StepProgressBar/StepProgressBar',
+  component: StepProgressBar,
+  parameters: {
+    layout: 'centered',
+  },
+  tags: ['autodocs'],
+  argTypes: {
+    currentStep: {
+      control: 'text',
+      description: '현재 활성화된 단계',
+    },
+    steps: {
+      control: 'object',
+      description: '바에 표시할 단계들',
+    },
+  },
+};
+
+export default meta;
+type Story = StoryObj<typeof StepProgressBar>;
+
+export const Default: Story = {
+  args: {
+    currentStep: '장바구니',
+    steps: ['장바구니', '주문결제', '주문완료'],
+  },
+};
+
+export const SecondStep: Story = {
+  args: {
+    currentStep: '주문결제',
+    steps: ['장바구니', '주문결제', '주문완료'],
+  },
+};
+
+export const LastStep: Story = {
+  args: {
+    currentStep: '주문완료',
+    steps: ['장바구니', '주문결제', '주문완료'],
+  },
+};
+
+export const TwoSteps: Story = {
+  args: {
+    currentStep: '정보 입력',
+    steps: ['정보 입력', '가입 완료'],
+  },
+};
+
+export const CustomSteps: Story = {
+  args: {
+    currentStep: 'Step 2',
+    steps: ['Step 1', 'Step 2', 'Step 3', 'Step 4', 'Step 5'],
+  },
+};


Reviews:
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:03:28Z
user: login: bbjbc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:06:11Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:16:18Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:18:32Z
user: login: bbjbc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:19:38Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:19:50Z
user: login: bbjbc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:21:14Z
user: login: vgotu99
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:26:54Z
user: login: bbjbc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:46:18Z
user: login: goldegg127
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:51:56Z
user: login: goldegg127
body: 고생하셨어요!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:56:30Z

Review comments:
diff_hunk: @@ -73,12 +73,13 @@ export const IconMap: Record<
 
 interface IconComponentProps extends React.SVGProps<SVGSVGElement> {
   iconName: IconType;
+  iconProps?: React.SVGProps<SVGSVGElement>;
 }
 
-const Icon = ({ iconName, ...props }: IconComponentProps) => {
+const Icon = ({ iconName, iconProps, ...props }: IconComponentProps) => {
   const IconComponent = IconMap[iconName];
 
-  return <IconComponent {...props} />;
+  return <IconComponent {...iconProps} {...props} />;
 };
 
 export default Icon;
path: src/components/common/Icon.tsx
user: login: goldegg127
body: Icon 컴포넌트에 icon이 또 들어가는 구조인가요?
혹시 이 경우 인가요? 원형 안에 check 아이콘? 
![image](https://github.com/user-attachments/assets/3166c7c6-05ba-4846-ab8d-e09ff3af3254)

created_at: 2025-03-18T07:03:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,12 +73,13 @@ export const IconMap: Record<
 
 interface IconComponentProps extends React.SVGProps<SVGSVGElement> {
   iconName: IconType;
+  iconProps?: React.SVGProps<SVGSVGElement>;
 }
 
-const Icon = ({ iconName, ...props }: IconComponentProps) => {
+const Icon = ({ iconName, iconProps, ...props }: IconComponentProps) => {
   const IconComponent = IconMap[iconName];
 
-  return <IconComponent {...props} />;
+  return <IconComponent {...iconProps} {...props} />;
 };
 
 export default Icon;
path: src/components/common/Icon.tsx
user: login: bbjbc
body: 아이콘이 또 들어가는 구조가 아니라, Icon 컴포넌트 내에 있는 아이콘의 크기나 색상 등을 변경할 수 있도록 `iconProps`를 추가한겁니다! `iconProps`가 없으면 크기를 조절할 수가 없습니다!
created_at: 2025-03-18T07:06:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+import { Icon, IconButton } from '@/components';
+import { cn } from '@/utils';
+
+type Step = string;
+
+interface StepProgressBarProps {
+  currentStep: Step;
+  steps?: Step[];
+}
+
+const StepProgressBar = ({
+  currentStep,
+  steps = ['장바구니', '주문결제', '주문완료'],
+}: StepProgressBarProps) => {
+  return (
+    <div className="gap-sm flex w-full items-center justify-center">
+      {steps.map((step, index) => {
+        const isActive = currentStep === step;
+        const isLastStep = index === steps.length - 1;
+
+        return (
+          <div key={index} className="gap-sm flex items-center">
+            <div className="gap-sm flex items-center">
+              <IconButton
+                className="hidden md:block"
+                icon="check"
+                variant={isActive ? 'primaryOutline' : 'tertiaryOutline'}
+              />
path: src/components/common/StepProgressBar.tsx
user: login: goldegg127
body: 버튼 역할이 아닌데 IconButton 컴포넌트를 사용하는게 애매하네요..

아이콘이 할 수 있는 역할이 3가지가 있어요.
- 상태 변화로 컬러 값이 변하지만 클릭 이벤트가 필요 없는 경우 -> 상태를 나타내는 역할
- hover 등 이벤트 별로 컬러 값이 변해야하고 클릭 이벤트를 사용하는 경우 -> 버튼 역할
- hover 등 이벤트, 상태 별로 컬러 값이 변해야하고 클릭했을 때 페이지 이동하도록 사용하는 경우 -> Link, 네비게이션 역할

저는 Header 컴포넌트 작업을 할 때 네비게이션과 클릭해야하는 경우 2가지가 생겨서 <Link> 컴포넌트와 <button> 태그를 컴포넌트 내부에서 처리하지 않고 바깥에서 사용했었어요. 이참에 위 3가지 경우에 대해서 어떻게 정의할지 정리가 되면 좋을 거 같아요

```typescript
<nav className="md:gap-xl gap-md flex">
  <Link href={ROUTER_PATH.COUNTRY_SETTING}>
    <NavIcon icon={<NationIcon />} label={PATH_NAME.COUNTRY_SETTING} />
  </Link>
  <Link href={ROUTER_PATH.CART}>
    <NavIcon icon={<CartIcon />} label={PATH_NAME.CART} />
  </Link>
  <Link href={ROUTER_PATH.MY_PAGE}>
    <NavIcon icon={<UserIcon />} label={PATH_NAME.MY_PAGE} />
  </Link>
</nav>
<button
  type="button"
  className="block md:hidden"
  onClick={toggleSearch}
>
  <NavIcon icon={<SearchIcon />} label="검색" />
</button>
``` 

created_at: 2025-03-18T07:16:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+import { Icon, IconButton } from '@/components';
+import { cn } from '@/utils';
+
+type Step = string;
+
+interface StepProgressBarProps {
+  currentStep: Step;
+  steps?: Step[];
+}
+
+const StepProgressBar = ({
+  currentStep,
+  steps = ['장바구니', '주문결제', '주문완료'],
path: src/components/common/StepProgressBar.tsx
user: login: goldegg127
body: 하드 코딩이 조금 걸리긴하지만 아직 사용하는 곳에서 정의가 안돼서 컴포넌트를 사용해서 기능 구현하시는 분이 의견을 주시면 좋을 거 같네요!
created_at: 2025-03-18T07:18:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+import { Icon, IconButton } from '@/components';
+import { cn } from '@/utils';
+
+type Step = string;
+
+interface StepProgressBarProps {
+  currentStep: Step;
+  steps?: Step[];
+}
+
+const StepProgressBar = ({
+  currentStep,
+  steps = ['장바구니', '주문결제', '주문완료'],
+}: StepProgressBarProps) => {
+  return (
+    <div className="gap-sm flex w-full items-center justify-center">
+      {steps.map((step, index) => {
+        const isActive = currentStep === step;
+        const isLastStep = index === steps.length - 1;
+
+        return (
+          <div key={index} className="gap-sm flex items-center">
+            <div className="gap-sm flex items-center">
+              <IconButton
+                className="hidden md:block"
+                icon="check"
+                variant={isActive ? 'primaryOutline' : 'tertiaryOutline'}
+              />
path: src/components/common/StepProgressBar.tsx
user: login: bbjbc
body: `onClick` 속성만 사용하지 않으면 괜찮지 않을까요 ? 이름이 버튼이긴 하지만 ,,
그렇다면 `Icon` 컴포넌트를 통해 `check`를 표시하되 겉에 다른 태그의 스타일링을 통해 원형을 만들고 내부에 `Icon` 컴포넌트를 사용하면 좋을 것 같다는 말씀이시죠?
created_at: 2025-03-18T07:19:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,12 +73,13 @@ export const IconMap: Record<
 
 interface IconComponentProps extends React.SVGProps<SVGSVGElement> {
   iconName: IconType;
+  iconProps?: React.SVGProps<SVGSVGElement>;
 }
 
-const Icon = ({ iconName, ...props }: IconComponentProps) => {
+const Icon = ({ iconName, iconProps, ...props }: IconComponentProps) => {
   const IconComponent = IconMap[iconName];
 
-  return <IconComponent {...props} />;
+  return <IconComponent {...iconProps} {...props} />;
 };
 
 export default Icon;
path: src/components/common/Icon.tsx
user: login: goldegg127
body: 아 그렇군요! interface에서 `iconProps?: React.SVGProps<SVGSVGElement>;` iconProps 타입 정의를 보고 헷갈렸습니다!
created_at: 2025-03-18T07:19:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+import { Icon, IconButton } from '@/components';
+import { cn } from '@/utils';
+
+type Step = string;
+
+interface StepProgressBarProps {
+  currentStep: Step;
+  steps?: Step[];
+}
+
+const StepProgressBar = ({
+  currentStep,
+  steps = ['장바구니', '주문결제', '주문완료'],
path: src/components/common/StepProgressBar.tsx
user: login: bbjbc
body: 저하고 금란님이 사용하실 컴포넌트입니다. 원래는 장바구니쪽에서만 쓰일 줄 알았는데 피그마를 두고 보니 다른 곳에서도 사용해서 후딱 바꾸긴 했는데 초기값을 저렇게 설정한거고 사용하는 쪽에서는 새로 정의해서 사용하면 됩니다!

초기값이 조금 걸린다면 옵셔널처리가 아니라 필수로 하고 사용하는 컴포넌트에서 정의하도록 할까요?
created_at: 2025-03-18T07:21:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+import { Icon, IconButton } from '@/components';
+import { cn } from '@/utils';
+
+type Step = string;
+
+interface StepProgressBarProps {
+  currentStep: Step;
+  steps?: Step[];
+}
+
+const StepProgressBar = ({
+  currentStep,
+  steps = ['장바구니', '주문결제', '주문완료'],
+}: StepProgressBarProps) => {
+  return (
+    <div className="gap-sm flex w-full items-center justify-center">
+      {steps.map((step, index) => {
+        const isActive = currentStep === step;
+        const isLastStep = index === steps.length - 1;
+
+        return (
+          <div key={index} className="gap-sm flex items-center">
+            <div className="gap-sm flex items-center">
+              <IconButton
+                className="hidden md:block"
+                icon="check"
+                variant={isActive ? 'primaryOutline' : 'tertiaryOutline'}
+              />
path: src/components/common/StepProgressBar.tsx
user: login: vgotu99
body: 버튼은 클릭이 가능하다는 거니까 의미 차원에서 아이콘 버튼 컴포넌트 보다는 아이콘 컴포넌트를 이용하는게 좋다고 생각해요!

created_at: 2025-03-18T07:24:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+import { Icon, IconButton } from '@/components';
+import { cn } from '@/utils';
+
+type Step = string;
+
+interface StepProgressBarProps {
+  currentStep: Step;
+  steps?: Step[];
+}
+
+const StepProgressBar = ({
+  currentStep,
+  steps = ['장바구니', '주문결제', '주문완료'],
path: src/components/common/StepProgressBar.tsx
user: login: vgotu99
body: 필수 타입으로 설정하고 각 페이지에서 사용하는 텍스트를 미리 입력해두고 props로 넘어온 값에 따라 매칭되도록 하면 가장 깔끔할 거 같아요!
created_at: 2025-03-18T07:26:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,58 @@
+import { Icon, IconButton } from '@/components';
+import { cn } from '@/utils';
+
+type Step = string;
+
+interface StepProgressBarProps {
+  currentStep: Step;
+  steps?: Step[];
+}
+
+const StepProgressBar = ({
+  currentStep,
+  steps = ['장바구니', '주문결제', '주문완료'],
+}: StepProgressBarProps) => {
+  return (
+    <div className="gap-sm flex w-full items-center justify-center">
+      {steps.map((step, index) => {
+        const isActive = currentStep === step;
+        const isLastStep = index === steps.length - 1;
+
+        return (
+          <div key={index} className="gap-sm flex items-center">
+            <div className="gap-sm flex items-center">
+              <IconButton
+                className="hidden md:block"
+                icon="check"
+                variant={isActive ? 'primaryOutline' : 'tertiaryOutline'}
+              />
path: src/components/common/StepProgressBar.tsx
user: login: bbjbc
body: 그러네요 `Icon` 버튼을 사용할 수 있었는데 `IconButton`을 사용해버렸군요.
created_at: 2025-03-18T07:46:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,7 +5,7 @@ import Button, { type ButtonVariant } from './Button';
 export interface IconButtonProps {
   icon: IconType;
   variant?: ButtonVariant;
-  onClick: () => void;
+  onClick?: () => void;
path: src/components/common/button/IconButton.tsx
user: login: goldegg127
body: Button 컴포넌트 인 이상 onClick prop은 필수값으로 다시 적용하면 좋을거 같습니다!
created_at: 2025-03-18T07:51:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-18T05:45:14Z
body: 🎵Tutti의 storybook 확인: https://67c074b44451fef93aa0663d-navlxhaqzs.chromatic.com/
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-18T07:47:02Z
body: 🎵Tutti의 storybook 확인: https://67c074b44451fef93aa0663d-pcgwnflrqb.chromatic.com/
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-18T07:56:10Z
body: 🎵Tutti의 storybook 확인: https://67c074b44451fef93aa0663d-ylxrcxdpem.chromatic.com/
author_association: NONE