ERf0E^|ha2
zZAW_t*}iRS&CmaE%ts%7P}qw%Z{4o=+Zn#@@xS>Ni5ob!
zj(1!`@WBmqT^)5=RSAnAa_yfirw9*{E<1b?tSI|xpHM{56w{D
z@fr~2I=x>MUM5-nTMMpaQy~hZ-lw0BlWS8qMSAXOPGl1VQvoEP&Jr$2T9{6Ls2=N4
z!6Yk7_xs%>kbFAH2kDnK@&Q2z1iSD12ltUQ!d1h%DyqQ^?5*slhArak=TBpI|6dN~
zuYPsge!;gEXdx)jvY->~ZT=<)CDt?L|6e0uaZ_mj;B&i1&o`~ouh9TCsWVL{+e8Q)
zD-TMq5pb2+zPlE=n?377OZu6^qQ;&QR}K5F>9B|0*J}jKNW9->wEY?t2(Ih&h^5{z
zbJm328Uc9}+gAeW+@EcwkX+2K8G($&vSumh2TH9qVJ8R(olGSg2kIQLk%qWmvmu{f
zCAuINR&3C<0|fy)Itsqzb7$Md1vfH}_CirM?$mszddpJs*uuoe*C>$N~3AX
zkLd0VhLO4W&M0!g44R!gw`sXS+>yaiYd&w?p7qTOZ^*E{^wQ*w;S6P-dwc{fW<(E1
zT(esIP(JWS6(G}NkA16R_@wIlyh^cDfdbS|K0hl6SPLl2dhXeS6@GjE`bJ`DL>KrM#}
z!v5gEKHoWkUcj;b=KbIKlBgWd9r~Uim8z5mAgS@b>IV2Y;7&mZQwJa`t)q;*mUSFD
zNU2ZxjAHSr$wJcdX;VQFHUoe%eb|IJRruRQXQy~_Hg4nZ?urb1EmH0OFjYtjd*~f58PW{&
zfZ%~%VUy#1(OdG*BR|yUI0f0x3s~ki=)7pRUdqaXoI789I1e}SYOP@k~*
zOwX1UPTBh(5?z=t4aQW3EE__(1hlx5qC8I33nq&Rs%P!BPzl(Yx(W@1Iu20KDidCe
z3qMX$>MsEmJfQ0NcRDdOb(6HZhWagI(4ihn%(
z?7A5*)p+mZ`(nCQqXH>BNJ3f0L>@umHXw<5For8zkTpmOAis=!?z%=tT$!%O1Z2hu
zsAqhI3U=*O3-oTSu~mafY>jaSwHH=ms&0xd0UfghM6`dWhKRom#XUpR(s4;jX;Xh6
zYB&z<7ny*W1UAuoo~NBmtGOQ>d|Iiz3qU|v$D}J2$foym&-ef`sq4jUVJ
z0rCY^{reIc$?^Q5gTxf<7+NWKq=ruTEs*tVsMP=KfV<_DPDuz!hWzRi-`!1u0>}3c
z!-t4T1d-BlKxY3d?Y2&0K*maDQ)*Wd>aYnx>{pKbFp|>)d-eOA6ea-jg}G6pAQN>;
z8lyAx%H;GJzn08CG3C>rU`YKSPnaaUhDwG3NcY^|AB;_f7I$BNUrYQFH_G$RelHQn
zOvVlAv@A--p*?4a4^;GgyOjjtlEiO&)+i7*>&Q!?idt&{UiOb)
z8a8V4l><#jvOXbO;w3ZY%vyw5z7kN?)h=#em#h>7T)R*uW3l$0-r`Q@kAGWl=maE3
zWp&i@e%O1cfKGDorAp(H%n4!K0p?CDn~3ruE+7Rv4R2QM1(5KkM_y98&`@8m@U>g}
z^=A)UK+1yfK_xW^43%rz*{FrCPC5Cd5fl)x;P{7ORs(E)hv}Q09qAZH3%GWn7oPm?
z(5&@OkGBKs7t)p<;?}NfLrepBX(z5>cOjUq{=+VFhfApat
zsqv296RgL&uxT~)DM*;||q!`L|g!JyY#Z1)s~fTFv;qfHR8o+hAv@_D5?M2R~j*kg}9DwSkHrE*DD
zz0)Q>Q${A(g+Ke(m-&U8<2QY}MuLbY^-UXVW67c?PHC?Y@u_w`Gv>D#5-ZLrrcmeZu06dy-CD>0h5^VDHCS8@d{(^?5URGj
ztCKrn8v~QC*eB%Am%?&ZNI>z)LqnTqWMx^&&J;F}I0h1l%mi|J$#6erTaUI*Fb*l*&$vNkEKd`}SW8`wv}iwRX#_
zgR{Fh1=ZIZ=VcAUKq~K1T
zB8Q4o2fpF)!RymEg`+V$J6BrME7o;#hXkIk?(J8jBFMzEFj$pL%AgiGwf5S5p%ReR
zxl<-Q?b|cS_4_sxR5KZ2pt56T`Nq|2M^Ur`@9tt)3we#+D$0AJ3`1{04Z8PxmZf09
zvGQHhD~^O)V8163K0GSm?zf;9NDkfaP1Q7w2Hnz6
z?8GSvzhtv)to;8KM}wWLW#Jgz1qQf7(@@yjXmAIQ>x5iGvWfow{t9Pz-R-~#dz<(`
zuv}v*Elq{3jfCQjtxx)?3@K$*>KCxq|DZ5Rd_P3*PKyB4aLXZy4>*@`jZ^c2%8=3w
zwco7%**XHkLy>vI%LWEg7W0oSRU8%2C?=dc*?{_L@7IOpvHBG8fjb{gCxk=NQpJ&h
zw3<%U3qbYCoDavtw5xe!`K9bAk@O3jC^Su5k4e1&*_g2T+#ryTXl4^T2Kq;9{iY@X
zMWUFIw}=lQ0Zr_H3FA4=y^5O!q7GBjPMGBoJdoOd)|lsSyno^Ei~uI2AZ!?vZ0S}b
zjRd!V)C5#EC$h-uaqr)GK*bp`B?a$RE)kZo4Fc%u$<%f7EMMXAw-~dx7G*TUfm>!2cQHG4Pz?OZ~H)hplzB08xT&0y(V45
z4|HiUxv5yFeS>kXy9Uk#VCzL`5|0Crd@47yMeGA4AOaI`N3o<@s)}I?COIF_9g2jD
zSC2%5y2Y_4Ve6Fx;kBhD`E_)N6G-*C2>s2jPEMa8KKqNfP}yh11aJqZeN0?&w}Cb>
zaVrhy47U8ttV*wTaMkM~v=495!eN2hTmMlK0g>4tX3De|POI31x`*P#30w#e(H*4e#x^lf
z6ThpT@(TM658Pnb!U_|o+-FEQn$jDu8tOY7u!)uBg}p;VLsB!$53!t;TrM~0(Cir=
zh3u29ttS#T8-kDzc3&8;w;HkCmX~wW`?*p%t+KX7!t^1?-YzV|_U@>fa1a);86qg#
z;)+RHH3J|NGS9!TsT#r-ya7`p5c%MA#6&(IR!bPIcaB1k8UP9BxUt(JVRCRC4ps6Y
z*c`>zxqwc!TMlF4`Jf~O
zFc+q)H6be!pmG-$mjmt3XNr7)(fp8V@j^LT(l8w0<)TR}=;##0a=j*5>45PB<&~+a
zJ(f*QFhU1|yssq<)c{F{Lg2-q73q}BIE)-dLV*}WA{lbzjv2|E4oX6I&~_t~+Uo!+
zdBl{8pp`s~ja$?{97k?9C=$_Y_@epc4SQ93jmv=(ixl3i+jB;ya@7mm-HC!a8UQ@q
zmHfhiB*+IOULz#zLW?^m3Y9d~K$#QNa@q%@5;c?8lhDiPgYjCvxYJM`)V>X!
zsEbaSxQXb(Ds5E{Qo4lH-8@v@>}2z25Rb)Nd;xWfnT~02GGwT$d1ktZSPl>nL=;_Z
zrAMaZ(74ZDEtRfw;P8|JUf+E#cGbBZb=r=d6VEO>8DpeQP?rPsOc1f2P>;lOQP&e4
zT_SRM^h{J|rS4G7-M*D2h0%IYwO*)e0+yrbHPa)q*@g8EF)*HzQ~9O&(KI_3s%RJj
zx`0$Hu*}g6B`-9JRKza!HeqkqyR@r?h9h7(MCq49_wzjS3-u-$OLpDw`OC3-wCz;U
z&;%?8B;zXEu*ag#FlBVN$}Ag`5K@jgrzo|?SC{XcYy|bHf~Fy0H7;Jf*q80txQ9D1
z`+4MLH_sLfnj$60MkpKS5t*|#qvLk|&RDY$t{P2CKrcXTI|XYnYq3GwVS^cq?n0>;
zCrclX5%w|4`n*cV1hI$+HqTI`zzCb*p6V?BjERCnC#vg}(saiB_Tr
SbWH&O0000;
+
+export default meta;
+type Story = StoryObj;
+
+export const NavBar: Story = {
+ args: {
+ variant: 'navbar',
+ onChangeSelection: (e) => alert(e),
+ },
+};
+
+export const OnOff: Story = {
+ args: {
+ variant: 'onOff',
+ onChangeSelection: (e) => alert(e),
+ },
+};
+
+export const MemberCount: Story = {
+ args: {
+ variant: 'memberCount',
+ onChangeSelection: (e) => alert(e),
+ },
+};
+
+export const SortingReview: Story = {
+ args: {
+ variant: 'sortingReview',
+ onChangeSelection: (e) => alert(e),
+ },
+};
diff --git a/src/components/drop-down/DropDown.test.tsx b/src/components/drop-down/DropDown.test.tsx
new file mode 100644
index 00000000..a57727ec
--- /dev/null
+++ b/src/components/drop-down/DropDown.test.tsx
@@ -0,0 +1,76 @@
+import '@testing-library/jest-dom';
+import DropDown from './DropDown';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MENU_ITEMS } from '@/constants';
+
+describe('DropDown variant:navbar 일 때', () => {
+ const mockHandleSelectionChange = jest.fn();
+
+ it('드롭다운 버튼 클릭 이벤트로 드롭다운 메뉴 아이템 렌더링 확인', () => {
+ render(
+ ,
+ );
+ //드롭다운 버튼 렌더링 확인
+ const button = screen.getByRole('button');
+ expect(button).toBeInTheDocument();
+
+ //유저가 드롭다운 클릭 전엔 메뉴 아이템들이 나타나지 않음
+ const menuItems = screen.queryByRole('listbox');
+ expect(menuItems).not.toBeInTheDocument();
+
+ //유저가 드롭다운 클릭 후엔 메뉴 아이템들이 나타남
+ userEvent.click(button);
+ expect(menuItems).toBeInTheDocument;
+ });
+
+ it('드롭다운 메뉴 아이템 클릭 시 해당 아이템 value 값 반환', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ //유저가 드롭다운 클릭 전엔 메뉴 아이템들이 나타나지 않음
+ const menuItems = screen.queryByRole('listbox');
+ expect(menuItems).not.toBeInTheDocument();
+
+ //유저가 드롭다운 클릭 후엔 메뉴 아이템들이 나타남
+ userEvent.click(button);
+ expect(menuItems).toBeInTheDocument;
+
+ //유저가 메뉴 아이템의 label을 보고 클릭하면 메뉴 아이템의 value값과 handle 함수를 호출
+ const menuItem = screen.getByText(MENU_ITEMS['navbar'][0].label);
+ await user.click(menuItem);
+
+ expect(mockHandleSelectionChange).toHaveBeenCalledWith(
+ MENU_ITEMS['navbar'][0].value,
+ );
+ });
+
+ it('드롭다운 외부 영역을 클릭했을 때 드롭다운 메뉴가 닫히는지 확인', async () => {
+ const user = userEvent.setup();
+ render(
+ <>
+
+ 드롭다운 외부 영역
+ >,
+ );
+
+ const button = screen.getByRole('button');
+ user.click(button);
+ expect(button).toBeInTheDocument();
+
+ user.click(screen.getByText('드롭다운 외부 영역'));
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/drop-down/DropDown.tsx b/src/components/drop-down/DropDown.tsx
new file mode 100644
index 00000000..28b2c6ef
--- /dev/null
+++ b/src/components/drop-down/DropDown.tsx
@@ -0,0 +1,94 @@
+import React, { useRef, useState } from 'react';
+import { IcDropDown } from '../../../public/icons';
+import Avatar from '../avatar/Avatar';
+import useDropDownClose from './hooks/useDropDownClose';
+import { MENU_ITEMS, DROPDOWN_LABELS } from '@/constants/index';
+
+interface DropDownProps {
+ variant: 'navbar' | 'onOff' | 'memberCount' | 'sortingReview';
+ onChangeSelection: (selectedLabel: string | undefined) => void;
+ imgSrc?: string;
+}
+
+interface DropDownItem {
+ label: string;
+ value: string;
+}
+
+function DropDown({ variant, imgSrc, onChangeSelection }: DropDownProps) {
+ const dropDownRef = useRef(null);
+ const [isOpen, setIsOpen] = useDropDownClose(dropDownRef, false);
+ const [isActive, setIsActive] = useState(false);
+ const [selectedLabel, setSelectedLabel] = useState(
+ DROPDOWN_LABELS[variant],
+ );
+
+ const items = MENU_ITEMS[variant];
+
+ const onClickDropDownItem = (item: DropDownItem): void => {
+ setIsActive(true);
+ setSelectedLabel(item.label);
+
+ if (onChangeSelection) {
+ onChangeSelection(item.value);
+ }
+ setIsOpen(false);
+ };
+
+ const renderButton = (variant: string, isActive: boolean) => {
+ const colorClass = isActive
+ ? 'border-green-normal-01 text-green-normal-01'
+ : 'border-gray-normal-02 text-gray-dark-02';
+
+ switch (variant) {
+ case 'navbar':
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ };
+
+ return (
+
+ {renderButton(variant, isActive)}
+
+
+ {items.map((item) => (
+ - onClickDropDownItem(item)}
+ key={item.value}
+ className={`flex h-[40px] w-full items-center justify-start bg-gray-white px-[16px] py-[10px] text-sm font-medium text-gray-dark-01 first:rounded-t-xl last:rounded-b-xl hover:bg-gray-light-02 hover:font-semibold hover:text-gray-darker`}
+ >
+ {item.label}
+
+ ))}
+
+
+ );
+}
+
+export default DropDown;
diff --git a/src/components/drop-down/hooks/useDropDownClose.ts b/src/components/drop-down/hooks/useDropDownClose.ts
new file mode 100644
index 00000000..125022a8
--- /dev/null
+++ b/src/components/drop-down/hooks/useDropDownClose.ts
@@ -0,0 +1,27 @@
+import { useEffect, useState } from 'react';
+
+const useDropDownClose = (
+ ref: React.RefObject,
+ initialState: boolean,
+) => {
+ const [isOpen, setIsOpen] = useState(initialState);
+
+ useEffect(() => {
+ const onClickPage = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ window.addEventListener('click', onClickPage);
+ }
+
+ return () => {
+ window.removeEventListener('click', onClickPage);
+ };
+ }, [isOpen, ref]);
+ return [isOpen, setIsOpen] as const;
+};
+
+export default useDropDownClose;
diff --git a/src/constants/dropdown.ts b/src/constants/dropdown.ts
new file mode 100644
index 00000000..9a95080d
--- /dev/null
+++ b/src/constants/dropdown.ts
@@ -0,0 +1,33 @@
+export const DROPDOWN_LABELS = {
+ navbar: '',
+ onOff: '온/오프라인',
+ memberCount: '인원수',
+ sortingReview: '최신순',
+} as const;
+
+export const MENU_ITEMS = {
+ navbar: [
+ { value: 'MY_PAGE', label: '마이페이지' },
+ { value: 'LOGOUT', label: '로그아웃' },
+ ],
+ onOff: [
+ { value: 'TOTAL', label: '온/오프라인' },
+ { value: 'ONLINE', label: '온라인' },
+ { value: 'OFFLINE', label: '오프라인' },
+ ],
+ memberCount: [
+ { value: 'TOTAL', label: '전체' },
+ { value: 'TWO_FOUR', label: '2~4명' },
+ { value: 'FIVE_SEVEN', label: '5~7명' },
+ { value: 'EIGHT_TEN', label: '8~10명' },
+ { value: 'OVER_ELEVEN', label: '11명 이상' },
+ ],
+ sortingReview: [
+ { value: 'LATEST', label: '최신순' },
+ { value: 'HIGHEST', label: '리뷰높은순' },
+ { value: 'LOWEST', label: '리뷰낮은순' },
+ ],
+} as const;
+
+export type DropDownLabels = keyof typeof DROPDOWN_LABELS;
+export type MenuItems = keyof typeof MENU_ITEMS;
diff --git a/src/constants/index.ts b/src/constants/index.ts
index ac7445d5..12491aac 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -1,2 +1,3 @@
export * from './avatar';
export * from './navigation';
+export * from './dropdown';
From a002fdb8a6a394ea83b5233113055ebe4f76e790 Mon Sep 17 00:00:00 2001
From: Sungu Kim <108677235+haegu97@users.noreply.github.com>
Date: Tue, 10 Dec 2024 14:56:18 +0900
Subject: [PATCH 23/47] =?UTF-8?q?=E2=9C=A8[Feat]=20Tab=20=EC=BB=B4?=
=?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20#107=20(#111?=
=?UTF-8?q?)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* ✨[Feat] Tab 컴포넌트 구현 #107
* ✅[Test] Tab 컴포넌트 테스트 코드 작성 #107
* ✅[Test] Tab 컴포넌트 storybook 추가 #107
* ♻️[Refactor] TabType 메인탭, 서브탭 두개로 수정 #107
---
src/components/tab/Tab.stories.tsx | 46 +++++++++++++++++
src/components/tab/Tab.test.tsx | 81 ++++++++++++++++++++++++++++++
src/components/tab/Tab.tsx | 44 ++++++++++++++++
src/constants/index.ts | 1 +
src/constants/tabs.ts | 12 +++++
5 files changed, 184 insertions(+)
create mode 100644 src/components/tab/Tab.stories.tsx
create mode 100644 src/components/tab/Tab.test.tsx
create mode 100644 src/components/tab/Tab.tsx
create mode 100644 src/constants/tabs.ts
diff --git a/src/components/tab/Tab.stories.tsx b/src/components/tab/Tab.stories.tsx
new file mode 100644
index 00000000..2e05f3c1
--- /dev/null
+++ b/src/components/tab/Tab.stories.tsx
@@ -0,0 +1,46 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import Tab from './Tab';
+import { BOOK_TABS, CONTENT_TABS, MY_PAGE_TABS } from '@/constants/tabs';
+
+const meta = {
+ title: 'Components/Tab',
+ component: Tab,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ tabType: {
+ control: 'select',
+ options: ['MAIN_TAB', 'SUB_TAB'],
+ description: '탭의 종류를 선택합니다',
+ },
+ items: {
+ control: 'select',
+ options: [BOOK_TABS, CONTENT_TABS, MY_PAGE_TABS],
+ mapping: {
+ BOOK_TABS: BOOK_TABS,
+ CONTENT_TABS: CONTENT_TABS,
+ MY_PAGE_TABS: MY_PAGE_TABS,
+ },
+ description: '탭 아이템 목록',
+ },
+ activeTab: {
+ control: 'select',
+ options: [...BOOK_TABS, ...CONTENT_TABS, ...MY_PAGE_TABS],
+ description: '현재 선택된 탭',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ items: BOOK_TABS,
+ activeTab: '전체',
+ onTabChange: (tab) => console.log(`Selected tab: ${tab}`),
+ tabType: 'MAIN_TAB',
+ },
+};
diff --git a/src/components/tab/Tab.test.tsx b/src/components/tab/Tab.test.tsx
new file mode 100644
index 00000000..448b641f
--- /dev/null
+++ b/src/components/tab/Tab.test.tsx
@@ -0,0 +1,81 @@
+import '@testing-library/jest-dom';
+import { render, screen, fireEvent } from '@testing-library/react';
+import Tab from './Tab';
+
+describe('Tab 컴포넌트', () => {
+ const mockOnTabChange = jest.fn();
+ const items = ['탭1', '탭2', '탭3'] as const;
+
+ beforeEach(() => {
+ mockOnTabChange.mockClear();
+ });
+
+ it('모든 탭 아이템이 렌더링되어야 한다', () => {
+ render(
+ ,
+ );
+
+ items.forEach((item) => {
+ expect(screen.getByText(item)).toBeInTheDocument();
+ });
+ });
+
+ it('활성화된 탭은 올바른 스타일이 적용되어야 한다', () => {
+ render(
+ ,
+ );
+
+ const activeTab = screen.getByText('탭1');
+ expect(activeTab).toHaveClass('border-green-dark-01', 'text-green-dark-01');
+ });
+
+ it('탭 클릭시 onTabChange가 호출되어야 한다', () => {
+ render(
+ ,
+ );
+
+ const secondTab = screen.getByText('탭2');
+ fireEvent.click(secondTab);
+
+ expect(mockOnTabChange).toHaveBeenCalledWith('탭2');
+ });
+
+ it('tabType에 따라 올바른 텍스트 크기가 적용되어야 한다', () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByText('탭1')).toHaveClass('text-xl');
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText('탭1')).toHaveClass('text-lg');
+ });
+});
diff --git a/src/components/tab/Tab.tsx b/src/components/tab/Tab.tsx
new file mode 100644
index 00000000..642adc38
--- /dev/null
+++ b/src/components/tab/Tab.tsx
@@ -0,0 +1,44 @@
+import { TabType } from '@/constants/tabs';
+
+interface TabProps {
+ items: readonly T[];
+ activeTab: T;
+ onTabChange: (tab: T) => void;
+ tabType: TabType;
+}
+
+function Tab({
+ items,
+ activeTab,
+ onTabChange,
+ tabType,
+}: TabProps) {
+ const getTabStyle = () => {
+ switch (tabType) {
+ case 'MAIN_TAB':
+ return 'text-xl';
+ case 'SUB_TAB':
+ return 'text-lg';
+ }
+ };
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
+
+export default Tab;
diff --git a/src/constants/index.ts b/src/constants/index.ts
index 12491aac..b3d394c1 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -1,3 +1,4 @@
export * from './avatar';
export * from './navigation';
export * from './dropdown';
+export * from './tabs';
diff --git a/src/constants/tabs.ts b/src/constants/tabs.ts
new file mode 100644
index 00000000..c6c70d92
--- /dev/null
+++ b/src/constants/tabs.ts
@@ -0,0 +1,12 @@
+export const BOOK_TABS = ['전체', '자유책', '지정책'] as const;
+export const CONTENT_TABS = ['모임', '교환'] as const;
+export const MY_PAGE_TABS = [
+ '나의 모임',
+ '내가 만든 모임',
+ '나의 리뷰',
+] as const;
+
+export type TabType = 'MAIN_TAB' | 'SUB_TAB';
+export type BookTab = (typeof BOOK_TABS)[number];
+export type ContentTab = (typeof CONTENT_TABS)[number];
+export type MyPageTab = (typeof MY_PAGE_TABS)[number];
From 2ed81882d3f2cbe2e352500d349397d6b7a17ed9 Mon Sep 17 00:00:00 2001
From: Minkyung Kim <97824352+wynter24@users.noreply.github.com>
Date: Tue, 10 Dec 2024 16:32:26 +0900
Subject: [PATCH 24/47] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[Refactor]=20common?=
=?UTF-8?q?=20Button=20UI=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20#99=20(?=
=?UTF-8?q?#110)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* ♻️[Refactor] as const 추가 #99
* 💄[Design] isSubmitting props 추가 및 관련 스타일 추가 #99
* 💄[Design] fillType variant, themeColor 추가 #99
* 🚚[Rename] COLOR_CLASSES 대신COLOR_GROUPS으로 수정 #99
* 💄[Design] 누락된 medium variant 추가 #99
* 💄[Design] lightOutline variant 추가 #99
* ✅[Test] 스토리북에 변경된 디자인 추가 #99
* ✅[Test] 테스트 코드 props 수정 #99
* ♻️[Refactor] DEFAULT_COLOR 제거 및 잘못된 fillType 값 에러 처리 #99
* ♻️[Refactor] constants 폴더로 상수 분리 #99
* ♻️[Refactor] Button 컴포넌트에 props 기본값 추가 #99
---
src/components/button/Button.stories.ts | 23 -------
src/components/button/Button.stories.tsx | 74 ++++++++++++++++++++++
src/components/button/Button.test.tsx | 4 +-
src/components/button/Button.tsx | 81 +++++++++++++++++-------
src/constants/button.ts | 40 ++++++++++++
src/constants/index.ts | 1 +
6 files changed, 176 insertions(+), 47 deletions(-)
delete mode 100644 src/components/button/Button.stories.ts
create mode 100644 src/components/button/Button.stories.tsx
create mode 100644 src/constants/button.ts
diff --git a/src/components/button/Button.stories.ts b/src/components/button/Button.stories.ts
deleted file mode 100644
index 4cc2810b..00000000
--- a/src/components/button/Button.stories.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react';
-import Button from './Button';
-
-const meta: Meta = {
- title: 'Components/Button',
- component: Button,
- parameters: {
- layout: 'centered',
- },
-};
-
-export default meta;
-type Story = StoryObj;
-
-export const DefaultButton: Story = {
- args: {
- text: '기본 버튼',
- size: 'large',
- fillType: 'solid',
- themeColor: 'orange-600',
- disabled: false,
- },
-};
diff --git a/src/components/button/Button.stories.tsx b/src/components/button/Button.stories.tsx
new file mode 100644
index 00000000..4396e2de
--- /dev/null
+++ b/src/components/button/Button.stories.tsx
@@ -0,0 +1,74 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import Button from './Button';
+
+const meta = {
+ title: 'Components/Button',
+ component: Button,
+ parameters: {
+ componentSubtitle: '다양한 스타일과 크기를 지원하는 버튼 컴포넌트',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ text: '확인',
+ size: 'medium',
+ fillType: 'solid',
+ themeColor: 'green-normal-01',
+ },
+};
+
+export const Small: Story = {
+ args: {
+ text: '확인',
+ size: 'small',
+ fillType: 'outline',
+ themeColor: 'green-light-03',
+ },
+};
+
+export const LightSolid: Story = {
+ args: {
+ text: '확인',
+ size: 'modal',
+ fillType: 'lightSolid',
+ themeColor: 'gray-normal-03',
+ },
+};
+
+export const Submitting: Story = {
+ args: {
+ text: '확인',
+ size: 'large',
+ fillType: 'solid',
+ themeColor: 'gray-normal-03',
+ isSubmitting: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: '로그인 및 회원가입에서 사용하는 버튼입니다.',
+ },
+ },
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ text: '확인',
+ size: 'large',
+ fillType: 'solid',
+ themeColor: 'gray-darker',
+ disabled: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: '비활성화된 버튼입니다. 클릭이 불가능한 상태입니다.',
+ },
+ },
+ },
+};
diff --git a/src/components/button/Button.test.tsx b/src/components/button/Button.test.tsx
index aca48259..203d5d9e 100644
--- a/src/components/button/Button.test.tsx
+++ b/src/components/button/Button.test.tsx
@@ -9,7 +9,7 @@ describe('Button', () => {
,
);
@@ -25,7 +25,7 @@ describe('Button', () => {
onClick={handleClick}
size="small"
fillType="solid"
- themeColor="orange-600"
+ themeColor="green-normal-01"
text="제출하기"
/>,
);
diff --git a/src/components/button/Button.tsx b/src/components/button/Button.tsx
index c2d35be7..03cd6523 100644
--- a/src/components/button/Button.tsx
+++ b/src/components/button/Button.tsx
@@ -1,49 +1,86 @@
import React from 'react';
import { twMerge } from 'tailwind-merge';
+import { BASE_CLASSES, COLOR_GROUPS, SIZE } from '@/constants';
interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
text: string;
- size: 'large' | 'small' | 'modal';
- fillType: 'solid' | 'bordered';
- themeColor: 'orange-600' | 'gray-400';
+ size: 'large' | 'medium' | 'small' | 'modal';
+ fillType: 'solid' | 'outline' | 'lightSolid' | 'lightOutline';
+ themeColor:
+ | 'green-normal-01'
+ | 'green-light-03'
+ | 'gray-normal-03'
+ | 'gray-darker';
+ isSubmitting?: boolean;
}
-const SIZE = {
- modal: 'min-w-[120px] h-[44px] px-4 text-base',
- large: 'min-w-[332px] h-[44px] px-4 text-base',
- small: 'min-w-[120px] h-[40px] px-3 text-sm',
-};
-
-const COLOR_STYLES = {
- 'orange-600': ['bg-orange-600', 'text-orange-600', 'border-orange-600'],
- 'gray-400': ['bg-gray-400', 'text-gray-400', 'border-gray-400'],
-};
-
export default function Button({
text,
size,
- fillType,
- themeColor,
+ fillType = 'solid',
+ themeColor = 'green-normal-01',
+ isSubmitting,
...buttonProps
}: ButtonProps) {
const { disabled } = buttonProps;
const sizeClasses = SIZE[size];
const baseClasses = 'rounded-[12px] font-semibold cursor-pointer';
- const variantClasses =
- fillType === 'solid'
- ? `${COLOR_STYLES[themeColor][0]} text-white`
- : `bg-white border ${COLOR_STYLES[themeColor][1]} ${COLOR_STYLES[themeColor][2]}`;
+
+ const resolvedColor =
+ isSubmitting !== undefined
+ ? isSubmitting
+ ? 'gray-normal-03'
+ : 'green-normal-01'
+ : themeColor;
+
+ const variantClasses = (() => {
+ const color = COLOR_GROUPS[resolvedColor];
+
+ type TextClassType =
+ | 'text-green-normal-01'
+ | 'text-gray-darker'
+ | 'text-white';
+
+ let textClass: TextClassType = color.text;
+
+ // 배경색과 글자색이 동일한 경우 textClass를 흰색으로 덮어쓰기
+ if (
+ (fillType === 'lightSolid' || fillType === 'lightOutline') &&
+ color.bg.includes(color.text.replace('text-', 'bg-'))
+ ) {
+ textClass = 'text-white';
+ }
+
+ switch (fillType) {
+ case 'solid':
+ return `${color.bg} ${BASE_CLASSES.solid}`;
+ case 'outline':
+ return `${BASE_CLASSES.outline} ${color.text} ${color.border}`;
+ case 'lightSolid':
+ return `${color.bg} ${textClass}`;
+ case 'lightOutline':
+ return `${BASE_CLASSES.lightOutline} ${color.bg} ${textClass} ${color.border}`;
+ default:
+ throw new Error(`잘못된 fillType 값입니다: ${fillType}`);
+ }
+ })();
+
+ const isButtonDisabled = isSubmitting || disabled;
const buttonClassName = twMerge(
sizeClasses,
baseClasses,
variantClasses,
- disabled && 'cursor-not-allowed',
+ isButtonDisabled && 'cursor-not-allowed',
);
return (
-