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'; diff --git a/eslint.config.mjs b/eslint.config.mjs index b9290c1..d13150b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,14 +1,27 @@ // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from "eslint-plugin-storybook"; +import storybook from 'eslint-plugin-storybook'; import { defineConfig, globalIgnores } from 'eslint/config'; import nextVitals from 'eslint-config-next/core-web-vitals'; import nextTs from 'eslint-config-next/typescript'; -export default defineConfig([...nextVitals, ...nextTs, { - rules: { - 'prefer-const': 'error', - - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], +export default defineConfig([ + ...nextVitals, + ...nextTs, + { + rules: { + 'prefer-const': 'error', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, + }, + { + files: ['**/*.stories.@(ts|tsx)', '**/*.mdx'], + rules: { + 'react/no-unescaped-entities': 'off', + }, }, -}, globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']), ...storybook.configs["flat/recommended"]]); + + globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']), + + ...storybook.configs['flat/recommended'], +]); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1afcee4..f2e339e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import '@/shared/styles/color.css'; import './globals.css'; import { pretendard } from '@/shared/styles/font'; diff --git a/src/assets/buttons/plus/plusBoxButton.svg b/src/assets/buttons/plus/plusBoxButton.svg new file mode 100644 index 0000000..f630456 --- /dev/null +++ b/src/assets/buttons/plus/plusBoxButton.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/buttons/plus/plusGnbAddButton.svg b/src/assets/buttons/plus/plusGnbAddButton.svg new file mode 100644 index 0000000..41617eb --- /dev/null +++ b/src/assets/buttons/plus/plusGnbAddButton.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/check/hovercheck.svg b/src/assets/icons/check/hovercheck.svg new file mode 100644 index 0000000..ef75c64 --- /dev/null +++ b/src/assets/icons/check/hovercheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/check/inactivecheck.svg b/src/assets/icons/check/inactivecheck.svg new file mode 100644 index 0000000..0fe3e37 --- /dev/null +++ b/src/assets/icons/check/inactivecheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/check/pressedcheck.svg b/src/assets/icons/check/pressedcheck.svg new file mode 100644 index 0000000..cd2bbe4 --- /dev/null +++ b/src/assets/icons/check/pressedcheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/check/primarycheck.svg b/src/assets/icons/check/primarycheck.svg new file mode 100644 index 0000000..b610373 --- /dev/null +++ b/src/assets/icons/check/primarycheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Button/base/BaseButton.module.css b/src/components/Button/base/BaseButton.module.css new file mode 100644 index 0000000..ba2c682 --- /dev/null +++ b/src/components/Button/base/BaseButton.module.css @@ -0,0 +1,88 @@ +.base { + display: flex; + width: 100%; + font-weight: 600; +} + +/* ============================== + Size + ============================== */ + +.default { + height: 48px; + padding: 0 20px; /* ✅ 기본 버튼 좌우 여백 */ + border-radius: 12px; + font-size: 16px; +} + +.small { + height: 33px; + padding: 0 12px; /* ✅ 작은 버튼 좌우 여백 (시안 느낌) */ + border-radius: 8px; + font-size: 14px; +} + +/* ============================== + Variant + ============================== */ + +/* primary */ +.primary { + background-color: var(--color-brand-primary); + color: var(--color-text-inverse); +} + +.primary:hover:not(:disabled) { + background-color: var(--color-interaction-hover); +} + +.primary:active:not(:disabled) { + background-color: var(--color-interaction-pressed); +} + +.primary:disabled { + background-color: var(--color-interaction-inactive); + color: var(--color-text-inverse); +} + +/* outline */ +.outline { + background-color: transparent; + color: var(--color-brand-primary); + border: 1.5px solid var(--color-brand-primary); +} + +.outline:hover:not(:disabled) { + background-color: var(--color-brand-secondary); +} + +.outline:active:not(:disabled) { + background-color: var(--color-brand-secondary); + border-color: var(--color-interaction-pressed); + color: var(--color-interaction-pressed); +} + +.outline:disabled { + background-color: transparent; + border-color: var(--color-interaction-inactive); + color: var(--color-interaction-inactive); +} + +/* danger */ +.danger { + background-color: var(--color-status-danger); + color: var(--color-text-inverse); +} + +.danger:hover:not(:disabled) { + background-color: var(--color-status-danger-hover); +} + +.danger:active:not(:disabled) { + background-color: var(--color-status-danger-pressed); +} + +.danger:disabled { + background-color: var(--color-interaction-inactive); + color: var(--color-text-inverse); +} diff --git a/src/components/Button/base/BaseButton.stories.tsx b/src/components/Button/base/BaseButton.stories.tsx new file mode 100644 index 0000000..c6c2997 --- /dev/null +++ b/src/components/Button/base/BaseButton.stories.tsx @@ -0,0 +1,513 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import BaseButton from '@/components/Button/base/BaseButton'; + +/** + * BaseButton 컴포넌트 + * + * Coworkers 프로젝트의 기본 버튼 컴포넌트입니다. + * + * ## 설계 철학 + * + * BaseButton은 모든 버튼의 기초가 되는 컴포넌트로, 합성(Composition) 패턴을 사용합니다. + * 상속이 아닌 ButtonBehavior.module.css를 합성하여 일관된 동작을 보장합니다. + * + * + * ### Props + * - variant: 'primary' | 'outline' | 'danger' (기본값: 'primary') + * - size: 'default' | 'small' (기본값: 'default', 48px / 33px) + * - disabled: boolean (기본값: false) + * - type: 'button' | 'submit' | 'reset' (기본값: 'button') + * + * ## 용도 + * + * ### ✅ 사용해야 할 때 + * - 범용적인 확인/취소/저장 버튼 + * - 특별한 기능 없이 단순 클릭만 필요할 때 + * - 빠른 프로토타이핑 + * + * ### ❌ 사용하지 말아야 할 때 + * - 특정 도메인 기능 (편집, 좋아요, 추가 등) + * - 이런 경우 도메인 버튼을 사용하거나 새로 만드세요 + * + * ## 컴포넌트 목록 + * + * ### 도메인 버튼들 + * + * **ArrowButton** - 좌/우 방향 전환 (캐러셀, 달력) + * + * **DatePickerButton** - 요일 선택 (반복 일정) + * + * **EditButton** - 이미지 편집 (프로필, 썸네일) + * + * **EnterButton** - 댓글/메시지 전송 + * + * **FilledRoundButton** - CTA 버튼 (모달, 완료) + * + * **FloatingButton** - 주요 액션 (우측 하단 고정) + * + * **FloatingLikeButton** - 좋아요 토글 + * + * **GnbAddButton** - GNB에서 팀/프로젝트 추가 + * + * **OutlineIconTextButton** - 완료/확인 액션 + * + * **ProgressButton** - 칸반 보드 항목 추가 + * + * ## 확장 방법 + * + * ### 1. className으로 확장 (간단) + * ```tsx + * 커스텀 + * ``` + * + * ### 2. Wrapper 컴포넌트 (권장) + * ```tsx + * export function DeleteButton(props) { + * return 삭제; + * } + * ``` + * + * ### 3. ButtonBehavior만 재사용 (완전히 다른 스타일) + * ```tsx + * import behavior from '@/components/Button/shared/ButtonBehavior.module.css'; + * + * export function CustomButton({ children }) { + * return ; + * } + * ``` + * + * ## 자주 묻는 질문 + * + * **Q1. BaseButton vs 도메인 버튼, 언제 뭘 써야 하나요?** + * A. 용도가 명확하면 도메인 버튼, 범용이면 BaseButton을 사용하세요. + * + * **Q2. Form 안에서 버튼이 자동으로 submit 되는데요?** + * A. 기본 type이 'button'이므로 submit 안됩니다. submit 원하면 type='submit' 사용하세요. + * + * **Q3. 버튼이 클릭되지 않아요!** + * A. disabled prop 확인하세요. onClick에 함수() 말고 함수만 전달하세요. + * + * **Q4. 새로운 버튼을 만들어야 하나요?** + * A. 프로젝트 전체에서 3번 이상 사용되고 명확한 용도가 있으면 만드세요. + * + * **Q5. 모바일에서 버튼 크기를 조정하려면?** + * A. size='small' prop을 사용하거나 CSS media query를 사용하세요. + * + * ## CSS 구조 + * + * - ButtonBehavior.module.css (공통 레이어): focus, transition, cursor + * - BaseButton.module.css (개별 스타일): variant, size + * + * ## 디자인 토큰 + * + * - --color-brand-primary: Primary 배경색 + * - --color-interaction-hover: Hover 상태 + * - --color-interaction-pressed: Active 상태 + * - --color-interaction-inactive: Disabled 상태 + * + * ## 접근성 + * + * - 키보드 네비게이션 (Tab, Enter, Space) 지원 + * - Focus-visible 스타일 제공 + * - Disabled 상태에서 포커스 차단 + * - 기본 type="button"으로 의도치 않은 form submit 방지 + */ +const meta: Meta = { + title: 'Components/Button/BaseButton', + component: BaseButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['primary', 'outline', 'danger'], + description: '버튼의 스타일 변형', + table: { + defaultValue: { summary: 'primary' }, + }, + }, + size: { + control: 'select', + options: ['default', 'small'], + description: '버튼의 크기 (default: 48px, small: 33px)', + table: { + defaultValue: { summary: 'default' }, + }, + }, + disabled: { + control: 'boolean', + description: '버튼 비활성화 여부', + table: { + defaultValue: { summary: 'false' }, + }, + }, + type: { + control: 'select', + options: ['button', 'submit', 'reset'], + description: '버튼 타입', + table: { + defaultValue: { summary: 'button' }, + }, + }, + children: { + control: 'text', + description: '버튼 내부 텍스트', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================== +// 기본 스토리 +// ============================== + +export const Primary: Story = { + args: { + children: 'Primary', + variant: 'primary', + }, +}; + +export const Outline: Story = { + args: { + children: 'Outline', + variant: 'outline', + }, +}; + +export const Danger: Story = { + args: { + children: 'Danger', + variant: 'danger', + }, +}; + +// ============================== +// Size 변형 +// ============================== + +export const SmallPrimary: Story = { + args: { + children: 'Small Primary', + size: 'small', + variant: 'primary', + }, +}; + +export const SmallOutline: Story = { + args: { + children: 'Small Outline', + size: 'small', + variant: 'outline', + }, +}; + +export const SmallDanger: Story = { + args: { + children: 'Small Danger', + size: 'small', + variant: 'danger', + }, +}; + +// ============================== +// Disabled 상태 +// ============================== + +export const DisabledPrimary: Story = { + args: { + children: 'Disabled Primary', + disabled: true, + }, +}; + +export const DisabledOutline: Story = { + args: { + children: 'Disabled Outline', + variant: 'outline', + disabled: true, + }, +}; + +export const DisabledDanger: Story = { + args: { + children: 'Disabled Danger', + variant: 'danger', + disabled: true, + }, +}; + +// ============================== +// Variant × Size 조합 (한눈에 보기) +// ============================== + +export const AllVariantsDefault: Story = { + render: () => ( +
+
+ Primary +
+
+ Outline +
+
+ Danger +
+
+ ), + parameters: { + docs: { + description: { + story: 'default 사이즈(48px)의 모든 variant를 한눈에 확인', + }, + }, + }, +}; + +export const AllVariantsSmall: Story = { + render: () => ( +
+
+ Primary +
+
+ + Outline + +
+
+ + Danger + +
+
+ ), + parameters: { + docs: { + description: { + story: 'small 사이즈(33px)의 모든 variant를 한눈에 확인', + }, + }, + }, +}; + +export const AllVariantsDisabled: Story = { + render: () => ( +
+
+

default (48px)

+
+
+ Primary +
+
+ + Outline + +
+
+ + Danger + +
+
+
+ +
+

small (33px)

+
+
+ + Primary + +
+
+ + Outline + +
+
+ + Danger + +
+
+
+
+ ), + parameters: { + docs: { + description: { + story: 'disabled 상태의 모든 조합을 확인', + }, + }, + }, +}; + +// ============================== +// Width 테스트 (부모 컨테이너 너비에 따라 변동) +// ============================== + +export const WidthResponsiveDefault: Story = { + render: () => ( +
+
+ 200px +
+
+ 280px +
+
+ 400px +
+
+ ), + parameters: { + docs: { + description: { + story: 'BaseButton은 width: 100%로 설정되어 부모 컨테이너의 너비를 따릅니다.', + }, + }, + }, +}; + +export const WidthResponsiveSmall: Story = { + render: () => ( +
+
+ 160px +
+
+ 240px +
+
+ 360px +
+
+ ), + parameters: { + docs: { + description: { + story: 'small 사이즈에서도 동일하게 부모 너비를 따릅니다.', + }, + }, + }, +}; + +export const WidthResponsiveOutline: Story = { + render: () => ( +
+
+ 180px +
+
+ 300px +
+
+ 420px +
+
+ ), + parameters: { + docs: { + description: { + story: 'outline variant도 동일한 반응형 너비 동작을 합니다.', + }, + }, + }, +}; + +// ============================== +// Focus 테스트 (키보드 탭 이동) +// ============================== + +export const FocusTest: Story = { + render: () => ( +
+
+ Tab me +
+
+ Then me +
+
+ + Skip me + +
+
+ And me +
+
+ ), + parameters: { + docs: { + description: { + story: 'Tab 키로 버튼 간 이동을 테스트합니다. disabled 버튼은 focus를 받지 않습니다.', + }, + }, + }, +}; + +// ============================== +// Form Submit 동작 테스트 +// ============================== + +export const FormSubmitBehavior: Story = { + render: () => { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + alert('Form submitted'); + }; + + return ( +
+ +
+
+ Default (no submit) +
+
+ Submit +
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: `type="button"(기본값)은 submit하지 않고, type="submit"만 form을 submit합니다.`, + }, + }, + }, +}; diff --git a/src/components/Button/base/BaseButton.tsx b/src/components/Button/base/BaseButton.tsx new file mode 100644 index 0000000..fcbeb7c --- /dev/null +++ b/src/components/Button/base/BaseButton.tsx @@ -0,0 +1,55 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +import behavior from '@/components/Button/shared/ButtonBehavior.module.css'; +import styles from '@/components/Button/base/BaseButton.module.css'; + +export type ButtonVariant = 'primary' | 'outline' | 'danger'; +export type ButtonSize = 'default' | 'small'; + +interface BaseButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + children: ReactNode; +} + +/** + * BaseButton 컴포넌트 + * + * @description + * 프로젝트에서 사용하는 버튼의 “베이스” 컴포넌트로, 공통적인 버튼 동작/리셋(behavior)과 + * 버튼 기본 스타일(base), 변형(variant), 크기(size) 스타일을 조합하여 적용한다. + * + * @remarks + * - 스타일은 `ButtonBehavior.module.css`(행동/리셋) + `BaseButton.module.css`(베이스/프리셋) 조합으로 구성된다. + * - `type` 기본값은 `button`이며, 폼 내부에서 의도치 않은 submit을 방지한다. + * - `className`을 추가로 전달하면 마지막에 합쳐져 외부에서 확장이 가능하다. + * + * @param props.variant - 버튼 스타일 변형(기본값: `primary`) + * @param props.size - 버튼 크기 프리셋(기본값: `default`) + * @param props.type - 버튼 타입(기본값: `button`) + * @param props.disabled - 비활성화 여부 + * @param props.children - 버튼 내부 콘텐츠 + * @returns 버튼 엘리먼트 + */ +export default function BaseButton({ + variant = 'primary', + size = 'default', + type = 'button', + children, + className, + disabled, + ...rest +}: BaseButtonProps) { + return ( + + ); +} diff --git a/src/components/Button/base/index.ts b/src/components/Button/base/index.ts new file mode 100644 index 0000000..e95f5b3 --- /dev/null +++ b/src/components/Button/base/index.ts @@ -0,0 +1,2 @@ +export { default as BaseButton } from '@/components/Button/base/BaseButton'; +export type { ButtonVariant, ButtonSize } from '@/components/Button/base/BaseButton'; diff --git a/src/components/Button/domain/ArrowButton/ArrowButton.module.css b/src/components/Button/domain/ArrowButton/ArrowButton.module.css new file mode 100644 index 0000000..71866a6 --- /dev/null +++ b/src/components/Button/domain/ArrowButton/ArrowButton.module.css @@ -0,0 +1,49 @@ +.button { + border: none; + background: none; + padding: 0; + appearance: none; + cursor: pointer; + + display: inline-flex; + align-items: center; + justify-content: center; + + transition: + transform 0.15s ease, + opacity 0.15s ease; +} + +.button:hover { + opacity: 0.8; +} + +.button:active { + transform: scale(0.95); +} + +.button:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.large { + width: 32px; + height: 32px; +} + +.small { + width: 16px; + height: 16px; +} + +.icon { + display: block; + width: 100%; + height: 100%; +} diff --git a/src/components/Button/domain/ArrowButton/ArrowButton.stories.tsx b/src/components/Button/domain/ArrowButton/ArrowButton.stories.tsx new file mode 100644 index 0000000..3af53ef --- /dev/null +++ b/src/components/Button/domain/ArrowButton/ArrowButton.stories.tsx @@ -0,0 +1,391 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import ArrowButton from '@/components/Button/domain/ArrowButton/ArrowButton'; + +/** + * ArrowButton 컴포넌트 + * + * 좌/우 방향 전환(이전/다음) 용도로 사용하는 아이콘 버튼입니다. + * 캐러셀, 달력 네비게이션 등에서 사용됩니다. + */ +const meta: Meta = { + title: 'Components/Button/ArrowButton', + component: ArrowButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + direction: { + control: 'select', + options: ['left', 'right'], + description: '화살표 방향', + table: { + defaultValue: { summary: 'left' }, + }, + }, + size: { + control: 'select', + options: ['large', 'small'], + description: '버튼 크기 (large: 32px, small: 16px)', + table: { + defaultValue: { summary: 'large' }, + }, + }, + disabled: { + control: 'boolean', + description: '버튼 비활성화 여부', + table: { + defaultValue: { summary: 'false' }, + }, + }, + onClick: { + action: 'clicked', + description: '클릭 핸들러', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================== +// 기본 스토리 +// ============================== + +export const LeftLarge: Story = { + args: { + direction: 'left', + size: 'large', + onClick: () => console.log('Left clicked'), + }, +}; + +export const RightLarge: Story = { + args: { + direction: 'right', + size: 'large', + onClick: () => console.log('Right clicked'), + }, +}; + +export const LeftSmall: Story = { + args: { + direction: 'left', + size: 'small', + onClick: () => console.log('Left small clicked'), + }, +}; + +export const RightSmall: Story = { + args: { + direction: 'right', + size: 'small', + onClick: () => console.log('Right small clicked'), + }, +}; + +export const Disabled: Story = { + args: { + direction: 'left', + size: 'large', + disabled: true, + onClick: () => console.log('This should not fire'), + }, +}; + +// ============================== +// Size & Direction 조합 (한눈에 보기) +// ============================== + +export const AllSizesAndDirections: Story = { + render: () => ( +
+
+

Large (32px)

+
+ console.log('Left')} /> + console.log('Right')} /> + console.log('Disabled')} + disabled + /> +
+
+ +
+

