Skip to content
10 changes: 6 additions & 4 deletions packages/timo-design-system/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export { Checkbox } from "@components/checkbox/Checkbox";
export { Color } from "@components/color/Color";
export { Typography } from "@components/typography/Typography";
export { Tag } from "@components/tag/Tag";
export { PriorityIcon } from "@components/priority-icon/PriorityIcon";
export { Color } from "@components/tokens/color/Color";
export { Typography } from "@components/tokens/typography/Typography";
export { TagIcon } from "@components/tag/tag-icon/TagIcon";
export { TagSelector } from "@components/tag/tag-selector/TagSelector";
export { PriorityIcon } from "@components/priority/priority-icon/PriorityIcon";
export { PrioritySelector } from "@components/priority/priority-selector/PrioritySelector";
export { CreateButton } from "@components/button/create-button/CreateButton";
export { TodayBadge } from "@components/badge/today-badge/TodayBadge";
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Dropdown } from "./Dropdown";

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

const meta = {
title: "Components/layout/Dropdown",
parameters: {
layout: "centered",
backgrounds: {
default: "light-gray",
values: [
{ name: "light-gray", value: "#F5F5F5" },
{ name: "dark", value: "#333333" },
{ name: "white", value: "#FFFFFF" },
],
},
},
} satisfies Meta;

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

const SAMPLE_ITEMS = ["옵션 1", "옵션 2", "옵션 3"];

