# 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`가 받습니다.