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>
);
}
140 changes: 140 additions & 0 deletions src/components/ui/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
'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'` : 성공 상태 (메인 컬러 테두리 적용)
* - `'error'` : 에러 상태 (경고 컬러 테두리 및 텍스트 적용)
*/
state?: 'default' | 'success' | 'error';
/**
* 입력 필드의 크기 (기본값: `'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',
error: 'border border-warning text-warning caret-warning',
},
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` 컴포넌트
*
* 기본적인 입력 필드로, 텍스트 및 비밀번호 입력을 지원하며,
* 에러 상태 및 성공 상태를 지정할 수 있습니다.
*
* @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',
...props
},
ref,
) => {
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'
}
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}
/>
Copy link

@coderabbitai coderabbitai bot Feb 19, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

입력 필드의 접근성을 개선해야 합니다.

입력 필드에 aria-invalidaria-describedby 속성이 누락되었습니다.

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

 <input
   type={type === 'password' ? (isVisible ? 'text' : 'password') : 'text'}
+  aria-invalid={state === 'error' || !!errorMessage}
+  aria-describedby={errorMessage ? `${props.id}-error` : undefined}
   className={cn(
     inputVariants({ state, inputSize }),
     !!errorMessage && 'border border-warning text-warning caret-warning',
   )}
   ref={ref}
   {...props}
 />

그리고 에러 메시지에 ID를 추가합니다:

 {errorMessage && (
-  <p className={cn(errorTextVariants({ inputSize, className }))}>
+  <p id={`${props.id}-error`} className={cn(errorTextVariants({ inputSize, className }))}>
     {errorMessage}
   </p>
 )}
📝 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
<input
type={
type === 'password' ? (isVisible ? 'text' : 'password') : 'text'
}
className={cn(
inputVariants({ state, inputSize }),
!!errorMessage &&
'border border-warning text-warning caret-warning',
)}
ref={ref}
{...props}
/>
<input
type={
type === 'password' ? (isVisible ? 'text' : 'password') : 'text'
}
aria-invalid={state === 'error' || !!errorMessage}
aria-describedby={errorMessage ? `${props.id}-error` : undefined}
className={cn(
inputVariants({ state, inputSize }),
!!errorMessage &&
'border border-warning text-warning caret-warning',
)}
ref={ref}
{...props}
/>
{errorMessage && (
<p id={`${props.id}-error`} className={cn(errorTextVariants({ inputSize, className }))}>
{errorMessage}
</p>
)}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

aria 라벨 중에서 label, invalid, describedby 는 어떤 역할을 하기에 웹 접근성을 위한 속성이라는거야?

Copy link

Choose a reason for hiding this comment

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

@dbswl701 각 ARIA 속성의 역할을 설명해드리겠습니다:

  1. aria-label: 시각적으로 보이지 않는 요소에 대한 설명을 제공합니다. 예를 들어, 아이콘 버튼에 "비밀번호 보기" 같은 레이블을 제공할 수 있습니다.

  2. aria-invalid: 입력값이 유효하지 않음을 스크린 리더에 알립니다. 에러 상태일 때 스크린 리더가 "유효하지 않은 입력"이라고 읽어줍니다.

  3. aria-describedby: 입력 필드와 관련된 추가 설명(예: 에러 메시지)을 연결합니다. 스크린 리더가 입력 필드에 포커스될 때 연결된 설명을 함께 읽어줍니다.

이러한 속성들은 시각 장애인이 스크린 리더를 통해 웹 페이지를 더 잘 이해하고 사용할 수 있도록 도와줍니다.

(`・ω・´)ゞ


✏️ Learnings added
Learnt from: dbswl701
PR: MoimService/Moim-FE#15
File: src/components/ui/Input.tsx:104-115
Timestamp: 2025-02-19T02:38:34.072Z
Learning: ARIA 속성들의 주요 역할:
- aria-label: 시각적으로 보이지 않는 요소에 대한 설명 제공
- aria-invalid: 입력값의 유효성 상태를 스크린 리더에 전달
- aria-describedby: 입력 필드와 관련 설명(에러 메시지 등)을 연결하여 스크린 리더가 함께 읽도록 함

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

에러 메시지에 id를 추가하는 이유가 뭐야?

Copy link

Choose a reason for hiding this comment

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

@dbswl701 에러 메시지에 ID를 추가하는 이유는 aria-describedby 속성과 함께 작동하기 위해서입니다. 입력 필드의 aria-describedby가 에러 메시지의 ID를 참조하면, 스크린 리더는 이 두 요소가 연결되어 있다는 것을 인식합니다. 그래서 사용자가 입력 필드에 포커스할 때, 스크린 리더가 입력 필드와 함께 관련된 에러 메시지도 읽어줄 수 있게 됩니다.


✏️ Learnings added
Learnt from: dbswl701
PR: MoimService/Moim-FE#15
File: src/components/ui/Input.tsx:104-115
Timestamp: 2025-02-19T02:43:36.851Z
Learning: HTML 접근성에서 `aria-describedby`와 요소 ID의 관계:
- 에러 메시지와 같은 설명 텍스트에는 고유한 ID가 필요함
- 입력 필드의 `aria-describedby` 속성은 이 ID를 참조하여 스크린 리더가 두 요소를 연결하여 읽도록 함

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

그럼 id를 props로 꼭 받아야하는거네?

Copy link

Choose a reason for hiding this comment

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

@dbswl701 네, 접근성을 위해서는 id가 필요합니다. 하지만 컴포넌트를 더 사용하기 쉽게 만들기 위해, id가 제공되지 않았을 때는 내부적으로 고유한 ID를 생성하도록 개선할 수 있습니다. 예를 들어:

const Input = React.forwardRef<HTMLInputElement, IInputProps>(
  ({ className, type, state = 'default', errorMessage, inputSize = 'l', id, ...props }, ref) => {
    const [isVisible, setIsVisible] = React.useState(false);
    const uniqueId = React.useId(); // 내부적으로 고유한 ID 생성
    const inputId = id || uniqueId;

    return (
      <div className={`w-full ${className}`}>
        <div className="relative focus-within:text-Cgray700">
          <input
            id={inputId}
            type={type === 'password' ? (isVisible ? 'text' : 'password') : 'text'}
            aria-invalid={state === 'error' || !!errorMessage}
            aria-describedby={errorMessage ? `${inputId}-error` : undefined}
            // ... 나머지 코드
          />
          {errorMessage && (
            <p id={`${inputId}-error`} className={cn(errorTextVariants({ inputSize, className }))}>
              {errorMessage}
            </p>
          )}
        </div>
      </div>
    );
  }
);

✏️ Learnings added
Learnt from: dbswl701
PR: MoimService/Moim-FE#15
File: src/components/ui/Input.tsx:104-115
Timestamp: 2025-02-19T02:46:56.222Z
Learning: Input 컴포넌트에서 접근성을 위한 ID 처리:
- 에러 메시지와 입력 필드를 연결하기 위해 ID가 필요함
- ID가 제공되지 않은 경우 React.useId()를 사용하여 내부적으로 고유한 ID를 생성할 수 있음

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

<button
onClick={() => setIsVisible((prev) => !prev)}
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>
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

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

비밀번호 표시/숨기기 버튼에 대한 접근성 개선이 필요합니다:

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

 <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'}`}
 >
📝 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)}
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 className={cn(errorTextVariants({ inputSize, className }))}>
{errorMessage}
</p>
)}
</div>
);
},
);

Input.displayName = 'Input';

export { Input };
Loading