# 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 "@seed-design/lynx-css/base.css"; import type { NativeFile } from "@seed-design/lynx-react"; import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field"; import { VStack, useSeedClassName } 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() { const seedClassName = useSeedClassName({ colorMode: "system" }); 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가 포함되지 않습니다. - 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 `AttachmentField` 안에 `AttachmentInput`을 조합합니다. `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을 사용하지 않습니다. `accept`를 이미지 전용으로 설정하면 이미지 항목을 표시하며, `previewUrl`이 없으면 `uri`를 사용합니다. 일반 파일 모드에서는 파일 아이콘과 이름·크기를 표시합니다. 호스트 adapter가 반환한 파일은 `AttachmentInput`이 `accept`, `maxFiles`, `minFileSize`, `maxFileSize`, `validate` 조건으로 검증합니다. picker의 MIME filter는 UI 편의일 뿐이며 최종 검증은 앱과 업로드 서버에서도 수행하세요. adapter가 취소를 나타내는 빈 배열을 반환하면 목록은 변경되지 않습니다. ### Item 직접 구성하기 `AttachmentInput`은 context render prop을 지원합니다. `acceptedFileEntries`를 `AttachmentInputItem`으로 직접 렌더링하고 `updateFileEntryStatus`로 앱의 업로드 상태를 갱신할 수 있습니다. ```tsx {({ acceptedFileEntries }) => acceptedFileEntries.map((fileEntry) => ( ))} ; ``` `AttachmentInputItemProps`는 실제 `AttachmentInput.Item`의 root native props를 포함하므로 `id`, accessibility props, style, `dragging` 등을 전달할 수 있습니다. `fileEntry`는 필수이며, `onRetry`를 전달하면 error 상태에서 재시도 action이 표시됩니다. ## Uploading Files ### Trigger 기본 `AttachmentInput`은 파일 선택 trigger와 첨부 항목 목록을 함께 제공합니다. trigger를 탭하면 호스트 adapter가 호출되고 반환된 파일이 목록에 추가됩니다. ```tsx import "@seed-design/lynx-css/base.css"; import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react"; import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field"; import { VStack, useSeedClassName } 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() { const seedClassName = useSeedClassName({ colorMode: "system" }); return ( PICKED_FILES} > ); } ``` ### Listening to Accepted File Changes `acceptedFileEntries`는 검증을 통과한 파일 목록입니다. `onAcceptedFileEntriesChange`는 추가·삭제·상태·순서 변경을 포함한 목록 변화를 전달합니다. 업로드 callback과 로그를 함께 확인하려면 다음 예제를 실행하세요. ```tsx import "@seed-design/lynx-css/base.css"; 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, useSeedClassName } 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 seedClassName = useSeedClassName({ colorMode: "system" }); 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`는 package 레벨에서 최종 파일 개수를 제한합니다. picker에 상한이나 filter를 전달하는 것은 UX를 위한 보조 동작이며, 초과 결과는 `TOO_MANY_FILES`로 reject됩니다. ```tsx import "@seed-design/lynx-css/base.css"; import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react"; import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field"; import { VStack, useSeedClassName } 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() { const seedClassName = useSeedClassName({ colorMode: "system" }); return ( PICKED_FILES} > ); } ``` ### Invalid File Type `accept`는 MIME type(`image/png`, `image/*`) 또는 확장자 패턴을 받을 수 있습니다. 호스트 picker에서 제한을 우회해 반환한 파일도 package 검증을 거치며 `INVALID_TYPE`과 함께 `onFileReject`가 호출됩니다. ```tsx import "@seed-design/lynx-css/base.css"; 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, useSeedClassName } 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 seedClassName = useSeedClassName({ colorMode: "system" }); 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 "@seed-design/lynx-css/base.css"; 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, useSeedClassName } 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 seedClassName = useSeedClassName({ colorMode: "system" }); 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 "@seed-design/lynx-css/base.css"; 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, useSeedClassName } 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 seedClassName = useSeedClassName({ colorMode: "system" }); 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로 `uploading` progress, `success`, `error`를 갱신하세요. 실제 업로드 API와 재시도 정책은 호스트 앱이 소유합니다. ```tsx import "@seed-design/lynx-css/base.css"; 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, useSeedClassName } 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 seedClassName = useSeedClassName({ colorMode: "system" }); 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 상태입니다. image mode는 이미지 위에서 보이도록 `staticWhite` tone을 사용합니다. - `error`: `onRetry`가 전달된 경우 재시도 action이 표시됩니다. 재시도 callback에서 같은 id를 다시 `uploading`으로 바꾸고 앱 업로드를 재개하세요. - picker가 동기 throw 또는 rejected Promise를 반환하면 `onSelectError`가 호출되고 기존 목록은 유지됩니다. ## Reordering Files `ui:attachment-field-reorderable`의 `AttachmentInputReorderable`은 브라우저 `dnd-kit` 대신 네이티브 가로 long-press gesture를 사용합니다. gesture가 끝나면 `reorderFileEntry(fromIndex, toIndex)`가 호출되고 목록 순서가 변경됩니다. `disabled`와 `readOnly`에서는 drag가 차단됩니다. 기본 아이템은 snippet이 `SortableAttachmentInputItem`으로 구성합니다. 직접 구성할 때는 context를 그대로 받아 exported `SortableAttachmentInputItem`에 반드시 `index`를 전달하세요. callback 결과를 `Children.toArray`로 다시 매핑하거나 clone하지 않습니다. ```tsx import { AttachmentInputReorderable, SortableAttachmentInputItem, } from "@/components/ui/attachment-field-reorderable"; {({ acceptedFileEntries }) => acceptedFileEntries.map((fileEntry, index) => ( ))} ; ``` ```tsx import "@seed-design/lynx-css/base.css"; import IconXmarkFill from "@karrotmarket/lynx-monochrome-icon/IconXmarkFill"; import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react"; import { AttachmentInput as SeedAttachmentInput, Icon, VStack, useSeedClassName, } from "@seed-design/lynx-react"; import { ProgressCircle } from "@/components/ui/progress-circle"; import { AttachmentField } from "@/components/ui/attachment-field"; import { AttachmentInputReorderable, SortableAttachmentInputItem, } 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" }, ]; function SortableImageItem({ fileEntry, index, isCover, }: { fileEntry: AttachmentFileEntry; index: number; isCover: boolean; }) { return ( {(dragging) => ( {isCover ? ( 대표사진 ) : null} {(entry) => ( )} } /> )} ); } export default function AttachmentFieldReorderable() { const seedClassName = useSeedClassName({ colorMode: "system" }); return ( {({ acceptedFileEntries }) => acceptedFileEntries.map((fileEntry, index) => ( )) } ); } ``` ## Examples ### Showing Thumbnails `accept="image/*"`으로 이미지 파일만 허용하고, 호스트가 반환한 `previewUrl`로 썸네일을 표시합니다. ```tsx import "@seed-design/lynx-css/base.css"; 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, useSeedClassName } 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 seedClassName = useSeedClassName({ colorMode: "system" }); const pickCount = useRef(0); const [errorMessage, setErrorMessage] = useState(); return ( [pickCount.current++ === 0 ? IMAGE_FILE : OTHER_FILE]} onAcceptedFileEntriesChange={() => setErrorMessage(undefined)} onFileReject={() => setErrorMessage("지원하지 않는 파일 형식입니다")} > ); } ``` ### Disabled `disabled`는 trigger와 추가·정렬을 막습니다. Lynx 계약에서는 기존 항목의 삭제는 허용되므로, 삭제까지 막아야 한다면 `readOnly` 또는 앱 정책을 사용하세요. ```tsx import "@seed-design/lynx-css/base.css"; import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react"; import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field"; import { VStack, useSeedClassName } 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() { const seedClassName = useSeedClassName({ colorMode: "system" }); return ( ); } ``` ### Read Only `readOnly`는 trigger, 추가, 삭제, clear, 정렬을 모두 막습니다. 외부에서 controlled 상태를 hydrate하거나 갱신하는 것은 허용됩니다. ```tsx import "@seed-design/lynx-css/base.css"; import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react"; import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field"; import { VStack, useSeedClassName } 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() { const seedClassName = useSeedClassName({ colorMode: "system" }); return ( ); } ``` ### Controlled `acceptedFileEntries`와 `onAcceptedFileEntriesChange`로 목록을 외부에서 제어할 수 있습니다. ```tsx import "@seed-design/lynx-css/base.css"; import { useState } from "@lynx-js/react"; import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react"; import { ActionButton, HStack, Text, VStack, useSeedClassName } 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 seedClassName = useSeedClassName({ colorMode: "system" }); 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 "@seed-design/lynx-css/base.css"; 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, useSeedClassName } 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() { const seedClassName = useSeedClassName({ colorMode: "system" }); return ( ({ id: `${index}`, file, status: "pending", }))} style={INSET_STYLE} onSelectFiles={() => [MOCKED_FILES[0]]} > ); } ``` ### Field Integration `label`, `description`, `indicator`, `errorMessage`, `required`, `showRequiredIndicator`, `invalid`를 Field slot에 연결할 수 있습니다. HTML form submit 대신 앱의 submit-equivalent validation에서 `invalid`와 메시지를 관리하세요. ```tsx import "@seed-design/lynx-css/base.css"; import type { NativeFile } from "@seed-design/lynx-react"; import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field"; import { Divider, VStack, useSeedClassName } 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() { const seedClassName = useSeedClassName({ colorMode: "system" }); return ( [PICKED_FILE]} > ); } ``` ### Customizing Items 기본 item 대신 package의 compound `AttachmentInput.Item`과 `ItemBadge`, `ItemBackdrop`, `ProgressCircle`, action/icon slot을 직접 조합할 수 있습니다. representative badge는 앱의 순서 정책에 맞춰 표시하세요. ```tsx import "@seed-design/lynx-css/base.css"; import IconArrowClockwiseCircularFill from "@karrotmarket/lynx-monochrome-icon/IconArrowClockwiseCircularFill"; import IconXmarkFill from "@karrotmarket/lynx-monochrome-icon/IconXmarkFill"; import { AttachmentInput as SeedAttachmentInput, Icon, VStack, useSeedClassName, } 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() { const seedClassName = useSeedClassName({ colorMode: "system" }); 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 React `AttachmentDropzone`의 브라우저 drag-and-drop 이벤트와 HTML file input은 Lynx에서 지원하지 않습니다. 실행 가능한 dropzone 예제를 만들지 않습니다. 대신 네이티브 파일 picker를 `onSelectFiles`에 연결하고, 앱에서 지원하는 drag gesture나 별도 host action을 picker 호출로 매핑하세요. 파일 목록의 순서 변경은 `ui:attachment-field-reorderable`을 사용합니다. ### HTML form Lynx에는 브라우저 `form`, `FormData`, `input[type=file]` 제출 경로가 없습니다. `onAcceptedFileEntriesChange` 또는 `onFileAccept`로 앱 state를 유지한 뒤, submit action에서 `NativeFile[]`를 host upload/submit 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`가 받습니다.