# 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`로 전달됩니다.