Skip to content
142 changes: 142 additions & 0 deletions src/app/preview/input/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Input } from '@/components/ui/Input';

export default function ButtonExamples() {
return (
<div className="w-fill h-full bg-BG p-10 text-white">
<h1 className="typo-head1">Normal</h1>
<h2>large normal</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Default" />
</div>

<h2>large typing</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Default" defaultValue={'Typing'} />
</div>

<h2>large done</h2>
<div className="mb-4 w-[460px]">
<Input defaultValue="Done" state="success" />
</div>

<h2>large error</h2>
<div className="mb-4 w-[460px]">
<Input defaultValue="Error" errorMessage="필수 입력 사항입니다." />
</div>

<h2>large disable</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Disabled" disabled />
</div>

<h2>small normal</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Default" inputSize="s" />
</div>

<h2>small typing</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Default" defaultValue={'Typing'} inputSize="s" />
</div>

<h2>small done</h2>
<div className="mb-4 w-[460px]">
<Input defaultValue="Done" state="success" inputSize="s" />
</div>

<h2>small error</h2>
<div className="mb-4 w-[460px]">
<Input
defaultValue="Error"
errorMessage="필수 입력 사항입니다."
inputSize="s"
/>
</div>

<h2>small disable</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Disabled" disabled inputSize="s" />
</div>

<h1 className="typo-head1">Password</h1>
<h2>large normal</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Default" type="password" />
</div>

<h2>large typing</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Default" defaultValue={'Typing'} type="password" />
</div>

<h2>large done</h2>
<div className="mb-4 w-[460px]">
<Input defaultValue="Done" state="success" type="password" />
</div>

<h2>large error</h2>
<div className="mb-4 w-[460px]">
<Input
defaultValue="Error"
errorMessage="필수 입력 사항입니다."
type="password"
/>
</div>

<h2>large disable</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Disabled" disabled type="password" />
</div>

<h2>small normal</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Default" inputSize="s" type="password" />
</div>

<h2>small typing</h2>
<div className="mb-4 w-[460px]">
<Input
placeholder="Default"
defaultValue={'Typing'}
inputSize="s"
type="password"
/>
</div>

<h2>small done</h2>
<div className="mb-4 w-[460px]">
<Input
defaultValue="Done"
state="success"
inputSize="s"
type="password"
/>
</div>

<h2>small error</h2>
<div className="mb-4 w-[460px]">
<Input
defaultValue="Error"
errorMessage="필수 입력 사항입니다."
inputSize="s"
type="password"
/>
</div>

<h2>small disable</h2>
<div className="mb-4 w-[460px]">
<Input placeholder="Disabled" disabled inputSize="s" type="password" />
</div>

<h1 className="typo-head1">width 커스텀</h1>
<div className="mb-4 w-[260px]">
<Input placeholder="Default" inputSize="s" />
</div>
<div className="mb-4 w-[500px]">
<Input placeholder="Default" inputSize="s" />
</div>

<Input className="w-[200px]" placeholder="Default" inputSize="s" />
</div>
);
}
147 changes: 147 additions & 0 deletions src/components/ui/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'use client';

import { cn } from '@/util/cn';
import { VariantProps, cva } from 'class-variance-authority';
import { Eye, EyeOff } from 'lucide-react';
import * as React from 'react';

interface IInputProps
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
/**
* 입력 필드의 상태 (기본값: `'default'`)
* - `'default'` : 기본 상태 (테두리 없음)
* - `'success'` : 성공 상태 (메인 컬러 테두리 적용)
*/
state?: 'default' | 'success';
/**
* 입력 필드의 크기 (기본값: `'l'`)
* - `'s'` : 작은 크기 (36px)
* - `'l'` : 기본 크기 (50px)
*/
inputSize?: 'l' | 's';
/**
* 에러 메시지 (에러 상태일 때 표시)
*/
errorMessage?: string;
}

