-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] #14 - 공통 Tabs 컴포넌트 추가 #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
617247a
d4c516a
1baf502
dd8a233
1f1df74
41b0ade
2c0becf
e7a88db
57fb44d
46a1ed1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import React, { useState } from "react"; | ||
| import type { Meta, StoryObj } from "@storybook/react-vite"; | ||
| import { Tabs, type TabItem } from "./tabs"; | ||
|
|
||
| // 데모용 뱃지 컴포넌트 (실제 프로젝트에 Badge 컴포넌트가 있다면 그것으로 교체하세요) | ||
| const BadgeMock = ({ count }: { count: number }): React.ReactElement => ( | ||
| <span className="inline-flex items-center justify-center min-w-[16px] h-[16px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full"> | ||
| {count} | ||
| </span> | ||
| ); | ||
|
|
||
| const meta = { | ||
| title: "Shared/UI/Tabs", | ||
| component: Tabs, | ||
| parameters: { | ||
| layout: "padded", | ||
| componentSubtitle: "다양한 형태(Horizontal, Vertical, Pill)를 지원하는 탭 컴포넌트", | ||
| }, | ||
| tags: ["autodocs"], | ||
| argTypes: { | ||
| variant: { | ||
| control: "radio", | ||
| options: ["horizontal", "vertical", "pill"], | ||
| description: "탭의 스타일 변형", | ||
| table: { defaultValue: { summary: "horizontal" } }, | ||
| }, | ||
| items: { | ||
| description: "탭 아이템 배열 ({ id, label, badge?, isDisabled? })", // 설명 업데이트 | ||
| }, | ||
| value: { | ||
| control: "text", | ||
| description: "현재 선택된 탭 ID", | ||
| }, | ||
| onChange: { action: "changed", description: "탭 변경 핸들러" }, | ||
| }, | ||
| } satisfies Meta<typeof Tabs>; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| // ---------------------------------------------------------------------- | ||
| // 1. 기본 목업 데이터 | ||
| // ---------------------------------------------------------------------- | ||
| const defaultItems: TabItem[] = [ | ||
| { id: "tab1", label: "전체" }, | ||
| { id: "tab2", label: "진행중" }, | ||
| { id: "tab3", label: "완료" }, | ||
| { id: "tab4", label: "보류" }, | ||
| ]; | ||
|
|
||
| // ---------------------------------------------------------------------- | ||
| // 2. 상태 관리용 Wrapper 컴포넌트 (ESLint 훅 규칙 준수) | ||
| // ---------------------------------------------------------------------- | ||
| const TabsWithState = (args: React.ComponentProps<typeof Tabs>): React.ReactElement => { | ||
| const [selected, setSelected] = useState(args.value); | ||
| return <Tabs {...args} value={selected} onChange={setSelected} />; | ||
| }; | ||
|
|
||
| // ---------------------------------------------------------------------- | ||
| // 3. 스토리 정의 | ||
| // ---------------------------------------------------------------------- | ||
|
|
||
| // --- 기본 (수평) 탭 --- | ||
| export const Horizontal: Story = { | ||
| render: (args) => <TabsWithState {...args} />, | ||
| args: { | ||
| items: defaultItems, | ||
| variant: "horizontal", | ||
| value: "tab1", | ||
| onChange: () => {}, | ||
| }, | ||
| }; | ||
|
|
||
| // --- 수직 탭 --- | ||
| export const Vertical: Story = { | ||
| render: (args) => <TabsWithState {...args} />, | ||
| args: { | ||
| items: defaultItems, | ||
| variant: "vertical", | ||
| value: "tab1", | ||
| onChange: () => {}, | ||
| }, | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| story: "Vertical Tabs는 기본적으로 200px 너비를 가집니다.", | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| // --- 필(알약) 형태 탭 --- | ||
| export const Pill: Story = { | ||
| render: (args) => <TabsWithState {...args} />, | ||
| args: { | ||
| items: defaultItems, | ||
| variant: "pill", | ||
| value: "tab1", | ||
| onChange: () => {}, | ||
| }, | ||
| }; | ||
|
|
||
| // --- 뱃지가 포함된 탭 --- | ||
| export const WithBadges: Story = { | ||
| render: (args) => <TabsWithState {...args} />, | ||
| args: { | ||
| items: [ | ||
| { id: "mail", label: "메일", badge: <BadgeMock count={5} /> }, | ||
| { id: "alarm", label: "알림", badge: <BadgeMock count={12} /> }, | ||
| { id: "settings", label: "설정" }, // 뱃지 없음 | ||
| ], | ||
| variant: "horizontal", | ||
| value: "mail", // 초기 선택값 | ||
| onChange: () => {}, | ||
| }, | ||
| }; | ||
|
|
||
| // --- 비활성화된 아이템이 포함된 탭 --- | ||
| export const WithDisabledItem: Story = { | ||
| render: (args) => <TabsWithState {...args} />, | ||
| args: { | ||
| items: [ | ||
| { id: "tab1", label: "사용 가능" }, | ||
| { id: "tab2", label: "사용 불가", isDisabled: true }, // 불린 컨벤션(isDisabled) 적용 | ||
| { id: "tab3", label: "관리자 전용", isDisabled: true }, | ||
| ], | ||
| variant: "horizontal", | ||
| value: "tab1", | ||
| onChange: () => {}, | ||
| }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,122 @@ | ||||||||||||||||||
| import React from "react"; | ||||||||||||||||||
|
|
||||||||||||||||||
| // ---------------------------------------------------------------------- | ||||||||||||||||||
| // 1. 타입 및 인터페이스 | ||||||||||||||||||
| // ---------------------------------------------------------------------- | ||||||||||||||||||
| // 💡 배열 생략: 사용하지 않는 배열로 인한 ESLint 에러 방지 및 가이드라인(Literal Union) 준수 | ||||||||||||||||||
| export type TabVariant = "horizontal" | "vertical" | "pill"; | ||||||||||||||||||
|
|
||||||||||||||||||
| export type TabItem = { | ||||||||||||||||||
| id: string; | ||||||||||||||||||
| label: string; | ||||||||||||||||||
| badge?: React.ReactNode; | ||||||||||||||||||
| isDisabled?: boolean; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| export type TabsProps = { | ||||||||||||||||||
| items: TabItem[]; | ||||||||||||||||||
| value: string; | ||||||||||||||||||
| onChange: (id: string) => void; | ||||||||||||||||||
| variant?: TabVariant; | ||||||||||||||||||
| className?: string; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| // ---------------------------------------------------------------------- | ||||||||||||||||||
| // 2. 스타일 토큰 (상수) | ||||||||||||||||||
| // ---------------------------------------------------------------------- | ||||||||||||||||||
| const containerBaseClasses = "flex"; | ||||||||||||||||||
|
|
||||||||||||||||||
| const containerVariantClasses: Record<TabVariant, string> = { | ||||||||||||||||||
| horizontal: | ||||||||||||||||||
| "w-full h-[48px] flex-row gap-0 border-b-[2px] border-[color:var(--color-gray-200,#E5E5E5)] p-0", | ||||||||||||||||||
| vertical: "w-[200px] flex-col gap-1 p-2 h-auto", | ||||||||||||||||||
| pill: "w-fit flex-row gap-2 p-1 bg-[color:var(--color-gray-100,#F2F2F2)] rounded-lg", | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| const itemBaseClasses = | ||||||||||||||||||
| "relative flex items-center justify-center cursor-pointer transition-all duration-200 outline-none select-none whitespace-nowrap"; | ||||||||||||||||||
|
Comment on lines
+36
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restore a visible focus indicator.
🎯 Proposed fix const itemBaseClasses =
- "relative flex items-center justify-center cursor-pointer transition-all duration-200 outline-none select-none whitespace-nowrap";
+ "relative flex items-center justify-center cursor-pointer transition-all duration-200 outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary-800,`#004A9C`)] select-none whitespace-nowrap";📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| const itemVariantClasses: Record<TabVariant, string> = { | ||||||||||||||||||
| horizontal: "h-[48px] px-6 gap-2 text-[14px] leading-[20px]", | ||||||||||||||||||
| vertical: | ||||||||||||||||||
| "h-[40px] px-4 gap-2 text-[14px] leading-[20px] rounded-[6px] border-l-[3px] justify-start", | ||||||||||||||||||
| pill: "h-[32px] px-4 gap-2 text-[14px] font-medium rounded-[6px]", | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| // ---------------------------------------------------------------------- | ||||||||||||||||||
| // 3. 헬퍼 함수 (클래스 조립) | ||||||||||||||||||
| // ---------------------------------------------------------------------- | ||||||||||||||||||
| const getTabItemClasses = (variant: TabVariant, isActive: boolean, isDisabled: boolean): string => { | ||||||||||||||||||
| const base = itemBaseClasses; | ||||||||||||||||||
| const variantClasses = itemVariantClasses[variant]; | ||||||||||||||||||
| let stateClasses = ""; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (isDisabled) { | ||||||||||||||||||
| stateClasses = "text-[color:var(--color-gray-300,#CCCCCC)] cursor-not-allowed"; | ||||||||||||||||||
| } else { | ||||||||||||||||||
| switch (variant) { | ||||||||||||||||||
| case "horizontal": | ||||||||||||||||||
| stateClasses = isActive | ||||||||||||||||||
| ? "text-[color:var(--color-primary-800,#004A9C)] font-[600]" | ||||||||||||||||||
| : "text-[color:var(--color-gray-600,#666666)] font-[500] hover:bg-[color:var(--color-gray-50,#F9F9F9)] hover:text-[#333333]"; | ||||||||||||||||||
| break; | ||||||||||||||||||
| case "vertical": | ||||||||||||||||||
| stateClasses = isActive | ||||||||||||||||||
| ? "bg-[color:var(--color-primary-50,#F0F6FD)] border-[color:var(--color-primary-800,#004A9C)] text-[color:var(--color-primary-800,#004A9C)] font-medium" | ||||||||||||||||||
| : "border-transparent text-[color:var(--color-gray-900,#1A1A1A)] hover:bg-[color:var(--color-gray-50,#F9F9F9)]"; | ||||||||||||||||||
| break; | ||||||||||||||||||
| case "pill": | ||||||||||||||||||
| stateClasses = isActive | ||||||||||||||||||
| ? "bg-white text-[color:var(--color-primary-800,#004A9C)] shadow-[0_2px_4px_rgba(0,0,0,0.08)]" | ||||||||||||||||||
| : "text-[color:var(--color-gray-600,#666666)] hover:text-[color:var(--color-gray-900,#1A1A1A)]"; | ||||||||||||||||||
| break; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return [base, variantClasses, stateClasses].filter(Boolean).join(" "); | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| // ---------------------------------------------------------------------- | ||||||||||||||||||
| // 4. 컴포넌트 정의 | ||||||||||||||||||
| // ---------------------------------------------------------------------- | ||||||||||||||||||
| export const Tabs = ({ | ||||||||||||||||||
| items, | ||||||||||||||||||
| value, | ||||||||||||||||||
| onChange, | ||||||||||||||||||
| variant = "horizontal", | ||||||||||||||||||
| className = "", | ||||||||||||||||||
| }: TabsProps): React.ReactElement => { | ||||||||||||||||||
| const containerClasses = [containerBaseClasses, containerVariantClasses[variant], className].join( | ||||||||||||||||||
| " ", | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| return ( | ||||||||||||||||||
| <div className={containerClasses} role="tablist"> | ||||||||||||||||||
| {items.map((item) => { | ||||||||||||||||||
|
Comment on lines
+94
to
+95
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Expose vertical orientation on the tablist.
♿ Proposed fix- <div className={containerClasses} role="tablist">
+ <div
+ className={containerClasses}
+ role="tablist"
+ aria-orientation={variant === "vertical" ? "vertical" : undefined}
+ >📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| const isActive = value === item.id; | ||||||||||||||||||
| const isDisabled = !!item.isDisabled; | ||||||||||||||||||
|
|
||||||||||||||||||
| return ( | ||||||||||||||||||
| <button | ||||||||||||||||||
| type="button" | ||||||||||||||||||
|
Comment on lines
+100
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n src/shared/ui/tabs/tabs.tsx | head -120Repository: ajou-industry-matching/aim-frontend Length of output: 5378 Add a stable The 🛠️ Proposed fix <button
+ key={item.id}
type="button"
role="tab"🤖 Prompt for AI Agents |
||||||||||||||||||
| role="tab" | ||||||||||||||||||
| aria-selected={isActive} | ||||||||||||||||||
| disabled={isDisabled} | ||||||||||||||||||
| onClick={() => !isDisabled && onChange(item.id)} | ||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||
| className={getTabItemClasses(variant, isActive, isDisabled)} | ||||||||||||||||||
| > | ||||||||||||||||||
|
jaeu5325 marked this conversation as resolved.
|
||||||||||||||||||
| <span>{item.label}</span> | ||||||||||||||||||
| {item.badge && ( | ||||||||||||||||||
| <span className="ml-[6px] inline-flex items-center justify-center">{item.badge}</span> | ||||||||||||||||||
|
Comment on lines
+109
to
+110
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n src/shared/ui/tabs/tabs.tsx | sed -n '100,120p'Repository: ajou-industry-matching/aim-frontend Length of output: 983 🏁 Script executed: # Also check the type definition for the item object
rg -A 10 -B 5 "interface.*Tab|type.*Tab" src/shared/ui/tabs/Repository: ajou-industry-matching/aim-frontend Length of output: 4726 🏁 Script executed: # Search for any usage of badge with numeric values in tests or elsewhere
rg "badge.*[0-9]|badge.*zero" src/Repository: ajou-industry-matching/aim-frontend Length of output: 286 Fix nullish check to allow numeric zero badges.
Proposed fix- {item.badge && (
+ {item.badge != null && (
<span className="ml-[6px] inline-flex items-center justify-center">{item.badge}</span>
)}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| )} | ||||||||||||||||||
| {variant === "horizontal" && isActive && !isDisabled && ( | ||||||||||||||||||
| <div className="absolute bottom-0 left-0 w-full h-[2px] bg-[color:var(--color-primary-800,#004A9C)] transition-all duration-200" /> | ||||||||||||||||||
| )} | ||||||||||||||||||
| </button> | ||||||||||||||||||
| ); | ||||||||||||||||||
| })} | ||||||||||||||||||
| </div> | ||||||||||||||||||
| ); | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| Tabs.displayName = "Tabs"; | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep Storybook args as the source of truth.
useState(args.value)only snapshots the initial selection, and the per-storyonChange: () => {}args suppress the action handler entirely. After the first interaction, Controls/Actions can drift from what the story is actually rendering.Also applies to: 70-70, 81-81, 99-99, 114-114, 129-129
🤖 Prompt for AI Agents