Small (16px)

+
+ console.log('Left')} /> + console.log('Right')} /> + console.log('Disabled')} + disabled + /> +
+
+
+ ), + parameters: { + docs: { + description: { + story: '모든 크기와 방향, disabled 상태를 한눈에 확인합니다.', + }, + }, + }, +}; + +// ============================== +// 실제 사용 예시 - 캐러셀 +// ============================== + +export const CarouselExample: Story = { + render: () => { + const [currentIndex, setCurrentIndex] = useState(0); + const totalItems = 5; + + const handlePrev = () => { + setCurrentIndex((prev) => Math.max(0, prev - 1)); + }; + + const handleNext = () => { + setCurrentIndex((prev) => Math.min(totalItems - 1, prev + 1)); + }; + + return ( +
+ + +
+ 아이템 {currentIndex + 1} / {totalItems} +
+ + +
+ ); + }, + parameters: { + docs: { + description: { + story: + '캐러셀에서 ArrowButton을 사용하는 예시입니다. 첫 번째/마지막 아이템에서는 해당 방향 버튼이 비활성화됩니다.', + }, + }, + }, +}; + +// ============================== +// 실제 사용 예시 - 달력 네비게이션 +// ============================== + +export const CalendarNavigationExample: Story = { + render: () => { + const [currentMonth, setCurrentMonth] = useState(5); + const [currentYear, setCurrentYear] = useState(2025); + + const handlePrevMonth = () => { + if (currentMonth === 1) { + setCurrentMonth(12); + setCurrentYear(currentYear - 1); + } else { + setCurrentMonth(currentMonth - 1); + } + }; + + const handleNextMonth = () => { + if (currentMonth === 12) { + setCurrentMonth(1); + setCurrentYear(currentYear + 1); + } else { + setCurrentMonth(currentMonth + 1); + } + }; + + return ( +
+ + + + {currentYear}년 {currentMonth}월 + + + +
+ ); + }, + parameters: { + docs: { + description: { + story: + '달력 헤더에서 ArrowButton을 사용하는 예시입니다. Small 사이즈를 사용하여 컴팩트하게 표현합니다.', + }, + }, + }, +}; + +// ============================== +// Hover & Active 상태 테스트 +// ============================== + +export const InteractionStates: Story = { + render: () => ( +
+

