Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
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,130 @@
"use client";

import { cn } from "@lib";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
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);
}
};

document.addEventListener("mousedown", handleOutsideClick);

return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [isOpen]);
Comment thread
kimminna marked this conversation as resolved.
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();

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.

isOpen 추가해서 버튼에 aria-expanded={isOpen}과 aria-haspopup="listbox"를 달아주는 건 어떨까요? 스크린 리더 사용자가 버튼을 눌렀을 때 뭔가 열린다는 것과 현재 열림 상태를 알 수 있어서 접근성에 도움이 될 것 같아요 - !

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

aria-expanded={isOpen}처럼 "지금 열려있는지 아닌지"는 내용과 상관없이 항상 참인 정보라 Dropdown 레벨에 안전하게 추가할 수 있지만, aria-haspopup="listbox"는 "내부가 전부 선택 가능한 옵션으로만 구성돼 있다"는 가정을 둔다고 생각해요.

실제로 TagSelector는 태그 옵션들 사이에 "추가" 액션 버튼이 섞여 있어서, 이 경우 listbox 패턴(옵션들 + 화살표 키 이동 + aria-selected)과 맞지 않게 됩니다.

그래서 aria-haspopup 값은 Dropdown 공통 레벨에서 고정하기보다, 각 소비 컴포넌트가 자기 콘텐츠 구조(순수 옵션 목록인지, 액션이 섞인 메뉴인지)에 맞게 결정하는 게 스크린 리더 사용자에게 더 정확한 정보를 준다고 생각합니다! 그래서 해당 방향으로 커밋 올려두겠습니다 😁


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 interface DropdownItemProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}

const DropdownItem = ({ children, className, onClick }: DropdownItemProps) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-4 flex w-full items-center transition-colors duration-200 ease-in-out",
className,
)}
>
{children}
</button>
);
};
Comment thread
kimminna marked this conversation as resolved.
Resolved

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이 더 적절하다는 차이가 있더라구요!! (제가 이해한게 맞을까..요?..)

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

맞습니다!

탭 -> 단일 항목 토글 선택
셀렉터 -> 여러 항목 중 단일 선택

이라서 props로 전달할 속성이 다를 것 같네요!

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 = "매우중요" | "중요" | "보통" | "낮음";

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.

PriorityLevel 타입이 현재 한글 문자열을 값으로 직접 사용하고 있는데, 값과 라벨을 분리하는 건 어떨까요?
현재는 타입이 표시용 문자열을 겸하고 있어서, 나중에 API 연동 시 서버가 "HIGH" | "MEDIUM" 같은 형태로 내려오면 타입 자체를 바꿔야 해서 파급 범위가 커질 수 있을 것 같아요. 또한 프로덕트가 영문 버전도 있어 영문 키도 고려하면 좋을 것 같아요-!

export type PriorityLevel = "urgent" | "high" | "medium" | "low";

const PRIORITY_LABEL: Record<PriorityLevel, string> = {
  urgent: "매우중요",
  high: "중요",
  medium: "보통",
  low: "낮음",
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

그렇네요! 영문 타입으로 변경해 두겠습니다


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.
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)}
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