Attachment Display Field
외부 소스에서 제공된 미디어를 표시하고 호스트 동작으로 관리하는 컴포넌트입니다.
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 (
<view className={seedClassName}>
<VStack gap="x4" p="x6" width="100%">
<AttachmentDisplayField defaultEntries={FIXTURE_ENTRIES} maxEntries={5}>
<AttachmentDisplay
onTriggerTap={({ addEntries }) => {
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 미디어 picker adapter를 전달하세요.
const id = `fixture-added-${nextFixtureId.current++}`;
addEntries([
{
id,
thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`,
status: "success",
},
]);
}}
/>
</AttachmentDisplayField>
</VStack>
</view>
);
}Installation
기본 항목 표시만 필요하면 다음 패키지와 기본 snippet을 설치하세요.
npm install @seed-design/lynx-react @seed-design/lynx-csspnpm add @seed-design/lynx-react @seed-design/lynx-cssyarn add @seed-design/lynx-react @seed-design/lynx-cssbun add @seed-design/lynx-react @seed-design/lynx-css의존성 설치
npm install @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactyarn add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactpnpm add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactbun add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react아래 코드를 복사 후 붙여넣고 사용하세요
/**
* @file ui:attachment-display-field
* @requires @seed-design/lynx-react@>=0.8.0 <1.0.0
* @requires @seed-design/lynx-css@>=0.12.0 <1.0.0
* @requires @karrotmarket/lynx-monochrome-icon@>=1.20.0 <2.0.0
**/
import * as React from "@lynx-js/react";
import IconArrowClockwiseCircularFill from "@karrotmarket/lynx-monochrome-icon/IconArrowClockwiseCircularFill";
import IconCameraFill from "@karrotmarket/lynx-monochrome-icon/IconCameraFill";
import IconExclamationmarkCircleFill from "@karrotmarket/lynx-monochrome-icon/IconExclamationmarkCircleFill";
import IconXmarkFill from "@karrotmarket/lynx-monochrome-icon/IconXmarkFill";
import {
AttachmentDisplay as SeedAttachmentDisplay,
Field as SeedField,
Icon,
PrefixIcon,
HStack,
} from "@seed-design/lynx-react";
import { ProgressCircle } from "./progress-circle";
import type {
AttachmentDisplayContextProps,
AttachmentDisplayEntry,
AttachmentDisplayRootProps,
AttachmentDisplayStatusDetails,
} from "@seed-design/lynx-react";
const LABEL_SELECT_FILE = "파일 선택";
const LABEL_RETRY = "재시도";
const LABEL_REMOVE = "파일 제거";
type FieldRootRef = React.ComponentRef<typeof SeedField.Root>;
type DisplayEntry = AttachmentDisplayEntry;
type DisplayItemRootProps = React.ComponentProps<typeof SeedAttachmentDisplay.Item>;
type DisplayItemRef = React.ComponentRef<typeof SeedAttachmentDisplay.Item>;
type DisplayRetryHelpers = {
updateEntryStatus: (id: string, details: AttachmentDisplayStatusDetails) => void;
};
type DisplayContext = Parameters<AttachmentDisplayContextProps["children"]>[0];
type DisplayTriggerHelpers = Pick<DisplayContext, "addEntries" | "updateEntryStatus">;
export type AttachmentDisplayProps = {
onTriggerTap: (helpers: DisplayTriggerHelpers) => void;
} & (
| { children: AttachmentDisplayContextProps["children"]; onRetry?: never }
| {
children?: undefined;
onRetry?: (entry: DisplayEntry, helpers: DisplayRetryHelpers) => void;
}
);
export interface AttachmentDisplayFieldProps extends Omit<AttachmentDisplayRootProps, "children"> {
children?: React.ReactNode;
label?: React.ReactNode;
labelWeight?: SeedField.LabelProps["weight"];
indicator?: React.ReactNode;
description?: React.ReactNode;
errorMessage?: React.ReactNode;
showRequiredIndicator?: boolean;
}
/**
* @see https://seed-design.io/lynx/components/attachment-display-field
*/
export const AttachmentDisplayField = React.forwardRef<FieldRootRef, AttachmentDisplayFieldProps>(
(
{
label,
labelWeight,
indicator,
description,
errorMessage,
showRequiredIndicator,
children,
disabled,
required,
invalid,
readOnly,
...props
},
ref,
) => {
const renderHeader = label != null || indicator != null;
const renderErrorMessage = errorMessage != null && invalid;
const renderDescription = description != null && !renderErrorMessage;
const renderFooter = renderDescription || renderErrorMessage;
return (
<SeedField.Root
ref={ref}
disabled={disabled}
required={required}
invalid={invalid}
readOnly={readOnly}
>
{renderHeader ? (
<SeedField.Header>
<SeedField.Label weight={labelWeight}>
{label}
{showRequiredIndicator ? <SeedField.RequiredIndicator /> : null}
{indicator != null ? (
<SeedField.IndicatorText>{indicator}</SeedField.IndicatorText>
) : null}
</SeedField.Label>
</SeedField.Header>
) : null}
<SeedAttachmentDisplay.Root
{...props}
disabled={disabled}
invalid={invalid}
readOnly={readOnly}
>
<SeedAttachmentDisplay.Control>{children}</SeedAttachmentDisplay.Control>
</SeedAttachmentDisplay.Root>
{renderFooter ? (
<SeedField.Footer>
{renderDescription ? (
<SeedField.Description>{description}</SeedField.Description>
) : null}
{renderErrorMessage ? (
<HStack gap="x1_5" align="flex-start" width="100%">
<HStack height="var(--seed-line-height-t4)" align="center" shrink={0}>
<PrefixIcon
icon={<IconExclamationmarkCircleFill />}
size="x4"
color="fg.critical"
/>
</HStack>
<SeedField.ErrorMessage style={{ flexShrink: 1 }}>
{errorMessage}
</SeedField.ErrorMessage>
</HStack>
) : null}
</SeedField.Footer>
) : null}
</SeedField.Root>
);
},
);
AttachmentDisplayField.displayName = "AttachmentDisplayField";
/**
* @see https://seed-design.io/lynx/components/attachment-display-field
*/
export const AttachmentDisplay = React.forwardRef<
React.ComponentRef<typeof SeedAttachmentDisplay.Container>,
AttachmentDisplayProps
>(({ onTriggerTap, children, onRetry }, ref) => {
return (
<SeedAttachmentDisplay.Context>
{({ addEntries, updateEntryStatus }) => (
<SeedAttachmentDisplay.Container ref={ref}>
<SeedAttachmentDisplay.Trigger
bindtap={() => {
"background only";
onTriggerTap({ addEntries, updateEntryStatus });
}}
accessibility-label={LABEL_SELECT_FILE}
>
<SeedAttachmentDisplay.TriggerIcon image={<IconCameraFill />} />
<SeedAttachmentDisplay.TriggerItemCount />
</SeedAttachmentDisplay.Trigger>
<SeedAttachmentDisplay.ItemGroup>
<SeedAttachmentDisplay.Context>
{typeof children === "function"
? children
: ({ entries, updateEntryStatus: updateStatus }) =>
entries.map((entry) => (
<AttachmentDisplayItem
key={entry.id}
entry={entry}
{...(onRetry
? { onRetry: () => onRetry(entry, { updateEntryStatus: updateStatus }) }
: {})}
/>
))}
</SeedAttachmentDisplay.Context>
</SeedAttachmentDisplay.ItemGroup>
</SeedAttachmentDisplay.Container>
)}
</SeedAttachmentDisplay.Context>
);
});
AttachmentDisplay.displayName = "AttachmentDisplay";
export interface AttachmentDisplayItemProps extends Omit<DisplayItemRootProps, "entry"> {
entry: DisplayEntry;
onRetry?: () => void;
}
/**
* @see https://seed-design.io/lynx/components/attachment-display-field
*/
export const AttachmentDisplayItem = React.forwardRef<DisplayItemRef, AttachmentDisplayItemProps>(
({ entry, onRetry, children, ...itemProps }, ref) => {
return (
<SeedAttachmentDisplay.Item ref={ref} {...itemProps} entry={entry}>
{children ?? (
<>
<SeedAttachmentDisplay.ItemSurface>
<SeedAttachmentDisplay.ItemImage />
<SeedAttachmentDisplay.ItemBackdrop status="uploading">
{(item) => (
<ProgressCircle
size="24"
tone="staticWhite"
{...("progress" in item ? { value: item.progress } : {})}
/>
)}
</SeedAttachmentDisplay.ItemBackdrop>
{onRetry ? (
<SeedAttachmentDisplay.ItemBackdrop status="error">
<SeedAttachmentDisplay.ItemActionButton bindtap={onRetry}>
<Icon icon={<IconArrowClockwiseCircularFill />} />
{LABEL_RETRY}
</SeedAttachmentDisplay.ItemActionButton>
</SeedAttachmentDisplay.ItemBackdrop>
) : null}
</SeedAttachmentDisplay.ItemSurface>
<SeedAttachmentDisplay.ItemRemoveButton accessibility-label={LABEL_REMOVE}>
<Icon icon={<IconXmarkFill />} />
</SeedAttachmentDisplay.ItemRemoveButton>
</>
)}
</SeedAttachmentDisplay.Item>
);
},
);
AttachmentDisplayItem.displayName = "AttachmentDisplayItem";
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
Reorderable
항목 순서를 변경해야 하면 native 가로 long-press gesture를 포함한 별도 snippet을 설치하세요.
npx @seed-design/cli@latest add ui:attachment-display-field-reorderablepnpm dlx @seed-design/cli@latest add ui:attachment-display-field-reorderableyarn dlx @seed-design/cli@latest add ui:attachment-display-field-reorderablebun x @seed-design/cli@latest add ui:attachment-display-field-reorderable의존성 설치
npm install @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactyarn add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactpnpm add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-reactbun add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react아래 코드를 복사 후 붙여넣고 사용하세요
/**
* @file ui:attachment-display-field-reorderable
* @requires @seed-design/lynx-react@>=0.8.0 <1.0.0
* @requires @seed-design/lynx-css@>=0.12.0 <1.0.0
* @requires @karrotmarket/lynx-monochrome-icon@>=1.20.0 <2.0.0
**/
import * as React from "@lynx-js/react";
import IconCameraFill from "@karrotmarket/lynx-monochrome-icon/IconCameraFill";
import { AttachmentDisplay as SeedAttachmentDisplay } from "@seed-design/lynx-react";
import {
AttachmentDisplayItem,
type AttachmentDisplayItemProps,
type AttachmentDisplayProps,
} from "./attachment-display-field";
import { HorizontalReorderItem, HorizontalReorderList } from "../lib/attachment-sortable";
const LABEL_SELECT_FILE = "파일 선택";
let nextReorderInstanceId = 0;
type ReorderableContext = Parameters<NonNullable<AttachmentDisplayProps["children"]>>[0];
export type AttachmentDisplayReorderableProps = {
onTriggerTap: AttachmentDisplayProps["onTriggerTap"];
id?: string;
scrollEdgeOffset?: number;
onDragStateChange?: (dragging: boolean) => void;
} & (
| { children: NonNullable<AttachmentDisplayProps["children"]>; onRetry?: never }
| { children?: undefined; onRetry?: AttachmentDisplayProps["onRetry"] }
);
/**
* Horizontal native long-press reorder for AttachmentDisplay.
*
* The gesture only emits a reorder intent; AttachmentDisplay remains the
* source of truth for entries and applies disabled/readOnly guards.
*
* @see https://seed-design.io/lynx/components/attachment-display-field
*/
export const AttachmentDisplayReorderable = React.forwardRef<
React.ComponentRef<typeof SeedAttachmentDisplay.Container>,
AttachmentDisplayReorderableProps
>(({ onTriggerTap, children, onRetry, id, scrollEdgeOffset, onDragStateChange }, ref) => {
const [instanceId] = React.useState(() => nextReorderInstanceId++);
const boundaryId = id ? `${id}-container` : `attachment-display-reorder-container-${instanceId}`;
const reorderId = id ? `${id}-list` : `attachment-display-reorder-list-${instanceId}`;
return (
<SeedAttachmentDisplay.Context>
{(context: ReorderableContext) => (
<HorizontalReorderList
items={context.entries}
getItemKey={(entry) => entry.id}
disabled={context.disabled}
readOnly={context.readOnly}
id={reorderId}
scrollableBoundaryId={boundaryId}
scrollEdgeOffset={scrollEdgeOffset}
onReorder={context.reorderEntry}
onDragStateChange={onDragStateChange}
>
{({ onScroll, dragging }) => (
<SeedAttachmentDisplay.Container
ref={ref}
id={boundaryId}
main-thread:bindscroll={onScroll}
enable-scroll={!dragging}
>
<SeedAttachmentDisplay.Trigger
bindtap={() => {
"background only";
onTriggerTap({
addEntries: context.addEntries,
updateEntryStatus: context.updateEntryStatus,
});
}}
accessibility-label={LABEL_SELECT_FILE}
>
<SeedAttachmentDisplay.TriggerIcon image={<IconCameraFill />} />
<SeedAttachmentDisplay.TriggerItemCount />
</SeedAttachmentDisplay.Trigger>
<SeedAttachmentDisplay.ItemGroup>
{typeof children === "function"
? children(context)
: context.entries.map((entry, index) => (
<SortableAttachmentDisplayItem
key={entry.id}
entry={entry}
index={index}
{...(onRetry
? {
onRetry: () =>
onRetry(entry, { updateEntryStatus: context.updateEntryStatus }),
}
: {})}
/>
))}
</SeedAttachmentDisplay.ItemGroup>
</SeedAttachmentDisplay.Container>
)}
</HorizontalReorderList>
)}
</SeedAttachmentDisplay.Context>
);
});
AttachmentDisplayReorderable.displayName = "AttachmentDisplayReorderable";
export type SortableAttachmentDisplayItemProps = AttachmentDisplayItemProps & {
index: number;
};
export const SortableAttachmentDisplayItem = React.forwardRef<
React.ComponentRef<typeof AttachmentDisplayItem>,
SortableAttachmentDisplayItemProps
>(({ index, entry, ...props }, ref) => (
<HorizontalReorderItem itemId={entry.id} index={index}>
{(dragging) => <AttachmentDisplayItem ref={ref} entry={entry} {...props} dragging={dragging} />}
</HorizontalReorderItem>
));
SortableAttachmentDisplayItem.displayName = "SortableAttachmentDisplayItem";
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
Lynx AttachmentDisplayField는 미디어 picker나 업로드 API를 직접 호출하지 않습니다. trigger 탭에서 onTriggerTap을 실행하고, 호스트 앱의 media picker가 반환한 AttachmentDisplayEntry[]를 callback의 addEntries에 전달하세요. 문서 예제의 picsum.photos URL은 고정 demo fixture이며, 실제 앱의 native module 이름이나 bridge API를 가정하지 않습니다.
Props
AttachmentDisplayField
Prop
Type
children?React.ReactNodelabel?React.ReactNodelabelWeight?"medium" | "bold" | undefinedindicator?React.ReactNodedescription?React.ReactNodeerrorMessage?React.ReactNodeshowRequiredIndicator?boolean | undefinedentries?AttachmentDisplayEntry[] | undefineddefaultEntries?AttachmentDisplayEntry[] | undefinedonEntriesChange?((entries: AttachmentDisplayEntry[]) => void) | undefineddisabled?boolean | undefinedinvalid?boolean | undefinedreadOnly?boolean | undefinedrequired?boolean | undefinedmaxEntries?number | undefinedonTriggerTap?((helpers: { addEntries: (entries: AttachmentDisplayEntry[]) => void; updateEntryStatus: (id: string, details: AttachmentDisplayStatusDetails) => void; }) => void) | undefinedstyle?CSSProperties | undefinedclassName?string | undefinedAttachmentDisplay
Prop
Type
onTriggerTap(helpers: DisplayTriggerHelpers) => voidchildren?((context: AttachmentDisplayContextValue) => React.ReactNode) | undefinedonRetry?((entry: DisplayEntry, helpers: DisplayRetryHelpers) => void) | undefinedAttachmentDisplayItem
Prop
Type
entryAttachmentDisplayEntryonRetry?(() => void) | undefinedchildren?React.ReactNodestyle?CSSProperties | undefinedclassName?string | undefinedUsage
AttachmentDisplayField 안에 AttachmentDisplay를 조합합니다. onTriggerTap은 호스트 미디어 picker를 호출하고 반환된 entries를 addEntries에 전달하는 callback입니다.
<AttachmentDisplayField defaultEntries={[]} maxEntries={10}>
<AttachmentDisplay
onTriggerTap={async ({ addEntries }) => {
const entries = await hostMediaPicker();
addEntries(entries);
}}
/>
</AttachmentDisplayField>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 구성이 자동으로 렌더링됩니다.
<AttachmentDisplay onTriggerTap={async ({ addEntries }) => addEntries(await hostMediaPicker())}>
{({ entries }) => entries.map((entry) => <AttachmentDisplayItem key={entry.id} entry={entry} />)}
</AttachmentDisplay>Adding Entries
Trigger
기본 AttachmentDisplay는 trigger와 항목 목록을 함께 제공합니다. trigger를 탭하면 onTriggerTap({ addEntries, updateEntryStatus })가 호출됩니다. 피커 결과를 addEntries에 전달하면 maxEntries 상한과 single-mode(maxEntries={1}) 치환이 적용됩니다.
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 (
<view className={seedClassName}>
<VStack gap="x4" p="x6" width="100%">
<AttachmentDisplayField defaultEntries={[INITIAL_ENTRY]} maxEntries={3}>
<AttachmentDisplay
onTriggerTap={({ addEntries }) => {
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 media picker 결과를 전달하세요.
const id = `trigger-added-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
/>
</AttachmentDisplayField>
</VStack>
</view>
);
}Listening to Entry Changes
entries와 onEntriesChange로 목록을 controlled 방식으로 관리할 수 있습니다. trigger로 추가하거나 삭제 action을 탭한 결과 모두 onEntriesChange로 전달됩니다. value-changes 예제는 added/removed 값을 누적하여 보여줍니다.
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<AttachmentDisplayEntry[]>([]);
const entriesRef = useRef(entries);
const [logs, setLogs] = useState<string[]>([]);
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 (
<view className={seedClassName}>
<VStack gap="x4" width="100%" align="center">
<VStack gap="x1">
{logs.length === 0 ? (
<Text color="fg.neutralMuted">아이템을 추가하거나 삭제하면 로그가 표시됩니다.</Text>
) : null}
{logs.map((log, index) => (
<Text key={`${log}-${index}`}>{log}</Text>
))}
</VStack>
<AttachmentDisplayField
entries={entries}
onEntriesChange={handleEntriesChange}
maxEntries={3}
>
<AttachmentDisplay
onTriggerTap={({ addEntries }) => {
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker adapter가 반환한 entries를 전달하세요.
const id = `value-change-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
/>
</AttachmentDisplayField>
</VStack>
</view>
);
}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이며 실제 앱에서는 호스트 업로드 결과로 갱신하세요.
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 (
<view className={seedClassName}>
<VStack gap="x4" p="x6" width="100%">
<AttachmentDisplayField defaultEntries={INITIAL_ENTRIES} maxEntries={5}>
<AttachmentDisplay
onTriggerTap={({ addEntries, updateEntryStatus }) => {
// 문서 고정 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)
}
/>
</AttachmentDisplayField>
</VStack>
</view>
);
}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하지 않습니다.
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 (
<view className={seedClassName}>
<VStack p="x6" width="100%">
<AttachmentDisplayField entries={entries} onEntriesChange={setEntries} maxEntries={5}>
<AttachmentDisplayReorderable
onTriggerTap={({ addEntries }) => {
// 문서 고정 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) => (
<SortableAttachmentDisplayItem key={entry.id} entry={entry} index={index}>
<SeedAttachmentDisplay.ItemSurface>
<SeedAttachmentDisplay.ItemImage />
{index === 0 ? (
<SeedAttachmentDisplay.ItemBadge>대표사진</SeedAttachmentDisplay.ItemBadge>
) : null}
</SeedAttachmentDisplay.ItemSurface>
<SeedAttachmentDisplay.ItemRemoveButton accessibility-label="파일 제거">
<Icon icon={<IconXmarkFill />} />
</SeedAttachmentDisplay.ItemRemoveButton>
</SortableAttachmentDisplayItem>
))
}
</AttachmentDisplayReorderable>
</AttachmentDisplayField>
</VStack>
</view>
);
}Examples
Disabled
disabled는 trigger 추가와 정렬 gesture를 막지만, 기존 항목의 remove action은 허용합니다.
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 (
<view className={seedClassName}>
<VStack gap="x4" p="x6" width="100%">
<AttachmentDisplayField defaultEntries={INITIAL_ENTRIES} maxEntries={5} disabled>
<AttachmentDisplay onTriggerTap={() => {}} />
</AttachmentDisplayField>
</VStack>
</view>
);
}Read Only
readOnly는 trigger, remove, 정렬 gesture를 모두 막습니다. 외부에서 entries를 갱신하거나 초기 목록을 hydrate하는 것은 허용됩니다.
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 (
<view className={seedClassName}>
<VStack gap="x4" p="x6" width="100%">
<AttachmentDisplayField defaultEntries={INITIAL_ENTRIES} maxEntries={5} readOnly>
<AttachmentDisplay onTriggerTap={() => {}} />
</AttachmentDisplayField>
</VStack>
</view>
);
}Controlled
entries와 onEntriesChange를 사용하여 외부에서 아이템 목록을 제어할 수 있습니다. 외부 reset은 setEntries([])처럼 앱 state를 갱신하여 수행합니다.
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<AttachmentDisplayEntry[]>([]);
const nextId = useRef(0);
return (
<view className={seedClassName}>
<VStack gap="x4" p="x6" width="100%">
<AttachmentDisplayField entries={entries} onEntriesChange={setEntries} maxEntries={5}>
<AttachmentDisplay
onTriggerTap={({ addEntries }) => {
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker 결과를 전달하세요.
const id = `controlled-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
/>
</AttachmentDisplayField>
<Text>현재 아이템: {entries.length}개</Text>
<HStack gap="x2">
<ActionButton bindtap={() => setEntries([])}>전체 삭제</ActionButton>
</HStack>
</VStack>
</view>
);
}Custom Inset
실제 horizontal scroll-view를 감싸는 layout에서 --seed-attachment-input-extend-x CSS 변수를 사용하면 목록을 global gutter 바깥으로 확장할 수 있습니다. 예제는 400px 회색 외곽과 안쪽 TextField/AttachmentDisplay를 포함합니다.
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<typeof AttachmentDisplayField>["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 (
<view className={seedClassName}>
<VStack
px="x6"
width="400px"
maxWidth="full"
bg="palette.gray300"
borderWidth={1}
borderColor="stroke.neutralMuted"
>
<VStack gap="spacingY.componentDefault" bg="bg.layerDefault">
<TextField label="이름">
<TextFieldInput placeholder="홍길동" />
</TextField>
<AttachmentDisplayField
defaultEntries={INITIAL_ENTRIES}
maxEntries={10}
style={INSET_STYLE}
>
<AttachmentDisplay
onTriggerTap={({ addEntries }) => {
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker 결과를 전달하세요.
const id = `inset-added-${nextId.current++}`;
addEntries([
{
id,
thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`,
status: "success",
},
]);
}}
/>
</AttachmentDisplayField>
</VStack>
</VStack>
</view>
);
}Field Integration
label, indicator, description, errorMessage, showRequiredIndicator를 Field 슬롯과 함께 사용할 수 있습니다. 예제는 목록이 비었을 때 invalid와 error footer를 표시합니다.
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<AttachmentDisplayEntry[]>([INITIAL_ENTRY]);
const nextId = useRef(0);
const invalid = entries.length < 1;
return (
<view className={seedClassName}>
<VStack gap="x4" p="x6" width="100%">
<AttachmentDisplayField
entries={entries}
onEntriesChange={setEntries}
maxEntries={5}
invalid={invalid}
label="프로필 사진"
description="최대 5장까지 첨부할 수 있어요"
errorMessage="최소 1장은 첨부해야 해요"
required
showRequiredIndicator
>
<AttachmentDisplay
onTriggerTap={({ addEntries }) => {
// 문서 고정 fixture입니다. 실제 앱에서는 호스트 picker 결과를 전달하세요.
const id = `field-added-${nextId.current++}`;
addEntries([
{ id, thumbnailUrl: `https://picsum.photos/seed/${id}/200/200`, status: "success" },
]);
}}
/>
</AttachmentDisplayField>
</VStack>
</view>
);
}Customizing Items
기본 item 구성이 아닌 경우 SeedAttachmentDisplay.Item compound slots를 사용하여 badge, progress, retry, remove action을 직접 구성할 수 있습니다. 예제의 첫 번째 fixture에는 대표사진 badge가 있습니다.
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 (
<view className={seedClassName}>
<VStack gap="x4" p="x6" width="100%">
<AttachmentDisplayField entries={entries} onEntriesChange={setEntries} maxEntries={10}>
<AttachmentDisplay
onTriggerTap={({ addEntries }) => {
// 문서 고정 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) => (
<CustomImageItem key={entry.id} entry={entry} isCover={index === 0} />
))
}
</AttachmentDisplay>
</AttachmentDisplayField>
</VStack>
</view>
);
}
function CustomImageItem({ entry, isCover }: { entry: AttachmentDisplayEntry; isCover: boolean }) {
return (
<SeedAttachmentDisplay.Item entry={entry}>
<SeedAttachmentDisplay.ItemSurface>
<SeedAttachmentDisplay.ItemImage />
{isCover ? (
<SeedAttachmentDisplay.ItemBadge>대표사진</SeedAttachmentDisplay.ItemBadge>
) : null}
<SeedAttachmentDisplay.ItemBackdrop status="uploading">
{(item) => (
<ProgressCircle
size="24"
tone="staticWhite"
{...("progress" in item ? { value: item.progress } : {})}
/>
)}
</SeedAttachmentDisplay.ItemBackdrop>
<SeedAttachmentDisplay.ItemBackdrop status="error">
<SeedAttachmentDisplay.ItemActionButton bindtap={() => {}}>
<Icon icon={<IconArrowClockwiseCircularFill />} />
재시도
</SeedAttachmentDisplay.ItemActionButton>
</SeedAttachmentDisplay.ItemBackdrop>
</SeedAttachmentDisplay.ItemSurface>
<SeedAttachmentDisplay.ItemRemoveButton accessibility-label="파일 제거">
<Icon icon={<IconXmarkFill />} />
</SeedAttachmentDisplay.ItemRemoveButton>
</SeedAttachmentDisplay.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
<input type="file">,File,Blob, object URL: 호스트 media picker가 URL 기반AttachmentDisplayEntry를 반환하도록 연결하세요. - HTML form 제출 및
react-hook-form: 앱 state와 submit/controller adapter로 대체하세요.
Last updated on