+ 버튼 위에 마우스를 올리거나 클릭해보세요: +

+
+ console.log('Hover me!')} /> + console.log('Click me!')} /> +
+
    +
  • Hover: opacity 0.8
  • +
  • Active: scale(0.95)
  • +
  • Focus: 2px outline
  • +
+
+ ), + parameters: { + docs: { + description: { + story: '버튼의 hover, active, focus 상태를 테스트합니다.', + }, + }, + }, +}; + +// ============================== +// 접근성 (Accessibility) 테스트 +// ============================== + +export const AccessibilityTest: Story = { + render: () => ( +
+

+ Tab 키로 포커스를 이동하고 Enter/Space로 실행해보세요: +

+
+ alert('이전 버튼 클릭!')} /> + alert('다음 버튼 클릭!')} /> + alert('실행되지 않아야 함')} + disabled + /> +
+
    +
  • aria-label: direction에 따라 "이전" 또는 "다음"
  • +
  • 키보드 네비게이션: Tab으로 포커스 이동
  • +
  • disabled 버튼은 포커스를 받지 않음
  • +
+
+ ), + parameters: { + docs: { + description: { + story: '키보드 네비게이션과 스크린 리더 지원을 테스트합니다.', + }, + }, + }, +}; diff --git a/src/components/Button/domain/ArrowButton/ArrowButton.tsx b/src/components/Button/domain/ArrowButton/ArrowButton.tsx new file mode 100644 index 0000000..3b13f53 --- /dev/null +++ b/src/components/Button/domain/ArrowButton/ArrowButton.tsx @@ -0,0 +1,49 @@ +import styles from './ArrowButton.module.css'; + +import leftArrowIcon from '@/assets/buttons/arrow/leftArrowButton.svg'; +import rightArrowIcon from '@/assets/buttons/arrow/rightArrowButton.svg'; + +interface ArrowButtonProps { + direction: 'left' | 'right'; + size?: 'large' | 'small'; + onClick: () => void; + disabled?: boolean; +} + +/** + * ArrowButton 컴포넌트 + * + * @description + * 좌/우 방향 전환(이전/다음) 용도로 사용하는 아이콘 버튼이다. + * `direction` 값에 따라 아이콘과 aria-label(이전/다음)을 자동으로 설정한다. + * + * @remarks + * - `disabled`가 true이면 버튼 클릭이 비활성화된다. + * + * @param props.direction - 화살표 방향(`left` | `right`) + * @param props.size - 버튼 크기 프리셋(기본값: `large 32px small: 16px`) + * @param props.onClick - 클릭 핸들러 + * @param props.disabled - 비활성화 여부 + * @returns 화살표 아이콘 버튼 + */ +export default function ArrowButton({ + direction, + size = 'large', + onClick, + disabled, +}: ArrowButtonProps) { + const iconSrc = direction === 'left' ? leftArrowIcon : rightArrowIcon; + const ariaLabel = direction === 'left' ? '이전' : '다음'; + + return ( + + ); +} diff --git a/src/components/Button/domain/DatePickerButton/DatePickerButton.module.css b/src/components/Button/domain/DatePickerButton/DatePickerButton.module.css new file mode 100644 index 0000000..9e958ea --- /dev/null +++ b/src/components/Button/domain/DatePickerButton/DatePickerButton.module.css @@ -0,0 +1,68 @@ +.button { + /* Reset */ + border: none; + background: none; + padding: 0; + appearance: none; + cursor: pointer; + + /* Layout */ + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 48px; + border-radius: 12px; + + /* Typography */ + font-size: 14px; + font-weight: 500; + line-height: 1; + + /* Default (미선택) */ + background: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + color: var(--color-text-primary); + + /* Transition */ + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + transform 0.15s ease; +} + +/* Selected (선택됨) */ +.button.selected { + background: var(--color-brand-primary); + border: 1px solid var(--color-brand-primary); + color: var(--color-text-inverse); +} + +/* Hover */ +.button:hover:not(:disabled):not(.selected) { + border-color: var(--color-border-secondary); + background: var(--color-background-secondary); +} + +.button.selected:hover:not(:disabled) { + background: var(--color-interaction-pressed); + border-color: var(--color-interaction-pressed); +} + +/* Active */ +.button:active:not(:disabled) { + transform: scale(0.95); +} + +/* Focus */ +.button:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; +} + +/* Disabled */ +.button:disabled { + cursor: not-allowed; + opacity: 0.4; +} diff --git a/src/components/Button/domain/DatePickerButton/DatePickerButton.stories.tsx b/src/components/Button/domain/DatePickerButton/DatePickerButton.stories.tsx new file mode 100644 index 0000000..7891a0f --- /dev/null +++ b/src/components/Button/domain/DatePickerButton/DatePickerButton.stories.tsx @@ -0,0 +1,558 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import DatePickerButton, { + type Weekday, +} from '@/components/Button/domain/DatePickerButton/DatePickerButton'; + +/** + * DatePickerButton 컴포넌트 + * + * 요일 선택을 위한 버튼 컴포넌트입니다. + * 반복 일정 설정, 근무일 선택 등에서 사용됩니다. + */ +const meta: Meta = { + title: 'Components/Button/DatePickerButton', + component: DatePickerButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + day: { + control: 'select', + options: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'], + description: '요일 식별자', + }, + label: { + control: 'text', + description: '버튼에 표시될 텍스트', + }, + selected: { + control: 'boolean', + description: '선택 여부', + table: { + defaultValue: { summary: 'false' }, + }, + }, + disabled: { + control: 'boolean', + description: '버튼 비활성화 여부', + table: { + defaultValue: { summary: 'false' }, + }, + }, + onClick: { + action: 'clicked', + description: '요일 클릭 시 호출되는 핸들러', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================== +// 기본 스토리 +// ============================== + +export const Unselected: Story = { + args: { + day: 'mon', + label: '월', + selected: false, + onClick: (day) => console.log('Clicked:', day), + }, +}; + +export const Selected: Story = { + args: { + day: 'mon', + label: '월', + selected: true, + onClick: (day) => console.log('Clicked:', day), + }, +}; + +export const Disabled: Story = { + args: { + day: 'mon', + label: '월', + selected: false, + disabled: true, + onClick: (day) => console.log('This should not fire:', day), + }, +}; + +export const SelectedDisabled: Story = { + args: { + day: 'mon', + label: '월', + selected: true, + disabled: true, + onClick: (day) => console.log('This should not fire:', day), + }, +}; + +// ============================== +// 모든 상태 (한눈에 보기) +// ============================== + +export const AllStates: Story = { + render: () => ( +
+
+

기본 상태

+
+ {}} /> + {}} /> +
+
+ +
+

