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
5 changes: 5 additions & 0 deletions src/assets/icons/kebab/kebabLarge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/assets/icons/kebab/kebabSmall.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
137 changes: 137 additions & 0 deletions src/components/comment/CommentCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';

import { fn } from 'storybook/test';

import CommentCard from './CommentCard';

const meta = {
title: 'Components/CommentCard',
component: CommentCard,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
name: '안해나',
content: '오늘 할 일 목록을 정리했습니다.',
date: '2025.01.15',
},
decorators: [
(Story) => (
<div style={{ width: 460 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof CommentCard>;

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

export const Default: Story = {};

export const WithProfileImage: Story = {
args: {
profileImage: (
<div style={{ width: 32, height: 32, borderRadius: 12, background: '#cbd5e1' }} />
),
},
};

export const WithIcon: Story = {
args: {
profileImage: (
<div style={{ width: 32, height: 32, borderRadius: 12, background: '#cbd5e1' }} />
),
icon: (
<button
type="button"
onClick={fn()}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 16 }}
>
</button>
),
},
};

export const WithActions: Story = {
args: {
profileImage: (
<div style={{ width: 32, height: 32, borderRadius: 12, background: '#cbd5e1' }} />
),
icon: (
<button
type="button"
onClick={fn()}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 16 }}
>
</button>
),
actions: (
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={fn()}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 12,
color: '#64748b',
}}
>
취소
</button>
<button
type="button"
onClick={fn()}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 12,
color: '#3b82f6',
}}
>
수정하기
</button>
</div>
),
},
};

export const Overview: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: 460 }}>
<CommentCard name="안해나" content="기본 댓글입니다." date="2025.01.15" />
<CommentCard
name="안해나"
content="프로필 이미지가 있는 댓글입니다."
date="2025.01.15"
profileImage={
<div style={{ width: 32, height: 32, borderRadius: 12, background: '#cbd5e1' }} />
}
/>
<CommentCard
name="안해나"
content="모든 슬롯이 채워진 댓글입니다."
date="2025.01.15"
profileImage={
<div style={{ width: 32, height: 32, borderRadius: 12, background: '#cbd5e1' }} />
}
icon={<span style={{ fontSize: 16, cursor: 'pointer' }}>⋮</span>}

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.

high

Overview 스토리에서 icon 슬롯에 <span> 태그를 사용하고 cursor: 'pointer' 스타일을 적용했습니다. 이는 시각적으로는 클릭 가능한 요소처럼 보이지만, 스크린 리더 사용자에게는 버튼으로 인식되지 않아 접근성 문제가 발생할 수 있습니다. 상호작용이 필요한 요소는 <button> 태그를 사용하는 것이 좋습니다.

<button type="button" onClick={fn()} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 16 }}>⋮</button>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

span으로 자리만 잡아둔 것이라 아이콘 주입하여 사용하면 됩니다.

actions={
<div style={{ display: 'flex', gap: 8 }}>
<span style={{ fontSize: 12, color: '#64748b', cursor: 'pointer' }}>취소</span>
<span style={{ fontSize: 12, color: '#3b82f6', cursor: 'pointer' }}>수정하기</span>
Comment on lines +127 to +128

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.

high

Overview 스토리의 actions 슬롯에서도 <span> 태그를 사용하여 '취소' 및 '수정하기' 버튼을 구현했습니다. 이 또한 접근성 문제를 야기할 수 있으므로, 상호작용이 필요한 요소에는 <button> 태그를 사용하는 것이 적절합니다.

<button
          type="button"
          onClick={fn()}
          style={{
            background: 'none',
            border: 'none',
            cursor: 'pointer',
            fontSize: 12,
            color: '#64748b',
          }}
        >
          취소
        </button>
        <button
          type="button"
          onClick={fn()}
          style={{
            background: 'none',
            border: 'none',
            cursor: 'pointer',
            fontSize: 12,
            color: '#3b82f6',
          }}
        >
          수정하기
        </button>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

span으로 자리만 잡아둔 것이라 버튼 주입하여 사용하면 됩니다.

</div>
}
/>
</div>
),
parameters: {
controls: { disable: true },
},
};
40 changes: 40 additions & 0 deletions src/components/comment/CommentCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import styles from './styles/CommentCard.module.css';
import type { CommentCardProps } from './types/types';

