diff --git a/package.json b/package.json index 67bdfc07..afba44da 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@vanilla-extract/sprinkles": "^1.6.1", "axios": "^1.6.2", "clsx": "^2.0.0", + "framer-motion": "^10.18.0", "next": "14.0.3", "react": "^18", "react-dom": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31555464..e3b4a390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: clsx: specifier: ^2.0.0 version: 2.0.0 + framer-motion: + specifier: ^10.18.0 + version: 10.18.0(react-dom@18.2.0)(react@18.2.0) next: specifier: 14.0.3 version: 14.0.3(@babel/core@7.23.5)(react-dom@18.2.0)(react@18.2.0) @@ -1596,6 +1599,20 @@ packages: /@emotion/hash@0.9.1: resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==} + /@emotion/is-prop-valid@0.8.8: + resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} + requiresBuild: true + dependencies: + '@emotion/memoize': 0.7.4 + dev: false + optional: true + + /@emotion/memoize@0.7.4: + resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + requiresBuild: true + dev: false + optional: true + /@emotion/use-insertion-effect-with-fallbacks@1.0.1(react@18.2.0): resolution: {integrity: sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==} peerDependencies: @@ -7819,6 +7836,24 @@ packages: engines: {node: '>= 0.6'} dev: true + /framer-motion@10.18.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-oGlDh1Q1XqYPksuTD/usb0I70hq95OUzmL9+6Zd+Hs4XV0oaISBa/UUMSjYiq6m8EUF32132mOJ8xVZS+I0S6w==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.6.2 + optionalDependencies: + '@emotion/is-prop-valid': 0.8.8 + dev: false + /fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} diff --git a/src/assets/icons/pencil-active.svg b/src/assets/icons/pencil-active.svg new file mode 100644 index 00000000..d06ee955 --- /dev/null +++ b/src/assets/icons/pencil-active.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/pencil-default.svg b/src/assets/icons/pencil-default.svg new file mode 100644 index 00000000..a3f538f5 --- /dev/null +++ b/src/assets/icons/pencil-default.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/template-active.svg b/src/assets/icons/template-active.svg new file mode 100644 index 00000000..fb4341e5 --- /dev/null +++ b/src/assets/icons/template-active.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/template-default.svg b/src/assets/icons/template-default.svg new file mode 100644 index 00000000..a4d3709a --- /dev/null +++ b/src/assets/icons/template-default.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/components/Tabs/Tabs.stories.tsx b/src/components/Tabs/Tabs.stories.tsx index 305698e5..b33eb2ee 100644 --- a/src/components/Tabs/Tabs.stories.tsx +++ b/src/components/Tabs/Tabs.stories.tsx @@ -1,7 +1,23 @@ +import { type ReactNode } from 'react'; + import type { StoryObj } from '@storybook/react'; import { type Meta } from '@storybook/react'; -import Tabs from '.'; +import Tabs, { type TabsRootProps } from '.'; +import { type TabsTriggerProps } from './components/TabsTrigger'; + +interface TabsMeta extends TabsRootProps, TabsTriggerProps { + tabListChildrenProp: ReactNode; + tabContentChildrenProp: ReactNode; + tabContentValueProp: string; +} + +const CATEGORY = { + TABS: 'Tabs', + TABS_LIST: 'Tabs.List', + TABS_TRIGGER: 'Tabs.Trigger', + TABS_CONTENT: 'Tabs.Content', +}; const COMPONENT_DESCRIPTION = ` - \`\`: 모든 컴포넌트에 대한 컨텍스트와 상태를 제공합니다. @@ -10,13 +26,61 @@ const COMPONENT_DESCRIPTION = ` - \`\`: 선택된 탭에 해당되는 컨텐츠를 보여줍니다. `; -const meta: Meta = { +const meta: Meta = { title: 'Components/Tabs', component: Tabs, - tags: ['autodocs'], argTypes: { + type: { + description: 'Tabs 컴포넌트의 종류', + table: { + category: CATEGORY.TABS, + }, + }, defaultValue: { description: 'Tabs 컴포넌트의 초기에 활성화될 탭의 기본값', + table: { + category: CATEGORY.TABS, + }, + }, + tabListChildrenProp: { + name: 'children', + description: '여러 개의 Tabs.Trigger 컴포넌트를 감싸줍니다.', + table: { + type: { summary: 'ReactNode' }, + category: CATEGORY.TABS_LIST, + }, + }, + value: { + description: + 'Tabs.Trigger 컴포넌트와 Tabs.Content 컴포넌트를 연결하는 고유한 값', + table: { + type: { summary: 'string' }, + category: CATEGORY.TABS_TRIGGER, + }, + }, + icon: { + description: 'Tabs.Trigger 컴포넌트 내 아이콘', + table: { + type: { summary: 'Icons' }, + category: CATEGORY.TABS_TRIGGER, + }, + }, + tabContentChildrenProp: { + name: 'children', + description: '활성화되었을 때 보여줄 컨텐츠를 감싸줍니다.', + table: { + type: { summary: 'ReactNode' }, + category: CATEGORY.TABS_CONTENT, + }, + }, + tabContentValueProp: { + name: 'value', + description: + 'Tabs.Trigger 컴포넌트와 Tabs.Content 컴포넌트를 연결하는 고유한 값', + table: { + type: { summary: 'string' }, + category: CATEGORY.TABS_CONTENT, + }, }, }, parameters: { @@ -33,9 +97,53 @@ export default meta; type Story = StoryObj; -export const Basic: Story = { +export const Filter: Story = { + render: () => ( + + + 끄적이는 + 참고하는 + + +
끄적이는 내용
+
+ +
참고하는 내용
+
+
+ ), +}; + +export const FilterWithIcon: Story = { render: () => ( - + + + + 끄적이는 + + + 참고하는 + + + +
끄적이는 내용
+
+ +
참고하는 내용
+
+
+ ), +}; + +export const Switcher: Story = { + render: () => ( + 끄적이는 참고하는 @@ -49,3 +157,30 @@ export const Basic: Story = { ), }; + +export const SwitcherWithIcon: Story = { + render: () => ( + + + + 끄적이는 + + + 참고하는 + + + +
끄적이는 내용
+
+ +
참고하는 내용
+
+
+ ), +}; diff --git a/src/components/Tabs/components/TabsContent.tsx b/src/components/Tabs/components/TabsContent.tsx index c7432cdf..bec8b6c0 100644 --- a/src/components/Tabs/components/TabsContent.tsx +++ b/src/components/Tabs/components/TabsContent.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react'; import { useTabsContext } from '../../../hooks/useTabsContext'; -interface TabsContentProps { +export interface TabsContentProps { value: string; } diff --git a/src/components/Tabs/components/TabsList.tsx b/src/components/Tabs/components/TabsList.tsx index 882c1f53..199b301b 100644 --- a/src/components/Tabs/components/TabsList.tsx +++ b/src/components/Tabs/components/TabsList.tsx @@ -1,9 +1,13 @@ import type { PropsWithChildren } from 'react'; +import { useTabsContext } from '@hooks/useTabsContext'; + import * as styles from '../style.css'; const TabsList = ({ children }: PropsWithChildren) => { - return
    {children}
; + const { type } = useTabsContext(); + + return
    {children}
; }; export default TabsList; diff --git a/src/components/Tabs/components/TabsTrigger.tsx b/src/components/Tabs/components/TabsTrigger.tsx index f400adea..28353447 100644 --- a/src/components/Tabs/components/TabsTrigger.tsx +++ b/src/components/Tabs/components/TabsTrigger.tsx @@ -1,26 +1,53 @@ -import type { PropsWithChildren } from 'react'; +import { type PropsWithChildren } from 'react'; +import clsx from 'clsx'; +import { motion } from 'framer-motion'; + +import Icon from '@components/Icon'; +import { type Icons } from '@constants/icon'; import { useTabsContext } from '../../../hooks/useTabsContext'; import * as styles from '../style.css'; -interface TabsTriggerProps { +export interface TabsTriggerProps { value: string; + icon?: { default: Icons; active: Icons }; } const TabsTrigger = ({ children, value, + icon, }: PropsWithChildren) => { - const { selectedTab, onSelectTab } = useTabsContext(); + const { type, selectedTab, onSelectTab } = useTabsContext(); + + const isActive = selectedTab === value; + const isActiveFilterTab = isActive && type === 'filter'; + const isActiveSwitcherTab = isActive && type === 'switcher'; return ( -
  • +
  • + {isActiveFilterTab && ( + + )} + {isActiveSwitcherTab && ( + + )}
  • ); }; diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx index 07a44962..97600e30 100644 --- a/src/components/Tabs/index.tsx +++ b/src/components/Tabs/index.tsx @@ -4,12 +4,16 @@ import TabsContent from './components/TabsContent'; import TabsList from './components/TabsList'; import TabsTrigger from './components/TabsTrigger'; +type TabsType = 'filter' | 'switcher'; + interface TabsContextProps { + type?: TabsType; selectedTab: string; onSelectTab: (selectedTab: string) => void; } export interface TabsRootProps { + type?: TabsType; defaultValue: string; } @@ -17,6 +21,7 @@ export const TabsContext = createContext(null); const TabsRoot = ({ children, + type = 'filter', defaultValue, }: PropsWithChildren) => { const [selectedTab, setSelectedTab] = useState(defaultValue); @@ -26,7 +31,9 @@ const TabsRoot = ({ }; return ( - + {children} ); diff --git a/src/components/Tabs/style.css.ts b/src/components/Tabs/style.css.ts index 3c1b4735..102c6975 100644 --- a/src/components/Tabs/style.css.ts +++ b/src/components/Tabs/style.css.ts @@ -4,35 +4,99 @@ import { recipe } from '@vanilla-extract/recipes'; import { COLORS } from '@styles/tokens'; import * as utils from '@styles/utils.css'; -export const tabsList = style([ - utils.flexCenter, - { - padding: '16px 0 12px', - gap: '40px', - backgroundColor: COLORS['Grey/White'], +export const tabsList = recipe({ + base: [ + utils.flexCenter, + { + width: 'fit-content', + margin: '16px 0 12px', + }, + ], + variants: { + type: { + switcher: { + borderRadius: '100px', + backgroundColor: COLORS['Grey/200'], + }, + filter: { + gap: '20px', + backgroundColor: COLORS['Grey/White'], + }, + }, }, -]); +}); + +export const tabItem = style({ + position: 'relative', +}); export const tabsTrigger = recipe({ - base: { - fontSize: '15px', - fontWeight: 500, - lineHeight: '18px', - letterSpacing: '0px', - paddingBottom: '4px', - borderBottom: '2px solid transparent', - transition: 'all 150ms ease-in-out', - - ':hover': { - fontWeight: 700, + base: [ + utils.flexCenter, + { + gap: '5px', + whiteSpace: 'nowrap', + transition: 'all 150ms ease-in-out', + + ':hover': { + fontWeight: 700, + }, }, - }, + ], variants: { - isActive: { - true: { - fontWeight: 700, - borderBottomColor: '#121212', + type: { + switcher: { + position: 'relative', + width: '183px', + height: '42px', + padding: '8px 0', + fontSize: '16px', + fontWeight: 500, + lineHeight: '19px', + letterSpacing: '-0.5px', + color: COLORS['Grey/500'], + zIndex: 20, }, + filter: [ + { + fontSize: '15px', + fontWeight: 500, + lineHeight: '18px', + letterSpacing: '0px', + position: 'relative', + paddingBottom: '4px', + borderBottom: '2px solid transparent', + }, + ], }, }, }); + +export const isActiveSwitcher = style({ + fontWeight: 700, + color: COLORS['Blue/Dark'], +}); + +export const isActiveFilter = style({ + fontWeight: 700, +}); + +export const underline = style({ + position: 'absolute', + bottom: '-2px', + left: 0, + right: 0, + height: '2px', + background: COLORS['Grey/900'], +}); + +export const handle = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '42px', + borderRadius: '100px', + backgroundColor: COLORS['Grey/White'], + border: `1.5px solid ${COLORS['Blue/Default']}`, +}); diff --git a/src/constants/icon.ts b/src/constants/icon.ts index 9d171579..45150291 100644 --- a/src/constants/icon.ts +++ b/src/constants/icon.ts @@ -4,10 +4,14 @@ import Close from '@assets/icons/close.svg'; import Copy from '@assets/icons/copy.svg'; import Logout from '@assets/icons/logout.svg'; import Menu from '@assets/icons/menu.svg'; +import PencilActive from '@assets/icons/pencil-active.svg'; +import PencilDefault from '@assets/icons/pencil-default.svg'; import Profle from '@assets/icons/profile.svg'; import ProfileDialog from '@assets/icons/profileDialog.svg'; import Setting from '@assets/icons/setting.svg'; import Submit from '@assets/icons/submit.svg'; +import TemplateActive from '@assets/icons/template-active.svg'; +import TemplateDefault from '@assets/icons/template-default.svg'; export const iconFactory = { add: Add, @@ -16,10 +20,14 @@ export const iconFactory = { copy: Copy, logout: Logout, menu: Menu, + pencilActive: PencilActive, + pencilDefault: PencilDefault, profile: Profle, profileDialog: ProfileDialog, setting: Setting, submit: Submit, + templateActive: TemplateActive, + templateDefault: TemplateDefault, }; export type Icons = keyof typeof iconFactory;