diff --git a/apps/spectator/app/game/[id]/page.css.ts b/apps/spectator/app/game/[id]/page.css.ts
index c457fff3..317bae3c 100644
--- a/apps/spectator/app/game/[id]/page.css.ts
+++ b/apps/spectator/app/game/[id]/page.css.ts
@@ -1,4 +1,4 @@
-import { theme } from '@hcc/styles';
+import { theme, rem } from '@hcc/styles';
import { style, styleVariants } from '@vanilla-extract/css';
const contentSection = style({ overflowY: 'auto', padding: '1.25rem' });
@@ -26,3 +26,34 @@ export const cheerTalk = styleVariants({
...theme.textVariants.lg,
},
});
+
+const panelItemBase = style({
+ ...theme.textVariants.default,
+ textAlign: 'center',
+ paddingBlock: rem(12),
+ color: theme.colors.gray[4],
+ borderBlock: `1px solid ${theme.colors.gray[2]}`,
+});
+
+export const panel = styleVariants({
+ wrapper: {
+ position: 'relative',
+ },
+ menu: {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
+ width: '100%',
+ },
+});
+
+export const item = styleVariants({
+ active: [
+ panelItemBase,
+ {
+ background: theme.colors.primary[1],
+ color: theme.colors.primary[3],
+ borderBottom: `1px solid ${theme.colors.primary[3]}`,
+ },
+ ],
+ inactive: [panelItemBase],
+});
diff --git a/apps/spectator/app/game/[id]/page.tsx b/apps/spectator/app/game/[id]/page.tsx
index 907af6ab..c7d1274d 100644
--- a/apps/spectator/app/game/[id]/page.tsx
+++ b/apps/spectator/app/game/[id]/page.tsx
@@ -1,10 +1,11 @@
'use client';
+import { Tabs } from '@hcc/ui';
+
import Live from '@/app/_components/Live';
import AsyncBoundary from '@/components/AsyncBoundary';
import CheerTalkModal from '@/components/cheertalk/Modal/CheerTalkModal';
import Loader from '@/components/Loader';
-import Panel from '@/components/Panel';
import Banner from './_components/Banner';
import BannerFallback from './_components/Banner/Error';
@@ -16,13 +17,22 @@ import Lineup from './_components/Lineup';
import Timeline from './_components/Timeline';
import * as styles from './page.css';
-export default function Page({ params }: { params: { id: string } }) {
- const options = [
- { label: '라인업' },
- { label: '타임라인' },
- { label: '경기영상' },
- ];
+const tabs = [
+ {
+ label: '라인업',
+ renderer: (gameId: string) => ,
+ },
+ {
+ label: '타임라인',
+ renderer: (gameId: string) => ,
+ },
+ {
+ label: '경기영상',
+ renderer: (gameId: string) =>
{gameId} 경기영상
,
+ },
+];
+export default function Page({ params }: { params: { id: string } }) {
return (
-
- {({ selected }) => (
- <>
- {selected === '라인업' && (
- 에러
}
- loadingFallback={}
- >
-
-
- )}
- {selected === '타임라인' && (
- 에러
}
- loadingFallback={}
- >
-
-
- )}
- {selected === '경기영상' && (
- 에러
}
- loadingFallback={}
- >
-
-
- )}
- >
- )}
-
+
+
+ {tabs.map(tab => (
+ styles.item[state]}
+ >
+ {tab.label}
+
+ ))}
+
+ {tabs.map(tab => (
+
+ 에러
}
+ loadingFallback={}
+ >
+ {tab.renderer(params.id)}
+
+
+ ))}
+
+
);
diff --git a/apps/spectator/components/Panel/Panel.css.ts b/apps/spectator/components/Panel/Panel.css.ts
deleted file mode 100644
index e1b1506f..00000000
--- a/apps/spectator/components/Panel/Panel.css.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { rem, theme } from '@hcc/styles';
-import { style, styleVariants } from '@vanilla-extract/css';
-
-const panelItemBase = style({
- ...theme.textVariants.default,
- textAlign: 'center',
- paddingBlock: rem(12),
- color: theme.colors.gray[4],
- borderBlock: `1px solid ${theme.colors.gray[2]}`,
-});
-
-export const panel = styleVariants({
- wrapper: {
- position: 'relative',
- },
- menu: {
- display: 'grid',
- gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
- width: '100%',
- },
- item: [panelItemBase],
- itemSelected: [
- panelItemBase,
- {
- background: theme.colors.primary[1],
- color: theme.colors.primary[3],
- borderBottom: `1px solid ${theme.colors.primary[3]}`,
- },
- ],
-});
diff --git a/apps/spectator/components/Panel/index.tsx b/apps/spectator/components/Panel/index.tsx
deleted file mode 100644
index 82baf315..00000000
--- a/apps/spectator/components/Panel/index.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { MouseEvent, ReactNode, useState } from 'react';
-
-import { Dropdown } from '@/components/Dropdown';
-
-import { panel } from './Panel.css';
-
-type PanelProps = {
- options: Array<{ label: string }>;
- children: ({ selected }: { selected: string }) => ReactNode;
- defaultValue: string;
-};
-
-export default function Panel({ defaultValue, options, children }: PanelProps) {
- const [selected, setSelected] = useState(defaultValue);
-
- const handleClickItem = (e: MouseEvent) => {
- const selectedValue = (e.target as Element).textContent;
-
- if (!selectedValue) return;
- if (selected === selectedValue) return;
-
- setSelected(selectedValue);
- };
-
- return (
-
-
-
- {options.map(option => (
-
- {option.label}
-
- ))}
-
- {children({ selected })}
-
-
- );
-}
diff --git a/docs/storybook/stories/Tabs.stories.tsx b/docs/storybook/stories/Tabs.stories.tsx
new file mode 100644
index 00000000..a6f45942
--- /dev/null
+++ b/docs/storybook/stories/Tabs.stories.tsx
@@ -0,0 +1,35 @@
+import { Tabs } from '@hcc/ui';
+import { Meta, StoryObj } from '@storybook/react';
+import React from 'react';
+
+const meta: Meta = {
+ title: '@hcc/Tabs',
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => {
+ return (
+
+
+ A
+ B
+
+
+
+ 저는 A입니다.
+
+
+ 저는 B입니다.
+
+
+ );
+ },
+};
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 687a4bf0..b7c05b65 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -3,5 +3,6 @@ export { default as Icon } from './icon';
export { default as Uploader } from './image-uploader';
export { default as Input } from './input';
export { default as Modal } from './modal';
+export { default as Tabs } from './tabs';
export { default as Toast } from './toast';
export { default as Tooltip } from './tooltip';
diff --git a/packages/ui/src/tabs/Content.tsx b/packages/ui/src/tabs/Content.tsx
new file mode 100644
index 00000000..6c7dcd18
--- /dev/null
+++ b/packages/ui/src/tabs/Content.tsx
@@ -0,0 +1,15 @@
+import useTabs from './hooks';
+
+type TabsContentProps = {
+ value: string;
+ className?: string;
+ children: React.ReactNode;
+};
+
+const TabsContent = ({ value, className, children }: TabsContentProps) => {
+ const { value: valueState } = useTabs();
+
+ return value === valueState && {children}
;
+};
+
+export default TabsContent;
diff --git a/packages/ui/src/tabs/List.tsx b/packages/ui/src/tabs/List.tsx
new file mode 100644
index 00000000..35ddb840
--- /dev/null
+++ b/packages/ui/src/tabs/List.tsx
@@ -0,0 +1,14 @@
+import { clsx } from 'clsx';
+
+import * as styles from './Tabs.css';
+
+type TabsListProps = {
+ className?: string;
+ children: React.ReactNode;
+};
+
+const TabsList = ({ className, children }: TabsListProps) => {
+ return {children}
;
+};
+
+export default TabsList;
diff --git a/packages/ui/src/tabs/Tabs.css.ts b/packages/ui/src/tabs/Tabs.css.ts
new file mode 100644
index 00000000..4edc2dd7
--- /dev/null
+++ b/packages/ui/src/tabs/Tabs.css.ts
@@ -0,0 +1,14 @@
+import { style } from '@vanilla-extract/css';
+
+export const root = style({
+ display: 'flex',
+ flexDirection: 'column',
+
+ width: '100%',
+});
+
+export const list = style({
+ display: 'grid',
+ gridAutoFlow: 'column',
+ alignItems: 'center',
+});
diff --git a/packages/ui/src/tabs/Trigger.tsx b/packages/ui/src/tabs/Trigger.tsx
new file mode 100644
index 00000000..dfdfac99
--- /dev/null
+++ b/packages/ui/src/tabs/Trigger.tsx
@@ -0,0 +1,34 @@
+import useTabs from './hooks';
+
+type TabsTriggerProps = {
+ value: string;
+ className?: (state: DataState) => string;
+ children: React.ReactNode;
+};
+
+type DataStateParams = {
+ value: string;
+ stateValue: string;
+};
+type DataState = 'active' | 'inactive';
+
+const getDataState = ({ value, stateValue }: DataStateParams) => {
+ return stateValue === value ? 'active' : 'inactive';
+};
+
+const TabsTrigger = ({ value, className, children }: TabsTriggerProps) => {
+ const { value: stateValue, setValue } = useTabs();
+ const activeState = getDataState({ value, stateValue });
+
+ return (
+
+ );
+};
+
+export default TabsTrigger;
diff --git a/packages/ui/src/tabs/hooks.ts b/packages/ui/src/tabs/hooks.ts
new file mode 100644
index 00000000..ae7ac732
--- /dev/null
+++ b/packages/ui/src/tabs/hooks.ts
@@ -0,0 +1,15 @@
+import { useContext } from 'react';
+
+import { TabsContext } from '.';
+
+const useTabs = () => {
+ const context = useContext(TabsContext);
+
+ if (!context) {
+ throw new Error('useTabs must be used within a TabsProvider');
+ }
+
+ return context;
+};
+
+export default useTabs;
diff --git a/packages/ui/src/tabs/index.tsx b/packages/ui/src/tabs/index.tsx
new file mode 100644
index 00000000..7ae05a67
--- /dev/null
+++ b/packages/ui/src/tabs/index.tsx
@@ -0,0 +1,39 @@
+import { clsx } from 'clsx';
+import { createContext, useState } from 'react';
+
+import TabsContent from './Content';
+import TabsList from './List';
+import * as styles from './Tabs.css';
+import TabsTrigger from './Trigger';
+
+type TabsContextType = {
+ value: string;
+ setValue: (value: string) => void;
+};
+
+export const TabsContext = createContext({
+ value: '',
+ setValue: () => {},
+});
+
+type TabsProps = {
+ defaultValue: string;
+ className?: string;
+ children: React.ReactNode;
+};
+
+const Tabs = ({ defaultValue, className, children }: TabsProps) => {
+ const [value, setValue] = useState(defaultValue);
+
+ return (
+
+ {children}
+
+ );
+};
+
+Tabs.List = TabsList;
+Tabs.Trigger = TabsTrigger;
+Tabs.Content = TabsContent;
+
+export default Tabs;