Skip to content
Merged
84 changes: 84 additions & 0 deletions src/components/ui/input/input.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Input> = {
title: 'Form/Input',
component: Input,
tags: ['autodocs'],
};
export default meta;

type Story = StoryObj<typeof Input>;

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 (
<Input
id='wage'
label='시급*'
requiredMark
placeholder='입력'
inputMode='numeric'
suffix='원'
value={v}
onChange={e => setV(e.currentTarget.value.replace(/\D+/g, ''))} // 숫자만
/>
);
},
};

/** 미니 폼 데모 */
export const MiniFormDemo: Story = {
render: () => {
const [wage2, setWage2] = useState('');
return (
<div className='max-w-md space-y-5'>
<Input id='email2' label='이메일' placeholder='입력' type='email' />
<Input id='pw3' label='비밀번호' type='password' placeholder='••••••••' />
<Input
id='pw4'
label='비밀번호 확인'
type='password'
placeholder='••••••••'
error='비밀번호가 일치하지 않습니다.'
/>
<Input
id='wage2'
label='시급*'
requiredMark
inputMode='numeric'
placeholder='입력'
suffix='원'
value={wage2}
onChange={e => setWage2(e.currentTarget.value.replace(/\D+/g, ''))} // 숫자만
/>
<Button variant='primary' size='md' full>
제출
</Button>
</div>
);
},
};
91 changes: 91 additions & 0 deletions src/components/ui/input/input.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>;

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 (
<div className={cn('flex w-full flex-col gap-1', className)}>
{/* Label */}
{label && (
<label
htmlFor={id}
className={cn(
'text-black text-sm',
// 팀 토큰의 line-height 반영 (Body-S)
'leading-[var(--lh-body-s)]'
)}
>
{label}
{requiredMark && <span className="ml-0.5 text-red-500">*</span>}
</label>
)}

{/* Field */}
<div className="relative">
<input
id={id}
disabled={isDisabled}
aria-invalid={hasError || undefined}
aria-describedby={errorId}
{...rest}
className={cn(
// 전역 공통 (tailwind.config에 정의됨)
'base-input',
'w-full outline-none transition',
hasError && 'border-red-500',
!hasError && 'focus:border-red-500',
isDisabled && 'cursor-not-allowed bg-gray-100',
// suffix가 있을 때 오른쪽 여백 확보
suffix && 'pr-10'
)}
/>

{/* Suffix (예: 단위/아이콘) */}
{suffix && (
<span
className={cn(
'pointer-events-none absolute right-5 top-1/2 -translate-y-1/2',
'text-sm text-gray-500'
)}
>
{suffix}
</span>
)}
</div>

{/* Error message */}
{hasError && (
<p
id={errorId}
className={cn(
'text-xs text-red-500',
// 팀 토큰의 caption line-height 반영
'leading-[var(--lh-caption)]'
)}
>
{error}
</p>
)}
</div>
);
}
92 changes: 92 additions & 0 deletions src/components/ui/modal/modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/nextjs';
import { useState } from 'react';
import Modal from './modal';

const meta: Meta<typeof Modal> = {
title: 'UI/Modal',
component: Modal,
tags: ['autodocs'],
};
export default meta;

type Story = StoryObj<typeof Modal>;

/** 1) 경고(Alert) */
export const AlertWarning: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<button
className='rounded-lg bg-red-500 px-3 py-2 text-white'
onClick={() => setOpen(true)}
>
모달 열기
</button>

<Modal
open={open}
onClose={() => setOpen(false)}
variant='warning'
title='가게 정보를 먼저 등록해 주세요.'
primaryText='확인'
onPrimary={() => setOpen(false)}
/>
</>
);
},
};

/** 2) 확인/취소(Confirm) */
export const ConfirmSuccess: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<button
className='rounded-lg bg-red-500 px-3 py-2 text-white'
onClick={() => setOpen(true)}
>
모달 열기
</button>

<Modal
open={open}
onClose={() => 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 (
<>
<button
className='rounded-lg bg-red-500 px-3 py-2 text-white'
onClick={() => setOpen(true)}
>
모달 열기
</button>

<Modal
open={open}
onClose={() => setOpen(false)}
variant='success'
title='수정이 완료되었습니다.'
primaryText='확인'
onPrimary={() => setOpen(false)}
/>
</>
);
},
};
122 changes: 122 additions & 0 deletions src/components/ui/modal/modal.tsx
Original file line number Diff line number Diff line change
@@ -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<Variant, { circle: IconName; glyph: IconName }> = {
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 = (
<div
aria-modal='true'
role='dialog'
className='fixed inset-0 z-[1000] flex items-center justify-center p-4'
>
{closeOnDimmed ? (
<button
type='button'
aria-label='모달 닫기'
className='absolute inset-0 bg-[var(--modal-dimmed)]'
onClick={onClose}
/>
) : (
<div className='absolute inset-0 bg-[var(--modal-dimmed)]' aria-hidden />
)}

<div
className={[
'relative w-full max-w-md rounded-2xl bg-[var(--modal-frame)]',
'flex flex-col',
className,
].join(' ')}
>
{/* Header (아이콘, 제목) */}
<div className='flex flex-col items-center gap-3 px-6 pt-6 text-center'>
<span className='relative inline-flex items-center justify-center'>
<Icon
iconName={ICON_MAP[variant].circle}
iconSize='lg'
className='bg-[var(--red-500)]'
ariaLabel={`${variant} circle`}
decorative
/>
<Icon
iconName={ICON_MAP[variant].glyph}
iconSize='x-sm'
className='absolute bg-white'
ariaLabel={`${variant} glyph`}
decorative
/>
</span>
<h2 className='font-medium leading-[var(--lh-modal)] text-[var(--black)] text-[var(--fs-modal)]'>
{title}
</h2>
</div>

{/* Body(description ->선택) */}
{description && (
<div className='px-6 py-4 leading-[var(--lh-body-m)] text-[var(--black)] text-[var(--fs-body-m)]'>
{description}
</div>
)}

{/* Footer (버튼)*/}
<div className='flex items-center justify-center gap-3 px-6 pb-6 pt-6'>
{secondaryText && (
<Button size='md' variant='secondary' onClick={onSecondary}>
{secondaryText}
</Button>
)}
<Button size='md' variant='primary' onClick={onPrimary}>
{primaryText}
</Button>
</div>
</div>
</div>
);

return disablePortal ? node : createPortal(node, document.body);
}