Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/timo-design-system/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { Tag } from "@components/tag/Tag";
export { PriorityIcon } from "@components/priority-icon/PriorityIcon";
export { CreateButton } from "@components/button/create-button/CreateButton";
export { TodayBadge } from "@components/badge/today-badge/TodayBadge";
export { Tab } from "@components/tab/Tab";
161 changes: 161 additions & 0 deletions packages/timo-design-system/src/components/tab/Tab.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import {
ChartHoverIcon,
ChartOffIcon,
ChartOnIcon,
HomeHoverIcon,
HomeOffIcon,
HomeOnIcon,
SettingHoverIcon,
SettingOffIcon,
SettingOnIcon,
TimerHoverIcon,
TimerOffIcon,
TimerOnIcon,
TodayHoverIcon,
TodayOffIcon,
TodayOnIcon,
} from "@icons";
import { useState } from "react";

import { Tab } from "./Tab";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

요거 절대 경로로 수정해 줘용


import type { Meta, StoryObj } from "@storybook/react";
import type { ComponentProps } from "react";

const meta = {
title: "Components/Tab",
component: Tab,
parameters: {
layout: "centered",
},
argTypes: {
label: {
control: "text",
},
isSelected: {
control: "boolean",
},
onClick: {
action: "clicked",
},
},
args: {
label: "홈",
icon: <HomeOffIcon width={24} height={24} />,
hoverIcon: <HomeHoverIcon width={24} height={24} />,
isSelected: false,
},
} satisfies Meta<typeof Tab>;

export default meta;
type Story = StoryObj<typeof meta>;

export const AllStates: Story = {
parameters: {
controls: { disable: true },
},
render: () => (
<div className="grid grid-cols-3 gap-6">
<div className="flex flex-col gap-3">
<Tab
label="홈"
icon={<HomeOnIcon width={24} height={24} />}
isSelected
/>
<Tab
label="홈"
icon={<HomeOffIcon width={24} height={24} />}
hoverIcon={<HomeHoverIcon width={24} height={24} />}
/>
<Tab label="홈" icon={<HomeOffIcon width={24} height={24} />} />
</div>

<div className="flex flex-col gap-3">
<Tab
label="오늘"
icon={<TodayOnIcon width={24} height={24} />}
isSelected
/>
<Tab
label="오늘"
icon={<TodayOffIcon width={24} height={24} />}
hoverIcon={<TodayHoverIcon width={24} height={24} />}
/>
<Tab label="오늘" icon={<TodayOffIcon width={24} height={24} />} />
</div>

<div className="flex flex-col gap-3">
<Tab
label="집중 모드"
icon={<TimerOnIcon width={24} height={24} />}
isSelected
/>
<Tab
label="집중 모드"
icon={<TimerOffIcon width={24} height={24} />}
hoverIcon={<TimerHoverIcon width={24} height={24} />}
/>
<Tab label="집중 모드" icon={<TimerOffIcon width={24} height={24} />} />
</div>

<div className="flex flex-col gap-3">
<Tab
label="통계"
icon={<ChartOnIcon width={24} height={24} />}
isSelected
/>
<Tab
label="통계"
icon={<ChartOffIcon width={24} height={24} />}
hoverIcon={<ChartHoverIcon width={24} height={24} />}
/>
<Tab label="통계" icon={<ChartOffIcon width={24} height={24} />} />
</div>

<div className="flex flex-col gap-3">
<Tab
label="설정"
icon={<SettingOnIcon width={24} height={24} />}
isSelected
/>
<Tab
label="설정"
icon={<SettingOffIcon width={24} height={24} />}
hoverIcon={<SettingHoverIcon width={24} height={24} />}
/>
<Tab label="설정" icon={<SettingOffIcon width={24} height={24} />} />
</div>
</div>
),
};

const InteractiveHomeTab = (args: ComponentProps<typeof Tab>) => {
const [isSelected, setIsSelected] = useState(false);

return (
<Tab
{...args}
label="홈"
icon={
isSelected ? (
<HomeOnIcon width={24} height={24} />
) : (
<HomeOffIcon width={24} height={24} />
)
}
hoverIcon={<HomeHoverIcon width={24} height={24} />}
isSelected={isSelected}
onClick={() => {
args.onClick?.();
setIsSelected((prevIsSelected) => !prevIsSelected);
}}
/>
);
};

export const Interactive: Story = {
parameters: {
controls: { disable: true },
},
render: (args) => <InteractiveHomeTab {...args} />,
};
51 changes: 51 additions & 0 deletions packages/timo-design-system/src/components/tab/Tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { cn } from "@lib";

