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
116 changes: 116 additions & 0 deletions src/components/todo-card/TodoCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import type { ComponentProps } from 'react';

import { useState } from 'react';
import { fn } from 'storybook/test';

import TodoCard from './TodoCard';
import type { TodoItem } from './types/types';

const sampleItems: TodoItem[] = [
{ id: '1', text: '법인 설립 안내 드리기', checked: false },
{ id: '2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: false },
{ id: '3', text: '입력해주신 정보를 바탕으로 등기신청서 제...', checked: true },
];

const completedItems: TodoItem[] = [
{ id: '1', text: '법인 설립 안내 드리기', checked: true },
{ id: '2', text: '법인 설립 혹은 변경 등기 비용 안내 드리기', checked: true },
{ id: '3', text: '입력해주신 정보를 바탕으로 등기신청서 제...', checked: true },
];

const meta = {
title: 'Components/TodoCard',
component: TodoCard,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
title: '법인 설립',
items: sampleItems,
onKebabClick: fn(),
},
argTypes: {
expanded: { control: 'boolean' },
},
decorators: [
(Story) => (
<div style={{ width: 300 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof TodoCard>;

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

const ControlledTodoCard = (args: ComponentProps<typeof TodoCard>) => {
const [items, setItems] = useState(args.items);

const handleCheckedChange = (id: string, checked: boolean) => {
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, checked } : item)));
};

return <TodoCard {...args} items={items} onItemCheckedChange={handleCheckedChange} />;
};

export const Default: Story = {
render: (args) => <ControlledTodoCard {...args} />,
};

export const AllCompleted: Story = {
render: (args) => <ControlledTodoCard {...args} />,
args: {
items: completedItems,
},
};

export const Collapsed: Story = {
render: (args) => <ControlledTodoCard {...args} />,
args: {
expanded: false,
},
};

export const CollapsedCompleted: Story = {
render: (args) => <ControlledTodoCard {...args} />,
args: {
items: completedItems,
expanded: false,
},
};

export const Overview: Story = {
render: (args) => {
const [items1, setItems1] = useState(sampleItems);
const [items2, setItems2] = useState(completedItems);

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<TodoCard
{...args}
title="진행 중인 할일"
items={items1}
onItemCheckedChange={(id, checked) =>
setItems1((prev) => prev.map((item) => (item.id === id ? { ...item, checked } : item)))
}
Comment on lines +96 to +98

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.

medium

Overview 스토리 내에서 setItems1setItems2에 전달되는 상태 업데이트 로직이 중복됩니다. 이 로직은 ControlledTodoCard 컴포넌트의 handleCheckedChange 함수에도 동일하게 존재합니다. 코드 중복을 줄이고 유지보수성을 높이기 위해 이 로직을 별도의 헬퍼 함수로 추출하여 재사용하는 것을 고려해 보세요.

예를 들어, 다음과 같은 헬퍼 함수를 정의할 수 있습니다.

const updateItemChecked = (prev: TodoItem[], id: string, checked: boolean) =>
  prev.map((item) => (item.id === id ? { ...item, checked } : item));

그리고 이 함수를 handleCheckedChangeOverview 스토리에서 사용하면 코드가 더 간결해집니다.

// In ControlledTodoCard
const handleCheckedChange = (id: string, checked: boolean) => {
  setItems((prev) => updateItemChecked(prev, id, checked));
};

// In Overview story
// ...
onItemCheckedChange={(id, checked) => setItems1((prev) => updateItemChecked(prev, id, checked))}
// ...

/>
<TodoCard
{...args}
title="완료된 할일"
items={items2}
onItemCheckedChange={(id, checked) =>
setItems2((prev) => prev.map((item) => (item.id === id ? { ...item, checked } : item)))
}
/>
<TodoCard {...args} title="접힌 상태 (진행 중)" items={sampleItems} expanded={false} />
<TodoCard {...args} title="접힌 상태 (완료)" items={completedItems} expanded={false} />
</div>
);
},
parameters: {
controls: { disable: true },
},
};
61 changes: 61 additions & 0 deletions src/components/todo-card/TodoCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client';

import clsx from 'clsx';
import Image from 'next/image';

