Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .github/ISSUE_TEMPLATE/custom.md

This file was deleted.

20 changes: 9 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file.
Empty file added src/shared/ui/lists/lists.tsx
Empty file.
131 changes: 131 additions & 0 deletions src/shared/ui/tabs/tabs.stories.tsx
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} />;
Comment on lines +54 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep Storybook args as the source of truth.

useState(args.value) only snapshots the initial selection, and the per-story onChange: () => {} 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
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/tabs/tabs.stories.tsx` around lines 54 - 56, The story
currently snapshots initial selection with useState(args.value) and overrides
onChange, causing Controls/Actions to drift; remove the local state and let
Storybook args be the single source of truth by returning the component with
value={args.value} and onChange={args.onChange} (i.e., replace the body of
TabsWithState to return <Tabs {...args} value={args.value}
onChange={args.onChange} />), and apply the same change to the other story
wrapper functions referenced (the similar wrappers at the other noted locations)
and remove any per-story onChange: () => {} arg overrides so the action handler
is not suppressed.

};

// ----------------------------------------------------------------------
// 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: () => {},
},
};
122 changes: 122 additions & 0 deletions src/shared/ui/tabs/tabs.tsx
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore a visible focus indicator.

outline-none removes the native ring, but none of the variant classes add a focus-visible replacement. Keyboard users won't be able to tell which tab currently has focus.

🎯 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const itemBaseClasses =
"relative flex items-center justify-center cursor-pointer transition-all duration-200 outline-none select-none whitespace-nowrap";
const itemBaseClasses =
"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";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/tabs/tabs.tsx` around lines 36 - 37, The tab base class string
itemBaseClasses currently includes outline-none which removes the native focus
ring without providing a replacement; restore an accessible visible focus
indicator by removing outline-none or by appending a focus-visible utility
(e.g., focus-visible:ring focus-visible:ring-offset-1
focus-visible:ring-primary) to itemBaseClasses or to the tab variant classes
used in src/shared/ui/tabs/tabs.tsx so keyboard users receive a clear focus
style when a tab element (the components using itemBaseClasses) receives focus.


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Expose vertical orientation on the tablist.

role="tablist" is announced as horizontal by default. The vertical variant needs aria-orientation="vertical" so assistive tech gets the correct interaction model.

♿ Proposed fix
-    <div className={containerClasses} role="tablist">
+    <div
+      className={containerClasses}
+      role="tablist"
+      aria-orientation={variant === "vertical" ? "vertical" : undefined}
+    >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className={containerClasses} role="tablist">
{items.map((item) => {
<div
className={containerClasses}
role="tablist"
aria-orientation={variant === "vertical" ? "vertical" : undefined}
>
{items.map((item) => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/tabs/tabs.tsx` around lines 94 - 95, The tablist div currently
uses role="tablist" but lacks aria-orientation for vertical mode; update the
Tabs component to set aria-orientation="vertical" on the div with
className={containerClasses} (the element wrapping items.map((item) => ...))
when the component is rendered in vertical orientation (use the component's
orientation/variant prop or class detection logic that produces
containerClasses) so assistive tech is informed of the vertical interaction
model.

const isActive = value === item.id;
const isDisabled = !!item.isDisabled;

return (
<button
type="button"
Comment on lines +100 to +101
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/shared/ui/tabs/tabs.tsx | head -120

Repository: ajou-industry-matching/aim-frontend

Length of output: 5378


Add a stable key prop for mapped tab items.

The <button> element returned from items.map() at line 100 is missing a key prop. Since TabItem includes a stable id field that uniquely identifies each tab, add key={item.id} to prevent React reconciliation issues and warnings.

🛠️ Proposed fix
          <button
+           key={item.id}
            type="button"
            role="tab"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/tabs/tabs.tsx` around lines 100 - 101, The mapped tab buttons
in the Tabs component are missing a stable key which causes React warnings; in
the items.map(...) return (the <button> element) add a unique key using the
TabItem id (e.g., key={item.id}) so each mapped element has a stable identifier;
locate the map over items in Tabs (where TabItem objects are iterated) and add
key={item.id} to the button JSX.

role="tab"
aria-selected={isActive}
disabled={isDisabled}
onClick={() => !isDisabled && onChange(item.id)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className={getTabItemClasses(variant, isActive, isDisabled)}
>
Comment thread
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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.

item.badge && suppresses 0 due to truthiness. Since badge accepts React.ReactNode (which includes numbers), numeric zero badges cannot render. Use item.badge != null && to allow zero while still excluding null and undefined.

Proposed fix
-            {item.badge && (
+            {item.badge != null && (
               <span className="ml-[6px] inline-flex items-center justify-center">{item.badge}</span>
             )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{item.badge && (
<span className="ml-[6px] inline-flex items-center justify-center">{item.badge}</span>
{item.badge != null && (
<span className="ml-[6px] inline-flex items-center justify-center">{item.badge}</span>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/tabs/tabs.tsx` around lines 109 - 110, Conditional rendering
currently uses a truthiness check (item.badge && ...) which prevents numeric
zero badges from showing; update the conditional in the Tabs render where
item.badge is checked (the expression that wraps the <span className="ml-[6px]
inline-flex items-center justify-center">{item.badge}</span>) to use a nullish
check (item.badge != null) so that 0 and other valid React nodes render while
still excluding null/undefined.

)}
{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";