ExpandableField

Data Input

필드 하나의 field item 영역을 라벨 우측 토글로 펼치고/접는 필드.

Usage

긴 입력 영역을 평소에는 접고 필요할 때만 한 필드 안에서 펼쳐 입력하게 할 때 사용한다.

import

import

import { ExpandableField } from '@mildang/design-system/Form';

예제를 복사해 쓸 때 필요한 준비

@mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.

@mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.

API Reference

ExpandableField Props

Prop

Type

Default

children

ReactNode

지정 안 함

defaultExpanded

boolean

false

description

ReactNode

지정 안 함

expanded

boolean

지정 안 함

fullWidth

boolean

false

helpIcon

boolean

지정 안 함

helpText

ReactNode

지정 안 함

helpVariant

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

지정 안 함

id

string

지정 안 함

label

ReactNode

지정 안 함

onExpandedChange

(expanded: boolean) => void

지정 안 함

required

boolean

지정 안 함

toggleDisabled

boolean

지정 안 함

같은 패밀리

@mildang/design-system/Form 에서 같이 내보내는 컴포넌트다.

라벨은 이 컴포넌트가 갖는다

ExpandableField 는 필드 하나의 item 영역이 길어질 때 접어두는 컴포넌트다. 성격이 다른 여러 필드를 토글 하나로 묶는 컨테이너로는 쓰지 않는다 — 그런 그룹핑은 FieldGroup 이나 화면 단위 조건부 렌더로 처리한다.

라벨은 ExpandableField 자신이 갖는다. children 에는 토글이 열렸을 때 보일 필드(컨트롤)만 넣고, 안쪽 완성형 필드에 label 을 또 주지 않는다 — 라벨 없는 완성형 필드는 컨트롤과 에러 도움말만 렌더하도록 맞춰 쓴다. 두 곳에 라벨을 모두 주면 라벨이 두 겹으로 보인다.

토글이 꺼져 있으면(OFF) children 은 마운트조차 되지 않는다 — 폼 등록·검증에서 완전히 빠지고, 켜지면(ON) 다시 마운트되어 폼에 등록된다. 확장부가 토글과 떨어진(형제) 위치거나 토글이 아닌 다른 필드/폼 상태에 의존할 때만 별도로 폼 상태를 구독한다 — 같은 서브트리 안이면 ExpandableField 가 알아서 반응하므로 그럴 필요가 없다.

행이 늘어나는 반복 추가는 조건부 노출과 다르다 — 배열 필드(mode="array") 로 처리한다.

예제

item 영역이 긴 필드 (배열 필드 · 안내 바 · 액션)

import { useState } from 'react';
import { useAppForm } from '@mildang/design-system/Form';
import { Box, HStack, VStack } from '@mildang/styled-system/jsx';
import InfoOutline from '@mildang/icons/react/info-outline';
import { Text } from '@mildang/design-system/Text';
import { SegmentedControl } from '@mildang/design-system/SegmentedControl';
import { IconButton } from '@mildang/design-system/IconButton';
import DeleteIcon from '@mildang/icons/react/delete';
import { Button } from '@mildang/design-system/Button';
import AddIcon from '@mildang/icons/react/add';

type GradeRow = {
  label: string;
  min: string;
  description: string;
};

type GradeFormValues = {
  operator: 'gte' | 'gt';
  grades: GradeRow[];
};

const DEFAULT_GRADES: GradeRow[] = [
  { label: 'A', min: '90', description: '매우 우수' },
  { label: 'B', min: '80', description: '우수' },
  { label: 'C', min: '70', description: '보통' },
  { label: 'D', min: '60', description: '미흡' },
  // 마지막 행은 항상 "최하위" — 경계값 없이 나머지를 모두 받는다.
  { label: 'E', min: '', description: '미달' },
];

