From 3119faaf1dd7d52fa2b5745e56d8dbc20e2e028b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Sat, 7 Feb 2026 04:45:53 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/preview.ts | 1 - src/components/list/TaskListItem.stories.tsx | 136 ++++++++++++++ src/components/list/TaskListItem.tsx | 136 ++++++++++++++ .../list/constants/taskListItemConstants.ts | 11 ++ src/components/list/index.ts | 2 + .../list/styles/TaskListItem.module.css | 173 ++++++++++++++++++ src/components/list/types/types.ts | 30 +++ 7 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 src/components/list/TaskListItem.stories.tsx create mode 100644 src/components/list/TaskListItem.tsx create mode 100644 src/components/list/constants/taskListItemConstants.ts create mode 100644 src/components/list/index.ts create mode 100644 src/components/list/styles/TaskListItem.module.css create mode 100644 src/components/list/types/types.ts diff --git a/.storybook/preview.ts b/.storybook/preview.ts index dfe9596..3555d52 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,3 @@ -import '../src/shared/styles/color.css'; import '../src/app/globals.css'; import './preview.css'; 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..93c1511 --- /dev/null +++ b/src/components/list/TaskListItem.tsx @@ -0,0 +1,136 @@ +'use client'; + +import clsx from 'clsx'; +import Image from 'next/image'; +import { useEffect, useState, 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'; + +const MOBILE_BREAKPOINT = 375; + +/** + * 할일 목록 아이템 카드 컴포넌트. + * 체크박스 + 제목 + 케밥 메뉴가 상단에, 날짜 + 반복 정보가 하단에 표시됩니다. + * 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 [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); + const handleChange = () => setIsMobile(mql.matches); + + handleChange(); + mql.addEventListener('change', handleChange); + return () => mql.removeEventListener('change', handleChange); + }, []); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + onTitleSubmit?.(); + } + }; + + const iconSize = isMobile ? 10 : 12; + + 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..490e0da --- /dev/null +++ b/src/components/list/styles/TaskListItem.module.css @@ -0,0 +1,173 @@ +.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; +} + +.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; +} + +.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; + } + + .title, + .titleInput { + font-size: 13px; + } + + .commentCount { + font-size: 11px; + } + + .metaRow { + font-size: 11px; + } +} 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; +}; From 836cbcc9dea2a9570d9658e7b3b28315f0902b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Sat, 7 Feb 2026 04:50:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=8A=A4=ED=86=A0=EB=A6=AC=EB=B6=81?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/preview.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 3555d52..dfe9596 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,3 +1,4 @@ +import '../src/shared/styles/color.css'; import '../src/app/globals.css'; import './preview.css'; From 974b7b706bdd769d756819d7f303161dea9fb6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Sat, 7 Feb 2026 13:21:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/list/TaskListItem.tsx | 47 +++++++++---------- .../list/styles/TaskListItem.module.css | 23 +++++++++ 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/components/list/TaskListItem.tsx b/src/components/list/TaskListItem.tsx index 93c1511..62d9384 100644 --- a/src/components/list/TaskListItem.tsx +++ b/src/components/list/TaskListItem.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import Image from 'next/image'; -import { useEffect, useState, type KeyboardEvent } from 'react'; +import type { KeyboardEvent } from 'react'; import CheckBox from '@/components/checkbox/CheckBox'; @@ -10,8 +10,6 @@ import styles from './styles/TaskListItem.module.css'; import { TASK_LIST_ITEM_ICONS } from './constants/taskListItemConstants'; import type { TaskListItemProps } from './types/types'; -const MOBILE_BREAKPOINT = 375; - /** * 할일 목록 아이템 카드 컴포넌트. * 체크박스 + 제목 + 케밥 메뉴가 상단에, 날짜 + 반복 정보가 하단에 표시됩니다. @@ -33,17 +31,6 @@ export default function TaskListItem({ onFrequencyClick, className, }: TaskListItemProps) { - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); - const handleChange = () => setIsMobile(mql.matches); - - handleChange(); - mql.addEventListener('change', handleChange); - return () => mql.removeEventListener('change', handleChange); - }, []); - const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); @@ -51,8 +38,6 @@ export default function TaskListItem({ } }; - const iconSize = isMobile ? 10 : 12; - return (
- +
+ +
+
+ +
{isEditing ? ( {date} {frequency && ( @@ -123,8 +118,8 @@ export default function TaskListItem({ className={styles.metaIcon} src={TASK_LIST_ITEM_ICONS.repeat} alt="" - width={iconSize} - height={iconSize} + width={12} + height={12} /> {frequency} diff --git a/src/components/list/styles/TaskListItem.module.css b/src/components/list/styles/TaskListItem.module.css index 490e0da..643fc8c 100644 --- a/src/components/list/styles/TaskListItem.module.css +++ b/src/components/list/styles/TaskListItem.module.css @@ -34,6 +34,14 @@ gap: 8px; } +.checkboxLarge { + display: block; +} + +.checkboxSmall { + display: none; +} + .titleGroup { flex: 1; min-width: 0; @@ -96,6 +104,8 @@ .metaIcon { flex-shrink: 0; display: block; + width: 12px; + height: 12px; } .separator { @@ -158,6 +168,14 @@ gap: 4px; } + .checkboxLarge { + display: none; + } + + .checkboxSmall { + display: block; + } + .title, .titleInput { font-size: 13px; @@ -170,4 +188,9 @@ .metaRow { font-size: 11px; } + + .metaIcon { + width: 10px; + height: 10px; + } } From 27ed330cfd17f912a9f06452cc2066a4711a0c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Sat, 7 Feb 2026 13:28:55 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4?= =?UTF-8?q?,=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/list/styles/TaskListItem.module.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/list/styles/TaskListItem.module.css b/src/components/list/styles/TaskListItem.module.css index 643fc8c..434f559 100644 --- a/src/components/list/styles/TaskListItem.module.css +++ b/src/components/list/styles/TaskListItem.module.css @@ -35,11 +35,13 @@ } .checkboxLarge { - display: block; + display: flex; + align-items: center; } .checkboxSmall { display: none; + align-items: center; } .titleGroup { @@ -173,7 +175,7 @@ } .checkboxSmall { - display: block; + display: flex; } .title,