Disabled 상태

+
+ {}} disabled /> + {}} disabled /> +
+
+
+ ), + parameters: { + docs: { + description: { + story: '모든 상태 조합을 한눈에 확인합니다.', + }, + }, + }, +}; + +// ============================== +// 전체 요일 표시 +// ============================== + +const WEEKDAYS: { day: Weekday; label: string }[] = [ + { day: 'sun', label: '일' }, + { day: 'mon', label: '월' }, + { day: 'tue', label: '화' }, + { day: 'wed', label: '수' }, + { day: 'thu', label: '목' }, + { day: 'fri', label: '금' }, + { day: 'sat', label: '토' }, +]; + +export const AllWeekdays: Story = { + render: () => ( +
+ {WEEKDAYS.map(({ day, label }) => ( + console.log('Clicked:', clickedDay)} + /> + ))} +
+ ), + parameters: { + docs: { + description: { + story: '일주일 전체 요일 버튼을 표시합니다.', + }, + }, + }, +}; + +// ============================== +// 실제 사용 예시 - 반복 요일 선택 +// ============================== + +export const WeekdaySelector: Story = { + render: () => { + const [selectedDays, setSelectedDays] = useState(['mon', 'wed', 'fri']); + + const handleDayClick = (day: Weekday) => { + setSelectedDays((prev) => + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day], + ); + }; + + const handleReset = () => { + setSelectedDays([]); + }; + + const handleSelectWeekdays = () => { + setSelectedDays(['mon', 'tue', 'wed', 'thu', 'fri']); + }; + + const handleSelectWeekend = () => { + setSelectedDays(['sat', 'sun']); + }; + + return ( +
+ {/* 요일 버튼 그룹 */} +
+ {WEEKDAYS.map(({ day, label }) => ( + + ))} +
+ + {/* 선택된 요일 표시 */} +
+ 선택된 요일: {selectedDays.length > 0 ? selectedDays.join(', ') : '없음'} +
+ + {/* 컨트롤 버튼 */} +
+ + + +
+
+ ); + }, + parameters: { + docs: { + description: { + story: + '반복 일정 설정에서 요일을 선택하는 예시입니다. 여러 요일을 토글 방식으로 선택/해제할 수 있습니다.', + }, + }, + }, +}; + +// ============================== +// 실제 사용 예시 - 근무일 선택 +// ============================== + +export const WorkdaySelector: Story = { + render: () => { + const [workdays, setWorkdays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']); + + const handleDayClick = (day: Weekday) => { + setWorkdays((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day])); + }; + + const workdayCount = workdays.length; + const weekendCount = 7 - workdayCount; + + return ( +
+

