# 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를 사용합니다.