diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx new file mode 100644 index 0000000..e5e78e3 --- /dev/null +++ b/src/components/Modal/Modal.stories.tsx @@ -0,0 +1,250 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import Modal from './Modal'; + +const meta = { + title: 'Components/Modal', + component: Modal, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +Modal은 사용자의 주의와 선택이 필요한 내용을 화면 위에 집중시켜 보여주는 컴포넌트입니다. + +### 언제 사용하나요 +- 삭제/로그아웃 같은 파괴적 액션을 확인할 때 +- 짧지만 진행을 멈추는 입력이 필요할 때 +- 반드시 확인해야 하는 중요 정보를 표시할 때 + +### 기본 사용 예시 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + ariaLabel="삭제 확인" +> +

이 항목을 삭제할까요?

+
+\`\`\` + +### Props +| Prop | 필수 | 설명 | +| --- | --- | --- | +| \`isOpen\` | 예 | 모달 렌더링 여부를 제어합니다. | +| \`onClose\` | 예 | 모달이 닫혀야 할 때 호출됩니다. | +| \`ariaLabel\` 또는 \`ariaLabelledby\` | 예 | 접근성 다이얼로그 이름입니다(둘 중 하나 필수). | +| \`children\` | 아니요 | 모달 내부 콘텐츠입니다. | +| \`ariaDescribedby\` | 아니요 | 설명 텍스트 요소의 id를 연결합니다. | +| \`closeOnOverlayClick\` | 아니요 | 배경 클릭 시 닫기. 기본값: \`true\` | +| \`closeOnEscape\` | 아니요 | Escape 입력 시 닫기. 기본값: \`true\` | +| \`className\`, \`contentClassName\` | 아니요 | 스타일 확장용 클래스입니다. | + +### 동작 메모 +- \`isOpen\`은 부모 상태에서 관리하세요. +- 모달 내부에 명확한 닫기 액션 버튼을 제공하세요. +- 모달이 열려 있는 동안 포커스가 모달 내부에 유지됩니다. + +### 자주 쓰는 패턴 +- 확인 모달: 제목 + 설명 + 확인/취소 버튼 +- 강제 선택 모달: overlay/Escape 닫기 비활성 + 명시적 버튼 선택 + `, + }, + }, + }, + args: { + isOpen: false, + ariaLabel: '예시 다이얼로그', + closeOnOverlayClick: true, + closeOnEscape: true, + onClose: fn(), + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부를 제어합니다.', + table: { category: 'Required' }, + }, + onClose: { + action: 'closed', + description: '모달이 닫힘을 요청할 때 호출됩니다.', + table: { category: 'Required' }, + }, + ariaLabel: { + control: 'text', + description: '접근성 라벨입니다(`ariaLabelledby`와 둘 중 하나 사용).', + table: { category: 'Required (one of)' }, + }, + ariaLabelledby: { + control: 'text', + description: '다이얼로그 라벨로 사용할 제목 요소의 ID입니다.', + table: { category: 'Required (one of)' }, + }, + children: { + control: false, + table: { category: 'Optional' }, + }, + ariaDescribedby: { + control: 'text', + description: '모달 내부 설명 텍스트 요소의 ID입니다.', + table: { category: 'Optional' }, + }, + closeOnOverlayClick: { + control: 'boolean', + description: '배경 클릭 시 모달을 닫을지 여부입니다.', + table: { + category: 'Optional', + defaultValue: { summary: 'true' }, + }, + }, + closeOnEscape: { + control: 'boolean', + description: 'Escape 키로 모달을 닫을지 여부입니다.', + table: { + category: 'Optional', + defaultValue: { summary: 'true' }, + }, + }, + className: { + control: false, + table: { category: 'Styling' }, + }, + contentClassName: { + control: false, + table: { category: 'Styling' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +const primaryButtonStyle = { + border: 0, + borderRadius: '10px', + background: '#2563eb', + color: '#ffffff', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +const secondaryButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + return ( +
+ + + +
+

할 일을 삭제할까요?

+

+ 이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: 'overlay/Escape 닫기가 활성화된 기본 확인 모달 예시입니다.', + }, + }, + }, +}; + +export const NonDismissible: Story = { + args: { + closeOnOverlayClick: false, + closeOnEscape: false, + }, + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + return ( +
+ + + +
+

필수 단계를 완료하세요

+

+ 계속하려면 버튼으로 명시적인 선택이 필요합니다. +

+
+ + +
+
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: 'overlay 클릭과 Escape 닫기를 모두 비활성화한 강제 선택 패턴입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/domain/components/AddTodoList/AddTodoList.stories.tsx b/src/components/Modal/domain/components/AddTodoList/AddTodoList.stories.tsx new file mode 100644 index 0000000..7fca9c8 --- /dev/null +++ b/src/components/Modal/domain/components/AddTodoList/AddTodoList.stories.tsx @@ -0,0 +1,142 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import AddTodoList from './AddTodoList'; + +const meta = { + title: 'Components/Modal/Domain/AddTodoList', + component: AddTodoList, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +한 줄 요약: 할 일 목록을 새로 만들 때 사용하는 입력 모달입니다. + +### 언제 사용하는가 +- 보드/섹션에 새 할 일 목록을 추가할 때 +- 목록 생성 전 제목 입력이 필요한 흐름에서 사용합니다 + +### 기본 사용 예시 코드 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + onSubmit={handleCreateTodoList} +/> +\`\`\` + +### Props 설명 +| 구분 | 이름 | 설명 | +| --- | --- | --- | +| 필수 | \`isOpen\` | 모달 열림/닫힘 상태 | +| 필수 | \`onClose\` | 닫기 버튼/오버레이/Escape로 닫힐 때 호출 | +| 필수 | \`onSubmit\` | 생성 버튼 제출 시 호출 | +| 선택 | \`text\` | 제목/버튼/placeholder 문구 커스터마이징 | +| 선택 | \`input\` | 입력창 속성(input props) 확장 | +| 선택 | \`closeOptions\` | 오버레이 클릭/Escape 닫기 허용 여부 | + +### 사용 시 주의사항 +- \`onSubmit\`은 입력값을 인자로 전달하지 않습니다. 값 제어가 필요하면 \`input.props\`로 별도 상태를 연결하세요. + `, + }, + }, + }, + args: { + isOpen: false, + onClose: fn(), + onSubmit: fn(), + text: { + title: '할 일 목록', + submitLabel: '만들기', + inputPlaceholder: '할 일을 입력하세요', + }, + closeOptions: { + overlayClick: true, + escape: true, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부입니다.', + table: { category: '필수' }, + }, + onClose: { + action: 'closed', + description: '모달 닫힘 요청 시 호출됩니다.', + table: { category: '필수' }, + }, + onSubmit: { + action: 'submitted', + description: '생성 버튼 제출 시 호출됩니다.', + table: { category: '필수' }, + }, + text: { + control: 'object', + description: '제목/버튼/placeholder 문구를 변경합니다.', + table: { category: '선택' }, + }, + input: { + control: 'object', + description: '입력창 속성(input props)을 확장합니다.', + table: { category: '선택' }, + }, + closeOptions: { + control: 'object', + description: 'overlayClick, escape 닫힘 동작을 제어합니다.', + table: { category: '선택' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + const handleSubmit = () => { + args.onSubmit?.(); + setIsOpen(false); + }; + + return ( +
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: '버튼 클릭으로 모달을 열고, 제출/닫기 동작을 확인하는 기본 예시입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/domain/components/Calender/CalenderModal.stories.tsx b/src/components/Modal/domain/components/Calender/CalenderModal.stories.tsx new file mode 100644 index 0000000..e6e22a7 --- /dev/null +++ b/src/components/Modal/domain/components/Calender/CalenderModal.stories.tsx @@ -0,0 +1,165 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import CalenderModal from './CalenderModal'; +import type { CalenderModalSubmitPayload } from './types/CalenderModal.types'; + +const meta = { + title: 'Components/Modal/Domain/CalenderModal', + component: CalenderModal, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +한 줄 요약: 시작 날짜/시간과 반복 조건을 포함해 할 일을 생성하는 일정 기반 모달입니다. + +### 언제 사용하는가 +- 날짜/시간이 포함된 할 일을 새로 등록할 때 +- 반복 일정(매일/주 반복/월 반복)을 설정해야 할 때 + +### 기본 사용 예시 코드 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + onSubmit={(payload) => createTodo(payload)} +/> +\`\`\` + +### Props 설명 +| 구분 | 이름 | 설명 | +| --- | --- | --- | +| 필수 | \`isOpen\` | 모달 열림/닫힘 상태 | +| 필수 | \`onClose\` | 모달 닫힘 처리 콜백 | +| 필수 | \`onSubmit\` | 생성 시 폼 payload를 전달받는 콜백 | +| 선택 | \`text\` | 라벨/설명/placeholder 문구 커스터마이징 | +| 선택 | \`input\` | 제목/메모 입력창 props 확장 | +| 선택 | \`initialValues\` | 초기 입력값(수정/재진입 시 유용) | +| 선택 | \`closeOptions\` | 오버레이 클릭/Escape 닫기 허용 여부 | + +### 사용 시 주의사항 +- \`onSubmit\` payload의 \`repeatDays\`는 반복 타입이 주/월 반복일 때만 의미가 있습니다. + `, + }, + }, + }, + args: { + isOpen: false, + onClose: fn(), + onSubmit: fn<(payload: CalenderModalSubmitPayload) => void>(), + closeOptions: { + overlayClick: true, + escape: true, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부입니다.', + table: { category: '필수' }, + }, + onClose: { + action: 'closed', + description: '모달 닫힘 요청 시 호출됩니다.', + table: { category: '필수' }, + }, + onSubmit: { + action: 'submitted', + description: '생성 시 일정 payload를 전달해 호출됩니다.', + table: { category: '필수' }, + }, + text: { + control: 'object', + description: '제목/설명/라벨/placeholder 문구를 변경합니다.', + table: { category: '선택' }, + }, + input: { + control: 'object', + description: '제목/메모 입력창 props를 확장합니다.', + table: { category: '선택' }, + }, + initialValues: { + control: 'object', + description: '폼 초기값을 설정합니다.', + table: { category: '선택' }, + }, + closeOptions: { + control: 'object', + description: 'overlayClick, escape 닫힘 동작을 제어합니다.', + table: { category: '선택' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + const handleSubmit = (payload: CalenderModalSubmitPayload) => { + args.onSubmit?.(payload); + setIsOpen(false); + }; + + return ( +
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: '일정/반복 설정 후 생성 콜백 payload를 확인하는 기본 사용 예시입니다.', + }, + }, + }, +}; + +export const WithInitialValues: Story = { + args: { + initialValues: { + todoTitle: '분기 리뷰 준비', + startDate: new Date('2026-02-15'), + startTime: '09:00', + repeatType: 'weekly', + repeatDays: ['mon', 'wed'], + memo: '회의 전에 안건 정리', + }, + }, + render: Playground.render, + parameters: { + docs: { + description: { + story: '초기값을 넣어 수정/재작성 흐름처럼 시작하는 패턴입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/domain/components/ChangePassword/ChangePassword.stories.tsx b/src/components/Modal/domain/components/ChangePassword/ChangePassword.stories.tsx new file mode 100644 index 0000000..7534d62 --- /dev/null +++ b/src/components/Modal/domain/components/ChangePassword/ChangePassword.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import ChangePassword from './ChangePassword'; + +const meta = { + title: 'Components/Modal/Domain/ChangePassword', + component: ChangePassword, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +한 줄 요약: 로그인한 사용자가 새 비밀번호를 입력하고 변경 요청을 보내는 모달입니다. + +### 언제 사용하는가 +- 계정 설정 화면에서 비밀번호 변경 시 +- 보안 점검 후 비밀번호 재설정을 유도할 때 + +### 기본 사용 예시 코드 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + onSubmit={handleChangePassword} +/> +\`\`\` + +### Props 설명 +| 구분 | 이름 | 설명 | +| --- | --- | --- | +| 필수 | \`isOpen\` | 모달 열림/닫힘 상태 | +| 필수 | \`onClose\` | 닫기 처리 콜백 | +| 필수 | \`onSubmit\` | 변경 제출 시 호출되는 콜백 | +| 선택 | \`text\` | 제목/라벨/버튼/placeholder 문구 변경 | +| 선택 | \`input\` | 새 비밀번호/확인 입력창 props 확장 | +| 선택 | \`closeOptions\` | 오버레이 클릭/Escape 닫기 허용 여부 | + +### 사용 시 주의사항 +- \`onSubmit\`은 입력값을 직접 넘겨주지 않습니다. 값 검증/전송은 \`input\` 제어와 함께 상위에서 구성하세요. + `, + }, + }, + }, + args: { + isOpen: false, + onClose: fn(), + onSubmit: fn(), + closeOptions: { + overlayClick: true, + escape: true, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부입니다.', + table: { category: '필수' }, + }, + onClose: { + action: 'closed', + description: '모달 닫힘 요청 시 호출됩니다.', + table: { category: '필수' }, + }, + onSubmit: { + action: 'submitted', + description: '변경 제출 시 호출됩니다.', + table: { category: '필수' }, + }, + text: { + control: 'object', + description: '제목/라벨/버튼 문구를 커스터마이징합니다.', + table: { category: '선택' }, + }, + input: { + control: 'object', + description: '비밀번호 입력창 props를 확장합니다.', + table: { category: '선택' }, + }, + closeOptions: { + control: 'object', + description: 'overlayClick, escape 닫힘 동작을 제어합니다.', + table: { category: '선택' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + const handleSubmit = () => { + args.onSubmit?.(); + setIsOpen(false); + }; + + return ( +
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: '버튼으로 모달을 열어 닫기/변경 제출 흐름을 확인하는 기본 예시입니다.', + }, + }, + }, +}; + +export const CloseLocked: Story = { + args: { + closeOptions: { + overlayClick: false, + escape: false, + }, + }, + render: Playground.render, + parameters: { + docs: { + description: { + story: '실수로 닫히면 안 되는 상황에서 overlay/Escape 닫기를 막는 패턴입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/domain/components/LogoutModal/LogoutModal.stories.tsx b/src/components/Modal/domain/components/LogoutModal/LogoutModal.stories.tsx new file mode 100644 index 0000000..02ed5cf --- /dev/null +++ b/src/components/Modal/domain/components/LogoutModal/LogoutModal.stories.tsx @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import LogoutModal from './LogoutModal'; + +const meta = { + title: 'Components/Modal/Domain/LogoutModal', + component: LogoutModal, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +한 줄 요약: 로그아웃 실행 전 한 번 더 확인하는 도메인 확인 모달입니다. + +### 언제 사용하는가 +- 사용자 메뉴에서 로그아웃을 누른 직후 +- 진행 중 작업 유실 가능성이 있어 재확인이 필요할 때 + +### 기본 사용 예시 코드 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + onConfirm={handleLogout} +/> +\`\`\` + +### Props 설명 +| 구분 | 이름 | 설명 | +| --- | --- | --- | +| 필수 | \`isOpen\` | 모달 열림/닫힘 상태 | +| 필수 | \`onClose\` | 닫기 처리 콜백 | +| 필수 | \`onConfirm\` | 로그아웃 확정 시 호출 | +| 선택 | \`text\` | 제목/버튼 문구 커스터마이징 | +| 선택 | \`closeOptions\` | 오버레이 클릭/Escape 닫기 허용 여부 | + +### 사용 시 주의사항 +- 중요한 작업 중 로그아웃을 막고 싶다면 \`closeOptions\`로 실수 닫힘을 제한하세요. + `, + }, + }, + }, + args: { + isOpen: false, + onClose: fn(), + onConfirm: fn(), + closeOptions: { + overlayClick: true, + escape: true, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부입니다.', + table: { category: '필수' }, + }, + onClose: { + action: 'closed', + description: '모달 닫힘 요청 시 호출됩니다.', + table: { category: '필수' }, + }, + onConfirm: { + action: 'confirmed', + description: '로그아웃 확정 시 호출됩니다.', + table: { category: '필수' }, + }, + text: { + control: 'object', + description: '제목/버튼 문구를 변경합니다.', + table: { category: '선택' }, + }, + closeOptions: { + control: 'object', + description: 'overlayClick, escape 닫힘 동작을 제어합니다.', + table: { category: '선택' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + const handleConfirm = () => { + args.onConfirm?.(); + setIsOpen(false); + }; + + return ( +
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: '기본 로그아웃 확인 플로우를 확인하는 예시입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/domain/components/MemberInvite/MemberInvite.module.css b/src/components/Modal/domain/components/MemberInvite/MemberInvite.module.css index 412ffc7..3b7c9e1 100644 --- a/src/components/Modal/domain/components/MemberInvite/MemberInvite.module.css +++ b/src/components/Modal/domain/components/MemberInvite/MemberInvite.module.css @@ -1,4 +1,4 @@ -@import '@shared/styles/color.css'; +@import '../../../../../shared/styles/color.css'; .container { position: relative; diff --git a/src/components/Modal/domain/components/MemberInvite/MemberInvite.stories.tsx b/src/components/Modal/domain/components/MemberInvite/MemberInvite.stories.tsx new file mode 100644 index 0000000..7b6e7d8 --- /dev/null +++ b/src/components/Modal/domain/components/MemberInvite/MemberInvite.stories.tsx @@ -0,0 +1,133 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import MemberInvite from './MemberInvite'; + +const meta = { + title: 'Components/Modal/Domain/MemberInvite', + component: MemberInvite, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +한 줄 요약: 팀/그룹 초대 링크를 복사하도록 안내하는 멤버 초대 모달입니다. + +### 언제 사용하는가 +- 그룹 설정에서 멤버를 초대할 때 +- 초대 링크를 생성한 뒤 사용자에게 복사 액션을 제공할 때 + +### 기본 사용 예시 코드 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + invite={{ + link: inviteLink, + onCopyLink: (link) => navigator.clipboard.writeText(link), + }} +/> +\`\`\` + +### Props 설명 +| 구분 | 이름 | 설명 | +| --- | --- | --- | +| 필수 | \`isOpen\` | 모달 열림/닫힘 상태 | +| 필수 | \`onClose\` | 닫기 처리 콜백 | +| 필수 | \`invite.link\` | 복사 대상 초대 링크 | +| 필수 | \`invite.onCopyLink\` | 복사 버튼 클릭 시 링크를 전달받는 콜백 | +| 선택 | \`text\` | 제목/설명/복사 버튼 문구 변경 | +| 선택 | \`closeOptions\` | 오버레이 클릭/Escape 닫기 허용 여부 | + +### 사용 시 주의사항 +- 실제 클립보드 복사 성공/실패 처리(토스트, 에러 안내)는 \`onCopyLink\`에서 함께 처리하세요. + `, + }, + }, + }, + args: { + isOpen: false, + onClose: fn(), + invite: { + link: 'https://coworkers.example/invite/abc123', + onCopyLink: fn<(link: string) => void>(), + }, + closeOptions: { + overlayClick: true, + escape: true, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부입니다.', + table: { category: '필수' }, + }, + onClose: { + action: 'closed', + description: '모달 닫힘 요청 시 호출됩니다.', + table: { category: '필수' }, + }, + invite: { + control: 'object', + description: '초대 링크와 복사 콜백을 전달합니다.', + table: { category: '필수' }, + }, + text: { + control: 'object', + description: '제목/설명/복사 버튼 문구를 변경합니다.', + table: { category: '선택' }, + }, + closeOptions: { + control: 'object', + description: 'overlayClick, escape 닫힘 동작을 제어합니다.', + table: { category: '선택' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + return ( +
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: '초대 링크 복사 흐름을 확인하는 기본 사용 예시입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/domain/components/ProfileModal/ProfileModal.stories.tsx b/src/components/Modal/domain/components/ProfileModal/ProfileModal.stories.tsx new file mode 100644 index 0000000..09ce331 --- /dev/null +++ b/src/components/Modal/domain/components/ProfileModal/ProfileModal.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import ProfileModal from './ProfileModal'; + +const meta = { + title: 'Components/Modal/Domain/ProfileModal', + component: ProfileModal, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +한 줄 요약: 사용자 프로필 정보(이름/이메일)를 보여주고 이메일 복사 액션을 제공하는 모달입니다. + +### 언제 사용하는가 +- 멤버 카드/아바타 클릭 후 상세 프로필을 확인할 때 +- 이메일 기반 초대/연락을 위해 즉시 복사 기능이 필요할 때 + +### 기본 사용 예시 코드 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + onCopyEmail={handleCopyEmail} + profile={{ + title: '이준서', + email: 'jieunsse@example.com', + }} +/> +\`\`\` + +### Props 설명 +| 구분 | 이름 | 설명 | +| --- | --- | --- | +| 필수 | \`isOpen\` | 모달 열림/닫힘 상태 | +| 필수 | \`onClose\` | 닫기 처리 콜백 | +| 필수 | \`onCopyEmail\` | 이메일 복사 버튼 클릭 시 호출 | +| 필수 | \`profile.title\` | 프로필 이름/타이틀 | +| 필수 | \`profile.email\` | 표시할 이메일 | +| 선택 | \`profile.imageSrc\` | 프로필 이미지 소스 | +| 선택 | \`profile.imageAlt\` | 프로필 이미지 대체 텍스트 | +| 선택 | \`profile.copyButtonLabel\` | 복사 버튼 라벨 변경 | +| 선택 | \`closeOptions\` | 오버레이 클릭/Escape 닫기 허용 여부 | + +### 사용 시 주의사항 +- 이메일 복사 성공/실패 안내는 \`onCopyEmail\`에서 처리하세요. + `, + }, + }, + }, + args: { + isOpen: false, + onClose: fn(), + onCopyEmail: fn(), + profile: { + title: '이준서', + email: 'jieunsse@example.com', + copyButtonLabel: '이메일 복사하기', + }, + closeOptions: { + overlayClick: true, + escape: true, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부입니다.', + table: { category: '필수' }, + }, + onClose: { + action: 'closed', + description: '모달 닫힘 요청 시 호출됩니다.', + table: { category: '필수' }, + }, + onCopyEmail: { + action: 'email-copied', + description: '이메일 복사 버튼 클릭 시 호출됩니다.', + table: { category: '필수' }, + }, + profile: { + control: 'object', + description: '프로필 표시 정보(title, email, image 등)를 전달합니다.', + table: { category: '필수/선택 혼합' }, + }, + closeOptions: { + control: 'object', + description: 'overlayClick, escape 닫힘 동작을 제어합니다.', + table: { category: '선택' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + return ( +
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: '프로필 표시와 이메일 복사 액션을 확인하는 기본 예시입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/domain/components/ResetPassword/ResetPassword.stories.tsx b/src/components/Modal/domain/components/ResetPassword/ResetPassword.stories.tsx new file mode 100644 index 0000000..741929e --- /dev/null +++ b/src/components/Modal/domain/components/ResetPassword/ResetPassword.stories.tsx @@ -0,0 +1,137 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import ResetPassword from './ResetPassword'; + +const meta = { + title: 'Components/Modal/Domain/ResetPassword', + component: ResetPassword, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +한 줄 요약: 계정 비밀번호 재설정 링크를 전송할 때 사용하는 이메일 입력 모달입니다. + +### 언제 사용하는가 +- 로그인 화면에서 비밀번호 찾기를 선택했을 때 +- 계정 복구 흐름에서 재설정 링크 전송이 필요할 때 + +### 기본 사용 예시 코드 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + onSubmit={handleSendResetLink} +/> +\`\`\` + +### Props 설명 +| 구분 | 이름 | 설명 | +| --- | --- | --- | +| 필수 | \`isOpen\` | 모달 열림/닫힘 상태 | +| 필수 | \`onClose\` | 닫기 처리 콜백 | +| 필수 | \`onSubmit\` | 링크 전송 제출 시 호출 | +| 선택 | \`text\` | 제목/설명/버튼/placeholder 문구 변경 | +| 선택 | \`input\` | 이메일 입력창 props 확장 | +| 선택 | \`closeOptions\` | 오버레이 클릭/Escape 닫기 허용 여부 | + +### 사용 시 주의사항 +- \`onSubmit\`은 이메일 값을 직접 전달하지 않습니다. 입력값 검증/전송은 \`input\` 제어와 함께 상위에서 처리하세요. + `, + }, + }, + }, + args: { + isOpen: false, + onClose: fn(), + onSubmit: fn(), + closeOptions: { + overlayClick: true, + escape: true, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부입니다.', + table: { category: '필수' }, + }, + onClose: { + action: 'closed', + description: '모달 닫힘 요청 시 호출됩니다.', + table: { category: '필수' }, + }, + onSubmit: { + action: 'submitted', + description: '링크 전송 제출 시 호출됩니다.', + table: { category: '필수' }, + }, + text: { + control: 'object', + description: '제목/설명/버튼 문구를 변경합니다.', + table: { category: '선택' }, + }, + input: { + control: 'object', + description: '이메일 입력창 props를 확장합니다.', + table: { category: '선택' }, + }, + closeOptions: { + control: 'object', + description: 'overlayClick, escape 닫힘 동작을 제어합니다.', + table: { category: '선택' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + const handleSubmit = () => { + args.onSubmit?.(); + setIsOpen(false); + }; + + return ( +
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: '이메일 입력 후 링크 전송 동작을 확인하는 기본 예시입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/domain/components/WarningModal/WarningModal.stories.tsx b/src/components/Modal/domain/components/WarningModal/WarningModal.stories.tsx new file mode 100644 index 0000000..1d9447b --- /dev/null +++ b/src/components/Modal/domain/components/WarningModal/WarningModal.stories.tsx @@ -0,0 +1,148 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import WarningModal from './WarningModal'; + +const meta = { + title: 'Components/Modal/Domain/WarningModal', + component: WarningModal, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +한 줄 요약: 탈퇴/삭제처럼 되돌리기 어려운 작업 전 경고와 최종 확인을 제공하는 모달입니다. + +### 언제 사용하는가 +- 회원 탈퇴, 리소스 삭제 등 파괴적 액션 직전 +- 사용자에게 영향 범위를 명확히 알리고 재확인이 필요할 때 + +### 기본 사용 예시 코드 +\`\`\`tsx +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} + onConfirm={handleWithdraw} +/> +\`\`\` + +### Props 설명 +| 구분 | 이름 | 설명 | +| --- | --- | --- | +| 필수 | \`isOpen\` | 모달 열림/닫힘 상태 | +| 필수 | \`onClose\` | 닫기 처리 콜백 | +| 필수 | \`onConfirm\` | 경고 액션 확정 시 호출 | +| 선택 | \`text\` | 제목/설명/버튼 문구 커스터마이징 | +| 선택 | \`closeOptions\` | 오버레이 클릭/Escape 닫기 허용 여부 | + +### 사용 시 주의사항 +- 파괴적 액션에서는 \`closeOptions\`로 실수 닫힘(overlay/Escape)을 제한하는 패턴을 권장합니다. + `, + }, + }, + }, + args: { + isOpen: false, + onClose: fn(), + onConfirm: fn(), + closeOptions: { + overlayClick: true, + escape: true, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + description: '모달 표시 여부입니다.', + table: { category: '필수' }, + }, + onClose: { + action: 'closed', + description: '모달 닫힘 요청 시 호출됩니다.', + table: { category: '필수' }, + }, + onConfirm: { + action: 'confirmed', + description: '경고 액션 확정 시 호출됩니다.', + table: { category: '필수' }, + }, + text: { + control: 'object', + description: '제목/설명/버튼 문구를 변경합니다.', + table: { category: '선택' }, + }, + closeOptions: { + control: 'object', + description: 'overlayClick, escape 닫힘 동작을 제어합니다.', + table: { category: '선택' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => { + setIsOpen(false); + args.onClose?.(); + }; + + const handleConfirm = () => { + args.onConfirm?.(); + setIsOpen(false); + }; + + return ( +
+ + + +
+ ); + }, + parameters: { + docs: { + description: { + story: '위험 액션 전 확인 모달 흐름을 점검하는 기본 예시입니다.', + }, + }, + }, +}; + +export const StrictClosePolicy: Story = { + args: { + closeOptions: { + overlayClick: false, + escape: false, + }, + }, + render: Playground.render, + parameters: { + docs: { + description: { + story: '실수 닫힘을 막아 명시적 버튼 선택을 강제하는 패턴입니다.', + }, + }, + }, +}; diff --git a/src/components/Modal/style/Modal.module.css b/src/components/Modal/style/Modal.module.css index b31e8b1..642405e 100644 --- a/src/components/Modal/style/Modal.module.css +++ b/src/components/Modal/style/Modal.module.css @@ -1,4 +1,4 @@ -@import '@shared/styles/color.css'; +@import '../../../shared/styles/color.css'; .overlay { display: flex; diff --git a/src/components/TeamStatusBar/style/TeamStatusBar.module.css b/src/components/TeamStatusBar/style/TeamStatusBar.module.css new file mode 100644 index 0000000..20de150 --- /dev/null +++ b/src/components/TeamStatusBar/style/TeamStatusBar.module.css @@ -0,0 +1,157 @@ +.title { + margin: 0; + color: var(--Text-Primary, #1e293b); + text-align: left; + font-family: Pretendard, sans-serif; + font-size: 24px; + font-style: normal; + font-weight: 700; + line-height: 28px; + white-space: nowrap; +} + +.subTitle { + color: var(--Color-slate-400, #94a3b8); + font-family: Pretendard; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 14px; +} + +.subContainer dl, +.countContainer dl { + margin: 0; +} + +.subContainer dd, +.countContainer dd { + margin: 8px 0 0; +} + +.settingButton { + width: 36px; + height: 36px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + top: 32px; + right: 24px; + border: none; + background: transparent; + cursor: pointer; +} + +.percent { + color: var(--Color-Brand-Primary, #5189fa); + font-family: Pretendard; + font-size: 32px; + font-style: normal; + font-weight: 700; + line-height: 38px; + text-transform: uppercase; +} + +.completedNumber { + color: var(--Color-blue-400, #5189fa); + font-family: Pretendard; + font-size: 32px; + font-style: normal; + font-weight: 700; + line-height: 28px; + text-transform: uppercase; +} + +.mainContainer { + width: 375px; + height: 196px; + position: relative; + border-radius: 20px; + background: var(--Background-Primary, #fff); + box-shadow: 0 15px 50px -12px rgba(0, 0, 0, 0.05); + padding: 32px 24px 24px 25px; +} + +.subContainer { + display: flex; + align-items: flex-start; + align-self: stretch; + justify-content: space-between; + margin: 37px 0 16px; +} + +.countContainer { + display: flex; + align-items: center; + gap: 24px; + margin-left: auto; +} + +.countContainer > div { + display: flex; + flex-direction: column; + align-items: center; +} + +.countContainer dt, +.countContainer dd { + text-align: center; +} + +.line { + width: 1px; + height: 56px; + background: var(--Border-Primary, #e2e8f0); +} + +.taskNumber { + color: var(--Text-Default, #64748b); + font-family: Pretendard; + font-size: 32px; + font-style: normal; + font-weight: 700; + line-height: 28px; + text-transform: uppercase; +} + +.barContainer { + display: flex; + gap: 16px; + margin-left: 0; + align-items: center; +} + +.progressBar { + flex: 1; + max-width: 100%; + min-width: 0; +} + +@media (min-width: 744px) { + .mainContainer { + width: 620px; + height: 239px; + } + + .countContainer { + margin-left: auto; + } +} + +@media (min-width: 1024px) { + .mainContainer { + width: 1120px; + height: 239px; + } + + .settingButton { + position: static; + } + + .subContainer { + align-items: flex-end; + padding-right: 52px; + } +} diff --git a/src/components/checkbox/CheckBox.stories.tsx b/src/components/checkbox/CheckBox.stories.tsx index 43d4567..1c59d69 100644 --- a/src/components/checkbox/CheckBox.stories.tsx +++ b/src/components/checkbox/CheckBox.stories.tsx @@ -6,24 +6,117 @@ import { fn } from 'storybook/test'; import CheckBox from './CheckBox'; const meta = { - title: 'Components/CheckBox', + title: 'Components/Checkbox', component: CheckBox, + tags: ['autodocs'], parameters: { layout: 'centered', + docs: { + description: { + component: ` +Checkbox는 단일 불리언 값을 켜고 끄는 선택 컴포넌트입니다. + +### 언제 사용하나요 +- 약관/정책 동의 체크가 필요할 때 +- 설정 화면에서 옵션 on/off를 토글할 때 +- 간단한 목록에서 항목 선택/해제를 처리할 때 + +### 기본 사용 예시 +\`\`\`tsx +const [checked, setChecked] = useState(false); + + +\`\`\` + +### Props +| Prop | 필수 | 설명 | +| --- | --- | --- | +| \`checked\` | 예 | 현재 체크 상태입니다. | +| \`label\` | 예* | 화면에 보이는 라벨입니다. | +| \`options.ariaLabel\` | 예* | \`label\`이 없을 때 필수입니다. | +| \`onCheckedChange\` | 아니요 | 다음 체크 상태를 전달해 호출됩니다. | +| \`size\` | 아니요 | \`large | small\` | +| \`disabled\` | 아니요 | 상호작용을 비활성화합니다. | +| \`options.readOnly\` | 아니요 | 상태 표시는 유지하고 토글만 막습니다. | +| \`id/name/value\` | 아니요 | 폼 연동을 위한 네이티브 input 속성입니다. | + +*접근성을 위해 \`label\` 또는 \`options.ariaLabel\` 중 하나는 반드시 제공하세요. + +### 동작 메모 +- 이 컴포넌트는 제어 컴포넌트이므로 부모 상태로 \`checked\`를 관리하세요. +- \`onCheckedChange\`가 없으면 읽기 전용처럼 동작합니다. + +### 자주 쓰는 패턴 +- 폼 필드: \`checked\` + \`onCheckedChange\` +- 아이콘 전용 체크박스: \`label\` 생략 + \`options.ariaLabel\` 제공 + `, + }, + }, }, - tags: ['autodocs'], args: { checked: false, - label: '동의합니다', + label: '이메일 업데이트 받기', + size: 'large', + disabled: false, onCheckedChange: fn(), }, argTypes: { + checked: { + control: 'boolean', + description: '현재 체크 상태입니다.', + table: { category: 'Required' }, + }, + label: { + control: 'text', + description: '표시할 라벨입니다. 생략 시 `options.ariaLabel`을 사용하세요.', + table: { category: 'Required*' }, + }, + onCheckedChange: { + action: 'checked-changed', + description: '다음 체크값으로 호출됩니다.', + table: { category: 'Optional' }, + }, size: { control: 'inline-radio', options: ['large', 'small'], + description: '체크박스 크기입니다.', + table: { + category: 'Optional', + defaultValue: { summary: 'large' }, + }, }, disabled: { control: 'boolean', + description: '입력을 비활성화합니다.', + table: { + category: 'Optional', + defaultValue: { summary: 'false' }, + }, + }, + id: { + control: 'text', + table: { category: 'Optional' }, + }, + name: { + control: 'text', + table: { category: 'Optional' }, + }, + value: { + control: 'text', + table: { category: 'Optional' }, + }, + className: { + control: false, + table: { category: 'Styling' }, + }, + options: { + control: 'object', + description: '고급 옵션: `ariaLabel`, `readOnly`, 커스텀 아이콘', + table: { category: 'Optional' }, }, }, } satisfies Meta; @@ -42,7 +135,7 @@ const ControlledCheckBox: Story['render'] = (args) => { return ; }; -export const Default: Story = { +export const Playground: Story = { render: ControlledCheckBox, }; @@ -51,6 +144,13 @@ export const Checked: Story = { args: { checked: true, }, + parameters: { + docs: { + description: { + story: '초기 체크 상태 예시입니다. 저장된 설정 표시 시 유용합니다.', + }, + }, + }, }; export const Small: Story = { @@ -58,6 +158,13 @@ export const Small: Story = { args: { size: 'small', }, + parameters: { + docs: { + description: { + story: '동일한 동작을 작은 사이즈로 보여주는 예시입니다.', + }, + }, + }, }; export const Disabled: Story = { @@ -65,40 +172,12 @@ export const Disabled: Story = { args: { disabled: true, }, -}; - -export const Overview: Story = { - render: (args) => ( -
- - Large - Small - 비활성 Large - 비활성 Small - - 체크됨 - - - - - - 체크 안됨 - - - - -
- ), parameters: { - controls: { disable: true }, + docs: { + description: { + story: '값은 보이지만 상호작용은 막는 비활성 상태 예시입니다.', + }, + }, }, }; @@ -107,7 +186,32 @@ export const WithoutLabel: Story = { args: { label: undefined, options: { - ariaLabel: '동의 체크박스', + ariaLabel: '이메일 업데이트 받기', + }, + }, + parameters: { + docs: { + description: { + story: '라벨 없는 사용 예시입니다. 보이는 라벨이 없으면 `options.ariaLabel`을 제공하세요.', + }, + }, + }, +}; + +export const ReadOnly: Story = { + args: { + checked: true, + label: '알림 활성화', + options: { + readOnly: true, + }, + onCheckedChange: undefined, + }, + parameters: { + docs: { + description: { + story: '토글 없이 상태만 표시하는 읽기 전용 체크박스 예시입니다.', + }, }, }, }; diff --git a/src/components/dropdown/Dropdown.stories.tsx b/src/components/dropdown/Dropdown.stories.tsx index f943dbd..ba7fc03 100644 --- a/src/components/dropdown/Dropdown.stories.tsx +++ b/src/components/dropdown/Dropdown.stories.tsx @@ -1,37 +1,141 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; import { fn } from 'storybook/test'; import Dropdown from './Dropdown'; import type { DropdownItemData } from './types/types'; -const ITEMS: DropdownItemData[] = [ - { value: 'all', label: '전체' }, - { value: 'popular', label: '인기순' }, +const SORT_ITEMS: DropdownItemData[] = [ { value: 'latest', label: '최신순' }, - { value: 'price', label: '가격순' }, + { value: 'popular', label: '인기순' }, + { value: 'due-date', label: '마감일순' }, + { value: 'title', label: '제목순 (A-Z)' }, ]; const meta = { title: 'Components/Dropdown', component: Dropdown, + tags: ['autodocs'], parameters: { layout: 'centered', + docs: { + description: { + component: ` +Dropdown은 짧은 옵션 목록에서 하나를 선택할 때 사용하는 컴포넌트입니다. + +### 언제 사용하나요 +- 정렬, 필터 모드, 보기 모드처럼 단일 선택이 필요할 때 +- 공간이 좁아 라디오 그룹을 드롭다운으로 대체할 때 +- 제어/비제어 방식으로 간단하게 선택 UI를 구성할 때 + +### 기본 사용 예시 +\`\`\`tsx + setSort(value)} +/> +\`\`\` + +### Props +| Prop | 필수 | 설명 | +| --- | --- | --- | +| \`items\` | 예 | 옵션 목록. 각 항목은 \`{ value, label }\` 형태입니다. | +| \`placeholder\` | 아니요 | 선택 전 노출되는 안내 텍스트입니다. | +| \`defaultValue\` | 아니요 | 비제어 사용 시 초기 선택값입니다. | +| \`value\` | 아니요 | 제어 사용 시 현재 선택값입니다. | +| \`onChange\` | 아니요 | 선택값이 바뀌면 \`value\`를 전달합니다. | +| \`size\` | 아니요 | 메뉴 크기: \`default | small | repeat\` | +| \`disabled\` | 아니요 | 트리거와 선택을 비활성화합니다. | +| \`ariaLabel\` | 아니요* | 접근성 라벨입니다. *보이는 라벨/placeholder가 없으면 권장됩니다.* | + +### 동작 메모 +- \`defaultValue\`(비제어) 또는 \`value\`(제어) 중 한 가지 패턴으로 사용하세요. +- 선택 불일치를 막기 위해 \`items\`의 value는 안정적으로 유지하세요. + +### 자주 쓰는 패턴 +- 비제어: \`defaultValue\` + \`onChange\` +- 제어: \`value\` + \`onChange\` + 부모 상태 + `, + }, + }, }, - tags: ['autodocs'], args: { - items: ITEMS, - placeholder: '정렬 기준 선택', - ariaLabel: '정렬 기준', + items: SORT_ITEMS, + placeholder: '정렬 기준', + ariaLabel: '정렬 옵션', + size: 'default', + disabled: false, onChange: fn(), }, argTypes: { + items: { + control: 'object', + description: '`{ value, label }` 형태의 옵션 목록입니다.', + table: { category: 'Required' }, + }, + placeholder: { + control: 'text', + description: '선택 전 표시할 플레이스홀더 텍스트입니다.', + table: { category: 'Optional' }, + }, + defaultValue: { + control: 'text', + description: '비제어 사용 시 초기 선택값입니다.', + table: { category: 'Optional' }, + }, + value: { + control: 'text', + description: '제어 사용 시 현재 선택값입니다.', + table: { category: 'Optional' }, + }, + onChange: { + action: 'changed', + description: '선택값이 변경될 때 호출됩니다.', + table: { category: 'Optional' }, + }, size: { control: 'inline-radio', options: ['default', 'small', 'repeat'], + description: '드롭다운 메뉴 크기입니다.', + table: { + category: 'Optional', + defaultValue: { summary: 'default' }, + }, }, disabled: { control: 'boolean', + description: '컴포넌트를 비활성화합니다.', + table: { + category: 'Optional', + defaultValue: { summary: 'false' }, + }, + }, + ariaLabel: { + control: 'text', + description: '트리거/리스트박스를 위한 접근성 라벨입니다.', + table: { category: 'Optional' }, + }, + className: { + control: false, + table: { category: 'Styling' }, + }, + buttonClassName: { + control: false, + table: { category: 'Styling' }, + }, + menuClassName: { + control: false, + table: { category: 'Styling' }, + }, + itemClassName: { + control: false, + table: { category: 'Styling' }, }, }, } satisfies Meta; @@ -39,42 +143,40 @@ const meta = { export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Playground: Story = {}; export const WithDefaultValue: Story = { args: { defaultValue: 'popular', }, -}; - -export const Small: Story = { - args: { - size: 'small', + parameters: { + docs: { + description: { + story: '초기 선택값을 주는 비제어 사용 예시입니다.', + }, + }, }, }; -export const Repeat: Story = { +export const Controlled: Story = { args: { - size: 'repeat', + value: 'latest', }, -}; + render: (args) => { + const [selectedValue, setSelectedValue] = useState(args.value ?? 'latest'); -export const Disabled: Story = { - args: { - disabled: true, - }, -}; + const handleChange = (nextValue: string) => { + setSelectedValue(nextValue); + args.onChange?.(nextValue); + }; -export const Overview: Story = { - render: (args) => ( -
- - - - -
- ), + return ; + }, parameters: { - controls: { disable: true }, + docs: { + description: { + story: '부모 상태가 선택값을 소유하는 제어 패턴 예시입니다.', + }, + }, }, }; diff --git a/src/components/toast/Toast.stories.tsx b/src/components/toast/Toast.stories.tsx new file mode 100644 index 0000000..3bf587f --- /dev/null +++ b/src/components/toast/Toast.stories.tsx @@ -0,0 +1,213 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import Toast from './Toast'; + +const meta = { + title: 'Components/Toast', + component: Toast, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +Toast는 사용자 액션에 대한 짧고 비차단성 피드백을 보여주는 컴포넌트입니다. + +### 언제 사용하나요 +- 저장/수정 완료 또는 경고 메시지를 잠깐 보여줄 때 +- 되돌리기, 재시도 같은 후속 액션을 안내할 때 +- 일정 시간 후 자동으로 사라지는 상태 알림이 필요할 때 + +### 기본 사용 예시 +\`\`\`tsx +const [open, setOpen] = useState(false); + + setOpen(false)} +/> +\`\`\` + +### Props +| Prop | 필수 | 설명 | +| --- | --- | --- | +| *(없음)* | 아니요 | 모든 prop이 선택 사항입니다. | +| \`message\` | 아니요 | 메인 메시지 텍스트입니다. | +| \`actionLabel\` | 아니요 | 액션 버튼 텍스트입니다. 비우면 버튼이 숨겨집니다. | +| \`isOpen\` | 아니요 | 표시 여부를 제어합니다. 기본값: \`true\` | +| \`autoDismissMs\` | 아니요 | 자동 닫힘 시간(ms). 기본값: \`3000\`, \`0\`이면 자동 닫힘 없음 | +| \`enterDurationMs\`, \`exitDurationMs\` | 아니요 | 애니메이션 시간(ms). 기본값: \`600\` | +| \`onAction\` | 아니요 | 액션 버튼 클릭 시 호출됩니다. | +| \`onDismiss\` | 아니요 | 닫힘 애니메이션이 끝난 뒤 호출됩니다. | + +### 동작 메모 +- Toast 열림/닫힘 상태는 부모에서 관리하고, \`onDismiss\`에서 상태를 정리하세요. +- Toast 메시지는 짧고 명확하게 유지하세요. + +### 자주 쓰는 패턴 +- 자동 닫힘 알림 +- 단일 액션 버튼이 있는 Toast(재시도/되돌리기/저장) + `, + }, + }, + }, + args: { + message: '저장되지 않은 변경사항이 있어요.', + actionLabel: '지금 저장', + isOpen: true, + autoDismissMs: 3000, + enterDurationMs: 600, + exitDurationMs: 600, + onAction: fn(), + onDismiss: fn(), + }, + argTypes: { + message: { + control: 'text', + description: 'Toast 메시지 텍스트입니다.', + table: { category: 'Optional' }, + }, + actionLabel: { + control: 'text', + description: '액션 버튼 라벨입니다. 비우면 버튼이 숨겨집니다.', + table: { category: 'Optional' }, + }, + isOpen: { + control: 'boolean', + description: 'Toast 표시 여부를 제어합니다.', + table: { + category: 'Optional', + defaultValue: { summary: 'true' }, + }, + }, + autoDismissMs: { + control: { type: 'number', min: 0, step: 100 }, + description: '자동 닫힘 시간(ms)입니다. 0이면 닫히지 않습니다.', + table: { + category: 'Optional', + defaultValue: { summary: '3000' }, + }, + }, + enterDurationMs: { + control: { type: 'number', min: 0, step: 100 }, + description: '진입 애니메이션 시간(ms)입니다.', + table: { + category: 'Optional', + defaultValue: { summary: '600' }, + }, + }, + exitDurationMs: { + control: { type: 'number', min: 0, step: 100 }, + description: '종료 애니메이션 시간(ms)입니다.', + table: { + category: 'Optional', + defaultValue: { summary: '600' }, + }, + }, + onAction: { + action: 'action-clicked', + description: '액션 버튼 클릭 시 호출됩니다.', + table: { category: 'Optional' }, + }, + onDismiss: { + action: 'dismissed', + description: '닫힘 애니메이션 완료 후 호출됩니다.', + table: { category: 'Optional' }, + }, + className: { + control: false, + table: { category: 'Styling' }, + }, + actionClassName: { + control: false, + table: { category: 'Styling' }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const triggerButtonStyle = { + border: '1px solid #d1d5db', + borderRadius: '10px', + background: '#ffffff', + color: '#111827', + padding: '10px 14px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', +}; + +export const Playground: Story = { + args: { + autoDismissMs: 0, + }, + parameters: { + docs: { + description: { + story: '`autoDismissMs`를 `0`으로 둔 고정 미리보기 예시입니다.', + }, + }, + }, +}; + +export const AutoDismiss: Story = { + args: { + isOpen: false, + autoDismissMs: 2000, + }, + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + const handleAction = () => { + args.onAction?.(); + setIsOpen(false); + }; + + const handleDismiss = () => { + args.onDismiss?.(); + setIsOpen(false); + }; + + return ( +
+ + +
+ ); + }, + parameters: { + docs: { + description: { + story: '버튼 클릭으로 Toast를 띄우고 2초 뒤 자동으로 닫히는 예시입니다.', + }, + }, + }, +}; + +export const WithoutAction: Story = { + args: { + actionLabel: undefined, + autoDismissMs: 0, + }, + parameters: { + docs: { + description: { + story: '액션 버튼 없이 메시지만 보여주는 상태 알림 예시입니다.', + }, + }, + }, +};