+ 근무일 설정 +

+ + {/* 요일 버튼 그룹 */} +
+ {WEEKDAYS.map(({ day, label }) => ( + + ))} +
+ + {/* 통계 정보 */} +
+
+
근무일
+
+ {workdayCount}일 +
+
+
+
휴무일
+
+ {weekendCount}일 +
+
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: '근무일을 설정하는 예시입니다. 선택된 근무일과 휴무일의 수를 표시합니다.', + }, + }, + }, +}; + +// ============================== +// 상호작용 테스트 +// ============================== + +export const InteractionStates: Story = { + render: () => ( +
+

+ 버튼 위에 마우스를 올리거나 클릭해보세요: +

+
+ {}} /> + {}} /> +
+
    +
  • 미선택 + Hover: 배경색 변경
  • +
  • 선택됨 + Hover: 더 진한 파란색
  • +
  • Active: scale(0.95)
  • +
  • Focus: 2px outline
  • +
+
+ ), + parameters: { + docs: { + description: { + story: '버튼의 hover, active, focus 상태를 테스트합니다.', + }, + }, + }, +}; + +// ============================== +// 접근성 (Accessibility) 테스트 +// ============================== + +export const AccessibilityTest: Story = { + render: () => { + const [selectedDays, setSelectedDays] = useState(['wed']); + + const handleDayClick = (day: Weekday) => { + setSelectedDays((prev) => + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day], + ); + }; + + return ( +
+

