-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Feat] TextField 컴포넌트 구현 #65
Changes from all commits
8b22845
fbc642f
d92e8e3
973b308
877eb15
f0e3625
1d5db73
23d1aa8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
|
@@ -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 | ||
|
@@ -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> | ||
Comment on lines
+164
to
+173
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 중복된 TextField를 제거해주세요. 동일한 id와 register를 사용하는 TextField가 중복 구현되어 있습니다. 이는 다음과 같은 문제를 일으킬 수 있습니다:
|
||
</div> | ||
</form> | ||
<LottieAnimation | ||
animationData="loadingBlack" | ||
width="2.4rem" | ||
|
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, | ||
}, | ||
}, | ||
}, | ||
}); |
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'; |
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> | ||
); | ||
}); | ||
Comment on lines
+13
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 접근성 및 유효성 검사 개선 필요
return (
<span
ref={ref}
className={`${counterStyle({ isError })} ${className}`}
+ aria-label={`현재 ${current}자, 최대 ${max}자`}
+ role="status"
{...props}
>
{current}/{max}
</span>
); 또한 props 타입에 유효성 검사를 추가하는 것이 좋습니다: export type TextFieldCounterProps = {
current: number;
max: number;
onExceed?: (current: number, max: number) => void;
} & ComponentPropsWithoutRef<'span'>; |
||
|
||
TextFieldCounter.displayName = 'TextField.Counter'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문자 제한 설정이 일관되지 않습니다.
다음과 같은 문제점이 있습니다:
다음과 같이 수정하는 것을 제안드립니다:
Also applies to: 155-159