-
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 6 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 | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,114 @@ | ||||||||||||
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 textFieldStyle = recipe({ | ||||||||||||
base: { | ||||||||||||
width: '100%', | ||||||||||||
minHeight: '59px', | ||||||||||||
padding: '16px', | ||||||||||||
borderRadius: '12px', | ||||||||||||
border: 'none', | ||||||||||||
outline: 'none', | ||||||||||||
resize: 'none', | ||||||||||||
overflow: 'hidden', | ||||||||||||
color: vars.colors.grey700, | ||||||||||||
fontSize: vars.typography.fontSize[18], | ||||||||||||
fontWeight: vars.typography.fontWeight.medium, | ||||||||||||
lineHeight: '150%', | ||||||||||||
fontFamily: 'inherit', | ||||||||||||
transition: 'all 0.2s ease', | ||||||||||||
}, | ||||||||||||
variants: { | ||||||||||||
variant: { | ||||||||||||
default: { | ||||||||||||
backgroundColor: vars.colors.grey25, | ||||||||||||
color: vars.colors.grey900, | ||||||||||||
paddingRight: '16px', | ||||||||||||
'::placeholder': { | ||||||||||||
color: vars.colors.grey400, | ||||||||||||
}, | ||||||||||||
}, | ||||||||||||
button: { | ||||||||||||
backgroundColor: vars.colors.grey50, | ||||||||||||
color: vars.colors.grey900, | ||||||||||||
paddingRight: '48px', | ||||||||||||
'::placeholder': { | ||||||||||||
color: vars.colors.grey400, | ||||||||||||
}, | ||||||||||||
}, | ||||||||||||
}, | ||||||||||||
}, | ||||||||||||
defaultVariants: { | ||||||||||||
variant: 'default', | ||||||||||||
}, | ||||||||||||
}); | ||||||||||||
|
||||||||||||
export const submitButtonStyle = recipe({ | ||||||||||||
base: { | ||||||||||||
position: 'absolute', | ||||||||||||
bottom: '45px', | ||||||||||||
right: vars.space[12], | ||||||||||||
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 Submit 버튼의 위치 계산 방식 개선이 필요합니다. 절대 위치 지정 시 하단 여백을 고정값으로 사용하는 것은 responsive 디자인에 문제가 될 수 있습니다. base: {
position: 'absolute',
- bottom: '45px',
+ bottom: '50%',
+ transform: 'translateY(50%)',
right: vars.space[12], 📝 Committable suggestion
Suggested change
|
||||||||||||
width: '32px', | ||||||||||||
height: '32px', | ||||||||||||
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'; |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,98 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
forwardRef, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ComponentPropsWithoutRef, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ChangeEvent, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
useState, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
useRef, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
useEffect, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
useContext, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import { TextFieldContext } from './context'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import { textFieldStyle } from './TextField.css'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import { TextFieldCounter } from './TextFieldCounter'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import { isNil, mergeRefs } from '@/utils'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
export type TextFieldInputProps = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
maxLength?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
showCounter?: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
value?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
defaultValue?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} & Omit< | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ComponentPropsWithoutRef<'textarea'>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
'maxLength' | 'value' | 'defaultValue' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
export const TextFieldInput = forwardRef< | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
HTMLTextAreaElement, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
TextFieldInputProps | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
>( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
maxLength = 500, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
showCounter = true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
value: controlledValue, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
defaultValue, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
className = '', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
onChange, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
...props | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ref | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const [uncontrolledValue, setUncontrolledValue] = useState( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
defaultValue ?? '' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const textareaRef = useRef<HTMLTextAreaElement>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const { variant, id } = useContext(TextFieldContext); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const [isMultiline, setIsMultiline] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const value = controlledValue ?? uncontrolledValue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const handleResizeHeight = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const textarea = textareaRef.current; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (isNil(textarea)) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// height 초기화 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
textarea.style.height = 'auto'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// 스크롤 높이에 따라 높이 조절 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const newHeight = textarea.scrollHeight; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
textarea.style.height = `${newHeight}px`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// 한 줄 높이 = 상하패딩(32px) + 라인높이(27px) = 59px | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
setIsMultiline(newHeight > 59); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+50
to
+63
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 높이 계산 로직을 개선할 필요가 있습니다.
다음과 같이 개선해보세요: +const LINE_HEIGHT = 27;
+const VERTICAL_PADDING = 32;
+const SINGLE_LINE_HEIGHT = LINE_HEIGHT + VERTICAL_PADDING;
+
const handleResizeHeight = () => {
const textarea = textareaRef.current;
if (isNil(textarea)) return;
// height 초기화
textarea.style.height = 'auto';
// 스크롤 높이에 따라 높이 조절
const newHeight = textarea.scrollHeight;
textarea.style.height = `${newHeight}px`;
- // 한 줄 높이 = 상하패딩(32px) + 라인높이(27px) = 59px
- setIsMultiline(newHeight > 59);
+ setIsMultiline(newHeight > SINGLE_LINE_HEIGHT);
}; 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (maxLength && e.target.value.length > maxLength) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
<textarea>의 `maxLength` 속성을 사용하지 않고 여기서 처리하신 이유가 있나요?
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (isNil(controlledValue)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
setUncontrolledValue(e.target.value); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
handleResizeHeight(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
onChange?.(e); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
handleResizeHeight(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}, [value]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
<> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
<textarea | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
rows={1} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
id={id} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ref={mergeRefs(ref, textareaRef)} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
className={`${textFieldStyle({ variant })} ${className}`} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
value={value} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
onChange={handleChange} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
data-multiline={isMultiline} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
{...props} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
{showCounter && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
<TextFieldCounter current={value.length} max={maxLength} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
)} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
</> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
TextFieldInput.displayName = 'TextField.Input'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 접근성 개선이 필요합니다.
다음과 같이 개선해보세요: <textarea
rows={1}
id={id}
ref={mergeRefs(ref, textareaRef)}
className={`${textFieldStyle({ variant })} ${className}`}
value={value}
onChange={handleChange}
data-multiline={isMultiline}
+ aria-labelledby={id}
+ aria-describedby={showCounter ? `${id}-counter` : undefined}
{...props}
/>
{showCounter && (
<TextFieldCounter
+ id={`${id}-counter`}
+ aria-label="현재 글자 수"
current={value.length}
max={maxLength}
/>
)}
|
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.
단위 rem으로 부탁드려요!!
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.
전부 수정했습니다~! 스크롤 스타일, rem, 디자인 요구사항 변경 모두 반영했어요!
1d5db73