# Attachment Field
URL: /react/components/attachment-field
Source: https://github.com/daangn/seed-design/blob/dev/docs/content/react/components/attachment-field.mdx
파일을 선택하거나 드래그 앤 드롭으로 업로드할 수 있는 컴포넌트입니다.
사용 가능 버전: @seed-design/react@2.0.0, @seed-design/css@2.0.0
## Preview
```tsx
import { VStack } from "@seed-design/react";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
export default function AttachmentFieldPreview() {
return (
);
}
```
## Installation
### Default
파일 순서 변경이 불필요한 경우 사용할 수 있는 컴포넌트를 포함합니다.
- npm: npx @seed-design/cli@latest add ui:attachment-field
- pnpm: pnpm dlx @seed-design/cli@latest add ui:attachment-field
- yarn: yarn dlx @seed-design/cli@latest add ui:attachment-field
- bun: bun x @seed-design/cli@latest add ui:attachment-field
### Reorderable
드래그 앤 드롭을 통한 파일 순서 변경이 필요한 경우 활용할 수 있는 컴포넌트를 포함합니다. 프로젝트에 [dnd-kit](https://dndkit.com/overview) 의존성이 추가됩니다.
- npm: npx @seed-design/cli@latest add ui:attachment-field-reorderable
- pnpm: pnpm dlx @seed-design/cli@latest add ui:attachment-field-reorderable
- yarn: yarn dlx @seed-design/cli@latest add ui:attachment-field-reorderable
- bun: bun x @seed-design/cli@latest add ui:attachment-field-reorderable
## Props
### `AttachmentField`
### `AttachmentInput`
### `AttachmentDropzone`
### `AttachmentInputItem`
## Usage
### 기본 사용법
`AttachmentField` 안에 `AttachmentInput`, `AttachmentDropzone`, `AttachmentInputReorderable`, `AttachmentDropzoneReorderable` 중 하나를 조합하여 사용합니다.
```tsx
import {
AttachmentField,
AttachmentInput,
AttachmentDropzone,
} from "seed-design/ui/attachment-field";
;
;
```
```tsx
import { AttachmentField } from "seed-design/ui/attachment-field";
import {
AttachmentInputReorderable,
AttachmentDropzoneReorderable,
} from "seed-design/ui/attachment-field-reorderable";
;
;
```
### Item 직접 구성하기
`AttachmentInput`, `AttachmentDropzone`, `AttachmentInputReorderable`, `AttachmentDropzoneReorderable`은 `children`을 render prop으로 사용합니다.
`acceptedFileEntries`을 활용하여 `AttachmentInputItem`을 직접 렌더링할 수 있습니다. 이때 `FileEntry` 타입이 제공하는 `id`를 `key`로 활용하는 것을 권장합니다.
`children`을 제공하지 않는 경우 자동으로 `acceptedFileEntries`에 등록된 파일을 `AttachmentInputItem`으로 렌더링합니다.
```tsx
import {
AttachmentField,
AttachmentInput,
AttachmentInputItem,
} from "seed-design/ui/attachment-field";
{({ acceptedFileEntries }) =>
acceptedFileEntries.map((entry) => (
))
}
;
```
## Uploading Files
### Trigger
`AttachmentInput`를 사용하면 trigger(업로드 버튼)가 포함된 레이아웃을 사용할 수 있습니다.
```tsx
import type { FileEntry } from "@seed-design/react/primitive";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
const defaultAcceptedFileEntries: FileEntry[] = [
{
id: "1",
file: new File(["hello"], "document.pdf", { type: "application/pdf" }),
status: "success",
},
];
export default function AttachmentFieldTriggerExample() {
return (
);
}
```
### Dropzone
`AttachmentDropzone`을 사용하면 드래그 앤 드롭 영역이 포함된 레이아웃을 사용할 수 있습니다.
```tsx
import { VStack } from "@seed-design/react";
import type { FileEntry } from "@seed-design/react/primitive";
import { AttachmentField, AttachmentDropzone } from "seed-design/ui/attachment-field";
const defaultAcceptedFileEntries: FileEntry[] = [
{
id: "1",
file: new File(["hello"], "document.pdf", { type: "application/pdf" }),
status: "success",
},
];
export default function AttachmentFieldDropzone() {
return (
);
}
```
### Listening to Accepted File Changes
`acceptedFileEntries`는 유효성 검사를 마친 파일의 목록입니다. `onAcceptedFileEntriesChange` 콜백으로 `acceptedFileEntries`에 등록된 파일 변경 이벤트를 감지할 수 있습니다.
```tsx
import { VStack, Text } from "@seed-design/react";
import { useState } from "react";
import type { FileStatusDetails } from "@seed-design/react/primitive";
import {
AttachmentField,
AttachmentInput,
AttachmentInputItem,
} from "seed-design/ui/attachment-field";
function simulateUpload(
_file: File,
id: string,
updateFileEntryStatus: (id: string, details: FileStatusDetails) => void,
) {
updateFileEntryStatus(id, { status: "uploading", progress: 0 });
let progress = 0;
const interval = setInterval(() => {
progress += 25;
if (progress >= 100) {
clearInterval(interval);
updateFileEntryStatus(id, { status: "success" });
} else {
updateFileEntryStatus(id, { status: "uploading", progress });
}
}, 500);
}
export default function AttachmentFieldValueChanges() {
const [logs, setLogs] = useState([]);
const addLog = (message: string) => {
setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]);
};
return (
{logs.length === 0 ? (
파일을 추가하거나 삭제하면 로그가 표시됩니다.
) : (
logs.map((log, index) => (
{log}
))
)}
{
addLog(`onFileAccept: ${entries.map((e) => e.file.name).join(", ")}`);
for (const entry of entries) {
simulateUpload(entry.file, entry.id, updateFileEntryStatus);
}
}}
onAcceptedFileEntriesChange={(files) => {
addLog(
`onAcceptedFileEntriesChange: ${files.map((f) => `${f.file.name} (${f.status})`).join(", ")}`,
);
}}
onFileReject={(files) => {
addLog(
`onFileReject: ${files.map((f) => `${f.file.name} (${f.errors.join(", ")})`).join(", ")}`,
);
}}
>
{({ acceptedFileEntries }) =>
acceptedFileEntries.map((fileEntry) => (
))
}
);
}
```
## Validating Files
사용자가 선택한 파일이 `acceptedFileEntries`에 등록되기 전 유효성을 확인할 수 있습니다.
유효하지 않은 파일에 대해 각각 `onFileReject` 콜백이 실행됩니다. 해당 콜백에서 파일과 에러 코드들을 확인하여 에러 메시지를 표시할 수 있습니다.
### Max Files
`maxFiles`로 업로드 가능한 최대 파일 수를 제한할 수 있습니다. 기본값은 `1`입니다. 최대 수에 도달한 경우 trigger 및 dropzone이 비활성화됩니다.
업로드 가능한 파일의 수보다 많은 파일을 선택한 경우 업로드 가능한 파일까지 `acceptedFileEntries`에 등록됩니다. 나머지 파일에 대해서는 각각 `onFileReject` 콜백이 실행됩니다. 이때 reject된 파일의 에러는 `"TOO_MANY_FILES"`입니다.
```tsx
import { VStack } from "@seed-design/react";
import type { FileEntry } from "@seed-design/react/primitive";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
const defaultAcceptedFileEntries: FileEntry[] = [
{
id: "1",
file: new File(["hello"], "document.pdf", { type: "application/pdf" }),
status: "success",
},
];
export default function AttachmentFieldMaxFiles() {
return (
);
}
```
### Invalid File Type
`accept`로 업로드 가능한 파일의 종류를 제한할 수 있습니다.
MIME type(`image/png`, `image/*`) 또는 확장자(`.png`, `.jpg, .jpeg`) 형식을 지정할 수 있으며, `string[]`을 전달하는 경우 각 `string` `,`로 join합니다.
``에 등록되는 `accept` 속성을 통해 사용자가 선택할 수 있는 파일의 종류를 제한하는 것은 브라우저 UI에서만 동작하는 편의 기능입니다. 따라서 사용자는 브라우저 파일 선택 다이얼로그의 `모든 파일 보기`와 같은 기능을 통해 제한된 종류의 파일도 선택할 수 있습니다. 이렇게 선택된 파일의 경우 `"INVALID_TYPE"` 에러와 함께 `onFileReject` 콜백이 실행됩니다.
파일 종류 검증이 필요한 경우 해당 검증은 서버에서도 수행되어야 합니다.
```tsx
import { useState } from "react";
import { VStack } from "@seed-design/react";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
function getErrorMessage(errorCode: string): string {
switch (errorCode) {
case "INVALID_TYPE":
return "지원하지 않는 파일 형식입니다";
default:
return "업로드에 실패했습니다";
}
}
export default function AttachmentFieldInvalidFileType() {
const [errorMessage, setErrorMessage] = useState();
return (
setErrorMessage(undefined)}
onFileReject={(files) => {
const messages = files.map(
({ file, errors }) => `"${file.name}": ${errors.map(getErrorMessage).join(", ")}`,
);
setErrorMessage(messages.join("\n"));
}}
>
);
}
```
### File Size
`minFileSize`, `maxFileSize` prop을 활용할 수 있습니다.
- 사용자가 선택한 파일이 `minFileSize`보다 작은 경우 `"FILE_TOO_SMALL"` 에러와 함께 `onFileReject` 콜백이 실행됩니다.
- 사용자가 선택한 파일이 `maxFileSize`보다 큰 경우 `"FILE_TOO_LARGE"` 에러와 함께 `onFileReject` 콜백이 실행됩니다.
```tsx
import { useState } from "react";
import { VStack } from "@seed-design/react";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
import { formatBytes } from "seed-design/lib/format-bytes";
const MIN_FILE_SIZE = 1 * 1024; // 1KB
const MAX_FILE_SIZE = 10 * 1024; // 10KB
function getErrorMessage(errorCode: string): string {
switch (errorCode) {
case "FILE_TOO_LARGE":
return `크기가 ${formatBytes(MAX_FILE_SIZE)}를 초과합니다`;
case "FILE_TOO_SMALL":
return `크기가 ${formatBytes(MIN_FILE_SIZE)} 미만입니다`;
case "TOO_MANY_FILES":
return "업로드 가능한 파일 개수를 초과했습니다";
default:
return "업로드에 실패했습니다";
}
}
export default function AttachmentFieldValidation() {
const [errorMessage, setErrorMessage] = useState();
return (
setErrorMessage(undefined)}
onFileReject={(files) => {
const messages = files.map(
({ file, errors }) => `"${file.name}": ${errors.map(getErrorMessage).join(", ")}`,
);
setErrorMessage(messages.join("\n"));
}}
>
);
}
```
### Custom Validation
`validate` prop으로 각 파일에 대한 유효성 검사를 직접 추가할 수 있습니다. 커스텀 에러 코드를 반환하여 `onFileReject`에서 에러 종류별로 메시지를 분기할 수 있습니다.
```tsx
import { useState } from "react";
import { VStack } from "@seed-design/react";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
function validateFileName(file: File) {
const nameWithoutExt = file.name.replace(/\.[^.]+$/, "");
if (nameWithoutExt.length > 8) {
return ["FILENAME_TOO_LONG"];
}
return null;
}
export default function AttachmentFieldCustomValidation() {
const [errorMessage, setErrorMessage] = useState();
return (
setErrorMessage(undefined)}
onFileReject={(files) => {
if (files.every((f) => f.errors.includes("FILENAME_TOO_LONG")) === false) {
return;
}
const names = files.map((f) => f.file.name).join(", ");
setErrorMessage(`"${names}"은(는) 파일 이름이 8자를 초과합니다.`);
}}
>
);
}
```
## Managing File Status
`acceptedFileEntries`의 각 항목은 `pending`, `uploading`, `success`, `error`의 status를 가질 수 있습니다. 새로 추가되는 항목의 status 기본값은 `pending`입니다.
파일 선택 직후 외부 업로드 API와 연동하는 경우, 사용자에게 각 파일 항목의 업로딩 상태를 보여줄 수 있습니다. `onFileAccept` 콜백에서 새로 추가된 파일을 받고, 함께 제공되는 `updateFileEntryStatus` 헬퍼를 사용하여 항목의 status를 업데이트합니다.
```tsx
import { useCallback } from "react";
import { VStack } from "@seed-design/react";
import type { FileStatusDetails } from "@seed-design/react/primitive";
import {
AttachmentField,
AttachmentInput,
AttachmentInputItem,
} from "seed-design/ui/attachment-field";
// 실제 환경에서는 fetch 등으로 교체하세요.
async function uploadFile(
file: File,
onProgress: (progress: number) => void,
): Promise<{ url: string }> {
const totalChunks = 5;
for (let i = 1; i <= totalChunks; i++) {
await new Promise((r) => setTimeout(r, 200 + Math.random() * 300));
onProgress(Math.round((i / totalChunks) * 100));
}
if (Math.random() > 0.5) {
throw new Error("Network error");
}
return { url: `https://example.com/uploads/${file.name}` };
}
export default function AttachmentFieldStatus() {
const startUpload = useCallback(
(
file: File,
id: string,
updateFileEntryStatus: (id: string, details: FileStatusDetails) => void,
) => {
updateFileEntryStatus(id, { status: "uploading", progress: 0 });
uploadFile(file, (progress) => {
updateFileEntryStatus(id, { status: "uploading", progress });
})
.then(() => updateFileEntryStatus(id, { status: "success" }))
.catch(() => updateFileEntryStatus(id, { status: "error" }));
},
[],
);
return (
{
for (const entry of entries) {
startUpload(entry.file, entry.id, updateFileEntryStatus);
}
}}
>
{({ acceptedFileEntries, updateFileEntryStatus }) =>
acceptedFileEntries.map((fileEntry) => (
startUpload(fileEntry.file, fileEntry.id, updateFileEntryStatus)}
/>
))
}
);
}
```
- `uploading`: [ProgressCircle](/react/components/progress-circle)이 표시됩니다.
- `progress`를 설정하여 업로드 진행률을 표시할 수 있습니다.
- `progress`를 지정하지 않는 경우 [indeterminate](/react/components/progress-circle#indeterminate) 상태로 표시됩니다.
- `error`: 재시도 버튼이 표시됩니다.
- 클릭 시 `AttachmentInputItem`에 지정한 `onRetry` 콜백이 실행됩니다.
유효성 검증에 성공한 파일 항목은 status와 관계없이 `acceptedFileEntries`에 유지되므로, 네이티브 form 제출 시 포함됩니다.
## Reordering Files
`AttachmentInputReorderable` 또는 `AttachmentDropzoneReorderable`을 사용하면 드래그로 파일의 순서를 변경할 수 있습니다.
두 컴포넌트는 `dnd-kit` 의존성 분리를 위해 별도 snippet [`ui:attachment-field-reorderable`](#reorderable)로 제공됩니다.
Context를 통해 `reorderFileEntry`가 제공되므로, 필요한 경우 원하는 드래그 앤 드롭 동작을 직접 구현하거나, 이미 프로젝트에서 사용 중인 드래그 앤 드롭 라이브러리와 연동하여 사용할 수 있습니다.
```tsx
import type { FileEntry } from "@seed-design/react/primitive";
import { AttachmentField } from "seed-design/ui/attachment-field";
import { AttachmentInputReorderable } from "seed-design/ui/attachment-field-reorderable";
function createMockImageFile(name: string, base64: string): File {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return new File([bytes], name, { type: "image/png" });
}
const defaultAcceptedFileEntries: FileEntry[] = [
{
id: "1",
file: createMockImageFile(
"sunset-landscape.png",
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGN4FcEDAAN+AU+hW/ICAAAAAElFTkSuQmCC",
),
status: "success",
},
{
id: "2",
file: createMockImageFile(
"city-night.png",
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOYbPwKAAMNAbHKe2UaAAAAAElFTkSuQmCC",
),
status: "success",
},
{
id: "3",
file: createMockImageFile(
"morning-coffee.png",
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGN4tZkDAAQwAaYlKXDxAAAAAElFTkSuQmCC",
),
status: "success",
},
];
export default function AttachmentFieldReorderableExample() {
return (
);
}
```
## Examples
### Showing Thumbnails
파일 이름 및 크기 대신 이미지 미리보기를 표시하려면 `accept`를 `"image/*"`, `["image/png", "image/jpeg"]` 등으로 설정하여 사용자가 이미지 파일만 선택할 수 있도록 제한합니다.
```tsx
import { VStack } from "@seed-design/react";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
export default function AttachmentFieldAcceptImage() {
return (
);
}
```
[`"image/heic"`](https://caniuse.com/heif) 등 일부 이미지 형식은 브라우저에
따라 이미지 미리보기가 표시되지 않을 수 있습니다.
### Disabled
`disabled` prop으로 trigger 및 dropzone을 비활성화하여 신규 파일 선택을 차단하고, ``을 `disabled` 처리하여 폼 제출 시 값이 전송되지 않도록 합니다.
```tsx
import { VStack } from "@seed-design/react";
import type { FileEntry } from "@seed-design/react/primitive";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
const defaultFiles: FileEntry[] = [
{
id: "mock-1",
file: new File(["hello"], "document.pdf", { type: "application/pdf" }),
status: "success",
},
{
id: "mock-2",
file: new File(["world"], "image.png", { type: "image/png" }),
status: "success",
},
];
export default function AttachmentFieldDisabled() {
return (
);
}
```
### Read Only
`readOnly` prop으로 첨부된 파일을 읽기 전용 상태로 표시할 수 있습니다. trigger, dropzone, 파일 제거 버튼, 순서 변경 모두 비활성화되지만 ``의 값은 유지되어 form 제출 시 함께 전송됩니다.
```tsx
import { VStack } from "@seed-design/react";
import type { FileEntry } from "@seed-design/react/primitive";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
const defaultFiles: FileEntry[] = [
{
id: "mock-1",
file: new File(["hello"], "document.pdf", { type: "application/pdf" }),
status: "success",
},
{
id: "mock-2",
file: new File(["world"], "image.png", { type: "image/png" }),
status: "success",
},
];
export default function AttachmentFieldReadOnly() {
return (
);
}
```
### Controlled
`acceptedFileEntries`와 `onAcceptedFileEntriesChange`를 사용하여 외부에서 파일 목록을 제어할 수 있습니다.
```tsx
import { VStack, HStack, Text } from "@seed-design/react";
import { useState } from "react";
import { ActionButton } from "seed-design/ui/action-button";
import type { FileEntry } from "@seed-design/react/primitive";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
export default function AttachmentFieldControlled() {
const [acceptedFileEntries, setAcceptedFileEntries] = useState([]);
return (
현재 파일: {JSON.stringify(acceptedFileEntries.map((f) => f.file.name))} setAcceptedFileEntries([])}
>
전체 삭제
);
}
```
### Custom Inset
`--seed-attachment-input-extend-x` CSS 변수를 사용하여 스크롤되는 아이템 목록이 레이아웃 바깥으로 빠져나오도록 구성할 수 있습니다.
```tsx
import { VStack } from "@seed-design/react";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
import { TextField, TextFieldInput } from "seed-design/ui/text-field";
import { vars } from "@seed-design/css/vars";
const mockedFiles = Array.from({ length: 8 }, (_, i) => {
const file = new File(["file content"], `file${i + 1}.txt`, { type: "text/plain" });
Object.defineProperty(file, "size", { value: 1 });
return file;
});
export default function AttachmentFieldCustomInset() {
return (
({
id: `${index}`,
file,
status: "pending",
}))}
rootProps={{
style: {
"--seed-attachment-input-extend-x": vars.$dimension.spacingX.globalGutter,
} as React.CSSProperties,
}}
>
);
}
```
### Field Integration
`label`, `description`, `errorMessage` 등의 Field 관련 prop을 사용할 수 있습니다.
```tsx
import { Divider, VStack } from "@seed-design/react";
import { AttachmentField, AttachmentInput } from "seed-design/ui/attachment-field";
export default function AttachmentFieldField() {
return (
);
}
```
### Form (Uncontrolled)
`acceptedFileEntries`에 등록된 파일이 ``의 `files`로 동기화되므로 `