/** 최하위(마지막) 행을 제외한 나머지 행의 최소 정답률을 한 번에 검증. */
const getGradesError = (grades: GradeRow[]) => {
  const mins = grades.slice(0, -1).map((grade) => Number(grade.min));

  if (mins.some((min) => Number.isNaN(min) || min < 0 || min > 100)) {
    return '최소 정답률은 0~100 사이의 숫자로 입력해 주세요.';
  }
  if (new Set(mins).size !== mins.length) {
    return '최소 정답률은 서로 다른 값으로 입력해 주세요.';
  }
  return undefined;
};

/**
 * 행 사이의 관계(중복 금지 등)는 **폼 레벨 validator** 에 건다. 셀 하나가 바뀌면 그 셀의 validator 와
 * 폼 레벨 validator 만 다시 돌고 **부모 배열 필드의 validator 는 돌지 않기** 때문이다
 * (배열 필드 validator 는 push/insert/remove 처럼 배열 자체가 바뀔 때만 실행된다).
 *
 * 반환값을 `{ fields: { grades: 메시지 } }` 형태로 주면 TanStack 이 그 에러를 `grades` 필드의
 * `meta.errorMap` 으로 내려보내므로, 배열 필드 쪽에서 `field.state.meta.errors` 로 그대로 읽을 수 있다.
 */
const validateGradesForm = ({ value }: { value: GradeFormValues }) => {
  const gradesError = getGradesError(value.grades);
  return gradesError ? { fields: { grades: gradesError } } : undefined;
};

