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
ReactNode
지정 안 함
boolean
false
ReactNode
지정 안 함
boolean
지정 안 함
boolean
false
boolean
지정 안 함
ReactNode
지정 안 함
"link" | "error" | "warning" | "success" | "info"
지정 안 함
string
지정 안 함
ReactNode
지정 안 함
(expanded: boolean) => void
지정 안 함
boolean
지정 안 함
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">></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;