import Badge from '@/components/badge/Badge';
import CheckBox from '@/components/checkbox/CheckBox';

import styles from './styles/TodoCard.module.css';
import { TODO_CARD_ICONS } from './constants/todoCardConstants';
import type { TodoCardProps } from './types/types';

/**
* 할일 카드 컴포넌트.
* 제목, 진행 상태 뱃지, 체크박스 리스트를 포함합니다.
* expanded가 false이면 헤더만 표시됩니다.
*/
export default function TodoCard({
title,
items,
onItemCheckedChange,
onKebabClick,
expanded = true,
className,
}: TodoCardProps) {
const checkedCount = items.filter((item) => item.checked).length;

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.

medium

items prop이 변경되지 않았음에도 컴포넌트가 리렌더링될 때마다 checkedCount가 다시 계산됩니다. items 배열이 클 경우 성능에 영향을 줄 수 있습니다. useMemo를 사용하여 items가 변경될 때만 값을 다시 계산하도록 최적화하는 것을 권장합니다.

이를 위해 react에서 useMemo를 import해야 합니다: import { useMemo } from 'react';

Suggested change
const checkedCount = items.filter((item) => item.checked).length;
const checkedCount = useMemo(() => items.filter((item) => item.checked).length, [items]);

const totalCount = items.length;
const badgeLabel = `${checkedCount}/${totalCount}`;
const badgeState = checkedCount === totalCount && totalCount > 0 ? 'done' : 'ongoing';

return (
<div className={clsx(styles.card, className)}>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<Badge state={badgeState} size="small" label={badgeLabel} />
<button type="button" className={styles.kebab} onClick={onKebabClick} aria-label="더보기">
<Image src={TODO_CARD_ICONS.kebab} alt="" width={16} height={16} />
</button>
</div>

{expanded && items.length > 0 && (
<div className={styles.body}>
{items.map((item) => (
<div key={item.id} className={clsx(styles.item, item.checked && styles.itemChecked)}>
<CheckBox
checked={item.checked}
size="small"
label={<span className={styles.itemLabel}>{item.text}</span>}
onCheckedChange={
onItemCheckedChange
? (checked) => onItemCheckedChange(item.id, checked)
: undefined
}
/>
</div>
))}
</div>
)}
</div>
);
}
5 changes: 5 additions & 0 deletions src/components/todo-card/constants/todoCardConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import kebabSmall from '@/assets/icons/kebab/kebabSmall.svg';

export const TODO_CARD_ICONS = {
kebab: kebabSmall,
} as const;
2 changes: 2 additions & 0 deletions src/components/todo-card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as TodoCard } from './TodoCard';
export type { TodoCardProps, TodoItem } from './types/types';
73 changes: 73 additions & 0 deletions src/components/todo-card/styles/TodoCard.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
.card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
border-radius: 12px;
background-color: var(--color-background-inverse);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06),
0 1px 3px rgba(0, 0, 0, 0.1);
}

.header {
display: flex;
align-items: center;
gap: 8px;
}

.title {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.kebab {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: none;
border-radius: 4px;
cursor: pointer;
}

.kebab:hover {
background-color: var(--color-background-tertiary);
}

.body {
display: flex;
flex-direction: column;
gap: 6px;
}

.item {
display: flex;
align-items: center;
}

.itemLabel {
font-size: 12px;
font-weight: 400;
line-height: 1.4;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.itemChecked .itemLabel {
color: var(--color-text-disabled);
text-decoration: line-through;
}
23 changes: 23 additions & 0 deletions src/components/todo-card/types/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export type TodoItem = {
/** 항목 고유 ID */
id: string;
/** 항목 텍스트 */
text: string;
/** 체크 여부 */
checked: boolean;
};

export type TodoCardProps = {
/** 카드 제목 */
title: string;
/** 체크박스 항목 목록 */
items: TodoItem[];
/** 항목 체크 상태 변경 시 호출되는 콜백 */
onItemCheckedChange?: (id: string, checked: boolean) => void;
/** 케밥(⋮) 버튼 클릭 시 호출되는 콜백 */
onKebabClick?: () => void;
/** 체크리스트 펼침 여부 (기본값: true) */
expanded?: boolean;
/** 추가 CSS 클래스 */
className?: string;
};
Loading