Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions src/app/(dashboard)/form/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import Button from '@/components/ui/Button/Button';
import { Input, Textarea, TagInput, DateInput, ImageUpload, SearchInput } from '@/components/ui/Field';

/**
* Input, Textarea, SearchInput 컴포넌트는 controlled, uncontrolled 둘다 사용하실 수 있습니다.
* TagInput, DateInput, ImageUpload는 controlled로만 작동합니다.
*
* controlled 컴포넌트를 RHF(React Hook Form)에 연결하는 샘플은
* 아래 코드에서 확인하시면 됩니다.
* (RHF의 Controller의 render함수에서 제공하는 field(value, onChange, onBlur)를
* 내부 컴포넌트에게 전달해서 작동을 하는 원리입니다.)
*
*
* 커스텀 컴포넌트를 제외한 대부분의 컴포넌트는 기본적으로 input attributes를 확장하여 제공하도록 작성하여
* 기본 input의 속성들을 전달하실 수 있습니다. (placeholder, required, disabled, readonly....)
*/

const sampleSchema = z.object({
title: z.string().min(2, { message: '최소 두글자 이상 작성해주세요' }),
content: z.string().min(2, { message: '최소 두글자 이상 작성해주세요' }),
date: z.date({ message: '날짜를 입력해주세요' }),
tags: z.array(z.string()).min(1, { message: '태그를 한개 이상 입력해주세요.' }),
keyword: z.string(),
image: z
.union([
z
.instanceof(File)
.refine((file) => file.type.startsWith('image/'), { message: '이미지 파일만 업로드 가능합니다.' })
.refine((file) => file.size < 2 * 1024 * 1024, { message: '2MB이하로 올려주세요' }),
z.string(),
])
.optional(),
// 파일업로드를 필수로 하려면 optional을 지우고 아래 refine을 붙여주시면됩니다.
// .refine((value) => value instanceof File || (typeof value === 'string' && value.length > 0), { message: '파일을 업로드해주세요' }),
});

type SampleFormType = z.infer<typeof sampleSchema>;

export default function FormPage() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<SampleFormType>({
resolver: zodResolver(sampleSchema),
mode: 'onBlur',
defaultValues: {
title: '제목',
content: '내용내용 테스트세트슽',
date: undefined,
tags: [],
keyword: '',
image: undefined,
},
});

const onSubmit = (formData: SampleFormType) => {
console.log(formData);
};

return (
<div className='p-10'>
<div className='rounded-md bg-white p-10'>
<div className='mb-10 grid gap-3'>
{/* input sample : controlled */}
<Input
value='readonly 설정을 하면 글자만 조금 투명해집니다.'
onChange={(e) => {
console.log(e.target.value);
}}
readOnly
/>
<Textarea
value='disabled 설정하면 배경도 어두워집니다.'
onChange={(e) => {
console.log(e.target.value);
}}
disabled
/>
<SearchInput placeholder='검색어를 입력해주세요' />
</div>
<form onSubmit={handleSubmit(onSubmit)} className='grid gap-4'>
{/* input sample : uncontrolled */}
<Input //
label='제목'
error={errors.title?.message}
placeholder='제목을 입력해주세요'
required
{...register('title')}
/>

{/* textarea sample : uncontrolled */}
<Textarea //
label='내용'
error={errors.content?.message}
placeholder='내용을 입력해주세요'
required
{...register('content')}
/>

{/* datepicker sample : controlled */}
<Controller //
name='date'
control={control}
render={({ field }) => {
return (
<DateInput //
label='마감일'
error={errors.date?.message}
placeholder='날짜를 입력해주세요'
required
{...field}
/>
);
}}
/>

{/* tags input sample : controlled */}
<Controller //
name='tags'
control={control}
render={({ field }) => {
return (
<TagInput //
label='태그'
error={errors.tags?.message}
placeholder='입력후 Enter'
required
{...field}
/>
);
}}
/>

{/* image upload sample : controlled */}
<Controller
name='image'
control={control}
render={({ field }) => {
return (
<ImageUpload //
label='이미지'
error={errors.image?.message}
{...field}
/>
);
}}
/>

<Button type='submit'>제출</Button>
</form>
</div>
</div>
);
}
4 changes: 4 additions & 0 deletions src/assets/icons/x_white.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions src/components/ui/Field/Base.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { LabelHTMLAttributes, PropsWithChildren } from 'react';

export function BaseItem({ children }: PropsWithChildren) {
return <div className='grid gap-2'>{children}</div>;
}

export function BaseLabel({ required, children, ...props }: PropsWithChildren<LabelHTMLAttributes<HTMLLabelElement> & { required?: boolean }>) {
return (
<label className='inline-flex items-center gap-[2px] text-md font-medium md:text-2lg' {...props}>
{children}
{required && <span className='pt-[2px] text-violet-20'>*</span>}
</label>
);
}

export function BaseError({ children }: PropsWithChildren) {
return <div className='text-md text-red'>{children}</div>;
}

