# 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 type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { useRef } from "@lynx-js/react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
import { VStack } 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 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
항목 순서를 변경하려면 네이티브 가로 long-press gesture를 포함한 별도 snippet을 설치합니다. 프로젝트에 `@seed-design/lynx-react-sortable` 의존성이 추가됩니다.
- 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를 호출하지 않습니다. 호스트 picker가 반환한 `AttachmentDisplayEntry[]`를 `onTriggerTap` callback의 `addEntries`에 전달해야 합니다. 문서의 `picsum.photos` URL은 demo fixture이며 실제 앱의 native bridge API를 가정하지 않습니다.
## Props
### `AttachmentDisplayField`
### `AttachmentDisplay`
### `AttachmentDisplayItem`
## Usage
`onTriggerTap`에서 호스트 미디어 picker를 호출하고 반환된 entries를 `addEntries`에 전달합니다.
```tsx
{
const entries = await hostMediaPicker();
addEntries(entries);
}}
/>
```
`hostMediaPicker`는 앱이 소유한 adapter이며, 취소하면 `[]`를 반환해야 합니다. Display entry는 URL 기반 모델로 `id`, `thumbnailUrl`, `status`를 사용하고 `uploading` 상태에서는 `progress`를 전달할 수 있습니다. `AttachmentDisplay`는 `File`, `Blob`을 다루지 않으며 파일 유효성도 검증하지 않습니다.
## Adding Entries
### Trigger
기본 `AttachmentDisplay`의 trigger를 탭하면 `onTriggerTap({ addEntries, updateEntryStatus })`가 호출됩니다. picker 결과를 `addEntries`에 전달하면 `maxEntries` 상한과 single mode(`maxEntries={1}`) 치환이 적용됩니다.
```tsx
import { useRef } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { VStack } 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 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`로 목록을 외부에서 관리할 수 있습니다. 추가와 삭제 결과는 모두 `onEntriesChange`로 전달됩니다.
```tsx
import { useRef, useState } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { Text, VStack } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
export default function AttachmentDisplayValueChanges() {
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이 표시됩니다. 재시도할 때 같은 id를 `uploading`으로 바꾸고 업로드를 다시 시작합니다.
- `success`: 완료된 thumbnail을 표시합니다.
예제의 상태 전이는 demo fixture입니다. 실제 앱에서는 호스트 업로드 결과로 상태를 갱신합니다.
```tsx
import { useRef } from "@lynx-js/react";
import type {
AttachmentDisplayEntry,
AttachmentDisplayStatusDetails,
} from "@seed-design/lynx-react";
import { VStack } 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 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
`ui:attachment-display-field-reorderable`은 브라우저 `dnd-kit` 대신 네이티브 가로 long-press gesture로 목록 순서를 변경합니다. `disabled`와 `readOnly`에서는 정렬할 수 없습니다.
사용자 지정 항목을 렌더링할 때는 `SortableAttachmentDisplayItem`에 현재 `index`를 전달해야 합니다. 스크린 리더는 항목 이름, 순서, 업로드 상태를 읽고 앞·뒤 이동과 파일 제거 동작을 제공합니다. error 상태에 `onRetry`가 있으면 재시도 동작도 제공합니다. iOS에서는 항목 내부 버튼 대신 이 사용자 지정 접근성 동작을 사용합니다. `readOnly`에서는 이동과 제거가, `disabled`에서는 이동만 비활성화됩니다.
```tsx
import { useRef, useState } from "@lynx-js/react";
import { VStack } from "@seed-design/lynx-react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { AttachmentDisplayField } from "@/components/ui/attachment-display-field";
import { AttachmentDisplayReorderable } 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 [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" },
]);
}}
/>
);
}
```
## Examples
### Disabled
`disabled`는 trigger 추가와 정렬 gesture를 막지만, 기존 항목의 remove action은 허용합니다.
```tsx
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { VStack } 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() {
return (
{}} />
);
}
```
### Read Only
`readOnly`는 trigger, remove, 정렬 gesture를 모두 막습니다. 외부에서 `entries`를 갱신하거나 초기 목록을 hydrate하는 것은 허용됩니다.
```tsx
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { VStack } 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() {
return (
{}} />
);
}
```
### Controlled
`entries`와 `onEntriesChange`를 사용하여 외부에서 아이템 목록을 제어할 수 있습니다. 외부 reset은 `setEntries([])`처럼 앱 state를 갱신하여 수행합니다.
```tsx
import { useRef, useState } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { ActionButton, HStack, Text, VStack } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
} from "@/components/ui/attachment-display-field";
export default function AttachmentDisplayControlled() {
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
`--seed-attachment-input-extend-x` CSS 변수를 사용하면 가로 목록을 global gutter 바깥으로 확장할 수 있습니다.
```tsx
import * as React from "@lynx-js/react";
import { useRef } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { 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 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
`Field`와 함께 안내 문구와 오류 상태를 표시할 수 있습니다. 예제는 목록이 비었을 때 `invalid`와 error footer를 표시합니다.
```tsx
import { useRef, useState } from "@lynx-js/react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import { VStack } 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 [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
기본 항목 구성을 바꾸는 방법은 다음 예제에서 확인할 수 있습니다. 첫 번째 항목에는 대표 사진 badge가 표시됩니다.
```tsx
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 } from "@seed-design/lynx-react";
import type { AttachmentDisplayEntry } from "@seed-design/lynx-react";
import {
AttachmentDisplay,
AttachmentDisplayField,
AttachmentDisplayItem,
} 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 [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
기본 trigger와 remove action에는 접근성 label이 제공됩니다. 사용자 지정 action에는 의미를 설명하는 `accessibility-label`을 지정해야 합니다.
## Lynx 미지원 기능
- 브라우저 drag-and-drop 및 React `dnd-kit`은 네이티브 long-press reorder snippet으로 대체합니다.
- HTML ``, `File`, `Blob`, object URL 대신 호스트 media picker가 URL 기반 `AttachmentDisplayEntry`를 반환해야 합니다.
- HTML form 제출 및 `react-hook-form` 대신 앱 state와 submit adapter를 사용합니다.