diff --git a/src/components/ui/input/input.stories.tsx b/src/components/ui/input/input.stories.tsx new file mode 100644 index 0000000..c6b68c4 --- /dev/null +++ b/src/components/ui/input/input.stories.tsx @@ -0,0 +1,84 @@ +// src/components/ui/input/input.stories.tsx +import Button from '@/components/ui/button/button'; +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { useState } from 'react'; +import Input from './input'; + +const meta: Meta = { + title: 'Form/Input', + component: Input, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Email: Story = { + args: { id: 'email', label: '이메일', placeholder: '입력', type: 'email' }, +}; + +export const Password: Story = { + args: { id: 'pw', label: '비밀번호', type: 'password', placeholder: '••••••••' }, +}; + +export const PasswordConfirm_Error: Story = { + args: { + id: 'pw2', + label: '비밀번호 확인', + type: 'password', + placeholder: '••••••••', + error: '비밀번호가 일치하지 않습니다.', + }, +}; + +/** 시급(원) — 스토리 내부에서 숫자만 허용(컴포넌트 변경 없음) */ +export const WageWithSuffix: Story = { + render: () => { + const [v, setV] = useState(''); + return ( + setV(e.currentTarget.value.replace(/\D+/g, ''))} // 숫자만 + /> + ); + }, +}; + +/** 미니 폼 데모 */ +export const MiniFormDemo: Story = { + render: () => { + const [wage2, setWage2] = useState(''); + return ( +
+ + + + setWage2(e.currentTarget.value.replace(/\D+/g, ''))} // 숫자만 + /> + +
+ ); + }, +}; diff --git a/src/components/ui/input/input.tsx b/src/components/ui/input/input.tsx index e69de29..c7cb212 100644 --- a/src/components/ui/input/input.tsx +++ b/src/components/ui/input/input.tsx @@ -0,0 +1,91 @@ +import { cn } from '@/lib/utils/cn'; +import { InputHTMLAttributes, ReactNode } from 'react'; + +type Props = { + label?: string; // 라벨 텍스트 + requiredMark?: boolean; // 라벨 옆 * 표시 + error?: string; // 에러 문구(있으면 빨간 테두리/문구) + suffix?: ReactNode; // 우측 단위/아이콘(예: '원') + className?: string; // 외부 커스텀 클래스 +} & InputHTMLAttributes; + +export default function Input({ + label, + requiredMark, + error, + suffix, + className, + id, + disabled, + ...rest // (type, placeholder, value, onChange 등) +}: Props) { + const hasError = Boolean(error); + const isDisabled = Boolean(disabled); + const errorId = id && hasError ? `${id}-error` : undefined; + + return ( +
+ {/* Label */} + {label && ( + + )} + + {/* Field */} +
+ + + {/* Suffix (예: 단위/아이콘) */} + {suffix && ( + + {suffix} + + )} +
+ + {/* Error message */} + {hasError && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/components/ui/modal/modal.stories.tsx b/src/components/ui/modal/modal.stories.tsx new file mode 100644 index 0000000..852c59a --- /dev/null +++ b/src/components/ui/modal/modal.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { useState } from 'react'; +import Modal from './modal'; + +const meta: Meta = { + title: 'UI/Modal', + component: Modal, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +/** 1) 경고(Alert) */ +export const AlertWarning: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( + <> + + + setOpen(false)} + variant='warning' + title='가게 정보를 먼저 등록해 주세요.' + primaryText='확인' + onPrimary={() => setOpen(false)} + /> + + ); + }, +}; + +/** 2) 확인/취소(Confirm) */ +export const ConfirmSuccess: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( + <> + + + setOpen(false)} + variant='success' + title='신청을 거절하시겠어요?' + secondaryText='아니오' + onSecondary={() => setOpen(false)} + primaryText='예' + onPrimary={() => setOpen(false)} + /> + + ); + }, +}; + +/** 3) 수정(correction) */ +export const Correction: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( + <> + + + setOpen(false)} + variant='success' + title='수정이 완료되었습니다.' + primaryText='확인' + onPrimary={() => setOpen(false)} + /> + + ); + }, +}; diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx index e69de29..c11bb02 100644 --- a/src/components/ui/modal/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -0,0 +1,122 @@ +import { Icon } from '@/components/ui'; +import Button from '@/components/ui/button/button'; +import type { IconName } from '@/constants/icon'; +import { ReactNode, useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +type Variant = 'success' | 'warning'; //체크 아이콘 | 느낌표 아이콘 + +type ModalProps = { + open: boolean; // 모달 열림 여부 + onClose: () => void; // 닫기 함수 + title: string; // 타이틀 (필수) + description?: ReactNode; // 본문 (선택) + variant?: Variant; // success | warning (기본 warning) + primaryText: string; // 주 버튼 라벨 + onPrimary: () => void; // 주 버튼 핸들러 + secondaryText?: string; // 보조 버튼 라벨 (선택) + onSecondary?: () => void; // 보조 버튼 핸들러 + closeOnDimmed?: boolean; // 딤 클릭 닫기 여부 (기본 true) + disablePortal?: boolean; // 포털 비활성화 (기본 false) + className?: string; // 커스텀 class 추가 +}; + +const ICON_MAP: Record = { + success: { circle: 'successCircle', glyph: 'success' }, + warning: { circle: 'warningCircle', glyph: 'warning' }, +}; + +export default function Modal({ + open, + onClose, + title, + description, + variant = 'warning', + primaryText, + onPrimary, + secondaryText, + onSecondary, + closeOnDimmed = true, + disablePortal = false, + className = '', +}: ModalProps) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + const node = ( +
+ {closeOnDimmed ? ( + + )} + +
+ + + ); + + return disablePortal ? node : createPortal(node, document.body); +}