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
11 changes: 7 additions & 4 deletions packages/timo-design-system/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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 { AddTaskButton } from "@components/button/add-task-button/AddTaskButton";
export { TodayBadge } from "@components/badge/today-badge/TodayBadge";
export { PlayButton } from "@components/button/play-button/PlayButton";
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Dropdown } from "@components/layout/dropdown/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,136 @@
"use client";

import { cn } from "@lib";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ButtonHTMLAttributes,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 +53 to +54

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 type DropdownTriggerProps = ButtonHTMLAttributes<HTMLButtonElement>;

const DropdownTrigger = ({ className, ...rest }: DropdownTriggerProps) => {
const { isOpen, toggle } = useDropdownContext();

return (
<button
type="button"
{...rest}
onClick={toggle}
aria-expanded={isOpen}
className={className}
/>
);
};

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,15 +3,15 @@ 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",
},
argTypes: {
priority: {
control: "select",
options: ["매우중요", "중요", "보통", "낮음", "Disable", "white", "blue"],
options: ["urgent", "high", "medium", "low", "Disable", "white", "blue"],
},
},
} satisfies Meta<typeof PriorityIcon>;
Expand All @@ -20,19 +20,19 @@ export default meta;
type Story = StoryObj<typeof meta>;

export const 매우중요: Story = {
args: { priority: "매우중요" },
args: { priority: "urgent" },
};

export const 중요: Story = {
args: { priority: "중요" },
args: { priority: "high" },
};

export const 보통: Story = {
args: { priority: "보통" },
args: { priority: "medium" },
};

export const 낮음: Story = {
args: { priority: "낮음" },
args: { priority: "low" },
};

export const Disable: Story = {
Expand All @@ -57,17 +57,17 @@ export const blue: Story = {
};

const ALL_PRIORITIES: Priority[] = [
"매우중요",
"중요",
"보통",
"낮음",
"urgent",
"high",
"medium",
"low",
"Disable",
"white",
"blue",
];

export const AllPriorities: Story = {
args: { priority: "매우중요" },
args: { priority: "urgent" },
parameters: {
backgrounds: {
default: "light-gray",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { cn } from "@lib";

export type Priority =
| "매우중요"
| "중요"
| "보통"
| "낮음"
| "urgent"
| "high"
| "medium"
| "low"
| "Disable"
| "white"
| "blue";

const PRIORITY_COLOR: Record<Priority, string> = {
매우중요: "bg-timo-red",
중요: "bg-timo-orange",
보통: "bg-timo-gray-600",
낮음: "bg-timo-black",
urgent: "bg-timo-red",
high: "bg-timo-orange",
medium: "bg-timo-gray-600",
low: "bg-timo-black",
Disable: "bg-timo-gray-500",
white: "bg-white",
blue: "bg-timo-blue-300",
Expand All @@ -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: ["urgent", "high", "medium", "low"],
},
},
} 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: "urgent" },
};

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

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

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