const inputVariants = cva(
'box-border flex w-full rounded-md border-transparent bg-Cgray200 px-3 px-[16px] py-1 py-[14px] text-base text-Cgray700 caret-Cgray500 shadow-sm transition-colors placeholder:text-Cgray400 focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-disable disabled:text-disable_text md:text-sm',
{
variants: {
state: {
default: '',
success: 'border border-main',
},
inputSize: {
s: 'typo-button2 h-[36px]',
l: 'typo-button1 h-[50px] py-[10px]',
},
},
defaultVariants: {
inputSize: 'l',
},
},
);

const errorTextVariants = cva('px-[10px] text-warning', {
variants: {
inputSize: {
s: 'typo-caption2 mt-[8px]',
l: 'typo-caption1 mt-[10px]',
},
defaultVariants: {
inputSize: 'l',
},
},
});
Comment on lines +48 to +58
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

오류 텍스트 스타일의 기본값 설정에 버그가 있습니다.

errorTextVariantsdefaultVariants 설정이 잘못된 위치에 있습니다. 현재는 variants 객체 내부에 있어 작동하지 않습니다.

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

 const errorTextVariants = cva('px-[10px] text-warning', {
   variants: {
     inputSize: {
       s: 'typo-caption2 mt-[8px]',
       l: 'typo-caption1 mt-[10px]',
     },
-    defaultVariants: {
-      inputSize: 'l',
-    },
   },
+  defaultVariants: {
+    inputSize: 'l',
+  },
 });
📝 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 errorTextVariants = cva('px-[10px] text-warning', {
variants: {
inputSize: {
s: 'typo-caption2 mt-[8px]',
l: 'typo-caption1 mt-[10px]',
},
defaultVariants: {
inputSize: 'l',
},
},
});
const errorTextVariants = cva('px-[10px] text-warning', {
variants: {
inputSize: {
s: 'typo-caption2 mt-[8px]',
l: 'typo-caption1 mt-[10px]',
},
},
defaultVariants: {
inputSize: 'l',
},
});


