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
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
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자 이내로 입력해주세요',
},
})}
/>
Comment on lines +140 to +150
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

문자 제한 설정이 일관되지 않습니다.

다음과 같은 문제점이 있습니다:

  1. maxLength prop이 5000으로 설정되어 있지만 validation은 500자로 제한되어 있습니다
  2. TextField 컴포넌트 간 maxLength 값이 일관되지 않습니다

다음과 같이 수정하는 것을 제안드립니다:

  <TextField.Input
    placeholder="주제를 적어주세요"
-   maxLength={5000}
+   maxLength={500}
    {...register('topic', {
      required: '주제를 입력해주세요',
      maxLength: {
        value: 500,
        message: '500자 이내로 입력해주세요',
      },
    })}
  />

Also applies to: 155-159

</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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

중복된 TextField를 제거해주세요.

동일한 id와 register를 사용하는 TextField가 중복 구현되어 있습니다. 이는 다음과 같은 문제를 일으킬 수 있습니다:

  1. id 중복으로 인한 접근성 문제
  2. 동일한 필드에 대한 중복 등록

</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>
);
});
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';
Loading
Loading