# Attachment Field
URL: /lynx/components/attachment-field
Source: https://github.com/daangn/seed-design/blob/dev/docs/content/lynx/components/attachment-field.mdx
호스트 네이티브 파일 선택 결과를 받아 첨부 항목을 표시하고 관리하는 컴포넌트입니다.
사용 가능 버전: @seed-design/lynx-react@0.8.0, @seed-design/lynx-css@0.12.0
## Preview
```tsx
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
// 문서 고정 fixture입니다. 실제 앱에서는 호스트가 제공하는 파일 선택 adapter를 전달하세요.
const FIXTURE_FILES: NativeFile[] = [
{
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
];
export default function AttachmentFieldPreview() {
return (
FIXTURE_FILES}
>
);
}
```
## Installation
### Default
순서 변경이 필요하지 않은 경우 기본 snippet을 설치합니다.
- 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
네이티브 가로 long-press gesture로 첨부 항목 순서를 변경하려면 별도 snippet을 설치합니다. 기본 snippet에는 reorder gesture가 포함되지 않습니다. 프로젝트에 `@seed-design/lynx-react-sortable` 의존성이 추가됩니다.
- 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
Lynx `AttachmentField`는 브라우저 파일 input을 만들지 않습니다. 호스트가 제공하는 네이티브 파일 선택 adapter를 `onSelectFiles`로 전달해야 합니다. 이 문서의 `fixture://` URI와 고정 이미지는 실행 가능한 picker가 아닌 문서 fixture입니다.
## Props
### `AttachmentField`
### `AttachmentInput`
### `AttachmentInputItem`
## Usage
`onSelectFiles`의 호스트 adapter는 `NativeFile[]` 또는 `Promise`를 반환합니다. 선택을 취소하면 빈 배열을 반환해야 하며, adapter 오류는 `onSelectError`로 전달됩니다.
```tsx
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
;
```
`NativeFile`은 `uri`, `name`, `type`, `size`와 선택적인 `previewUrl`로 구성됩니다. `File`, `Blob`, HTML ``, object URL은 사용할 수 없습니다. 이미지 모드에서 `previewUrl`이 없으면 `uri`를 사용하며, 일반 파일 모드에서는 파일 아이콘과 이름·크기를 표시합니다.
`AttachmentInput`은 호스트 adapter가 반환한 파일을 `accept`, `maxFiles`, `minFileSize`, `maxFileSize`, `validate` 조건으로 검증합니다. picker의 MIME filter는 UI를 위한 보조 수단이므로 앱과 업로드 서버에서도 검증해야 합니다. adapter가 빈 배열을 반환하면 목록은 변경되지 않습니다.
## Uploading Files
### Trigger
기본 `AttachmentInput`은 파일 선택 trigger와 첨부 항목 목록을 함께 제공합니다. trigger를 탭하면 호스트 adapter가 호출되고 반환된 파일이 목록에 추가됩니다.
```tsx
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const DEFAULT_FILES: AttachmentFileEntry[] = [
{
id: "document",
file: {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
status: "success",
},
];
const PICKED_FILES: NativeFile[] = [
{
uri: "fixture://attachment-field/photo.png",
name: "photo.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-trigger/200/200",
},
];
export default function AttachmentFieldTrigger() {
return (
PICKED_FILES}
>
);
}
```
### Listening to Accepted File Changes
`acceptedFileEntries`는 검증을 통과한 파일 목록입니다. `onAcceptedFileEntriesChange`는 추가·삭제·상태·순서 변경을 포함한 목록 변화를 전달합니다.
```tsx
import { useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import {
AttachmentField,
AttachmentInput,
AttachmentInputItem,
} from "@/components/ui/attachment-field";
import { Text, VStack } from "@seed-design/lynx-react";
const PICKED_FILES: NativeFile[] = [
{
uri: "fixture://attachment-field/value-changes.txt",
name: "value-changes.txt",
type: "text/plain",
size: 12,
},
];
export default function AttachmentFieldValueChanges() {
const [logs, setLogs] = useState([]);
const addLog = (message: string) => setLogs((previous) => [...previous, message]);
return (
{logs.length === 0 ? (
파일을 추가하거나 삭제하면 로그가 표시됩니다.
) : (
logs.map((log, index) => (
{log}
))
)}
PICKED_FILES}
onFileAccept={(entries, { updateFileEntryStatus }) => {
addLog(`onFileAccept: ${entries.map((entry) => entry.file.name).join(", ")}`);
for (const entry of entries) {
updateFileEntryStatus(entry.id, { status: "uploading", progress: 0 });
setTimeout(
() => updateFileEntryStatus(entry.id, { status: "uploading", progress: 50 }),
250,
);
setTimeout(() => updateFileEntryStatus(entry.id, { status: "success" }), 500);
}
}}
onAcceptedFileEntriesChange={(entries) =>
addLog(
`onAcceptedFileEntriesChange: ${entries.map((entry) => `${entry.file.name} (${entry.status})`).join(", ")}`,
)
}
onFileReject={(files) =>
addLog(
`onFileReject: ${files.map((file) => `${file.file.name} (${file.errors.join(", ")})`).join(", ")}`,
)
}
>
{({ acceptedFileEntries }) =>
acceptedFileEntries.map((fileEntry) => (
))
}
);
}
```
## Validating Files
### Max Files
`maxFiles`는 최종 파일 개수를 제한합니다. picker에 상한이나 filter를 전달해도 초과 결과는 `TOO_MANY_FILES`로 reject됩니다.
```tsx
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const DEFAULT_FILES: AttachmentFileEntry[] = [
{
id: "document",
file: {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
status: "success",
},
];
const PICKED_FILES: NativeFile[] = [
{ uri: "fixture://attachment-field/one.txt", name: "one.txt", type: "text/plain", size: 1 },
{ uri: "fixture://attachment-field/two.txt", name: "two.txt", type: "text/plain", size: 1 },
{ uri: "fixture://attachment-field/three.txt", name: "three.txt", type: "text/plain", size: 1 },
];
export default function AttachmentFieldMaxFiles() {
return (
PICKED_FILES}
>
);
}
```
### Invalid File Type
`accept`는 MIME type(`image/png`, `image/*`) 또는 확장자 패턴을 받을 수 있습니다. 호스트 picker가 제한을 우회해 반환한 파일도 검증하며, 유효하지 않으면 `INVALID_TYPE`과 함께 `onFileReject`가 호출됩니다.
```tsx
import { useRef, useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const INVALID_FILE: NativeFile = {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
};
const VALID_FILE: NativeFile = {
uri: "fixture://attachment-field/photo.jpg",
name: "photo.jpg",
type: "image/jpeg",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-invalid/200/200",
};
export default function AttachmentFieldInvalidFileType() {
const pickCount = useRef(0);
const [errorMessage, setErrorMessage] = useState();
return (
[pickCount.current++ === 0 ? INVALID_FILE : VALID_FILE]}
onAcceptedFileEntriesChange={() => setErrorMessage(undefined)}
onFileReject={(files) =>
setErrorMessage(
files
.map(
({ file, errors }) =>
`"${file.name}": ${errors.includes("INVALID_TYPE") ? "지원하지 않는 파일 형식입니다" : "업로드에 실패했습니다"}`,
)
.join("\n"),
)
}
>
);
}
```
### File Size
`minFileSize`, `maxFileSize`는 `NativeFile.size`(bytes)를 검증합니다. 범위를 벗어나면 각각 `FILE_TOO_SMALL`, `FILE_TOO_LARGE`로 reject됩니다.
```tsx
import { useRef, useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const MIN_FILE_SIZE = 1024;
const MAX_FILE_SIZE = 10 * 1024;
const SMALL_FILE: NativeFile = {
uri: "fixture://attachment-field/small.txt",
name: "small.txt",
type: "text/plain",
size: 1,
};
const VALID_FILE: NativeFile = {
uri: "fixture://attachment-field/valid.txt",
name: "valid.txt",
type: "text/plain",
size: 2048,
};
export default function AttachmentFieldValidation() {
const pickCount = useRef(0);
const [errorMessage, setErrorMessage] = useState();
return (
[pickCount.current++ === 0 ? SMALL_FILE : VALID_FILE]}
onAcceptedFileEntriesChange={() => setErrorMessage(undefined)}
onFileReject={(files) =>
setErrorMessage(
files
.map(
({ file, errors }) =>
`"${file.name}": ${errors.includes("FILE_TOO_SMALL") ? "크기가 1KB 미만입니다" : errors.includes("FILE_TOO_LARGE") ? "크기가 10KB를 초과합니다" : "업로드에 실패했습니다"}`,
)
.join("\n"),
)
}
>
);
}
```
### Custom Validation
`validate`는 각 `NativeFile`에 대해 custom error code 배열 또는 `null`을 반환합니다. 앱은 `onFileReject`에서 이 값을 오류 메시지로 변환합니다.
```tsx
import { useRef, useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const LONG_NAME_FILE: NativeFile = {
uri: "fixture://attachment-field/filename-too-long.txt",
name: "filename-too-long.txt",
type: "text/plain",
size: 5,
};
const VALID_FILE: NativeFile = {
uri: "fixture://attachment-field/short.txt",
name: "short.txt",
type: "text/plain",
size: 5,
};
export default function AttachmentFieldCustomValidation() {
const pickCount = useRef(0);
const [errorMessage, setErrorMessage] = useState();
return (
file.name.replace(/\.[^.]+$/, "").length > 8 ? ["FILENAME_TOO_LONG"] : null
}
invalid={!!errorMessage}
errorMessage={errorMessage}
label="파일 업로드"
description="파일 이름은 확장자를 제외하고 8자 이하여야 합니다"
onSelectFiles={() => [pickCount.current++ === 0 ? LONG_NAME_FILE : VALID_FILE]}
onAcceptedFileEntriesChange={() => setErrorMessage(undefined)}
onFileReject={(files) => {
if (files.every((file) => file.errors.includes("FILENAME_TOO_LONG")))
setErrorMessage(
`"${files.map((file) => file.file.name).join(", ")}"은(는) 파일 이름이 8자를 초과합니다.`,
);
}}
>
);
}
```
## Managing File Status
각 항목은 `pending`, `uploading`, `success`, `error` 상태를 갖습니다. 새 항목은 `pending`으로 시작합니다. 앱은 `onFileAccept`에서 업로드를 시작하고 helper로 진행률과 상태를 갱신합니다. 실제 업로드 API와 재시도 정책은 호스트 앱이 소유합니다.
```tsx
import { useRef } from "@lynx-js/react";
import type { AttachmentFileStatusDetails, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const PICKED_FILE: NativeFile = {
uri: "fixture://attachment-field/status.png",
name: "status.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-status/200/200",
};
export default function AttachmentFieldStatus() {
const attempts = useRef>({});
const startUpload = (
id: string,
update: (entryId: string, details: AttachmentFileStatusDetails) => void,
) => {
attempts.current[id] = (attempts.current[id] ?? 0) + 1;
update(id, { status: "uploading", progress: 0 });
setTimeout(() => update(id, { status: "uploading", progress: 50 }), 250);
setTimeout(() => update(id, { status: attempts.current[id] === 1 ? "error" : "success" }), 500);
};
return (
[PICKED_FILE]}
onFileAccept={(entries, { updateFileEntryStatus }) => {
for (const entry of entries) startUpload(entry.id, updateFileEntryStatus);
}}
>
startUpload(entry.id, updateFileEntryStatus)
}
/>
);
}
```
- `uploading`: `ProgressCircle`이 표시됩니다. progress가 없으면 indeterminate 상태입니다.
- `error`: `onRetry`가 있으면 재시도 action이 표시됩니다. 재시도할 때 같은 id를 `uploading`으로 바꾸고 업로드를 다시 시작합니다.
- picker가 동기 throw 또는 rejected Promise를 반환하면 `onSelectError`가 호출되고 기존 목록은 유지됩니다.
## Reordering Files
`ui:attachment-field-reorderable`은 브라우저 `dnd-kit` 대신 네이티브 가로 long-press gesture로 목록 순서를 변경합니다. `disabled`와 `readOnly`에서는 정렬할 수 없습니다.
사용자 지정 항목을 렌더링할 때는 `SortableAttachmentInputItem`에 현재 `index`를 전달해야 합니다. 스크린 리더는 항목의 파일 이름, 순서, 업로드 상태를 읽고 앞·뒤 이동과 파일 제거 동작을 제공합니다. error 상태에 `onRetry`가 있으면 재시도 동작도 제공합니다. iOS에서는 항목 내부 버튼 대신 이 사용자 지정 접근성 동작을 사용합니다. `readOnly`에서는 이동과 제거가, `disabled`에서는 이동만 비활성화됩니다.
```tsx
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { VStack } from "@seed-design/lynx-react";
import { AttachmentField } from "@/components/ui/attachment-field";
import { AttachmentInputReorderable } from "@/components/ui/attachment-field-reorderable";
const PIXEL_SUNSET =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGN4FcEDAAN+AU+hW/ICAAAAAElFTkSuQmCC";
const PIXEL_CITY =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOYbPwKAAMNAbHKe2UaAAAAAElFTkSuQmCC";
const PIXEL_COFFEE =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGN4tZkDAAQwAaYlKXDxAAAAAElFTkSuQmCC";
function createFixture(name: string, data: string): NativeFile {
return {
uri: `fixture://attachment-field/${name}`,
name,
type: "image/png",
size: 1,
previewUrl: `data:image/png;base64,${data}`,
};
}
const DEFAULT_FILES: AttachmentFileEntry[] = [
{ id: "1", file: createFixture("sunset-landscape.png", PIXEL_SUNSET), status: "success" },
{ id: "2", file: createFixture("city-night.png", PIXEL_CITY), status: "success" },
{ id: "3", file: createFixture("morning-coffee.png", PIXEL_COFFEE), status: "success" },
];
export default function AttachmentFieldReorderable() {
return (
);
}
```
## Examples
### Showing Thumbnails
`accept="image/*"`으로 이미지 파일만 허용하고, 호스트가 반환한 `previewUrl`로 썸네일을 표시합니다.
```tsx
import { useRef, useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const IMAGE_FILE: NativeFile = {
uri: "fixture://attachment-field/photo.png",
name: "photo.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-image/200/200",
};
const OTHER_FILE: NativeFile = {
uri: "fixture://attachment-field/readme.pdf",
name: "readme.pdf",
type: "application/pdf",
size: 5,
};
export default function AttachmentFieldAcceptImage() {
const pickCount = useRef(0);
const [errorMessage, setErrorMessage] = useState();
return (
[pickCount.current++ === 0 ? IMAGE_FILE : OTHER_FILE]}
onAcceptedFileEntriesChange={() => setErrorMessage(undefined)}
onFileReject={() => setErrorMessage("지원하지 않는 파일 형식입니다")}
>
);
}
```
### Disabled
`disabled`는 trigger와 추가·정렬을 막지만 기존 항목 삭제는 허용합니다. 삭제까지 막으려면 `readOnly` 또는 앱 정책을 사용해야 합니다.
```tsx
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const DEFAULT_FILES: AttachmentFileEntry[] = [
{
id: "document",
file: {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
status: "success",
},
{
id: "image",
file: {
uri: "fixture://attachment-field/image.png",
name: "image.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-disabled/200/200",
},
status: "success",
},
];
export default function AttachmentFieldDisabled() {
return (
);
}
```
### Read Only
`readOnly`는 trigger, 추가, 삭제, clear, 정렬을 모두 막습니다. 외부에서 controlled 상태를 hydrate하거나 갱신하는 것은 허용됩니다.
```tsx
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const DEFAULT_FILES: AttachmentFileEntry[] = [
{
id: "document",
file: {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
status: "success",
},
{
id: "image",
file: {
uri: "fixture://attachment-field/image.png",
name: "image.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-readonly/200/200",
},
status: "success",
},
];
export default function AttachmentFieldReadOnly() {
return (
);
}
```
### Controlled
`acceptedFileEntries`와 `onAcceptedFileEntriesChange`로 목록을 외부에서 제어할 수 있습니다.
```tsx
import { useState } from "@lynx-js/react";
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { ActionButton, HStack, Text, VStack } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
const PICKED_FILE: NativeFile = {
uri: "fixture://attachment-field/controlled.txt",
name: "controlled.txt",
type: "text/plain",
size: 5,
};
export default function AttachmentFieldControlled() {
const [acceptedFileEntries, setAcceptedFileEntries] = useState([]);
function clearFiles() {
"background only";
setAcceptedFileEntries([]);
}
return (
[PICKED_FILE]}
>
현재 파일: {JSON.stringify(acceptedFileEntries.map((entry) => entry.file.name))}
전체 삭제
);
}
```
### Custom Inset
`--seed-attachment-input-extend-x` style 변수를 사용하면 항목 가로 목록이 global gutter 바깥까지 확장됩니다. 실제 파일 metadata와 nested field를 함께 사용하는 예제입니다.
```tsx
import * as React from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { TextField, TextFieldInput } from "@/components/ui/text-field";
import { VStack } from "@seed-design/lynx-react";
type AttachmentFieldStyle = NonNullable["style"]> & {
"--seed-attachment-input-extend-x": string;
};
const INSET_STYLE: AttachmentFieldStyle = {
"--seed-attachment-input-extend-x": "var(--seed-dimension-spacing-x-global-gutter)",
};
const MOCKED_FILES: NativeFile[] = Array.from({ length: 8 }, (_, index) => ({
uri: `fixture://attachment-field/file${index + 1}.txt`,
name: `file${index + 1}.txt`,
type: "text/plain",
size: 1,
}));
export default function AttachmentFieldCustomInset() {
return (
({
id: `${index}`,
file,
status: "pending",
}))}
style={INSET_STYLE}
onSelectFiles={() => [MOCKED_FILES[0]]}
>
);
}
```
### Field Integration
`Field`와 함께 안내 문구와 오류 상태를 표시할 수 있습니다. HTML form submit 대신 앱의 validation 결과로 `invalid`와 `errorMessage`를 관리합니다.
```tsx
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { Divider, VStack } from "@seed-design/lynx-react";
const PICKED_FILE: NativeFile = {
uri: "fixture://attachment-field/field.txt",
name: "field.txt",
type: "text/plain",
size: 5,
};
export default function AttachmentFieldField() {
return (
[PICKED_FILE]}
>
);
}
```
### Customizing Items
기본 항목 구성을 바꾸는 방법은 다음 예제에서 확인할 수 있습니다. 대표 사진 배지는 앱의 순서 정책에 맞춰 표시합니다.
```tsx
import IconArrowClockwiseCircularFill from "@karrotmarket/lynx-monochrome-icon/IconArrowClockwiseCircularFill";
import IconXmarkFill from "@karrotmarket/lynx-monochrome-icon/IconXmarkFill";
import { AttachmentInput as SeedAttachmentInput, Icon, VStack } from "@seed-design/lynx-react";
import { ProgressCircle } from "@/components/ui/progress-circle";
import type {
AttachmentFileEntry,
AttachmentFileStatusDetails,
NativeFile,
} from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
const PICKED_FILE: NativeFile = {
uri: "fixture://attachment-field/custom.png",
name: "custom.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-custom/200/200",
};
function CustomImageItem({
fileEntry,
isCover,
onRetry,
}: {
fileEntry: AttachmentFileEntry;
isCover: boolean;
onRetry?: () => void;
}) {
return (
{isCover ? 대표사진 : null}
{(entry) => (
)}
{onRetry ? (
} />
재시도
) : null}
} />
);
}
export default function AttachmentFieldCustomizingItems() {
return (
[PICKED_FILE]}
onFileAccept={(entries, { updateFileEntryStatus }) => {
for (const entry of entries) {
updateFileEntryStatus(entry.id, { status: "uploading", progress: 0 });
setTimeout(() => updateFileEntryStatus(entry.id, { status: "success" }), 500);
}
}}
>
{({ acceptedFileEntries, updateFileEntryStatus }) =>
acceptedFileEntries.map((fileEntry, index) => (
{
updateFileEntryStatus(fileEntry.id, {
status: "uploading",
progress: 0,
} satisfies AttachmentFileStatusDetails);
setTimeout(() => updateFileEntryStatus(fileEntry.id, { status: "success" }), 500);
}}
/>
))
}
);
}
```
## Lynx 미지원 기능과 앱 대안
### Dropzone
Lynx는 React `AttachmentDropzone`의 브라우저 drag-and-drop 이벤트와 HTML file input을 지원하지 않습니다. 네이티브 파일 picker는 `onSelectFiles`에 연결하며, 목록 순서 변경에는 `ui:attachment-field-reorderable`을 사용합니다.
### HTML form
Lynx에는 브라우저 `form`, `FormData`, `input[type=file]` 제출 경로가 없습니다. 앱은 `onAcceptedFileEntriesChange` 또는 `onFileAccept`로 상태를 유지하고, submit action에서 `NativeFile[]`를 호스트 upload adapter에 전달합니다. 서버 검증 오류는 Field의 `invalid`와 `errorMessage`에 반영합니다.
### react-hook-form
`react-hook-form`을 사용하는 React 예제는 브라우저 파일 입력과 form 제출 경로에 의존하므로 그대로 실행할 수 없습니다. Lynx에서는 앱 state에 `acceptedFileEntries`와 `onAcceptedFileEntriesChange`를 연결하고, 제출할 때 목록 길이와 업로드 상태를 검증한 뒤 Field 오류 상태를 갱신합니다.
### Browser file APIs
`File`, `Blob`, `FileList`, object URL, `window.alert`는 Native 예제에서 사용할 수 없습니다. 호스트 adapter가 `NativeFile`의 `uri`, metadata, 선택적인 `previewUrl`을 제공해야 합니다. 취소하면 `[]`를 반환하며 선택기 또는 권한 오류는 `onSelectError`로 전달됩니다.