/**
* `Input` 컴포넌트
*
* 기본적인 입력 필드로, 텍스트 및 비밀번호 입력을 지원하며,
* 에러 상태 및 성공 상태를 지정할 수 있습니다.
* 접근성을 위해 id가 필수적으로 필요합니다.
*
* @example
* // 기본 사용법
* <Input placeholder="텍스트를 입력하세요" />
*
* @example
* // 에러 상태
* <Input state="error" errorMessage="필수 입력 사항입니다." />
*
* @example
* // 성공 상태
* <Input state="success" />
*
* @example
* // 비밀번호 입력 필드 (눈 아이콘 포함)
* <Input type="password" />
*
* @param {IInputProps} props - `Input` 컴포넌트의 속성
* @returns {JSX.Element} `Input` 요소
*/
const Input = React.forwardRef<HTMLInputElement, IInputProps>(
(
{
className,
type,
state = 'default',
errorMessage,
inputSize = 'l',
id,
...props
},
ref,
) => {
Comment on lines +86 to +98
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

접근성을 위한 ID 처리를 개선해야 합니다.

id가 필수적으로 필요하지만 제공되지 않았을 때의 처리가 누락되었습니다.

 const Input = React.forwardRef<HTMLInputElement, IInputProps>(
   (
     {
       className,
       type,
       state = 'default',
       errorMessage,
       inputSize = 'l',
-      id,
+      id: providedId,
       ...props
     },
     ref,
   ) => {
+    const uniqueId = React.useId();
+    const id = providedId || uniqueId;
📝 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 Input = React.forwardRef<HTMLInputElement, IInputProps>(
(
{
className,
type,
state = 'default',
errorMessage,
inputSize = 'l',
id,
...props
},
ref,
) => {
const Input = React.forwardRef<HTMLInputElement, IInputProps>(
(
{
className,
type,
state = 'default',
errorMessage,
inputSize = 'l',
id: providedId,
...props
},
ref,
) => {
const uniqueId = React.useId();
const id = providedId || uniqueId;
// ... rest of the component implementation

const [isVisible, setIsVisible] = React.useState(false);

return (
<div className={`w-full ${className}`}>
<div className="relative focus-within:text-Cgray700">
<input
type={
type === 'password' ? (isVisible ? 'text' : 'password') : 'text'
}
aria-invalid={!!errorMessage}
className={cn(
inputVariants({ state, inputSize }),
!!errorMessage &&
'border border-warning text-warning caret-warning',
)}
Comment on lines +109 to +113
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

에러 상태 처리 로직이 중복됩니다.

state가 'error'일 때와 errorMessage가 있을 때 모두 동일한 에러 스타일이 적용되고 있습니다. 이는 일관성 없는 상태를 초래할 수 있습니다.

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

 className={cn(
-  inputVariants({ state, inputSize }),
-  !!errorMessage &&
-    'border border-warning text-warning caret-warning',
+  inputVariants({ 
+    state: !!errorMessage ? 'error' : state,
+    inputSize 
+  }),
 )}
📝 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
className={cn(
inputVariants({ state, inputSize }),
!!errorMessage &&
'border border-warning text-warning caret-warning',
)}
className={cn(
inputVariants({
state: !!errorMessage ? 'error' : state,
inputSize
}),
)}

ref={ref}
{...props}
/>
<button
onClick={() => setIsVisible((prev) => !prev)}
type="button"
aria-label={isVisible ? '비밀번호 숨기기' : '비밀번호 표시'}
aria-describedby={errorMessage ? `${id}-error` : undefined}
className={`absolute right-[16px] top-1/2 -translate-y-1/2 text-Cgray500 focus-within:text-Cgray700 ${type !== 'password' && 'hidden'}
`}
>
{!isVisible ? (
<EyeOff className={`size-5 `} />
) : (
<Eye className={`size-5 `} />
)}
</button>
Comment on lines +117 to +130
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

비밀번호 토글 버튼의 ARIA 속성을 수정해야 합니다.

aria-describedby가 버튼에서 제거되어야 합니다.

 <button
   onClick={() => setIsVisible((prev) => !prev)}
   type="button"
   aria-label={isVisible ? '비밀번호 숨기기' : '비밀번호 표시'}
-  aria-describedby={errorMessage ? `${id}-error` : undefined}
   className={`absolute right-[16px] top-1/2 -translate-y-1/2 text-Cgray500 focus-within:text-Cgray700 ${
     type !== 'password' && 'hidden'
   }`}
 >
📝 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
<button
onClick={() => setIsVisible((prev) => !prev)}
type="button"
aria-label={isVisible ? '비밀번호 숨기기' : '비밀번호 표시'}
aria-describedby={errorMessage ? `${id}-error` : undefined}
className={`absolute right-[16px] top-1/2 -translate-y-1/2 text-Cgray500 focus-within:text-Cgray700 ${type !== 'password' && 'hidden'}
`}
>
{!isVisible ? (
<EyeOff className={`size-5 `} />
) : (
<Eye className={`size-5 `} />
)}
</button>
<button
onClick={() => setIsVisible((prev) => !prev)}
type="button"
aria-label={isVisible ? '비밀번호 숨기기' : '비밀번호 표시'}
className={`absolute right-[16px] top-1/2 -translate-y-1/2 text-Cgray500 focus-within:text-Cgray700 ${type !== 'password' && 'hidden'}
`}
>
{!isVisible ? (
<EyeOff className={`size-5 `} />
) : (
<Eye className={`size-5 `} />
)}
</button>

</div>
{errorMessage && (
<p
id={`${id}-error`}
className={cn(errorTextVariants({ inputSize, className }))}
>
{errorMessage}
</p>
)}
</div>
);
},
);

Input.displayName = 'Input';

export { Input };
Loading