+ Tab 키로 포커스를 이동하고 Space/Enter로 선택/해제해보세요: +

+
+ {WEEKDAYS.map(({ day, label }) => ( + + ))} +
+
    +
  • aria-pressed: 선택 상태를 스크린 리더에 전달
  • +
  • 키보드 네비게이션: Tab으로 포커스 이동
  • +
  • 키보드 실행: Space/Enter로 토글
  • +
  • disabled 버튼은 포커스를 받지 않음
  • +
+
+ ); + }, + parameters: { + docs: { + description: { + story: '키보드 네비게이션과 스크린 리더 지원을 테스트합니다.', + }, + }, + }, +}; + +// ============================== +// 크기 확인 +// ============================== + +export const SizeReference: Story = { + render: () => ( +
+
+ {}} /> +
+ 44px × 48px (너비 × 높이) +
+
+
+ • border-radius: 12px +
+ • font-size: 14px +
• font-weight: 500 +
+
+ ), + parameters: { + docs: { + description: { + story: '버튼의 크기와 스타일 스펙을 확인합니다.', + }, + }, + }, +}; diff --git a/src/components/Button/domain/DatePickerButton/DatePickerButton.tsx b/src/components/Button/domain/DatePickerButton/DatePickerButton.tsx new file mode 100644 index 0000000..1fc7dbd --- /dev/null +++ b/src/components/Button/domain/DatePickerButton/DatePickerButton.tsx @@ -0,0 +1,55 @@ +import styles from './DatePickerButton.module.css'; + +export type Weekday = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'; + +interface DatePickerButtonProps { + day: Weekday; + label: string; + selected: boolean; + onClick: (day: Weekday) => void; + disabled?: boolean; +} + +/** + * DatePickerButton 컴포넌트 + * + * @description + * 요일 선택을 위한 버튼 컴포넌트로, 단일 요일을 표현한다. + * 선택 상태(`selected`)에 따라 시각적 스타일이 변경되며, + * 클릭 시 해당 요일 값을 부모로 전달한다. + * + * @remarks + * - 선택 상태는 `aria-pressed`를 통해 접근성 속성으로 노출된다. + * - `disabled` 상태에서는 클릭이 비활성화되며 시각적으로 흐려진다. + * - 날짜 로직은 외부에서 관리하며, 이 컴포넌트는 표현과 이벤트 전달만 담당한다. + * + * @param props.day - 요일 식별자 값 + * @param props.label - 버튼에 표시될 텍스트 + * @param props.selected - 선택 여부 + * @param props.onClick - 요일 클릭 시 호출되는 핸들러 + * @param props.disabled - 비활성화 여부 + * @returns 요일 선택 버튼 + */ +export default function DatePickerButton({ + day, + label, + selected, + onClick, + disabled = false, +}: DatePickerButtonProps) { + const handleClick = () => { + onClick(day); + }; + + return ( + + ); +} diff --git a/src/components/Button/domain/EditButton/EditButton.module.css b/src/components/Button/domain/EditButton/EditButton.module.css new file mode 100644 index 0000000..4843f87 --- /dev/null +++ b/src/components/Button/domain/EditButton/EditButton.module.css @@ -0,0 +1,51 @@ +.button { + /* Reset */ + border: none; + background: none; + padding: 0; + appearance: none; + cursor: pointer; + + /* Layout */ + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + + /* Style */ + background: var(--color-background-tertiary); + + /* Transition */ + transition: + transform 0.15s ease, + background-color 0.2s ease; +} + +.button:active { + transform: scale(0.95); +} + +.button:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +/* Size variants */ +.large { + width: 32px; + height: 32px; +} + +.small { + width: 24px; + height: 24px; +} + +.icon { + display: block; +} diff --git a/src/components/Button/domain/EditButton/EditButton.stories.tsx b/src/components/Button/domain/EditButton/EditButton.stories.tsx new file mode 100644 index 0000000..bc1d0b6 --- /dev/null +++ b/src/components/Button/domain/EditButton/EditButton.stories.tsx @@ -0,0 +1,536 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import EditButton from '@/components/Button/domain/EditButton/EditButton'; + +/** + * EditButton 컴포넌트 + * + * 이미지 편집(수정) 액션을 트리거하는 아이콘 버튼입니다. + * 주로 프로필 이미지, 썸네일 등의 편집 기능에 사용됩니다. + */ +const meta: Meta = { + title: 'Components/Button/EditButton', + component: EditButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['large', 'small'], + description: '버튼 크기 (large: 32px, small: 24px)', + table: { + defaultValue: { summary: 'large' }, + }, + }, + disabled: { + control: 'boolean', + description: '버튼 비활성화 여부', + table: { + defaultValue: { summary: 'false' }, + }, + }, + onClick: { + action: 'clicked', + description: '클릭 핸들러', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================== +// 기본 스토리 +// ============================== + +export const Large: Story = { + args: { + size: 'large', + onClick: () => console.log('Edit clicked'), + }, +}; + +export const Small: Story = { + args: { + size: 'small', + onClick: () => console.log('Edit clicked'), + }, +}; + +export const Disabled: Story = { + args: { + size: 'large', + disabled: true, + onClick: () => console.log('This should not fire'), + }, +}; + +// ============================== +// Size 비교 (한눈에 보기) +// ============================== + +export const AllSizes: Story = { + render: () => ( +
+
+

