diff --git a/src/components/list/TaskListItem.stories.tsx b/src/components/list/TaskListItem.stories.tsx new file mode 100644 index 0000000..14951a1 --- /dev/null +++ b/src/components/list/TaskListItem.stories.tsx @@ -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) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const ControlledTaskListItem: Story['render'] = (args) => { + const [{ checked }, updateArgs] = useArgs(); + + const handleCheckedChange = (nextChecked: boolean) => { + updateArgs({ checked: nextChecked }); + args.onCheckedChange?.(nextChecked); + }; + + return ; +}; + +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 ( + alert(`입력 완료: ${title}`)} + /> + ); +}; + +export const Editing: Story = { + render: EditingTaskListItem, + args: { + date: '2024년 7월 29일', + frequency: '매일 반복', + }, +}; + +export const Overview: Story = { + render: () => ( +
+ + + + + + + +
+ ), + parameters: { + controls: { disable: true }, + }, +}; diff --git a/src/components/list/TaskListItem.tsx b/src/components/list/TaskListItem.tsx new file mode 100644 index 0000000..62d9384 --- /dev/null +++ b/src/components/list/TaskListItem.tsx @@ -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) => { + if (event.key === 'Enter') { + event.preventDefault(); + onTitleSubmit?.(); + } + }; + + return ( +
+
+
+ +
+
+ +
+
+ {isEditing ? ( + onTitleChange?.(e.target.value)} + onKeyDown={handleKeyDown} + aria-label="할일 제목 입력" + /> + ) : ( + {title} + )} + {!isEditing && commentCount != null && commentCount > 0 && ( + + + {commentCount} + + )} +
+ +
+ +
+ + {date} + {frequency && ( + <> + | + + + )} +
+
+ ); +} diff --git a/src/components/list/constants/taskListItemConstants.ts b/src/components/list/constants/taskListItemConstants.ts new file mode 100644 index 0000000..1d1d152 --- /dev/null +++ b/src/components/list/constants/taskListItemConstants.ts @@ -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; diff --git a/src/components/list/index.ts b/src/components/list/index.ts new file mode 100644 index 0000000..a184947 --- /dev/null +++ b/src/components/list/index.ts @@ -0,0 +1,2 @@ +export { default as TaskListItem } from './TaskListItem'; +export type { TaskListItemProps } from './types/types'; diff --git a/src/components/list/styles/TaskListItem.module.css b/src/components/list/styles/TaskListItem.module.css new file mode 100644 index 0000000..434f559 --- /dev/null +++ b/src/components/list/styles/TaskListItem.module.css @@ -0,0 +1,198 @@ +.card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + border-radius: 8px; + background-color: var(--color-background-inverse); + border: 1px solid var(--color-background-tertiary); + cursor: pointer; + transition: + background-color 0.15s, + border-color 0.15s; +} + +.card:hover { + background-color: var(--color-brand-secondary); +} + +.selected { + border-color: var(--color-icon-brand); +} + +.completed { + background-color: var(--color-background-secondary); +} + +.completed:hover { + background-color: var(--color-background-secondary); +} + +.topRow { + display: flex; + align-items: center; + gap: 8px; +} + +.checkboxLarge { + display: flex; + align-items: center; +} + +.checkboxSmall { + display: none; + align-items: center; +} + +.titleGroup { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 4px; +} + +.title { + min-width: 0; + font-size: 14px; + font-weight: 500; + line-height: 1.4; + color: var(--color-text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.titleInput { + width: 100%; + min-width: 0; + font-family: var(--font-pretendard), 'Pretendard', sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 1.4; + color: var(--color-text-tertiary); + background: transparent; + border: none; + outline: none; + padding: 0; +} + +.titleInput::placeholder { + color: var(--color-text-default); +} + +.titleCompleted { + color: var(--color-text-disabled); + text-decoration: line-through; +} + +.commentCount { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + font-size: 12px; + color: var(--color-text-disabled); +} + +.metaRow { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--color-text-disabled); +} + +.metaIcon { + flex-shrink: 0; + display: block; + width: 12px; + height: 12px; +} + +.separator { + margin: 0 8px; + color: var(--color-background-tertiary); +} + +.frequencyButton { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: none; + background: none; + font-family: inherit; + font-size: inherit; + color: inherit; + cursor: pointer; +} + +.frequencyButton:hover { + text-decoration: underline; +} + +.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); +} + +@media (max-width: 767px) { + .card { + padding: 10px; + } + + .topRow { + gap: 6px; + } +} + +@media (max-width: 375px) { + .card { + padding: 8px; + gap: 4px; + } + + .topRow { + gap: 4px; + } + + .checkboxLarge { + display: none; + } + + .checkboxSmall { + display: flex; + } + + .title, + .titleInput { + font-size: 13px; + } + + .commentCount { + font-size: 11px; + } + + .metaRow { + font-size: 11px; + } + + .metaIcon { + width: 10px; + height: 10px; + } +} diff --git a/src/components/list/types/types.ts b/src/components/list/types/types.ts new file mode 100644 index 0000000..f75e061 --- /dev/null +++ b/src/components/list/types/types.ts @@ -0,0 +1,30 @@ +export type TaskListItemProps = { + /** 할일 제목 텍스트 */ + title: string; + /** 날짜 텍스트 (예: "2024년 7월 29일") */ + date: string; + /** 체크(완료) 여부 */ + checked?: boolean; + /** 선택 상태 (파란 테두리 표시) */ + isSelected?: boolean; + /** 인라인 편집 모드 활성화 여부 */ + isEditing?: boolean; + /** 편집 모드에서 빈 입력 시 표시할 안내 텍스트 */ + placeholder?: string; + /** 반복 정보 텍스트 (예: "매일 반복"). 없으면 숨김 */ + frequency?: string; + /** 댓글 수. 0이거나 없으면 숨김 */ + commentCount?: number; + /** 체크 상태 변경 시 호출되는 콜백 */ + onCheckedChange?: (checked: boolean) => void; + /** 편집 모드에서 제목 텍스트 변경 시 호출되는 콜백 */ + onTitleChange?: (value: string) => void; + /** 편집 모드에서 Enter 입력 시 호출되는 콜백 */ + onTitleSubmit?: () => void; + /** 케밥(⋮) 버튼 클릭 시 호출되는 콜백 */ + onKebabClick?: () => void; + /** 반복 아이콘 클릭 시 호출되는 콜백 */ + onFrequencyClick?: () => void; + /** 추가 CSS 클래스 */ + className?: string; +};