Skip to content

Commit

Permalink
[Feat] TextField 컴포넌트 구현 (#65)
Browse files Browse the repository at this point in the history
* chore(apps/web): react-hook-form 설치

* feat(packages/ui): isNill 함수 추가

* chore(packages/ui): isNill export

* feat(packages/ui): TextField 컴포넌트 구현

* test(apps/web): 예시 추가

* fix(packages/ui): 디자인 요구사항 수정
  • Loading branch information
minseong0324 authored Jan 18, 2025
1 parent 2124a91 commit 7e20960
Show file tree
Hide file tree
Showing 14 changed files with 513 additions and 3 deletions.
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"next": "14.2.21",
"overlay-kit": "^1.4.1",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"react-hook-form": "^7.54.2"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
Expand Down
60 changes: 59 additions & 1 deletion apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useForm } from 'react-hook-form';
import {
Icon,
Toast,
Expand All @@ -9,18 +10,35 @@ import {
Checkbox,
Label,
Breadcrumb,
TextField,
} from '@repo/ui';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { overlay } from 'overlay-kit';

type FormValues = {
topic: string;
aiUpgrade: string;
};
const LottieAnimation = dynamic(
() => import('@repo/ui/LottieAnimation').then((mod) => mod.LottieAnimation),
{
ssr: false,
}
);

export default function Home() {
const { register, handleSubmit } = useForm<FormValues>({
defaultValues: {
topic: '',
aiUpgrade: '',
},
});

const onSubmit = (data: FormValues) => {
console.log('Form data:', data);
notify1(); // 성공 토스트 표시
};

const notify1 = () =>
overlay.open(({ isOpen, close, unmount }) => (
<Toast
Expand Down Expand Up @@ -115,6 +133,46 @@ export default function Home() {
<Label variant="required">어떤 글을 생성할까요?</Label>
<Label variant="optional">어떤 글을 생성할까요?</Label>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<TextField id="basic-field">
<TextField.Label>주제</TextField.Label>
<TextField.Input
placeholder="주제를 적어주세요"
maxLength={5000}
{...register('topic', {
required: '주제를 입력해주세요',
maxLength: {
value: 500,
message: '500자 이내로 입력해주세요',
},
})}
/>
</TextField>

<TextField id="ai-field" variant="button">
<TextField.Label>AI 업그레이드</TextField.Label>
<TextField.Input
placeholder="AI에게 요청하여 글 업그레이드하기"
maxLength={5000}
showCounter
{...register('aiUpgrade')}
/>
<TextField.Submit type="submit" />
</TextField>

<TextField id="ai-field" variant="button" isError>
<TextField.Label>AI 업그레이드</TextField.Label>
<TextField.Input
placeholder="AI에게 요청하여 글 업그레이드하기"
maxLength={5000}
showCounter
{...register('aiUpgrade')}
/>
<TextField.Submit type="submit" />
</TextField>
</div>
</form>
<LottieAnimation
animationData="loadingBlack"
width="2.4rem"
Expand Down
144 changes: 144 additions & 0 deletions packages/ui/src/components/TextField/TextField.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { vars } from '@repo/theme';

export const textFieldWrapperStyle = style({
position: 'relative',
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: vars.space[8],
});

export const textFieldContainerStyle = recipe({
base: {
padding: vars.space[16],
backgroundColor: vars.colors.grey50,
borderRadius: '1.2rem',
},
variants: {
variant: {
default: {
backgroundColor: vars.colors.grey25,
paddingRight: vars.space[16],
},
button: {
backgroundColor: vars.colors.grey50,
paddingRight: '4.8rem',
},
},
},
});

export const textFieldStyle = recipe({
base: {
width: '100%',
border: 'none',
outline: 'none',
resize: 'none',
color: vars.colors.grey700,
fontSize: vars.typography.fontSize[18],
fontWeight: vars.typography.fontWeight.medium,
lineHeight: '150%',
fontFamily: 'inherit',
paddingRight: vars.space[4],
maxHeight: `calc(${vars.typography.fontSize[18]} * 11 * 1.5)`,
overflowY: 'auto',
'::placeholder': {
color: vars.colors.grey400,
},
selectors: {
'&::-webkit-scrollbar': {
width: '0.6rem',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: vars.colors.grey200,
borderRadius: '0.4rem',
backgroundClip: 'padding-box',
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'transparent',
},
},
scrollbarWidth: 'thin',
scrollbarColor: `${vars.colors.grey200} transparent`,
},
variants: {
variant: {
default: {
backgroundColor: vars.colors.grey25,
'::placeholder': {
color: vars.colors.grey400,
},
},
button: {
backgroundColor: vars.colors.grey50,
'::placeholder': {
color: vars.colors.grey400,
},
},
},
},
});

export const submitButtonStyle = recipe({
base: {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
right: '1.2rem',
width: '3.2rem',
height: '3.2rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
background: 'transparent',
padding: 0,
cursor: 'pointer',

':hover': {
opacity: 0.8,
},
},
variants: {
isError: {
true: {
cursor: 'not-allowed',
},
},
},
});

export const counterStyle = recipe({
base: {
fontSize: vars.typography.fontSize[16],
fontWeight: vars.typography.fontWeight.medium,
margin: `0 ${vars.space[8]}`,
lineHeight: '1.5',
textAlign: 'right',
},
variants: {
isError: {
false: {
color: vars.colors.grey500,
},
true: {
color: vars.colors.warning,
},
},
},
defaultVariants: {
isError: false,
},
});

export const labelStyle = recipe({
variants: {
isError: {
true: {
color: vars.colors.warning,
},
},
},
});
55 changes: 55 additions & 0 deletions packages/ui/src/components/TextField/TextField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { TextFieldRoot } from './TextFieldRoot';
import { TextFieldLabel } from './TextFieldLabel';
import { TextFieldInput } from './TextFieldInput';
import { TextFieldSubmit } from './TextFieldSubmit';

/**
*
* @example
* // 1. 기본값이 있는 비제어 컴포넌트
* <TextField variant="button">
* <TextField.Label>메시지</TextField.Label>
* <TextField.Input
* placeholder="메시지를 입력하세요"
* {...register('message', {
* value: '초기값'
* })}
* />
* <TextField.Submit type="submit" />
* </TextField>
*
* // 2. onChange 이벤트가 필요한 제어 컴포넌트
* <TextField>
* <TextField.Input
* {...register('message')}
* onChange={(e) => {
* register('message').onChange(e);
* setValue('message', e.target.value);
* }}
* />
* </TextField>
*
* // 3. 유효성 검사와 에러 상태를 포함한 컴포넌트
* <TextField error={!!errors.message}>
* <TextField.Input
* {...register('message', {
* required: '메시지를 입력해주세요',
* maxLength: {
* value: 500,
* message: '최대 500자까지 입력 가능합니다'
* }
* })}
* />
* </TextField>
*/
export const TextField = Object.assign(TextFieldRoot, {
Label: TextFieldLabel,
Input: TextFieldInput,
Submit: TextFieldSubmit,
});

export type { TextFieldProps } from './TextFieldRoot';
export type { TextFieldLabelProps } from './TextFieldLabel';
export type { TextFieldInputProps } from './TextFieldInput';
export type { TextFieldSubmitProps } from './TextFieldSubmit';
export type { TextFieldCounterProps } from './TextFieldCounter';
27 changes: 27 additions & 0 deletions packages/ui/src/components/TextField/TextFieldCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { counterStyle } from './TextField.css';
import { ComponentPropsWithoutRef, forwardRef, useContext } from 'react';
import { TextFieldContext } from './context';

export type TextFieldCounterProps = {
current: number;
max: number;
} & ComponentPropsWithoutRef<'span'>;

export const TextFieldCounter = forwardRef<
HTMLSpanElement,
TextFieldCounterProps
>(({ current, max, className = '', ...props }, ref) => {
const { isError } = useContext(TextFieldContext);

return (
<span
ref={ref}
className={`${counterStyle({ isError })} ${className}`}
{...props}
>
{current}/{max}
</span>
);
});

TextFieldCounter.displayName = 'TextField.Counter';
Loading

0 comments on commit 7e20960

Please sign in to comment.