Large (32x32px)

+
+ console.log('Large')} /> + console.log('Disabled')} disabled /> +
+
+ +
+

Small (24x24px)

+
+ console.log('Small')} /> + console.log('Disabled')} disabled /> +
+
+
+ ), + parameters: { + docs: { + description: { + story: '모든 크기와 disabled 상태를 한눈에 확인합니다.', + }, + }, + }, +}; + +// ============================== +// 실제 사용 예시 - 프로필 이미지 +// ============================== + +export const ProfileImageExample: Story = { + render: () => { + const handleFileUpload = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + console.log('선택된 파일:', file.name); + alert(`파일 선택됨: ${file.name}`); + } + }; + input.click(); + }; + + return ( +
+
+ 프로필 +
+ +
+
+

+ 버튼 클릭 시 파일 업로더가 열립니다 +

+
+ ); + }, + parameters: { + docs: { + description: { + story: + '프로필 이미지에 EditButton을 배치한 예시입니다. 버튼은 이미지 우측 하단에 위치합니다.', + }, + }, + }, +}; + +// ============================== +// 실제 사용 예시 - 카드 썸네일 +// ============================== + +export const CardThumbnailExample: Story = { + render: () => { + const handleEdit = () => { + console.log('썸네일 편집'); + alert('썸네일 편집 기능'); + }; + + return ( +
+
+ 썸네일 +
+ +
+
+
+

카드 제목

+

카드 설명 텍스트입니다.

