Field
폼 컨트롤에 레이블·보조 텍스트·검증 메시지를 연결하는 컨테이너 컴포넌트.
개요
Field는 TextInput, TextArea, Checkbox, Radio 등 폼 컨트롤을 감싸는 레이아웃 + 접근성 크롬 컴포넌트입니다. Field.Root, Field.Label, Field.Description, Field.Message, Field.Count 다섯 서브컴포넌트로 구성됩니다.
- 자동 ID 연결 —
Field.Root가 고유fieldId,descriptionId,validationId를 생성하고 FieldContext로 자식에 전파 - 레이블 연결 —
Field.Label의htmlFor가fieldId에 자동 설정 - 보조 텍스트 연결 —
Field.Description의id가descriptionId에 자동 설정. 폼 컨트롤의aria-describedby에 연결됨 - 검증 메시지 —
Field.Message는status가none이 아닐 때만 렌더링. error는role="alert", 그 외는aria-live="polite"로 자동 안내 - 상태 전파 —
status,disabled,readOnly,required가 FieldContext를 통해 하위 서브컴포넌트로 전달 - 4가지
status—none(기본),success,warning,error required—Field.Root에 지정하면Field.Label에*인디케이터가 자동 표시되고 폼 컨트롤에aria-required가 자동 부여
언제 사용하나요
- 텍스트 입력, 드롭다운 등 폼 컨트롤에 레이블을 붙일 때
- 입력값 검증 결과(에러, 성공, 경고)를 사용자에게 알릴 때
- 입력 가이드나 제약 조건을 보조 텍스트로 안내할 때
- 글자 수 카운터처럼 레이블 영역에 부가 정보를 배치할 때
언제 사용하면 안 되나요
- 폼 컨트롤 없이 텍스트만 표시할 때 →
Typo사용 - 페이지 단위 안내 메시지 →
SectionMessage사용 - 버튼 그룹에 제목을 붙일 때 →
<fieldset>+<legend>직접 사용
접근성
키보드 동작
Field는 포커스를 받지 않습니다. 내부 폼 컨트롤의 키보드 동작을 따릅니다.
스크린리더 동작
Field.Label의htmlFor가 폼 컨트롤id에 연결되어 레이블을 읽음Field.Description의id가 폼 컨트롤의aria-describedby에 연결되어 보조 텍스트를 추가로 읽음Field.Message는status="error"일 때role="alert"로 즉시 안내, 그 외에는aria-live="polite"로 부드럽게 안내Field.Root에required를 지정하면 폼 컨트롤이aria-required="true"를 자동으로 받음disabled상태에서는Field.Message가 렌더링되지 않아 불필요한 안내를 방지
WCAG 기준
| 기준 | 수준 | 적용 |
|---|---|---|
| 1.3.1 Info and Relationships | A | htmlFor로 레이블과 입력 요소 연결 |
| 3.3.1 Error Identification | A | Field.Message로 오류 항목과 내용 명시 |
| 3.3.2 Labels or Instructions | A | Field.Description으로 입력 형식 안내 |
| 3.3.7 Redundant Entry | A | required context를 폼 컨트롤에 자동 전파 (aria-required) |
| 4.1.3 Status Messages | AA | error는 role="alert", 그 외는 aria-live="polite"로 자동 안내 |
Usage
기본 사용법
Field.Root로 폼 컨트롤을 감싸고 Field.Label과 Field.Description을 배치합니다. ID 연결은 자동으로 처리됩니다.
() => { const [value, setValue] = React.useState(''); return ( <Field.Root $css={{ width: '320px' }}> <Field.Label>이름</Field.Label> <TextInput.Root> <TextInput.Input placeholder="이름을 입력하세요" value={value} onChange={e => setValue(e.target.value)} /> </TextInput.Root> <Field.Description>실명을 입력해 주세요.</Field.Description> </Field.Root> ); }
검증 상태
status prop으로 네 가지 상태를 표현합니다. Field.Message는 status가 none이 아닐 때만 화면에 나타납니다.
<VStack $css={{ gap: '$spacing-500', alignItems: 'flex-start' }}> <Field.Root status="success" $css={{ width: '320px' }}> <Field.Label>아이디</Field.Label> <TextInput.Root> <TextInput.Input defaultValue="featuring_user" /> </TextInput.Root> <Field.Message>사용 가능한 아이디입니다.</Field.Message> </Field.Root> <Field.Root status="warning" $css={{ width: '320px' }}> <Field.Label>비밀번호</Field.Label> <TextInput.Root> <TextInput.Input type="password" defaultValue="1234" /> </TextInput.Root> <Field.Message>비밀번호가 너무 짧습니다.</Field.Message> </Field.Root> <Field.Root status="error" $css={{ width: '320px' }}> <Field.Label>이메일</Field.Label> <TextInput.Root> <TextInput.Input defaultValue="not-an-email" /> </TextInput.Root> <Field.Message>올바른 이메일 형식이 아닙니다.</Field.Message> </Field.Root> </VStack>
동적 검증
입력 중 실시간으로 status를 변경하면 Field.Message가 aria-live="polite"로 자동 안내합니다.
() => { const [value, setValue] = React.useState(''); const isError = value.length > 0 && value.length < 8; return ( <Field.Root status={isError ? 'error' : 'none'} $css={{ width: '320px' }}> <Field.Label>비밀번호</Field.Label> <TextInput.Root> <TextInput.Input type="password" placeholder="8자 이상 입력하세요" value={value} onChange={e => setValue(e.target.value)} /> </TextInput.Root> <Field.Description>영문, 숫자를 포함해 8자 이상 입력하세요.</Field.Description> <Field.Message>비밀번호는 8자 이상이어야 합니다.</Field.Message> </Field.Root> ); }
필수 항목
Field.Root에 required를 지정하면 Field.Label에 * 인디케이터가 자동으로 표시되고, 폼 컨트롤에는 aria-required="true"가 자동 부여됩니다. Field.Label에 직접 required를 줘도 동일하게 동작합니다.
<VStack $css={{ gap: '$spacing-400', alignItems: 'flex-start' }}> <Field.Root required $css={{ width: '320px' }}> <Field.Label>이메일</Field.Label> <TextInput.Root> <TextInput.Input type="email" placeholder="example@email.com" /> </TextInput.Root> <Field.Description>Field.Root에 required — Label과 input 모두 자동 동기화</Field.Description> </Field.Root> <Field.Root $css={{ width: '320px' }}> <Field.Label required>이름</Field.Label> <TextInput.Root> <TextInput.Input placeholder="이름을 입력하세요" /> </TextInput.Root> <Field.Description>Field.Label에 직접 required도 가능 (Root context 없이)</Field.Description> </Field.Root> </VStack>
Description과 Message 구분
Field.Description은 상태와 무관하게 항상 표시됩니다. Field.Message는 status가 활성(success, warning, error)일 때만 나타나며, disabled나 readOnly 상태에서도 숨겨집니다.
() => { const [hasError, setHasError] = React.useState(false); return ( <Field.Root status={hasError ? 'error' : 'none'} $css={{ width: '320px' }}> <Field.Label>비밀번호</Field.Label> <TextInput.Root> <TextInput.Input type="password" placeholder="8자 이상 입력하세요" onChange={(e) => setHasError(e.target.value.length > 0 && e.target.value.length < 8)} /> </TextInput.Root> <Field.Description>영문, 숫자를 포함해 8자 이상 입력하세요.</Field.Description> <Field.Message>비밀번호는 8자 이상이어야 합니다.</Field.Message> </Field.Root> ); }
비활성 상태
Field.Root에 disabled를 설정하면 FieldContext를 통해 하위 서브컴포넌트 전체에 전파됩니다. Field.Message는 비활성 상태에서 렌더링되지 않습니다.
<Field.Root disabled $css={{ width: '320px' }}> <Field.Label>사용자 ID</Field.Label> <TextInput.Root> <TextInput.Input value="ft_user_001" readOnly /> </TextInput.Root> <Field.Description>수정할 수 없는 항목입니다.</Field.Description> </Field.Root>
읽기 전용
<Field.Root readOnly $css={{ width: '320px' }}> <Field.Label>가입일</Field.Label> <TextInput.Root> <TextInput.Input defaultValue="2024-01-15" /> </TextInput.Root> <Field.Description>가입일은 변경할 수 없습니다.</Field.Description> </Field.Root>
Field.Count — 글자 수 카운터
Field.Count는 글자 수를 표시하는 서브컴포넌트입니다. 일반적으로 HStack으로 Field.Label과 같은 행에 배치합니다.
() => { const [value, setValue] = React.useState(''); const maxLength = 200; return ( <Field.Root $css={{ width: '320px' }}> <HStack $css={{ justifyContent: 'space-between', alignItems: 'center', width: '100%' }}> <Field.Label>소개</Field.Label> <Field.Count>{value.length}/{maxLength}</Field.Count> </HStack> <TextArea.Root> <TextArea.Input placeholder="간단한 소개를 입력하세요" maxLength={maxLength} value={value} onChange={(e) => setValue(e.target.value)} /> </TextArea.Root> <Field.Description>최대 {maxLength}자까지 입력할 수 있습니다.</Field.Description> </Field.Root> ); }
DatePicker와 함께
Calendar의 DatePicker 트리거를 Field에 배치합니다. 클릭 전용은 Dropdown.Trigger, 타이핑 가능은 TextInput을 사용합니다.
() => { const [date1, setDate1] = React.useState(null); const [date2, setDate2] = React.useState(null); return ( <VStack $css={{ gap: '$spacing-600', alignItems: 'flex-start' }}> <Field.Root> <Field.Label required>시작일 (클릭 전용)</Field.Label> <Calendar.Root inline={false} selected={date1} onChange={(d) => setDate1(d)} dateFormat="yyyy.MM.dd"> <Calendar.Trigger> {(triggerProps) => ( <Dropdown.Trigger size={triggerProps.size} $css={{ width: '240px' }}> <Dropdown.Icon><IconCalendarFilled /></Dropdown.Icon> <Dropdown.Value placeholder="날짜 선택">{triggerProps.value}</Dropdown.Value> <Dropdown.Indicator data-rotate /> </Dropdown.Trigger> )} </Calendar.Trigger> </Calendar.Root> <Field.Description>프로젝트 시작 날짜를 선택하세요</Field.Description> </Field.Root> <Field.Root> <Field.Label required>시작일 (타이핑)</Field.Label> <Calendar.Root inline={false} selected={date2} onChange={(d) => setDate2(d)} dateFormat="yyyy.MM.dd"> <Calendar.Trigger> {(triggerProps) => ( <TextInput.Root size={triggerProps.size} $css={{ width: '240px' }}> <TextInput.Icon><IconCalendarFilled /></TextInput.Icon> <TextInput.Input {...triggerProps} placeholder="yyyy.MM.dd" /> <TextInput.Icon data-rotate $css={{ color: '$icon-secondary' }}> <IconChevronDownOutline /> </TextInput.Icon> </TextInput.Root> )} </Calendar.Trigger> </Calendar.Root> <Field.Description>날짜를 직접 입력하거나 캘린더에서 선택하세요</Field.Description> </Field.Root> </VStack> ); }
DatePicker (타이핑 가능)
Calendar.Trigger + TextInput.Root 조합으로 직접 입력 + 캘린더 선택을 모두 허용합니다.
() => { const [date, setDate] = React.useState(null); return ( <Field.Root> <Field.Label required>시작일</Field.Label> <Calendar.Root inline={false} selected={date} onChange={(d) => setDate(d)} dateFormat="yyyy.MM.dd"> <Calendar.Trigger> {(triggerProps) => ( <TextInput.Root size={triggerProps.size} $css={{ width: '240px' }}> <TextInput.Icon><IconCalendarFilled /></TextInput.Icon> <TextInput.Input {...triggerProps} placeholder="yyyy.MM.dd" /> <TextInput.Icon data-rotate $css={{ color: '$icon-secondary' }}> <IconChevronDownOutline /> </TextInput.Icon> </TextInput.Root> )} </Calendar.Trigger> </Calendar.Root> <Field.Description>날짜를 직접 입력하거나 캘린더에서 선택하세요</Field.Description> </Field.Root> ); }
TextArea와 함께
TextArea와 Field.Count로 글자수 카운터 패턴.
() => { const [value, setValue] = React.useState(''); const maxLength = 200; return ( <Field.Root $css={{ width: '320px' }}> <HStack $css={{ justifyContent: 'space-between', alignItems: 'center', width: '100%' }}> <Field.Label>소개</Field.Label> <Field.Count>{value.length}/{maxLength}</Field.Count> </HStack> <TextArea.Root> <TextArea.Input placeholder="간단한 소개를 입력하세요" maxLength={maxLength} value={value} onChange={(e) => setValue(e.currentTarget.value)} /> </TextArea.Root> <Field.Description>Field.Count를 사용한 글자수 카운터 예시입니다.</Field.Description> </Field.Root> ); }
Select와 함께
Select를 Field에 배치하면 status, disabled가 자동 전파됩니다.
<Field.Root> <Field.Label>카테고리</Field.Label> <Select.Root> <Select.Trigger $css={{ width: '240px' }}> <Select.Value placeholder="선택하세요" /> </Select.Trigger> <Select.Portal> <Select.Positioner> <Select.Popup> <Select.Item value="design">디자인</Select.Item> <Select.Item value="develop">개발</Select.Item> <Select.Item value="marketing">마케팅</Select.Item> </Select.Popup> </Select.Positioner> </Select.Portal> </Select.Root> <Field.Description>프로젝트 카테고리를 선택하세요</Field.Description> </Field.Root>
Checkbox와 함께
Field는 TextInput 외에 Checkbox, Radio 등과도 함께 사용할 수 있습니다.
<Field.Root $css={{ width: '320px' }}> <Field.Label>약관 동의</Field.Label> <Label.Root> <Checkbox /> <Label.Text>이용약관에 동의합니다</Label.Text> </Label.Root> <Field.Description>서비스 이용을 위해 약관 동의가 필요합니다.</Field.Description> </Field.Root>
커스텀 레이아웃
$css로 레이아웃을 자유롭게 조정합니다. 여러 필드를 나란히 배치할 때는 HStack을 활용합니다.
<HStack $css={{ gap: '$spacing-400', alignItems: 'flex-start', width: '480px' }}> <Field.Root $css={{ flex: '1' }}> <Field.Label>성</Field.Label> <TextInput.Root> <TextInput.Input placeholder="홍" /> </TextInput.Root> </Field.Root> <Field.Root $css={{ flex: '2' }}> <Field.Label>이름</Field.Label> <TextInput.Root> <TextInput.Input placeholder="길동" /> </TextInput.Root> </Field.Root> </HStack>
Ellipsis Overflow (소비자 opt-in)
Field.Label / Field.Description / Field.Message는 기본 wrap입니다. 좁은 컬럼 레이아웃에서 한 줄 + ellipsis가 필요하면 $css로 opt-in합니다.
<Field.Root $css={{ width: '200px' }}> <Field.Label $css={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 매우 긴 레이블 — 데이터 보존 동의 </Field.Label> <TextInput.Root> <TextInput.Input placeholder="선택" /> </TextInput.Root> <Field.Description $css={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> 개인정보 처리방침에 따라 사용자 데이터는 90일간 보관됩니다 </Field.Description> </Field.Root>
$css 커스텀
<Field.Root $css={{ width: '320px', gap: '$spacing-200' }}> <Field.Label>메모</Field.Label> <TextInput.Root> <TextInput.Input placeholder="메모를 입력하세요" /> </TextInput.Root> <Field.Description>내부 참고용 메모입니다.</Field.Description> </Field.Root>
Props
공통 Props —
$css,render,className,style는 모든 서브컴포넌트에서 지원됩니다. useRenderComponent 가이드 →
Field.Root
폼 필드의 최상위 컨테이너입니다. 내부적으로 useId()로 fieldId, descriptionId, validationId를 생성하고 FieldContext로 자식에 전파합니다.
Prop
Type
Field.Label
레이블 요소입니다. htmlFor가 Field.Root의 fieldId에 자동 연결됩니다. Field.Root에 required가 지정되면 자동으로 * 인디케이터를 표시합니다 — 직접 prop으로 override 가능.
Prop
Type
Field.Description
항상 표시되는 보조 설명 텍스트입니다. id가 자동 설정되어 폼 컨트롤의 aria-describedby에 연결됩니다.
Prop
Type
Field.Message
검증 메시지입니다. status가 none이 아니고, disabled·readOnly가 아닌 상태에서 children이 있을 때만 렌더링됩니다. status="error"이면 role="alert"로 즉시 안내, 그 외에는 aria-live="polite"로 부드럽게 안내합니다.
Prop
Type
Field.Count
글자 수 카운터입니다. 일반적으로 Field.Label과 같은 행(HStack)에 배치합니다. 카운터 텍스트를 children으로 직접 전달합니다.
Prop
Type
스타일
Status Variants
| Status | Message 색상 | 아이콘 |
|---|---|---|
none | — | 없음 (Message 자체가 렌더링 안 됨) |
success | support-success-1 | CheckCircle |
warning | support-warning-1 | WarningCircle |
error | support-error-1 | XCircle |
FieldState
className, style 콜백에 전달되는 상태 객체입니다. Root, Label, Description, Message, Count 모두 동일한 상태를 받습니다.
Prop
Type