Field
Data Input
폼 아이템 하나를 감싸며 라벨·설명·도움말을 배치하는 컨테이너.
Usage
완성형 field.XField 로 표현되지 않는 조합(컨트롤 여러 개, 컨트롤 + 액션 버튼, 반복 행)을 한 필드로 묶을 때 사용한다. 라벨과 컨트롤의 접근성 연결(id · aria-describedby)을 컴포넌트에 맡기고 컨트롤은 순수하게 두고 싶을 때 사용한다. 폼 밖 정적 화면에서 폼과 똑같은 라벨·도움말 레이아웃이 필요할 때 사용한다.
import
import
import { Field } from '@mildang/design-system/Form';예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
• 외부 패키지를 따로 설치한다. DS 의 전이 의존성에 기대지 않는다.
API Reference
Field Props
Prop
Type
Default
ReactNode
지정 안 함
ReactNode
지정 안 함
boolean
false
boolean
지정 안 함
ReactNode
지정 안 함
"link" | "error" | "warning" | "success" | "info"
지정 안 함
string
지정 안 함
ReactNode
지정 안 함
boolean
지정 안 함
ReactNode
지정 안 함
같은 패밀리
@mildang/design-system/Form 에서 같이 내보내는 컴포넌트다.
폼은 어떻게 조립되는가
이 디자인시스템에 Form 이라는 컴포넌트는 없다. 폼 전체는 TanStack Form 의 useAppForm 훅과
표준 form 엘리먼트로 만든다 — 제출 방식(버튼·Enter·외부 트리거)이 화면마다 달라, 래퍼가 그걸
대신 정하면 매번 탈출구를 뚫어야 하기 때문이다.
폼 화면은 아래 세 컴포넌트의 중첩으로 짜인다(Figma field_group/field/expandableField 에
1:1 대응).
| 이름 | 무엇인가 | 역할 |
|---|---|---|
useAppForm | 훅 | 폼 전체의 상태·검증·제출. 컴포넌트가 아니다 |
form 엘리먼트 | HTML | 제출 이벤트를 받는 자리. DS 래퍼 없이 직접 쓴다 |
FieldGroup | 컴포넌트 | 필드들을 세로로 묶는 컨테이너. size 로 간격만 담당 |
Field | 컴포넌트 | 아이템 1개 — 라벨·설명·필수·컨트롤·도움말 |
ExpandableField | 컴포넌트 | 필드 하나의 본문을 라벨 우측 토글로 열고/닫기 |
한 줄 규칙: 값을 가진 컨트롤은 field.* 네임스페이스로, 값 없는 구조(FieldGroup,
ExpandableField)는 form.* 네임스페이스로 꺼낸다. 두 네임스페이스 모두 TanStack Form 의
createFormHook 이 만든다 — 이 저장소에서는 src/ui/Form/lib/form.tsx 가 한 번 정의해 앱
전역에서 재사용한다.
세 계층 — 컨트롤 / Base / Field
같은 컨트롤이 목적에 따라 세 겹으로 제공된다. 핵심 규칙은 하나 — 폼 안에서는 완성형
field.XField, 폼 밖에서는 XFieldBase 를 쓴다.
| 계층 | 예 | 폼 바인딩 | 라벨/도움말 | 언제 |
|---|---|---|---|---|
| 순수 컨트롤 | Input | ✗ | ✗ | 최소 단위. Field 와 직접 조합할 때 |
XFieldBase | InputFieldBase | ✗ | ✓ | 폼 밖 — 라벨/도움말은 필요하지만 값은 직접 관리 |
XField(완성형) | InputField | ✓ | ✓ | 폼 안 — field.* 로 import 없이 사용 |
완성형과 Base 는 한 파일에 co-locate 되어 있고, 완성형은 Base 에 TanStack 바인딩만 얹은 것이다. 그래서 라벨/도움말/접근성 동작은 폼 안·밖이 완전히 동일하다.
Input 자신은 label prop 이 아예 없다 — 라벨·필수 표시·설명은 전부 Field(또는
InputFieldBase/InputField) 래퍼의 몫이다. 순수 컨트롤에 라벨을 얹으려는 시도는 이 계층
구조와 어긋난다.
기본 사용
label·description·helpText 를 갖춘 이메일 입력 필드다. description 은 입력 위 안내, helpText 는 입력 아래 보충 문구로 역할이 나뉜다.
import { Field } from '@mildang/design-system/Form';
import { Input } from '@mildang/design-system/Input';
export default function FieldExample() {
return (
<Field
label="이메일"
required
description="회사 메일만 등록할 수 있습니다."
helpText="입력한 주소로 인증 메일이 갑니다."
>
<Input fullWidth placeholder="you@company.com" />
</Field>
);
}
Field 의 prop 과 자유 슬롯
Field 는 하나의 의미를 가진 폼 영역을 나타낸다 — 라벨·설명·필수 표시·상단 우측 슬롯·본문·
도움말. 서로 다른 의미의 필드는 하나의 Field 에 몰지 않고 FieldGroup 으로 묶는다.
| prop | Figma field | 설명 |
|---|---|---|
label | show_title | 라벨 텍스트를 컨트롤 위에 배치 |
required | essential | 라벨에 필수 별표(*) 표시 |
description | show_description | 라벨 아래 보조 설명 |
helpText | show_error_text | 컨트롤 아래 도움말 |
helpVariant | help_text.type | info/warning/error/link/success |
helpIcon | help_text.icon | 도움말 아이콘 표시 |
sideContent | side_content | 상단 우측 자유 슬롯. 폼 요소·버튼·텍스트·툴팁 등 |
children | content | 본문 자유 슬롯. 주로 폼 요소를 두지만 표·카드 등도 허용 |
htmlFor | — | 라벨↔컨트롤 연결 id. 미지정 시 자동 생성·주입 |
fullWidth | — | 컨테이너 가로 폭을 채운다 |
sideContent 는 상단 우측에 고정되고(토글·액션 버튼·툴팁 등), children 은 본문 전체 너비를
쓴다. 둘 중 하나가 없으면 그 래퍼와 간격도 렌더하지 않는다.
htmlFor 자동 연결은 children 이 단일 element일 때만 동작한다. 컨트롤이 여러 개거나
wrapper 로 감싸져 있으면 자동 주입이 엉뚱한 곳에 붙거나 아예 동작하지 않으므로, 그럴 때는
대표 컨트롤의 id 를 직접 정해 htmlFor 로 넘긴다 — htmlFor 를 주면 자동 주입은 꺼지고 라벨
연결에만 쓰인다. 값 상태가 필요한 컨트롤(date/time/file/combobox)은 자동 생성된 id 를 실제 입력
요소까지 내려보내는 prop(inputProps, ids 등)이 각각 따로 있다.
선언형 prop(label/description/helpText/sideContent)을 하나도 주지 않으면 Field 는
children 을 그대로 렌더하는 순수 컨테이너가 된다 — 특수 레이아웃이 필요할 때만 이 형태를 쓴다.
기본 사용
label·description·helpText 를 갖춘 이메일 입력 필드다. description 은 입력 위 안내, helpText 는 입력 아래 보충 문구로 역할이 나뉜다.
import { Field } from '@mildang/design-system/Form';
import { Input } from '@mildang/design-system/Input';
export default function FieldExample() {
return (
<Field
label="이메일"
required
description="회사 메일만 등록할 수 있습니다."
helpText="입력한 주소로 인증 메일이 갑니다."
>
<Input fullWidth placeholder="you@company.com" />
</Field>
);
}
실제 코드에서 반복되는 관례
이 저장소의 useAppForm 사용처를 훑어 추린 관례다.
noValidate는 사실상 필수 — 브라우저 기본 검증 풍선이 TanStack 의 검증·에러 도움말과 이중으로 뜬다.- 제출 핸들러는
preventDefault+void form.handleSubmit()—handleSubmit()은 Promise 를 돌려주므로void로 명시하고, 이벤트 핸들러를async로 만들지 않는다. - 필드 바인딩은 children render prop 을 우선한다 —
children={(field) => …}prop 형태도 동작하지만 중첩이 깊어져도 읽히는 쪽은 JSX children 형태다.
바텀시트·모달처럼 제출 버튼이 폼 트리 밖에 있으면 form 엘리먼트를 두지 않고, form.AppForm
으로 컨텍스트만 이은 뒤 버튼에서 직접 form.handleSubmit() 을 부른다. 이 형태에서는 Enter 키
제출이 없어지므로, form 엘리먼트를 둘 수 있으면 두는 쪽이 기본이다.
검증 시점의 기본값은 "입력 중에도 검증"이다. 제출 전에는 조용하고 제출 후부터만 재검증하려면
revalidateLogic 을 넘긴다.
tsx
useAppForm({
defaultValues,
validationLogic: revalidateLogic({ mode: 'submit', modeAfterSubmission: 'submit' }),
onSubmit,
});사용 가이드
권장
- 본문에 컨트롤이 여러 개거나 래퍼로 감싸이면 대표 컨트롤의 id 를 htmlFor 로 직접 넘긴다.
- 서로 다른 의미의 입력은 한 Field 에 몰지 않고 FieldGroup 으로 묶는다.
지양
- 완성형 field.XField 로 되는 자리를 Field + 순수 컨트롤로 다시 조립하지 않는다.
- 본문이 길어 접어두고 싶을 때 토글을 직접 만들지 않는다 — ExpandableField 를 쓴다.
예제
Playground
import type { ComponentProps } from 'react';
import { Field } from '@mildang/design-system/Form';
import { Input } from '@mildang/design-system/Input';
const STORY_DEFAULT_ARGS = { ...({}), ...({
label: 'Title',
required: true,
description: 'Description',
helpText: 'Help Text',
helpVariant: 'warning',
helpIcon: true,
}) } as ComponentProps<typeof Field>;
const FieldPlaygroundExampleRender = (args: ComponentProps<typeof Field>) => (
<div style={{ width: 320 }}>
<Field fullWidth {...args}>
<Input fullWidth placeholder="Placeholder" />
</Field>
</div>
);
export default function FieldPlaygroundExample(props: Partial<ComponentProps<typeof Field>>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Field>;
return FieldPlaygroundExampleRender(mergedProps);
}
컨트롤 종류
코드
import { Box } from '@mildang/styled-system/jsx';
import { Field } from '@mildang/design-system/Form';
import { Input } from '@mildang/design-system/Input';
import { TextArea } from '@mildang/design-system/TextArea';
import { Select, createSelectCollection, SelectItem } from '@mildang/design-system/Select';
import { Checkbox } from '@mildang/design-system/Checkbox';
import { ToggleSwitch } from '@mildang/design-system/ToggleSwitch';
import { RadioGroup } from '@mildang/design-system/Radio';
import { Radio } from '@mildang/design-system/Radio';
import { ButtonRadioGroup } from '@mildang/design-system/Radio';
import { SegmentedControl } from '@mildang/design-system/SegmentedControl';
import { RatingGroup } from '@mildang/design-system/RatingGroup';
import { useState } from 'react';
import { FileType } from '@mildang/design-system/FileField';
import { FileFieldBase } from '@mildang/design-system/FileField';
import { TimeFieldBase } from '@mildang/design-system/TimeField';
import { DateRangeValue } from '@mildang/design-system/DateRangePicker';
import { DateRangePicker } from '@mildang/design-system/DateRangePicker';
import { DatePicker } from '@mildang/design-system/DatePicker';
import { useFilter, useListCollection } from '@ark-ui/react';
import { Combobox } from '@mildang/design-system/Combobox';
const OPTIONS = [
{ label: '옵션 A', value: 'a' },
{ label: '옵션 B', value: 'b' },
{ label: '옵션 C', value: 'c' },
];
const selectCollection = createSelectCollection<SelectItem>({ items: OPTIONS });
/**
* 아래 샘플 컨트롤들은 값 상태나 collection 훅이 필요해 컴포넌트로 분리했다.
* Field 가 자동 주입하는 `id` 를 그대로 받아 실제 입력 요소까지 내려보내 라벨과 연결한다.
*/
type SampleControlProps = { id?: string };
const ComboboxSample = ({ id }: SampleControlProps) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { collection, filter } = useListCollection({
initialItems: OPTIONS,
itemToString: (item) => item.label,
itemToValue: (item) => item.value,
filter: (_itemText: string, filterText: string, item: (typeof OPTIONS)[number]) =>
contains(item.label, filterText),
});
return (
<Combobox
ids={{ input: id }}
collection={collection}
filter={filter}
placeholder="검색해서 선택"
fullWidth
>
{collection.items.map((item) => (
<Combobox.Option key={item.value} item={item.value}>
<Combobox.OptionLabel>{item.label}</Combobox.OptionLabel>
</Combobox.Option>
))}
</Combobox>
);
};
const DatePickerSample = ({ id }: SampleControlProps) => {
const [value, setValue] = useState<string>();
return (
<DatePicker
value={value}
onValueChange={(next) => setValue(next ?? undefined)}
inputProps={{ id, fullWidth: true }}
/>
);
};
const DateRangePickerSample = ({ id }: SampleControlProps) => {
const [value, setValue] = useState<DateRangeValue>();
return (
<DateRangePicker
value={value}
onValueChange={(next) => setValue(next ?? undefined)}
inputProps={{ id, fullWidth: true }}
/>
);
};
const TimeSample = ({ id }: SampleControlProps) => {
const [value, setValue] = useState('');
return <TimeFieldBase id={id} value={value} onChange={setValue} fullWidth />;
};
const FileSample = ({ id }: SampleControlProps) => {
const [value, setValue] = useState<FileType[]>([]);
return <FileFieldBase id={id} value={value} onChange={setValue} fullWidth />;
};
const FieldControlTypesExample = () => (
<Box
display="grid"
gridTemplateColumns="repeat(auto-fit, minmax(300px, 320px))"
gap="24"
alignItems="start"
maxWidth="680px"
>
<Field label="input" required description="Description" helpText="Help Text">
<Input placeholder="Placeholder" fullWidth />
</Field>
<Field label="textarea">
<TextArea placeholder="여러 줄 입력" />
</Field>
<Field label="select" required>
<Select collection={selectCollection} placeholder="옵션을 선택하세요" fullWidth>
{selectCollection.items.map((item) => (
<Select.Option key={item.value} item={item} />
))}
</Select>
</Field>
<Field label="combobox" description="입력해서 좁혀 찾는 선택">
<ComboboxSample />
</Field>
<Field label="checkbox">
<Checkbox label="약관에 동의합니다" description="필수 항목입니다" />
</Field>
<Field label="toggleSwitch">
<ToggleSwitch size="md" label="공개" description="Description" />
</Field>
<Field label="radioGroup" required helpText="Help Text">
<RadioGroup orientation="horizontal" defaultValue="a">
<Radio value="a" label="옵션 A" />
<Radio value="b" label="옵션 B" />
</RadioGroup>
</Field>
<Field label="buttonRadioGroup" description="선택지를 버튼으로 노출">
<ButtonRadioGroup
name="field-control-types-button-radio"
orientation="horizontal"
defaultValue="a"
options={OPTIONS}
/>
</Field>
<Field label="segmentedControl">
<SegmentedControl defaultValue="a" fullWidth aria-label="옵션 선택">
{OPTIONS.map((option) => (
<SegmentedControl.Item key={option.value} value={option.value}>
{option.label}
</SegmentedControl.Item>
))}
</SegmentedControl>
</Field>
<Field label="ratingGroup" helpText="1~5점">
<RatingGroup count={5} defaultValue={3} name="field-control-types-rating">
<RatingGroup.Control>
{Array.from({ length: 5 }).map((_, index) => (
<RatingGroup.Star key={index} index={index + 1} aria-label={`${index + 1}점`} />
))}
<RatingGroup.HiddenInput />
</RatingGroup.Control>
</RatingGroup>
</Field>
<Field label="datePicker" required>
<DatePickerSample />
</Field>
<Field label="dateRangePicker">
<DateRangePickerSample />
</Field>
<Field label="time">
<TimeSample />
</Field>
<Field label="file" helpText="10MB 이하">
<FileSample />
</Field>
</Box>
);
export default FieldControlTypesExample;
sideContent / children
import { Field } from '@mildang/design-system/Form';
import { ToggleSwitch } from '@mildang/design-system/ToggleSwitch';
import { Button } from '@mildang/design-system/Button';
import { Input } from '@mildang/design-system/Input';
const FieldContentSlotsExample = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 32, width: 360 }}>
<Field
label="공개 여부"
required
description="검색에 노출됩니다"
helpText="Help Text"
sideContent={<ToggleSwitch size="md" />}
/>
<Field
label="사업자 등록번호"
required
description="숫자만 입력하세요"
helpText="10자리 번호를 확인해주세요"
sideContent={<Button variant="secondary">중복 확인</Button>}
>
<Input placeholder="0000000000" fullWidth />
</Field>
</div>
);
export default FieldContentSlotsExample;
sideContent (컨트롤 · 툴팁 · 텍스트 · 액션)
import { Field } from '@mildang/design-system/Form';
import { Tooltip } from '@mildang/design-system/Tooltip';
import { IconButton } from '@mildang/design-system/IconButton';
import HelpCircleOutline from '@mildang/icons/react/help-circle-outline';
import { Input } from '@mildang/design-system/Input';
import { Text } from '@mildang/design-system/Text';
const FieldWithSideContentExample = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, width: 320 }}>
<Field
label="사업자 등록번호"
required
helpText="숫자 10자리"
sideContent={
<Tooltip content="'-' 없이 숫자 10자리만 입력하세요.">
<IconButton size="sm" variant="tertiary" aria-label="도움말">
<HelpCircleOutline width={16} />
</IconButton>
</Tooltip>
}
>
<Input placeholder="0000000000" fullWidth />
</Field>
<Field
label="닉네임"
description="다른 사용자에게 보여지는 이름"
sideContent={
<Text variant="caption-lg-medium" color="neutral.text.lowest">
선택사항
</Text>
}
>
<Input placeholder="닉네임 입력" fullWidth />
</Field>
</div>
);
export default FieldWithSideContentExample;
본문 조합 (컨트롤 여러 개 · 액션 · 반복 행)
import { HStack, VStack } from '@mildang/styled-system/jsx';
import { Field } from '@mildang/design-system/Form';
import { Input } from '@mildang/design-system/Input';
import { Button } from '@mildang/design-system/Button';
import { ToggleSwitch } from '@mildang/design-system/ToggleSwitch';
import { IconButton } from '@mildang/design-system/IconButton';
import DeleteIcon from '@mildang/icons/react/delete';
const FieldFieldItemCompositionExample = () => (
<VStack gap="32" alignItems="stretch" width="360px">
{/* ① 컨트롤 여러 개 + 액션 버튼 — 주소 검색으로 채우고 상세주소만 직접 입력 */}
<Field
fullWidth
label="주소 입력"
required
description="상세 정보를 입력해주세요."
htmlFor="field-item-address"
>
<HStack gap="8" alignItems="center" width="100%">
<Input id="field-item-address" fullWidth placeholder="주소" readOnly />
<Button variant="secondary" flexShrink="0">
주소 찾기
</Button>
</HStack>
<Input fullWidth placeholder="우편번호" readOnly />
<Input fullWidth placeholder="상세주소 입력" />
</Field>
{/* ② 상단 우측 슬롯 — 본문 없이 컨트롤만 배치 가능 */}
<Field
fullWidth
label="공개 여부"
required
description="검색에 노출됩니다."
sideContent={<ToggleSwitch size="md" />}
/>
{/* ③ 카드로 묶은 반복 행 — 행마다 컨트롤 + 삭제 버튼 */}
<Field
fullWidth
label="주소 입력"
required
helpText="Help Text"
helpVariant="error"
helpIcon
htmlFor="field-item-row-0"
>
<VStack gap="8" alignItems="stretch">
{[0, 1, 2].map((index) => (
<HStack key={index} gap="8" alignItems="center">
<Input id={`field-item-row-${index}`} fullWidth placeholder="placeholder" />
<IconButton variant="tertiary" aria-label={`${index + 1}번째 주소 삭제`}>
<DeleteIcon width={20} />
</IconButton>
</HStack>
))}
</VStack>
</Field>
</VStack>
);
export default FieldFieldItemCompositionExample;
수동 조합 (비선언형)
import { Field } from '@mildang/design-system/Form';
import { FormLabel } from '@mildang/design-system/FormLabel';
import { Input } from '@mildang/design-system/Input';
import { default as HelpText } from '@mildang/design-system/HelpText';
const FieldComposeExample = () => (
<div style={{ width: 320 }}>
<Field>
<FormLabel htmlFor="fc-compose">Label</FormLabel>
<Input id="fc-compose" placeholder="Placeholder" fullWidth />
<HelpText>도움말 텍스트</HelpText>
</Field>
</div>
);
export default FieldComposeExample;