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

import { useState } from 'react';
import { useArgs } from 'storybook/preview-api';
import { fn } from 'storybook/test';

import TaskListItem from './TaskListItem';

const meta = {
title: 'Components/TaskListItem',
component: TaskListItem,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
title: '할일 제목',
date: '2024년 7월 29일',
checked: false,
isSelected: false,
onCheckedChange: fn(),
onKebabClick: fn(),
onFrequencyClick: fn(),
},
argTypes: {
checked: { control: 'boolean' },
isSelected: { control: 'boolean' },
isEditing: { control: 'boolean' },
frequency: { control: 'text' },
commentCount: { control: 'number' },
},
decorators: [
(Story) => (
<div style={{ width: 400 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof TaskListItem>;

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

const ControlledTaskListItem: Story['render'] = (args) => {
const [{ checked }, updateArgs] = useArgs();

const handleCheckedChange = (nextChecked: boolean) => {
updateArgs({ checked: nextChecked });
args.onCheckedChange?.(nextChecked);
};

return <TaskListItem {...args} checked={checked} onCheckedChange={handleCheckedChange} />;
};

export const Default: Story = {
render: ControlledTaskListItem,
};

export const Selected: Story = {
render: ControlledTaskListItem,
args: {
isSelected: true,
},
};

export const Completed: Story = {
render: ControlledTaskListItem,
args: {
checked: true,
},
};

export const WithComments: Story = {
render: ControlledTaskListItem,
args: {
commentCount: 3,
},
};

export const WithFrequency: Story = {
render: ControlledTaskListItem,
args: {
frequency: '매일 반복',
},
};

const EditingTaskListItem: Story['render'] = (args) => {
const [title, setTitle] = useState('');

return (
<TaskListItem
{...args}
title={title}
isEditing
placeholder="할 일을 달성하기 위한 체크리스트를 입력해주세요."
onTitleChange={setTitle}
onTitleSubmit={() => alert(`입력 완료: ${title}`)}
/>
);
};

export const Editing: Story = {
render: EditingTaskListItem,
args: {
date: '2024년 7월 29일',
frequency: '매일 반복',
},
};

export const Overview: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<TaskListItem title="기본 상태" date="2024년 7월 29일" />
<TaskListItem title="선택 상태 (파란 테두리)" date="2024년 7월 29일" isSelected />
<TaskListItem title="완료 상태 (취소선 + 흐린 텍스트)" date="2024년 7월 29일" checked />
<TaskListItem title="댓글 있는 할일" date="2024년 7월 29일" commentCount={3} />
<TaskListItem title="반복 정보 있는 할일" date="2024년 7월 29일" frequency="매일 반복" />
<TaskListItem
title="모든 옵션이 있는 할일"
date="2024년 7월 29일"
frequency="매주 반복"
commentCount={5}
/>
<TaskListItem
title=""
date="2024년 7월 29일"
frequency="매일 반복"
isEditing
placeholder="할 일을 달성하기 위한 체크리스트를 입력해주세요."
/>
</div>
),
parameters: {
controls: { disable: true },
},
};
131 changes: 131 additions & 0 deletions src/components/list/TaskListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
'use client';

import clsx from 'clsx';
import Image from 'next/image';
import type { KeyboardEvent } from 'react';

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

import styles from './styles/TaskListItem.module.css';
import { TASK_LIST_ITEM_ICONS } from './constants/taskListItemConstants';
import type { TaskListItemProps } from './types/types';

/**
* 할일 목록 아이템 카드 컴포넌트.
* 체크박스 + 제목 + 케밥 메뉴가 상단에, 날짜 + 반복 정보가 하단에 표시됩니다.
* isEditing이 true이면 제목 영역이 인라인 텍스트 입력으로 전환됩니다.
*/
export default function TaskListItem({
title,
date,
checked = false,
isSelected = false,
isEditing = false,
placeholder,
frequency,
commentCount,
onCheckedChange,
onTitleChange,
onTitleSubmit,
onKebabClick,
onFrequencyClick,
className,
}: TaskListItemProps) {
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
onTitleSubmit?.();
}
};

return (
<div
className={clsx(
styles.card,
isSelected && styles.selected,
checked && styles.completed,
className,
)}
>
<div className={styles.topRow}>
<div className={styles.checkboxLarge}>
<CheckBox
checked={checked}
size="large"
onCheckedChange={onCheckedChange}
options={{ ariaLabel: `${title} 완료 체크` }}
/>
</div>
<div className={styles.checkboxSmall}>
<CheckBox
checked={checked}
size="small"
onCheckedChange={onCheckedChange}
options={{ ariaLabel: `${title} 완료 체크` }}
/>
</div>
<div className={styles.titleGroup}>
{isEditing ? (
<input
className={styles.titleInput}
type="text"
value={title}
placeholder={placeholder}
onChange={(e) => onTitleChange?.(e.target.value)}
onKeyDown={handleKeyDown}
aria-label="할일 제목 입력"
/>
) : (
<span className={clsx(styles.title, checked && styles.titleCompleted)}>{title}</span>
)}
{!isEditing && commentCount != null && commentCount > 0 && (
<span className={styles.commentCount}>
<Image
className={styles.metaIcon}
src={TASK_LIST_ITEM_ICONS.comment}
alt=""
width={16}
height={16}
/>
{commentCount}
</span>
)}
</div>
<button type="button" className={styles.kebab} onClick={onKebabClick} aria-label="더보기">
<Image src={TASK_LIST_ITEM_ICONS.kebab} alt="" width={16} height={16} />
</button>
</div>

<div className={styles.metaRow}>
<Image
className={styles.metaIcon}
src={TASK_LIST_ITEM_ICONS.calender}
alt=""
width={12}
height={12}
/>
<span>{date}</span>
{frequency && (
<>
<span className={styles.separator}>|</span>
<button
type="button"
className={styles.frequencyButton}
onClick={onFrequencyClick}
aria-label="반복 설정"
>
<Image
className={styles.metaIcon}
src={TASK_LIST_ITEM_ICONS.repeat}
alt=""
width={12}
height={12}
/>
<span>{frequency}</span>
</button>
</>
)}
</div>
</div>
);
}
11 changes: 11 additions & 0 deletions src/components/list/constants/taskListItemConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import calenderSmall from '@/assets/icons/calender/calenderSmall.svg';
import repeatSmall from '@/assets/icons/repeat/repeatSmall.svg';
import comment from '@/assets/icons/comment/comment.svg';
import kebabSmall from '@/assets/icons/kebab/kebabSmall.svg';

export const TASK_LIST_ITEM_ICONS = {
calender: calenderSmall,
repeat: repeatSmall,
comment,
kebab: kebabSmall,
} as const;
2 changes: 2 additions & 0 deletions src/components/list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as TaskListItem } from './TaskListItem';
export type { TaskListItemProps } from './types/types';
Loading
Loading