import type { ReactNode } from "react";

type TabVariant = "default" | "selected";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

타입 별칭 네이밍 컨벤션 위반: TabVariantTabVariantTypes

리터럴 유니언 타입에는 접미사 Types를 붙이는 것이 컨벤션이에요. 지금 이름은 규칙에서 살짝 벗어나 있네요 🐰

✏️ 제안 diff
-type TabVariant = "default" | "selected";
+type TabVariantTypes = "default" | "selected";

이후 TAB_VARIANT_CLASS_NAME: Record<TabVariantTypes, string>, const variant: TabVariantTypes = ... 등 참조부도 함께 변경해야 합니다.

As per path instructions, "type alias는 유니언·튜플·리터럴에만 사용, 접미사 Types".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/timo-design-system/src/components/tab/Tab.tsx` at line 5,
`TabVariant` is a literal union type alias that violates the naming convention;
rename it to `TabVariantTypes` and update every reference in `Tab.tsx`
accordingly, including `TAB_VARIANT_CLASS_NAME: Record<TabVariantTypes,
string>`, the `variant` variable annotation, and any other usages so the type
name stays consistent throughout the component.

Source: Path instructions


const TAB_VARIANT_CLASS_NAME: Record<TabVariant, string> = {
default: "text-timo-gray-800 hover:bg-timo-gray-500",
selected: "bg-timo-blue-65 text-timo-blue-300",
};

export interface TabProps {
label: string;
icon: ReactNode;
hoverIcon?: ReactNode;
isSelected?: boolean;
onClick?: () => void;
}

export const Tab = ({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Tab이라는 컴포넌트명보단, 지금 구현된 걸 보니까 버튼 컴포넌트인 것 같아요! 컴포넌트명 수정해서 button 내부에 구조화해 주면 좋을 것 같아요.

label,
icon,
hoverIcon,
isSelected = false,
onClick,
}: TabProps) => {
const baseButtonClassName =
"group flex w-45 items-center gap-2 rounded-8 px-3 py-1";

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.

rounded는 rem 영향을 받는 속성이 아니니 px 단위로 표기해도 괜찮을 것 같아요 🙂


const variant: TabVariant = isSelected ? "selected" : "default";

const hasHoverIcon = hoverIcon && !isSelected;

return (
<button
type="button"
onClick={onClick}
className={cn(baseButtonClassName, TAB_VARIANT_CLASS_NAME[variant])}
>
{hasHoverIcon ? (
<>
<span className="group-hover:hidden">{icon}</span>
<span className="hidden group-hover:block">{hoverIcon}</span>
</>
) : (
icon
)}
<span>{label}</span>
</button>
Comment on lines +34 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

선택 상태를 스크린리더에도 알려주면 완벽할 것 같아요.

현재 선택 여부는 배경색으로만 표현되어 있어서, 보조기술 사용자는 어떤 탭이 선택됐는지 알기 어려워요. aria-pressed 또는 role="tab" + aria-selected 속성을 추가하면 접근성이 한층 좋아집니다.

♿ 제안 diff
     <button
       type="button"
       onClick={onClick}
+      aria-pressed={isSelected}
       className={cn(baseButtonClassName, TAB_VARIANT_CLASS_NAME[variant])}
     >

관련 문서: WAI-ARIA aria-pressed

📝 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
return (
<button
type="button"
onClick={onClick}
className={cn(baseButtonClassName, TAB_VARIANT_CLASS_NAME[variant])}
>
{hasHoverIcon ? (
<>
<span className="group-hover:hidden">{icon}</span>
<span className="hidden group-hover:block">{hoverIcon}</span>
</>
) : (
icon
)}
<span>{label}</span>
</button>
return (
<button
type="button"
onClick={onClick}
aria-pressed={isSelected}
className={cn(baseButtonClassName, TAB_VARIANT_CLASS_NAME[variant])}
>
{hasHoverIcon ? (
<>
<span className="group-hover:hidden">{icon}</span>
<span className="hidden group-hover:block">{hoverIcon}</span>
</>
) : (
icon
)}
<span>{label}</span>
</button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/timo-design-system/src/components/tab/Tab.tsx` around lines 34 - 49,
The Tab component currently exposes selection only through styling, so add an
accessible state to the button in Tab so assistive tech can announce whether it
is selected. Update the Tab component’s button markup to include either
aria-pressed or the appropriate role="tab" with aria-selected, using the
existing variant/label rendering in Tab.tsx. Make sure the chosen attribute
reflects the selected state passed into the Tab component and keep the current
click handling and icon/label structure unchanged.

);
};