export const baseFieldClassName = 'border h-[50px] rounded-lg w-full p-4 text-md focus-visible:outline-none md:text-lg read-only:text-gray-40';
export const baseErrorClassName = 'border-red';
43 changes: 43 additions & 0 deletions src/components/ui/Field/DateInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { InputHTMLAttributes, useId } from 'react';
import Image from 'next/image';
import DatePicker from 'react-datepicker';
import { cn } from '@/utils/helper';
import { BaseError, baseErrorClassName, baseFieldClassName, BaseItem, BaseLabel } from './Base';
import calendarIcon from '@/assets/icons/calendar.svg';
import { BaseField } from './types';
import 'react-datepicker/dist/react-datepicker.css';

type DateInputProps = BaseField &
Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> & {
value: Date;
onChange: (value: Date | null) => void;
};

export function DateInput({ label, error, value, onChange, onBlur, placeholder, required, className, disabled, readOnly }: DateInputProps) {
const id = useId();

return (
<BaseItem>
{label && (
<BaseLabel required={required} htmlFor={id}>
{label}
</BaseLabel>
)}
<div className='relative grid'>
<Image src={calendarIcon} alt='날짜선택' className={cn('pointer-events-none absolute left-4 top-1/2 z-20 h-auto w-4 -translate-y-1/2 opacity-50', value && 'opacity-100')} />
<DatePicker //
id={id}
selected={value}
onChange={onChange}
onBlur={onBlur}
className={cn(baseFieldClassName, 'p-4 pl-10', error && baseErrorClassName, className)}
dateFormat='yyyy년 MM월 dd일'
placeholderText={placeholder}
disabled={disabled}
readOnly={readOnly}
/>
</div>
{error && <BaseError>{error}</BaseError>}
</BaseItem>
);
}
70 changes: 70 additions & 0 deletions src/components/ui/Field/ImageUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ChangeEvent, InputHTMLAttributes, useEffect, useId, useRef } from 'react';
import Image from 'next/image';
import { cn } from '@/utils/helper';
import { BaseError, BaseItem, BaseLabel } from './Base';
import { BaseField } from './types';
import deleteIcon from '@/assets/icons/x_white.svg';
import addIcon from '@/assets/icons/plus.svg';

type ImageUploadProps = BaseField &
Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'onBlur'> & {
value: File | string | undefined;
onChange: (file: File | undefined) => void;
onBlur: () => void;
};

export function ImageUpload({ value, onChange, onBlur, label, required, error, className }: ImageUploadProps) {
const preview = value instanceof File ? URL.createObjectURL(value) : value;
const fileRef = useRef<HTMLInputElement>(null);
const id = useId();

useEffect(() => {
return () => {
if (preview && value instanceof File) {
URL.revokeObjectURL(preview);
}
};
}, [preview, value]);

function handleChange(e: ChangeEvent<HTMLInputElement>) {
if (!e.target.files) return;

const files = e.target.files;
onChange(files[0]);
onBlur();

if (fileRef.current) {
fileRef.current.value = '';
}
}

function handleRemove() {
if (fileRef.current) {
fileRef.current.value = '';
}

onChange(undefined);
}

return (
<BaseItem>
{label && (
<BaseLabel required={required} htmlFor={id}>
{label}
</BaseLabel>
)}
<div className={cn('relative aspect-square w-[76px]', className)}>
<label className='relative flex h-full w-full cursor-pointer items-center justify-center overflow-hidden rounded-md bg-gray-10'>
{preview ? <Image src={preview} alt='thumbnail' fill sizes='40vw' className='absolute h-full w-full object-cover' /> : <Image src={addIcon} className='h-auto w-[18px]' alt='업로드' />}
<input id={id} type='file' accept='image/*' ref={fileRef} onChange={handleChange} className='sr-only' />
</label>
{preview && (
<button type='button' className='absolute -right-1 -top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black' onClick={handleRemove}>
<Image src={deleteIcon} alt='삭제' />
</button>
)}
</div>
{error && <BaseError>{error}</BaseError>}
</BaseItem>
);
}
24 changes: 24 additions & 0 deletions src/components/ui/Field/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { forwardRef, InputHTMLAttributes, useId } from 'react';
import { cn } from '@/utils/helper';
import { BaseError, baseErrorClassName, baseFieldClassName, BaseItem, BaseLabel } from './Base';
import { BaseField } from './types';

type InputProps = BaseField & InputHTMLAttributes<HTMLInputElement>;

export const Input = forwardRef<HTMLInputElement, InputProps>(({ label, error, className, ...props }, ref) => {
const id = useId();

return (
<BaseItem>
{label && (
<BaseLabel required={props.required} htmlFor={id}>
{label}
</BaseLabel>
)}
<input id={id} {...props} className={cn(baseFieldClassName, error && baseErrorClassName, className)} ref={ref} />
{error && <BaseError>{error}</BaseError>}
</BaseItem>
);
});

Input.displayName = 'Input';
19 changes: 19 additions & 0 deletions src/components/ui/Field/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import Image from 'next/image';
import { cn } from '@/utils/helper';
import { baseFieldClassName } from './Base';
import { BaseField } from './types';
import searchIcon from '@/assets/icons/search.svg';

type SearchInputProps = BaseField & InputHTMLAttributes<HTMLInputElement>;

export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(({ className, ...props }, ref) => {
return (
<div className='relative'>
<input {...props} className={cn(baseFieldClassName, 'pl-10', className)} ref={ref} />
<Image src={searchIcon} className='pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2' alt='search' />
</div>
);
});

SearchInput.displayName = 'SearchInput';
Loading