Skip to content
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

Merged
merged 8 commits into from
Jan 18, 2025
Merged
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
61 changes: 60 additions & 1 deletion apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

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

type FormValues = {
topic: string;
aiUpgrade: string;
};

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 @@ -108,6 +127,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={500}
{...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={500}
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={500}
showCounter
{...register('aiUpgrade')}
/>
<TextField.Submit type="submit" />
</TextField>
</div>
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<Breadcrumb>
<Breadcrumb.Item>
Expand Down
114 changes: 114 additions & 0 deletions packages/ui/src/components/TextField/TextField.css.ts
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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

단위 rem으로 부탁드려요!!

Suggested change
minHeight: '59px',
padding: '16px',
borderRadius: '12px',
minHeight: '5.9rem',
padding: '1.6rem',
borderRadius: '1.2rem',

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전부 수정했습니다~! 스크롤 스타일, rem, 디자인 요구사항 변경 모두 반영했어요!

1d5db73

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],
Copy link

Choose a reason for hiding this comment

The 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bottom: '45px',
right: vars.space[12],
bottom: '50%',
transform: 'translateY(50%)',
right: vars.space[12],

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,
},
},
},
});
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>
);
});
Comment on lines +13 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

접근성 및 유효성 검사 개선 필요

  1. 스크린 리더 사용자를 위한 aria-label 추가가 필요합니다.
  2. current 값이 max를 초과하는 경우에 대한 처리가 필요합니다.
 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';
98 changes: 98 additions & 0 deletions packages/ui/src/components/TextField/TextFieldInput.tsx
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

높이 계산 로직을 개선할 필요가 있습니다.

  1. 매직 넘버 59px이 하드코딩되어 있습니다.
  2. 높이 계산 로직이 스타일 변경에 취약할 수 있습니다.

다음과 같이 개선해보세요:

+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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
};
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`;
setIsMultiline(newHeight > SINGLE_LINE_HEIGHT);
};


const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
if (maxLength && e.target.value.length > maxLength) return;
Copy link
Member

Choose a reason for hiding this comment

The 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';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

접근성 개선이 필요합니다.

  1. 텍스트 영역에 aria-label 또는 aria-labelledby 속성이 없습니다.
  2. 문자 수 카운터에 대한 스크린 리더 지원이 필요합니다.

다음과 같이 개선해보세요:

 <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}
   />
 )}

Committable suggestion skipped: line range outside the PR's diff.

Loading
Loading