# Attachment Display Field
URL: /lynx/components/attachment-display-field
Source: https://github.com/daangn/seed-design/blob/dev/docs/content/lynx/components/attachment-display-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 { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { useRef } from "@lynx-js/react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
import { VStack, useSeedClassName } from "@seed-design/lynx-react";
const FIXTURE_ENTRIES: AttachmentDisplayEntry[] = [
{
id: "fixture-1",
thumbnailUrl: "https://picsum.photos/seed/seed1/200/200",
status: "success",
},
{
id: "fixture-2",
thumbnailUrl: "https://picsum.photos/seed/seed2/200/200",
status: "success",
},
];
export default function AttachmentDisplayPreview() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const nextFixtureId = useRef(0);
return (
{
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 미디어 picker adapter를 전달하세요.
const id = `fixture-added-${nextFixtureId.current++}`;
addEntries([
{
id,
thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`,
status: "success",
},
]);
}}
/>
);
}
```
## Installation
기본 항목 표시만 필요하면 다음 패키지와 기본 snippet을 설치하세요.
- npm: npm install @seed-design/lynx-react @seed-design/lynx-css
- pnpm: pnpm add @seed-design/lynx-react @seed-design/lynx-css
- yarn: yarn add @seed-design/lynx-react @seed-design/lynx-css
- bun: bun add @seed-design/lynx-react @seed-design/lynx-css
### Reorderable
항목 순서를 변경해야 하면 native 가로 long-press gesture를 포함한 별도 snippet을 설치하세요.
- npm: npx @seed-design/cli@latest add ui:attachment-display-field-reorderable
- pnpm: pnpm dlx @seed-design/cli@latest add ui:attachment-display-field-reorderable
- yarn: yarn dlx @seed-design/cli@latest add ui:attachment-display-field-reorderable
- bun: bun x @seed-design/cli@latest add ui:attachment-display-field-reorderable
Lynx `AttachmentDisplayField`는 미디어 picker나 업로드 API를 직접 호출하지 않습니다. trigger 탭에서 `onTriggerTap`을 실행하고, 호스트 앱의 media picker가 반환한 `AttachmentDisplayEntry[]`를 callback의 `addEntries`에 전달하세요. 문서 예제의 `picsum.photos` URL은 고정 demo fixture이며, 실제 앱의 native module 이름이나 bridge API를 가정하지 않습니다.
## Props
### `AttachmentDisplayField`
### `AttachmentDisplay`
### `AttachmentDisplayItem`
## Usage
`AttachmentDisplayField` 안에 `AttachmentDisplay`를 조합합니다. `onTriggerTap`은 호스트 미디어 picker를 호출하고 반환된 entries를 `addEntries`에 전달하는 callback입니다.
```tsx
{
const entries = await hostMediaPicker();
addEntries(entries);
}}
/>
```
`hostMediaPicker`는 앱이 소유한 adapter입니다. 특정 native module 이름을 snippet이나 문서에서 정하지 않으며, 취소한 경우 `[]`를 반환하도록 구현하세요. Display entry는 URL 기반 모델입니다. `id`, `thumbnailUrl`, `status`를 사용하며 `uploading` 상태에서는 선택적으로 `progress`를 전달합니다. `AttachmentDisplay`는 `File`, `Blob`을 다루지 않고 파일 유효성 검증도 수행하지 않습니다.
### Item 직접 구성하기
`AttachmentDisplay`의 children은 context render callback입니다. `entries`의 순서대로 `AttachmentDisplayItem`을 직접 렌더링할 수 있고, `AttachmentDisplayItem`에는 native item root props와 `dragging` variant prop을 전달할 수 있습니다. children을 생략하면 기본 image, uploading progress, retry, remove action 구성이 자동으로 렌더링됩니다.
```tsx
addEntries(await hostMediaPicker())}>
{({ entries }) => entries.map((entry) => )}
```
## Adding Entries
### Trigger
기본 `AttachmentDisplay`는 trigger와 항목 목록을 함께 제공합니다. trigger를 탭하면 `onTriggerTap({ addEntries, updateEntryStatus })`가 호출됩니다. 피커 결과를 `addEntries`에 전달하면 `maxEntries` 상한과 single-mode(`maxEntries={1}`) 치환이 적용됩니다.
```tsx
import "@seed-design/lynx-css/base.css";
import { useRef } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { VStack, useSeedClassName } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
const INITIAL_ENTRY: AttachmentDisplayEntry = {
id: "trigger-1",
thumbnailUrl: "https://picsum.photos/seed/trigger1/200/200",
status: "success",
};
export default function AttachmentDisplayTrigger() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const nextId = useRef(0);
return (
{
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 media picker 결과를 전달하세요.
const id = `trigger-added-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
/>
);
}
```
### Listening to Entry Changes
`entries`와 `onEntriesChange`로 목록을 controlled 방식으로 관리할 수 있습니다. trigger로 추가하거나 삭제 action을 탭한 결과 모두 `onEntriesChange`로 전달됩니다. `value-changes` 예제는 added/removed 값을 누적하여 보여줍니다.
```tsx
import "@seed-design/lynx-css/base.css";
import { useRef, useState } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { Text, VStack, useSeedClassName } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
export default function AttachmentDisplayValueChanges() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const [entries, setEntries] = useState([]);
const entriesRef = useRef(entries);
const [logs, setLogs] = useState([]);
const nextId = useRef(0);
entriesRef.current = entries;
const handleEntriesChange = (next: AttachmentDisplayEntry[]) => {
const previous = entriesRef.current;
const added = next.filter((entry) => !previous.some((oldEntry) => oldEntry.id === entry.id));
const removed = previous.filter((entry) => !next.some((newEntry) => newEntry.id === entry.id));
setLogs((current) => [
...current,
...(added.length > 0 ? [`added: ${added.map((entry) => entry.id).join(", ")}`] : []),
...(removed.length > 0 ? [`removed: ${removed.map((entry) => entry.id).join(", ")}`] : []),
]);
entriesRef.current = next;
setEntries(next);
};
return (
{logs.length === 0 ? (
아이템을 추가하거나 삭제하면 로그가 표시됩니다.
) : null}
{logs.map((log, index) => (
{log}
))}
{
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker adapter가 반환한 entries를 전달하세요.
const id = `value-change-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
/>
);
}
```
## Managing Item Status
항목의 status는 `pending`, `uploading`, `success`, `error` 중 하나입니다. 호스트 앱의 업로드 작업은 `updateEntryStatus`로 진행률과 최종 상태를 갱신하세요.
- `uploading`: `ProgressCircle`이 표시되며 `progress`가 있으면 해당 값(0–100)을 표시합니다.
- `error`: `onRetry`가 제공된 경우 재시도 action이 표시됩니다. 재시도 callback에서 같은 id를 `uploading`으로 되돌린 뒤 업로드를 다시 시작하세요.
- `success`: 완료된 thumbnail을 표시합니다.
예제는 `uploading(0 → 25 → 60) → success` 전이와 error 항목의 retry 전이를 모두 보여줍니다. 이 전이는 demo fixture를 위한 동기 callback이며 실제 앱에서는 호스트 업로드 결과로 갱신하세요.
```tsx
import "@seed-design/lynx-css/base.css";
import { useRef } from "@lynx-js/react";
import type {
AttachmentDisplayEntry,
AttachmentDisplayStatusDetails,
} from "@seed-design/lynx-react";
import { VStack, useSeedClassName } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
const INITIAL_ENTRIES: AttachmentDisplayEntry[] = [
{
id: "upload-1",
thumbnailUrl: "https://picsum.photos/seed/upload1/200/200",
status: "uploading",
progress: 30,
},
{ id: "upload-2", thumbnailUrl: "https://picsum.photos/seed/upload2/200/200", status: "success" },
{ id: "upload-3", thumbnailUrl: "https://picsum.photos/seed/upload3/200/200", status: "error" },
];
function runFixtureUpload(
id: string,
updateEntryStatus: (id: string, details: AttachmentDisplayStatusDetails) => void,
) {
"background only";
updateEntryStatus(id, { status: "uploading", progress: 0 });
setTimeout(() => updateEntryStatus(id, { status: "uploading", progress: 25 }), 250);
setTimeout(() => updateEntryStatus(id, { status: "uploading", progress: 60 }), 500);
setTimeout(() => updateEntryStatus(id, { status: "success" }), 750);
}
export default function AttachmentDisplayStatus() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const nextId = useRef(0);
return (
{
// 문서 고정 fixture입니다. 실제 앱에서는 host upload operation을 시작하세요.
const id = `upload-added-${nextId.current++}`;
addEntries([
{
id,
thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`,
status: "uploading",
},
]);
runFixtureUpload(id, updateEntryStatus);
}}
onRetry={(entry, { updateEntryStatus }) =>
runFixtureUpload(entry.id, updateEntryStatus)
}
/>
);
}
```
## Reordering Entries
React의 `dnd-kit` 의존성은 Lynx에서 사용하지 않습니다. `AttachmentDisplayReorderable`은 별도 snippet에서 native 가로 long-press gesture를 사용하며, gesture가 끝나면 `reorderEntry(fromIndex, toIndex)`를 호출하여 목록 순서를 바꿉니다. `disabled`와 `readOnly`에서는 정렬 gesture가 차단됩니다.
별도 snippet의 `SortableAttachmentDisplayItem`은 `index`를 필수로 받습니다. 기본 항목 map도 명시적인 `SortableAttachmentDisplayItem`을 사용하며, custom children callback을 사용할 때도 각 항목을 해당 컴포넌트로 직접 구성하세요. callback 결과를 `Children.toArray`로 다시 해석하거나 index로 clone하지 않습니다.
```tsx
import "@seed-design/lynx-css/base.css";
import IconXmarkFill from "@karrotmarket/lynx-monochrome-icon/IconXmarkFill";
import { useRef, useState } from "@lynx-js/react";
import {
AttachmentDisplay as SeedAttachmentDisplay,
Icon,
VStack,
useSeedClassName,
} from "@seed-design/lynx-react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { AttachmentDisplayField } from "@/components/ui/attachment-display-field";
import {
AttachmentDisplayReorderable,
SortableAttachmentDisplayItem,
} from "@/components/ui/attachment-display-field-reorderable";
const INITIAL_ENTRIES: AttachmentDisplayEntry[] = [
{
id: "reorder-1",
thumbnailUrl: "https://picsum.photos/seed/reorder1/200/200",
status: "success",
},
{
id: "reorder-2",
thumbnailUrl: "https://picsum.photos/seed/reorder2/200/200",
status: "success",
},
{
id: "reorder-3",
thumbnailUrl: "https://picsum.photos/seed/reorder3/200/200",
status: "success",
},
];
export default function AttachmentDisplayReorderableExample() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const [entries, setEntries] = useState(INITIAL_ENTRIES);
const nextId = useRef(0);
return (
{
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker 결과를 전달하세요.
const id = `reorder-added-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
>
{({ entries: currentEntries }) =>
currentEntries.map((entry, index) => (
{index === 0 ? (
대표사진
) : null}
} />
))
}
);
}
```
## Examples
### Disabled
`disabled`는 trigger 추가와 정렬 gesture를 막지만, 기존 항목의 remove action은 허용합니다.
```tsx
import "@seed-design/lynx-css/base.css";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { VStack, useSeedClassName } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
const INITIAL_ENTRIES: AttachmentDisplayEntry[] = [
{
id: "disabled-1",
thumbnailUrl: "https://picsum.photos/seed/disabled1/200/200",
status: "success",
},
];
export default function AttachmentDisplayDisabled() {
const seedClassName = useSeedClassName({ colorMode: "system" });
return (
{}} />
);
}
```
### Read Only
`readOnly`는 trigger, remove, 정렬 gesture를 모두 막습니다. 외부에서 `entries`를 갱신하거나 초기 목록을 hydrate하는 것은 허용됩니다.
```tsx
import "@seed-design/lynx-css/base.css";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { VStack, useSeedClassName } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
const INITIAL_ENTRIES: AttachmentDisplayEntry[] = [
{
id: "readonly-1",
thumbnailUrl: "https://picsum.photos/seed/readonly1/200/200",
status: "success",
},
{
id: "readonly-2",
thumbnailUrl: "https://picsum.photos/seed/readonly2/200/200",
status: "success",
},
];
export default function AttachmentDisplayReadOnly() {
const seedClassName = useSeedClassName({ colorMode: "system" });
return (
{}} />
);
}
```
### Controlled
`entries`와 `onEntriesChange`를 사용하여 외부에서 아이템 목록을 제어할 수 있습니다. 외부 reset은 `setEntries([])`처럼 앱 state를 갱신하여 수행합니다.
```tsx
import "@seed-design/lynx-css/base.css";
import { useRef, useState } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { ActionButton, HStack, Text, VStack, useSeedClassName } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
export default function AttachmentDisplayControlled() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const [entries, setEntries] = useState([]);
const nextId = useRef(0);
return (
{
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker 결과를 전달하세요.
const id = `controlled-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
/>
현재 아이템: {entries.length}개
setEntries([])}>전체 삭제
);
}
```
### Custom Inset
실제 horizontal scroll-view를 감싸는 layout에서 `--seed-attachment-input-extend-x` CSS 변수를 사용하면 목록을 global gutter 바깥으로 확장할 수 있습니다. 예제는 400px 회색 외곽과 안쪽 TextField/AttachmentDisplay를 포함합니다.
```tsx
import "@seed-design/lynx-css/base.css";
import * as React from "@lynx-js/react";
import { useRef } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { useSeedClassName, VStack } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
import { TextField, TextFieldInput } from "@/components/ui/text-field";
type AttachmentDisplayFieldStyle = NonNullable<
React.ComponentProps["style"]
> & {
"--seed-attachment-input-extend-x": string;
};
const INSET_STYLE: AttachmentDisplayFieldStyle = {
"--seed-attachment-input-extend-x": "var(--seed-dimension-spacing-x-global-gutter)",
};
const INITIAL_ENTRIES: AttachmentDisplayEntry[] = Array.from({ length: 8 }, (_, index) => ({
id: `inset-${index + 1}`,
thumbnailUrl: `https://picsum.photos/seed/inset${index + 1}/200/200`,
status: "success",
}));
export default function AttachmentDisplayCustomInset() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const nextId = useRef(0);
return (
{
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker 결과를 전달하세요.
const id = `inset-added-${nextId.current++}`;
addEntries([
{
id,
thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`,
status: "success",
},
]);
}}
/>
);
}
```
### Field Integration
`label`, `indicator`, `description`, `errorMessage`, `showRequiredIndicator`를 Field 슬롯과 함께 사용할 수 있습니다. 예제는 목록이 비었을 때 `invalid`와 error footer를 표시합니다.
```tsx
import "@seed-design/lynx-css/base.css";
import { useRef, useState } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { VStack, useSeedClassName } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
const INITIAL_ENTRY: AttachmentDisplayEntry = {
id: "field-1",
thumbnailUrl: "https://picsum.photos/seed/field1/200/200",
status: "success",
};
export default function AttachmentDisplayFieldExample() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const [entries, setEntries] = useState([INITIAL_ENTRY]);
const nextId = useRef(0);
const invalid = entries.length < 1;
return (
{
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker 결과를 전달하세요.
const id = `field-added-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
/>
);
}
```
### Customizing Items
기본 item 구성이 아닌 경우 `SeedAttachmentDisplay.Item` compound slots를 사용하여 badge, progress, retry, remove action을 직접 구성할 수 있습니다. 예제의 첫 번째 fixture에는 `대표사진` 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 { useRef, useState } from "@lynx-js/react";
import {
AttachmentDisplay as SeedAttachmentDisplay,
Icon,
VStack,
useSeedClassName,
} from "@seed-design/lynx-react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
import { ProgressCircle } from "@/components/ui/progress-circle";
const INITIAL_ENTRIES: AttachmentDisplayEntry[] = [
{
id: "customizing-1",
thumbnailUrl: "https://picsum.photos/seed/customizing1/200/200",
status: "success",
},
{
id: "customizing-2",
thumbnailUrl: "https://picsum.photos/seed/customizing2/200/200",
status: "success",
},
{
id: "customizing-3",
thumbnailUrl: "https://picsum.photos/seed/customizing3/200/200",
status: "success",
},
];
export default function AttachmentDisplayCustomizingItems() {
const seedClassName = useSeedClassName({ colorMode: "system" });
const [entries, setEntries] = useState(INITIAL_ENTRIES);
const nextId = useRef(0);
return (
{
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker 결과를 전달하세요.
const id = `customizing-added-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
>
{({ entries: currentEntries }) =>
currentEntries.map((entry, index) => (
))
}
);
}
function CustomImageItem({ entry, isCover }: { entry: AttachmentDisplayEntry; isCover: boolean }) {
return (
{isCover ? (
대표사진
) : null}
{(item) => (
)}
{}}>
} />
재시도
} />
);
}
```
## State and accessibility
`label`, `description`, `errorMessage`, `indicator`, `showRequiredIndicator`는 Field 슬롯으로 렌더링됩니다. 기본 trigger와 remove action에는 접근성 label이 제공되며, custom action을 구성할 때는 의미를 설명하는 `accessibility-label`을 지정하세요.
## Lynx 미지원 기능
- 브라우저 drag-and-drop 및 React `dnd-kit`: native long-press reorder snippet으로 대체하세요.
- HTML ``, `File`, `Blob`, object URL: 호스트 media picker가 URL 기반 `AttachmentDisplayEntry`를 반환하도록 연결하세요.
- HTML form 제출 및 `react-hook-form`: 앱 state와 submit/controller adapter로 대체하세요.