/**
* Input 컴포넌트
*
* 다양한 상태를 지원하는 입력 필드 컴포넌트입니다.
*/
import { InputHTMLAttributes, forwardRef, useId } from 'react';
import { cn } from '@/lib/cn';
export interface InputProps extends InputHTMLAttributesHTMLInputElement {
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
}
export const Input = forwardRefHTMLInputElement, InputProps(
({ className, label, error, helperText, fullWidth, id, ...props }, ref) = {
const generatedId = useId();
const inputId = id ?? input-${generatedId.replace(/:/g, '-')};
return (
div className={cn('space-y-2', fullWidth && 'w-full')}
{label && (
label
htmlFor={inputId}
className="block text-label4 font-medium text-black"
{label}
/label
)}
input
ref={ref}
id={inputId}
className={cn(
'w-full px-4 py-2 border rounded-lg max-h-11',
'focus:outline-none focus:ring-1 focus:ring-black focus:border-black',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100',
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300',
'bg-white text-black',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? ${inputId}-error : helperText ? ${inputId}-helper : undefined}
{...props}
/
{error && (
p id={${inputId}-error} className="text-sm text-red-600"
{error}
/p
)}
{helperText && !error && (
p id={${inputId}-helper} className="text-sm text-black"
{helperText}
/p
)}
/div
);
}
);
Input.displayName = 'Input';
1. 왜 Input을 따로 만들었는지
로그인, 연락처, 관리자 폼 등에서 텍스트 필드를 쓸 때마다 라벨에러 메시지도움말을 각자 붙이다 보니 스타일과 마크업이 달라졌습니다. 그래서 라벨 + input + 에러/도움말을 한 덩어리로 묶은 공용 Input을 만들기로 했습니다. 네이티브 input에 ref와 HTML 속성을 그대로 넘기고, 시각접근성만 우리 규칙으로 맞추는 방식입니다.
2. 컴포넌트 구성과 설계 방향
구조는 래퍼 div label(선택) input 에러/도움말(선택) 순으로 두었습니다. useId()로 input에 고유 id를 붙이고, 라벨은 htmlFor={inputId}로 input과만 연결했습니다. 에러가 있으면 aria-invalid="true"와 aria-describedby로 에러 문구 id를 연결하고, 에러가 없을 때만 helperText를 aria-describedby에 넣어서 스크린 리더가 유효하지 않음 또는 도움말을 구분해 읽도록 했습니다.
스타일은 한 세트만 두었습니다. 기본은 회색 테두리, 포커스 시 링 1px, 비활성은 opacity배경으로 구분하고, error가 있으면 테두리포커스 링을 빨간색으로 바꿉니다. className은 맨 뒤에 넣어 기본 스타일을 덮어쓸 수 있게 했고, 클라이언트 훅이 없어서 "use client" 없이 두어 서버 렌더링에 그대로 쓸 수 있게 했습니다.
3. 프롭스가 필요한 이유
label
필드마다 이메일, 제목 *, 카테고리 이름 같은 이름이 필요해서 넣었습니다. 있으면 label htmlFor={inputId}로 input과 연결해 클릭 시 포커스가 가고, 스크린 리더가 라벨, 편집 순으로 읽습니다. 없으면 input만 렌더링해서 플레이스홀더만 있는 단순 필드나 쇼케이스에도 쓸 수 있게 했습니다.
error
검증 실패 시 같은 위치에 같은 톤으로 메시지를 보여주려고 넣었습니다. 넘기면 input에 빨간 테두리포커스 링을 주고, 아래에 p id={inputId}-error로 메시지를 렌더링합니다. aria-invalid와 aria-describedby로 그 id를 연결해 두어 접근성 도구가 에러를 인식하도록 했습니다.
helperText
쉼표로 구분, 선택 사항 같은 설명을 필드 바로 아래에 두려고 넣었습니다. error가 있으면 helper는 숨기고 에러만 보이게 해서, 한 자리에서 에러와 도움말이 겹치지 않게 했고, id={inputId}-helper}로 aria-describedby에만 넣어 도움말이 있을 때만 보조 설명으로 읽히게 했습니다.
fullWidth
인라인으로 쓰는 곳과 폼 전체 너비로 쓰는 곳을 구분하려고 넣었습니다. true면 래퍼에 w-full을 줘서 컬럼 레이아웃 안에서도 한 줄을 채우게 했고, 기본은 false라서 그리드 셀 안에서 필요한 만큼만 차지하게 했습니다.
id
폼에 input이 여러 개일 때 각각 고유 id가 필요해서 받을 수 있게 했습니다. 없으면 input-xxx 형태로 생성해 라벨에러도움말의 for/id가 모두 이 값에 맞춰지도록 했습니다. 서버 폼테스트에서 id를 지정하고 싶을 때 쓰면 됩니다.
className, ...props
input에 넘기는 className은 기본에러 스타일 뒤에 적용되므로, 특정 페이지만 패딩폰트를 바꿀 수 있습니다. InputHTMLAttributes의 나머지(type, name, placeholder, disabled, value, onChange 등)는 ...props로 그대로 input에 넘겨서 네이티브 동작과 폼 라이브러리 연동을 그대로 쓸 수 있게 했습니다. ref는 forwardRef로 전달해 포커스 제어나 측정이 필요할 때 사용할 수 있게 했습니다.
4. 사용 용도와 쓰는 방식
로그인비밀번호 변경:
type="text"/type="password",name으로 제출 필드 이름을 주고,label만 넣거나 플레이스홀더와 함께 씁니다. 에러는 상위 폼에서 검증 후error로 넘깁니다.관리자 폼: 블로그 제목태그, 카테고리 이름slug, 유튜브 제목URL, 네비/푸터 링크명URL, 약관 제목버전 등 대부분
label+value/onChange로 제어 모드로 쓰고, 필요 시helperText(예: 쉼표로 구분)를 붙입니다.연락처: 이름연락처아이디에
label,required를 넘기고,type="tel"등 적절한 type만 지정해 씁니다.쇼케이스: 라벨 있음/없음, 에러, 비활성, 도움말 예시를 같은 컴포넌트로 보여주고, 폼 예시 블록에서는 InputSelectTextarea를 함께 배치해 사용합니다.
5. 정리
Input은 라벨에러도움말을 한 래퍼에 묶고, 네이티브 input은 그대로 써서 폼접근성을 통일하자는 목적으로 만들었습니다. label은 필드 식별과 접근성, errorhelperText는 검증 UX와 보조 설명, fullWidth는 레이아웃 선택을 위해 넣었고, idclassName...props는 연동스타일네이티브 속성 전달을 위해 두었습니다. 로그인, 연락처, 블로그/유튜브/네비/푸터/약관 등 관리자 설정 전반에서 공통으로 쓰이고 있습니다.