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

children

ReactNode

지정 안 함

description

ReactNode

지정 안 함

fullWidth

boolean

false

helpIcon

boolean

지정 안 함

helpText

ReactNode

지정 안 함

helpVariant

"link" | "error" | "warning" | "success" | "info"

지정 안 함

htmlFor

string

지정 안 함

label

ReactNode

지정 안 함

required

boolean

지정 안 함

sideContent

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 와 직접 조합할 때
XFieldBaseInputFieldBase폼 밖 — 라벨/도움말은 필요하지만 값은 직접 관리
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 으로 묶는다.

propFigma field설명
labelshow_title라벨 텍스트를 컨트롤 위에 배치
requiredessential라벨에 필수 별표(*) 표시
descriptionshow_description라벨 아래 보조 설명
helpTextshow_error_text컨트롤 아래 도움말
helpVarianthelp_text.typeinfo/warning/error/link/success
helpIconhelp_text.icon도움말 아이콘 표시
sideContentside_content상단 우측 자유 슬롯. 폼 요소·버튼·텍스트·툴팁 등
childrencontent본문 자유 슬롯. 주로 폼 요소를 두지만 표·카드 등도 허용
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;