const ExpandableFieldRichContentExample = () => {
    const [submitted, setSubmitted] = useState<GradeFormValues | null>(null);

    const form = useAppForm({
      defaultValues: {
        operator: 'gte',
        grades: DEFAULT_GRADES,
      } as GradeFormValues,
      validators: { onChange: validateGradesForm },
      onSubmit: async ({ value }) => {
        setSubmitted(value);
      },
    });

    return (
      <form
        noValidate
        onSubmit={(e) => {
          e.preventDefault();
          form.handleSubmit();
        }}
        style={{ width: 720 }}
      >
        <form.AppForm>
          <form.AppField name="grades" mode="array">
            {(gradesField) => {
              const rows = gradesField.state.value;
              const [firstError] = gradesField.state.meta.errors;
              const errorMessage = typeof firstError === 'string' ? firstError : undefined;
              const lastIndex = rows.length - 1;

              return (
                <form.ExpandableField
                  fullWidth
                  defaultExpanded
                  label="등급 설정"
                  description="점수별 등급 기준을 설정합니다."
                  helpText={errorMessage ?? '최소 정답률은 100% 이하의 서로 다른 값으로 입력해 주세요.'}
                  helpVariant={errorMessage ? 'error' : 'info'}
                  helpIcon={Boolean(errorMessage)}
                >
                  <VStack gap="16" alignItems="stretch" bg="neutral.surface.low" borderRadius="12" padding="16">
                    {/* 안내 바 — 기준 종류 표시 + 경계 연산자(폼 값) 선택 */}
                    <HStack
                      justify="space-between"
                      alignItems="center"
                      gap="8"
                      bg="neutral.surface.high"
                      borderRadius="8"
                      paddingX="12"
                      paddingY="8"
                    >
                      <HStack gap="4" alignItems="center" color="neutral.fill.base">
                        <InfoOutline width={16} />
                        <Text variant="body-md" color="neutral.text.low">
                          기준 : 정답률(%)
                        </Text>
                      </HStack>
                      <HStack gap="8" alignItems="center">
                        <Text variant="body-md" color="neutral.text.low">
                          기준 점수
                        </Text>
                        <form.AppField name="operator">
                          {(operatorField) => (
                            <operatorField.SegmentedControlField size="sm" aria-label="기준 점수 연산자">
                              <SegmentedControl.Item value="gte">≥</SegmentedControl.Item>
                              <SegmentedControl.Item value="gt">&gt;</SegmentedControl.Item>
                            </operatorField.SegmentedControlField>
                          )}
                        </form.AppField>
                      </HStack>
                    </HStack>

                    {/* 컬럼 헤더 */}
                    <HStack gap="8" alignItems="center" color="neutral.text.lowest">
                      <Text variant="caption-lg-medium" width="120px">
                        라벨
                      </Text>
                      <Text variant="caption-lg-medium" width="24px" />
                      <Text variant="caption-lg-medium" width="140px">
                        최소
                      </Text>
                      <Text variant="caption-lg-medium" flex="1">
                        설명(선택)
                      </Text>
                      <Box width="40px" />
                    </HStack>

                    {/* 반복 행 — 마지막 행은 경계값 없이 "최하위 (자동)" */}
                    {rows.map((_row, index) => {
                      const isLast = index === lastIndex;

                      return (
                        <HStack key={index} gap="8" alignItems="center">
                          <Box width="120px">
                            <form.AppField name={`grades[${index}].label`}>
                              {(cell) => <cell.InputField fullWidth aria-label={`${index + 1}번째 등급 라벨`} />}
                            </form.AppField>
                          </Box>

                          <Text variant="body-md" width="24px" textAlign="center" color="neutral.text.low">
                            {isLast ? (
                              '<'
                            ) : (
                              <form.Subscribe selector={(state) => state.values.operator}>
                                {(operator) => <>{operator === 'gte' ? '≥' : '>'}</>}
                              </form.Subscribe>
                            )}
                          </Text>

                          {isLast ? (
                            <Text variant="body-md" width="140px" textAlign="center" color="neutral.text.lowest">
                              최하위 (자동)
                            </Text>
                          ) : (
                            <Box width="140px">
                              <form.AppField name={`grades[${index}].min`}>
                                {(cell) => (
                                  <cell.InputField
                                    fullWidth
                                    inputMode="numeric"
                                    aria-label={`${index + 1}번째 최소 정답률`}
                                    endAdornment={
                                      <Text variant="body-md" color="neutral.text.lowest">
                                        %
                                      </Text>
                                    }
                                  />
                                )}
                              </form.AppField>
                            </Box>
                          )}

                          <Box flex="1" minWidth="0">
                            <form.AppField name={`grades[${index}].description`}>
                              {(cell) => (
                                <cell.InputField
                                  fullWidth
                                  placeholder="placeholder"
                                  aria-label={`${index + 1}번째 등급 설명`}
                                />
                              )}
                            </form.AppField>
                          </Box>

                          <IconButton
                            type="button"
                            variant="tertiary"
                            aria-label={`${index + 1}번째 등급 삭제`}
                            disabled={rows.length <= 2}
                            onClick={() => gradesField.removeValue(index)}
                          >
                            <DeleteIcon width={20} />
                          </IconButton>
                        </HStack>
                      );
                    })}

                    <Button
                      type="button"
                      variant="secondary"
                      size="sm"
                      startIcon={<AddIcon width={16} />}
                      alignSelf="flex-start"
                      // 최하위 행은 항상 마지막에 남도록 그 앞에 끼워 넣는다.
                      onClick={() => gradesField.insertValue(lastIndex, { label: '', min: '', description: '' })}
                    >
                      등급 추가
                    </Button>
                  </VStack>
                </form.ExpandableField>
              );
            }}
          </form.AppField>
        </form.AppForm>

        <HStack gap="8" alignItems="center" marginTop="20">
          <form.Subscribe selector={(state) => [state.isSubmitting, state.canSubmit] as const}>
            {([isSubmitting, canSubmit]) => (
              <Button type="submit" variant="primary" loading={isSubmitting} disabled={!canSubmit}>
                전송
              </Button>
            )}
          </form.Subscribe>
          <Button type="button" variant="quaternary" onClick={() => setSubmitted(null)}>
            결과 지우기
          </Button>
        </HStack>

        {submitted && (
          <VStack
            gap="8"
            alignItems="stretch"
            marginTop="12"
            bg="neutral.surface.low"
            borderRadius="8"
            padding="12"
          >
            <Text variant="caption-lg-medium" color="neutral.text.lowest">
              제출된 값
            </Text>
            <Text as="pre" variant="caption-lg" color="neutral.text.base" whiteSpace="pre-wrap">
              {JSON.stringify(submitted, null, 2)}
            </Text>
          </VStack>
        )}
      </form>
    );
  };

export default ExpandableFieldRichContentExample;