/**
* 댓글 카드 컴포넌트.
* 프로필 이미지, 이름, 내용, 날짜를 표시합니다.
* icon 슬롯에 케밥 메뉴를, actions 슬롯에 수정/취소 버튼을 주입할 수 있습니다.
*/
export default function CommentCard({
profileImage,
name,
content,
date,
dateTime,
icon,
actions,
}: CommentCardProps) {
return (
<article className={styles.card}>
{profileImage && (
<div className={styles.avatar} aria-hidden="true">

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

profileImage를 감싸는 divaria-hidden="true"가 설정되어 있습니다. 만약 profileImage가 사용자 프로필 사진과 같이 의미 있는 정보를 전달하는 이미지라면, 스크린 리더 사용자에게 해당 정보가 전달되지 않을 수 있습니다. 이 이미지가 순전히 장식적인 목적이 아니라면 aria-hidden 속성을 제거하고, profileImage로 전달되는 <img> 태그에 적절한 alt 속성을 포함하도록 안내하는 것이 좋습니다.

Suggested change
<div className={styles.avatar} aria-hidden="true">
<div className={styles.avatar}>

{profileImage}
</div>
)}
<div className={styles.body}>
<div className={styles.header}>
<span className={styles.name}>{name}</span>
{icon}
</div>
<p className={styles.content}>{content}</p>
<div className={styles.footer}>
<time className={styles.date} dateTime={dateTime}>
{date}
</time>
{actions}
</div>
</div>
</article>
);
}
2 changes: 2 additions & 0 deletions src/components/comment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as CommentCard } from './CommentCard';
export type { CommentCardProps } from './types/types';
66 changes: 66 additions & 0 deletions src/components/comment/styles/CommentCard.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
.card {
display: flex;
gap: 12px;
}

.avatar {

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

avatar 클래스의 크기가 @media (max-width: 375px) 조건에서만 24px로 명시되어 있습니다. 더 큰 화면에서는 avatar div 자체의 크기가 명시적으로 설정되어 있지 않아, profileImage로 전달되는 요소의 크기에 따라 레이아웃이 달라질 수 있습니다. 일관된 아바타 크기 관리를 위해 기본 avatar 스타일에 크기를 정의하고, 필요한 경우 미디어 쿼리에서 재정의하는 것이 좋습니다. Storybook의 기본 플레이스홀더 크기(32px)와 일치시키는 것을 고려해볼 수 있습니다.

.avatar {
  width: 32px;
  height: 32px;

flex-shrink: 0;
}

.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}

.header {
display: flex;
justify-content: space-between;
align-items: center;
}

.name {
font-weight: 600;
font-size: 14px;
color: var(--color-text-tertiary);
}

.content {
font-weight: 500;
font-size: 14px;
color: var(--color-text-secondary);
margin: 0;
}

.footer {
display: flex;
justify-content: space-between;
align-items: center;
}

.date {
font-weight: 400;
font-size: 12px;
color: var(--color-text-disabled);
}

@media (max-width: 375px) {
.card {
gap: 8px;
}

.avatar {
width: 24px;
height: 24px;
}

.name {
font-size: 12px;
}

.content {
font-size: 13px;
}
}
18 changes: 18 additions & 0 deletions src/components/comment/types/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';

export type CommentCardProps = {
/** 프로필 이미지 (ReactNode로 자유롭게 주입, 예: `<Image />`) */
profileImage?: ReactNode;
/** 댓글 작성자 이름 */
name: string;
/** 댓글 본문 내용 */
content: string;
/** 화면에 표시할 날짜 텍스트 (예: "2024년 7월 29일") */
date: string;
/** `<time>` 태그의 datetime 속성값 (예: "2024-07-29") */
dateTime?: string;

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

date prop이 항상 표시되는 반면, dateTime prop은 선택 사항입니다. <time> 태그의 datetime 속성은 날짜/시간 정보를 기계가 읽을 수 있는 형식으로 제공하여 접근성과 검색 엔진 최적화에 도움을 줍니다. date가 제공될 때 dateTime도 함께 제공되도록 하여 시맨틱 HTML을 강화하는 것을 권장합니다. 이렇게 변경하면 CommentCard.stories.tsx에서도 dateTime을 필수로 제공해야 합니다.

Suggested change
dateTime?: string;
dateTime: string;

/** 우측 상단 아이콘 슬롯 (예: 케밥 메뉴 버튼) */
icon?: ReactNode;
/** 하단 우측 액션 슬롯 (예: 취소/수정하기 버튼) */
actions?: ReactNode;
};