+
+
+ ); + }, + parameters: { + docs: { + description: { + story: + '카드 썸네일에 EditButton을 배치한 예시입니다. 버튼은 이미지 우측 상단에 위치합니다.', + }, + }, + }, +}; + +// ============================== +// 실제 사용 예시 - 여러 이미지 그리드 +// ============================== + +export const ImageGridExample: Story = { + render: () => { + const handleEdit = (index: number) => { + console.log(`이미지 ${index + 1} 편집`); + alert(`이미지 ${index + 1} 편집`); + }; + + return ( +
+ {[1, 2, 3, 4, 5, 6].map((item, index) => ( +
+ {`이미지 +
+ handleEdit(index)} /> +
+
+ ))} +
+ ); + }, + parameters: { + docs: { + description: { + story: '여러 이미지를 그리드로 배치하고 각각에 EditButton을 추가한 예시입니다.', + }, + }, + }, +}; + +// ============================== +// 상호작용 테스트 +// ============================== + +export const InteractionStates: Story = { + render: () => ( +
+

+ 버튼 위에 마우스를 올리거나 클릭해보세요: +

+
+ console.log('Hover me!')} /> + console.log('Click me!')} /> +
+
    +
  • 기본: 회색 원형 배경
  • +
  • Hover: 더 진한 회색
  • +
  • Active: scale(0.95)
  • +
  • Focus: 2px 파란색 outline
  • +
  • Disabled: opacity 0.5
  • +
+
+ ), + parameters: { + docs: { + description: { + story: '버튼의 hover, active, focus 상태를 테스트합니다.', + }, + }, + }, +}; + +// ============================== +// 접근성 (Accessibility) 테스트 +// ============================== + +export const AccessibilityTest: Story = { + render: () => ( +
+

+ Tab 키로 포커스를 이동하고 Enter/Space로 실행해보세요: +

+
+ alert('편집 실행!')} /> + alert('편집 실행!')} /> + alert('실행되지 않아야 함')} disabled /> +
+
    +
  • aria-label: "이미지 편집"
  • +
  • 키보드 네비게이션: Tab으로 포커스 이동
  • +
  • 키보드 실행: Enter/Space로 클릭
  • +
  • disabled 버튼은 포커스를 받지 않음
  • +
+
+ ), + parameters: { + docs: { + description: { + story: '키보드 네비게이션과 스크린 리더 지원을 테스트합니다.', + }, + }, + }, +}; + +// ============================== +// 다양한 배경에서의 표시 +// ============================== + +export const OnDifferentBackgrounds: Story = { + render: () => ( +
+ {/* 흰색 배경 */} +
+

+ 밝은 배경 +

+ console.log('Click')} /> +
+ + {/* 회색 배경 */} +
+

+ 회색 배경 +

+ console.log('Click')} /> +
+ + {/* 어두운 배경 */} +
+

+ 어두운 배경 +

+ console.log('Click')} /> +
+
+ ), + parameters: { + docs: { + description: { + story: '다양한 배경색에서 EditButton의 가시성을 확인합니다.', + }, + }, + }, +}; + +// ============================== +// 크기 스펙 +// ============================== + +export const SizeReference: Story = { + render: () => ( +
+
+
+ {}} /> +
Large: 32px × 32px
+
+
+ • border-radius: 50% (원형) +
• background: var(--color-background-tertiary) +
+
+ +
+
+ {}} /> +
Small: 24px × 24px
+
+
+ • border-radius: 50% (원형) +
• background: var(--color-background-tertiary) +
+
+
+ ), + parameters: { + docs: { + description: { + story: '버튼의 크기와 스타일 스펙을 확인합니다.', + }, + }, + }, +}; diff --git a/src/components/Button/domain/EditButton/EditButton.tsx b/src/components/Button/domain/EditButton/EditButton.tsx new file mode 100644 index 0000000..d3b4fbc --- /dev/null +++ b/src/components/Button/domain/EditButton/EditButton.tsx @@ -0,0 +1,41 @@ +import styles from './EditButton.module.css'; + +import editLargeIcon from '@/assets/buttons/edit/editButtonLarge.svg'; +import editSmallIcon from '@/assets/buttons/edit/editButtonSmall.svg'; + +interface EditButtonProps { + size?: 'large' | 'small'; + onClick: () => void; + disabled?: boolean; +} + +/** + * EditButton 컴포넌트 + * + * @description + * 이미지 편집(수정) 액션을 트리거하는 아이콘 버튼이다. + * `size` 값에 따라 large/small 아이콘을 선택하여 렌더링한다. + * + * @remarks + * - `disabled`가 true이면 버튼 클릭이 비활성화된다. + * + * @param props.size - 버튼 크기 프리셋(기본값: `large`) + * @param props.onClick - 클릭 핸들러 + * @param props.disabled - 비활성화 여부 + * @returns 이미지 편집 버튼 + */ +export default function EditButton({ size = 'large', onClick, disabled }: EditButtonProps) { + const iconSrc = size === 'large' ? editLargeIcon : editSmallIcon; + + return ( + + ); +} diff --git a/src/components/Button/domain/EnterButton/EnterButton.module.css b/src/components/Button/domain/EnterButton/EnterButton.module.css new file mode 100644 index 0000000..ee25fce --- /dev/null +++ b/src/components/Button/domain/EnterButton/EnterButton.module.css @@ -0,0 +1,44 @@ +.button { + /* Reset */ + border: none; + background: none; + padding: 0; + appearance: none; + cursor: pointer; + + /* Layout */ + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + + /* Transition */ + transition: + transform 0.15s ease, + opacity 0.2s ease; +} + +.button:hover:not(:disabled) { + opacity: 0.8; +} + +.button:active:not(:disabled) { + transform: scale(0.95); +} + +.button:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; +} + +.button:disabled { + cursor: not-allowed; + /* opacity 제거: NonActive SVG가 이미 회색이니까 */ +} + +.icon { + width: 24px; + height: 24px; + display: block; +} diff --git a/src/components/Button/domain/EnterButton/EnterButton.stories.tsx b/src/components/Button/domain/EnterButton/EnterButton.stories.tsx new file mode 100644 index 0000000..3638ad7 --- /dev/null +++ b/src/components/Button/domain/EnterButton/EnterButton.stories.tsx @@ -0,0 +1,604 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import EnterButton from '@/components/Button/domain/EnterButton/EnterButton'; + +/** + * EnterButton 컴포넌트 + * + * 댓글 등록(전송) 액션을 위한 아이콘 버튼입니다. + * 입력값이 있을 때만 활성화되는 패턴으로 사용됩니다. + */ +const meta: Meta = { + title: 'Components/Button/EnterButton', + component: EnterButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + active: { + control: 'boolean', + description: '활성화 여부 (false일 때 자동으로 disabled)', + table: { + defaultValue: { summary: 'false' }, + }, + }, + onClick: { + action: 'clicked', + description: '클릭 핸들러 (active가 true일 때만 실행)', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================== +// 기본 스토리 +// ============================== + +export const Active: Story = { + args: { + active: true, + onClick: () => console.log('Enter button clicked!'), + }, +}; + +export const Inactive: Story = { + args: { + active: false, + onClick: () => console.log('This should not fire'), + }, +}; + +// ============================== +// 상태 비교 (한눈에 보기) +// ============================== + +export const AllStates: Story = { + render: () => ( +
+
+

+ NonActive (회색) - 클릭 불가 +

+ console.log('Should not fire')} active={false} /> +
+ +
+

+ Active (파란색) - 클릭 가능 +

+ alert('댓글 전송!')} active={true} /> +
+
+ ), + parameters: { + docs: { + description: { + story: 'EnterButton의 모든 상태를 한눈에 확인합니다.', + }, + }, + }, +}; + +// ============================== +// 실제 사용 예시 - 댓글 입력 +// ============================== + +export const CommentInputExample: Story = { + render: () => { + const [inputValue, setInputValue] = useState(''); + const [comments, setComments] = useState([]); + + const handleSubmit = () => { + if (inputValue.trim()) { + setComments([...comments, inputValue]); + setInputValue(''); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && inputValue.trim()) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+ {/* 댓글 입력 영역 */} +
+