export const Default: Story = {
name: "Basic Usage",
render: () => (
<Dropdown>
<Dropdown.Trigger className="typo-headline-r-14 text-timo-black rounded-4 bg-timo-gray-300 px-3 py-1.5">
트리거
</Dropdown.Trigger>

<Dropdown.Panel className="gap-1">
{SAMPLE_ITEMS.map((item) => (
<Dropdown.Item key={item} className="px-1.5 py-1">
<span className="typo-headline-r-14 text-timo-black whitespace-nowrap">
{item}
</span>
</Dropdown.Item>
))}
</Dropdown.Panel>
</Dropdown>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { cn } from "@lib";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ButtonHTMLAttributes,
Comment on lines +1 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Next.js 15 App Router client component에서 "use client" 지시어를 제거하면 useState 사용 시 어떤 에러가 발생하나요?

💡 Result:

Next.js App Router에서 컴포넌트는 기본적으로 서버 컴포넌트(Server Component)로 간주됩니다 [1][2]. 서버 컴포넌트 환경에서는 useState와 같은 리액트 클라이언트 훅(React Client Hooks)을 사용할 수 없으며, "use client" 지시어를 제거하면 다음과 같은 에러가 발생합니다. 1. 개발 환경 및 빌드 단계: Next.js는 컴포넌트가 서버 컴포넌트임에도 불구하고 클라이언트 훅을 사용하려고 시도한다는 점을 감지하여 빌드/실행 시 "You are using a React client hook in a Server Component"와 같은 경고 또는 에러를 발생시킵니다 [3][2]. 2. 런타임 에러: "use client" 없이 클라이언트 훅을 호출하면 해당 훅을 서버 환경에서 실행하려고 시도하게 되어, 환경에 따라 "TypeError: Cannot read properties of null (reading 'useState')"와 같은 런타임 에러가 발생할 수 있습니다 [4]. 이는 서버 사이드 렌더링(SSR) 과정에서 리액트의 상태 관리 모듈이 적절하게 로드되지 않았거나 서버 환경에서 정의되지 않은 동작을 수행하려 하기 때문입니다 [4]. 결론적으로, "use client" 지시어는 해당 모듈이 클라이언트 번들에 포함되어야 함을 알리는 진입점 역할을 하며 [5], 이를 누락하면 리액트는 해당 컴포넌트를 서버 컴포넌트로 처리하여 클라이언트 전용 기능인 useState의 사용을 허용하지 않습니다 [1][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx"

echo "=== file outline ==="
ast-grep outline "$file" --view expanded || true

echo
echo "=== file content (numbered) ==="
wc -l "$file"
cat -n "$file" | sed -n '1,220p'

Repository: Team-Timo/Timo-client

Length of output: 5301


"use client"를 복원하세요. packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsxuseState, useEffect, document.addEventListener를 사용하므로 App Router에서 서버 컴포넌트로 해석되면 오류가 납니다. 공식 문서: https://nextjs.org/docs/app/building-your-application/rendering/client-components

🤖 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/layout/dropdown/Dropdown.tsx`
around lines 1 - 8, Dropdown.tsx는 useState, useEffect,
document.addEventListener를 사용하므로 App Router에서 클라이언트 컴포넌트로 명시되어야 합니다. Dropdown
컴포넌트 파일의 최상단에 "use client" 지시문을 복원하고, DropdownContext/DropdownProvider 관련 코드가 서버
컴포넌트로 해석되지 않도록 유지하세요.

type ReactNode,
} from "react";

interface DropdownContextValue {
isOpen: boolean;
toggle: () => void;
close: () => void;
}

const DropdownContext = createContext<DropdownContextValue | null>(null);

const useDropdownContext = (): DropdownContextValue => {

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.

방어코드 에러 메세지 처리까지 신경 쓰신 부분이 좋아요!! 잘못 써도 원인을 바로 알 수 있겠네요 👍

const context = useContext(DropdownContext);

if (!context) {
throw new Error(
"Dropdown.Trigger, Dropdown.Panel은 Dropdown 내부에서만 사용할 수 있습니다.",
);
}

return context;
};

export interface DropdownProps {
children: ReactNode;
className?: string;
}

const DropdownRoot = ({ children, className }: DropdownProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const rootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!isOpen) return;

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.

열려있을 때만 이벤트 리스너를 등록하고, cleanup 함수로 확실히 지워주는 패턴도 새롭네요!
이렇게 하면 불필요한 이벤트 감시를 줄이고 메모리 누수도 막을 수 있다는 점이 신기하고 사소하지만 꼭 챙겨야하는 부분이라고 느꼈어요👀


const handleOutsideClick = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};

const handleEscapeKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
Comment on lines +51 to +52

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.

Esc 키로 닫히는 기능까지!!👍👍 굿굿입니당

}
};

document.addEventListener("mousedown", handleOutsideClick);
document.addEventListener("keydown", handleEscapeKeydown);

return () => {
document.removeEventListener("mousedown", handleOutsideClick);
document.removeEventListener("keydown", handleEscapeKeydown);
};
}, [isOpen]);
Comment thread
kimminna marked this conversation as resolved.

const toggle = () => setIsOpen((prev) => !prev);
const close = () => setIsOpen(false);

return (
<DropdownContext.Provider value={{ isOpen, toggle, close }}>
<div ref={rootRef} className={cn("relative inline-block", className)}>
{children}
</div>
</DropdownContext.Provider>
);
};

export interface DropdownTriggerProps {
children: ReactNode;
className?: string;
}

const DropdownTrigger = ({ children, className }: DropdownTriggerProps) => {
const { toggle } = useDropdownContext();

return (
<button type="button" onClick={toggle} className={className}>
{children}
</button>
);
};

export interface DropdownPanelProps {
children: ReactNode;
className?: string;
}

const DropdownPanel = ({ children, className }: DropdownPanelProps) => {
const { isOpen } = useDropdownContext();

if (!isOpen) return null;

return (
<div
className={cn(
"rounded-4 absolute top-full left-0 z-10 mt-1 flex flex-col items-start bg-white p-2",
className,
)}
>
{children}
</div>
);
};

export type DropdownItemProps = ButtonHTMLAttributes<HTMLButtonElement>;

const DropdownItem = ({ className, ...rest }: DropdownItemProps) => {
return (
<button
type="button"
{...rest}
className={cn(
"rounded-4 flex w-full items-center transition-colors duration-200 ease-in-out",
className,
)}
/>
);
};

export const Dropdown = Object.assign(DropdownRoot, {
Trigger: DropdownTrigger,
Panel: DropdownPanel,
Item: DropdownItem,
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { PriorityIcon, type Priority } from "./PriorityIcon";
import type { Meta, StoryObj } from "@storybook/react";

const meta = {
title: "Components/PriorityIcon",
title: "Components/Priority/PriorityIcon",
component: PriorityIcon,
parameters: {
layout: "centered",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export interface PriorityIconProps {
export const PriorityIcon = ({ priority }: PriorityIconProps) => {
return (
<div
className={cn("size-4.5 shrink-0 rounded-full", PRIORITY_COLOR[priority])}
className={cn(
"size-2 shrink-0 rounded-full transition-colors duration-200 ease-in-out",
PRIORITY_COLOR[priority],
)}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { PrioritySelector } from "./PrioritySelector";

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

const meta = {
title: "Components/Priority/PrioritySelector",
component: PrioritySelector,
parameters: {
layout: "centered",
backgrounds: {
default: "light-gray",
values: [
{ name: "light-gray", value: "#F5F5F5" },
{ name: "dark", value: "#333333" },
{ name: "white", value: "#FFFFFF" },
],
},
},
argTypes: {
selected: {
control: "select",
options: ["매우중요", "중요", "보통", "낮음"],
},
},
} satisfies Meta<typeof PrioritySelector>;

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

const TRIGGER = (
<span className="typo-headline-r-14 text-timo-black rounded-4 bg-timo-gray-300 px-3 py-1.5">
우선순위
</span>
);

export const Default: Story = {
args: { trigger: TRIGGER },
};

export const 매우중요: Story = {
args: { trigger: TRIGGER, selected: "매우중요" },
};

export const 중요: Story = {
args: { trigger: TRIGGER, selected: "중요" },
};

export const 보통: Story = {
args: { trigger: TRIGGER, selected: "보통" },
};

export const 낮음: Story = {
args: { trigger: TRIGGER, selected: "낮음" },
};

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.

저도 Tab 컴포넌트를 구현할 때 부모 컴포넌트로부터 선택 여부를 props로 받았는데, 왜 제 경우에는 isSelected/onClick을 쓰고 이 컴포넌트에서는 selected/onSelect를 쓰는지 궁금해서 찾아봤습니다.

정리해보니 이 컴포넌트는 여러 항목 중 하나를 고르는 선택기라서 selected 값과 onSelect이 자연스럽고, 제가 만든 Tab은 단일 항목이라 isSelected와 onClick이 더 적절하다는 차이가 있더라구요!! (제가 이해한게 맞을까..요?..)

덕분에 제어 컴포넌트 패턴을 조금 더 이해하게 됐습니다.!! 민아님 코드 보면서 많이 배우네요🫰🏻👍😍

Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Dropdown } from "@components/layout/dropdown/Dropdown";
import { PriorityIcon } from "@components/priority/priority-icon/PriorityIcon";
import { cn } from "@lib";

import type { ReactNode } from "react";

export type PriorityLevel = "매우중요" | "중요" | "보통" | "낮음";

const PRIORITY_LEVELS: PriorityLevel[] = ["매우중요", "중요", "보통", "낮음"];

const PRIORITY_BG_COLOR: Record<PriorityLevel, string> = {
매우중요: "bg-timo-red",
중요: "bg-timo-orange",
보통: "bg-timo-gray",
낮음: "bg-timo-gray-900",
};
Comment thread
kimminna marked this conversation as resolved.

export interface PrioritySelectorProps {
trigger: ReactNode;
selected?: PriorityLevel;
onSelect?: (priority: PriorityLevel) => void;
}

export const PrioritySelector = ({
trigger,
selected,
onSelect,
}: PrioritySelectorProps) => {
return (
<Dropdown>
<Dropdown.Trigger>{trigger}</Dropdown.Trigger>

<Dropdown.Panel className="gap-1">
{PRIORITY_LEVELS.map((priority) => {
const isSelected = priority === selected;

return (
<Dropdown.Item
key={priority}
onClick={() => onSelect?.(priority)}
aria-pressed={isSelected}
className={cn(
"gap-2.25 py-0.5 pr-1 pl-2.75",
isSelected && PRIORITY_BG_COLOR[priority],
)}
>
<PriorityIcon priority={isSelected ? "white" : priority} />

<span
className={cn(
"typo-headline-r-14 whitespace-nowrap transition-colors duration-200 ease-in-out",
isSelected ? "text-white" : "text-timo-black",
)}
>
{priority}
</span>
</Dropdown.Item>
);
})}
</Dropdown.Panel>
</Dropdown>
);
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Tag } from "./Tag";
import { TagIcon } from "@components/tag/tag-icon/TagIcon";

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

const meta = {
title: "Components/Tag",
component: Tag,
title: "Components/Tag/TagIcon",
component: TagIcon,
parameters: {
layout: "centered",
},
Expand All @@ -19,7 +19,7 @@ const meta = {
description: "태그 스타일 베리언트",
},
},
} satisfies Meta<typeof Tag>;
} satisfies Meta<typeof TagIcon>;

export default meta;
type Story = StoryObj<typeof meta>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
export type TagVariant = "default" | "blue";

export interface TagProps {
export interface TagIconProps {
text: string;
variant?: TagVariant;
}

export const Tag = ({ text, variant = "default" }: TagProps) => {
export const TagIcon = ({ text, variant = "default" }: TagIconProps) => {
const isBlue = variant === "blue";
return (
<div
Expand Down
Loading
Loading