Antd FormItem은 내부 Input, Select와 같은 컴포넌트에게 value, onChange를 강제로 주입해주는 역할 을 했다.
그래서 바로 아래에서 div같은걸로 감싸면 안되고 무적권 Input이나 Select를 위치시켜야하는 까닭도 그랫다.
이 구조를 알게 되면 이런식으로 컴포넌트를 만들 수 도 있다.
import { Button, Upload } from 'antd';
import { UploadOutlined, DeleteOutlined, PaperClipOutlined } from '@ant-design/icons';
import tw from 'twin.macro';
interface FileInfo {
id: string;
name: string;
url: string;
}
interface FileUploadListProps {
value?: FileInfo[];
onChange?: (files: FileInfo[]) => void;
}
export const FileUploadList = ({ value = [], onChange }: FileUploadListProps) => {
const handleUpload = (info: any) => {
const { file } = info;
if (file.status !== 'uploading' && onChange) {
const newFile = {
id: Date.now().toString(),
name: file.name,
url: URL.createObjectURL(file.originFileObj),
};
onChange([...value, newFile]);
}
};
const handleDelete = (index: number) => {
if (onChange) {
onChange(value.filter((_, i) => i !== index));
}
};
return (
<div>
<Upload onChange={handleUpload} showUploadList={false}>
<Button icon={<UploadOutlined />}>File Update</Button>
</Upload>
<div css={tw`mt-[16px] flex flex-col gap-[8px]`}>
{value.map((file, index) => (
<div key={file.id} css={tw`flex items-center justify-between`}>
<div css={tw`flex items-center gap-[8px]`}>
<PaperClipOutlined />
<span css={tw`text-blue-500`}>{file.name}</span>
</div>
<Button type="text" icon={<DeleteOutlined />} onClick={() => handleDelete(index)} />
</div>
))}
</div>
</div>
);
};
value와 onChange를 받게 하고, 내부적으로 어떻게 쓰일지 재선언하면 된다.
궁금
어떻게 value, onChange를 명시하지도 않고 내려줄 수 잇는걸까?
React.cloneElement를 사용했다고 한다.
// Ant Design 내부 (간소화한 버전)
const FormItem = ({ children, name, ...props }) => {
const form = useFormContext(); // Form에서 제공하는 context
const value = form.getFieldValue(name);
const onChange = (newValue) => {
form.setFieldValue(name, newValue);
};
// 🔑 핵심: React.cloneElement로 props 주입!
const childWithProps = React.cloneElement(children, {
value: value,
onChange: onChange,
});
return <div>{childWithProps}</div>;
};
// 원본 컴포넌트
<FileUploadList />
// Form.Item이 내부적으로 변환
React.cloneElement(
<FileUploadList />, // 기존 element
{ value: [...], onChange: fn } // 추가할 props
)
// 결과적으로 이렇게 됨
<FileUploadList value={[...]} onChange={fn} />
실제 코드가 정말 그런지 확인해봤다.
https://github.dev/ant-design/ant-design
<Field
{...props}
messageVariables={variables}
trigger={trigger}
validateTrigger={mergedValidateTrigger}
onMetaChange={onMetaChange}
>
{(control, renderMeta, context) => {
// control 객체가 여기서 전달됨!
const childProps: React.ReactElement<any>['props'] = {
...mergedChildren.props, // 기존 props
...mergedControl, // 🔑 control 객체의 모든 속성!
};
// ... (id, aria-* 등 추가)
// 이벤트 핸들러 병합
triggers.forEach((eventName) => {
childProps[eventName] = (...args: any[]) => {
mergedControl[eventName]?.(...args); // Form의 핸들러 먼저
(mergedChildren as React.ReactElement<any>).props[eventName]?.(...args); // 원래 props 핸들러
};
});
// 최종적으로 cloneElement로 적용
childNode = (
<MemoInput>
{cloneElement(mergedChildren, childProps)}
</MemoInput>
);
클로드에게 던져주니, value, onChange 말고도,
const control = {
value: currentValue, // 현재 값
onChange: handleChange, // 값 변경 핸들러
onBlur: handleBlur, // blur 이벤트 (validation trigger)
onFocus: handleFocus, // focus 이벤트
// ... 기타 form control 관련 속성들
};
const childProps = {
...mergedChildren.props, // 원래 있던 props
...mergedControl, // control의 모든 속성 (value, onChange 등)
// 추가 속성들
id: fieldId, // form item id
'aria-describedby': '...', // 접근성
'aria-invalid': 'true', // 에러 상태
'aria-required': 'true', // 필수 필드
ref: itemRef, // ref 전달
};
이렇게 다양한 값들을 넘겨주고 잇으니 필요한 값이 잇으면 받아와서 처리하는 식으로 하면 될거 같다.