Attachment Display Field
외부 소스에서 제공된 미디어를 표시하고 호스트 동작으로 관리하는 컴포넌트입니다.
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 (
<VStack gap="x4" 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>
);
}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@^1.0.0
* @requires @seed-design/lynx-css@^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,
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}>
<Icon 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
항목 순서를 변경하려면 네이티브 가로 long-press gesture를 포함한 별도 snippet을 설치합니다. 프로젝트에 @seed-design/lynx-react-sortable 의존성이 추가됩니다.
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-react @seed-design/lynx-react-sortableyarn add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react @seed-design/lynx-react-sortablepnpm add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react @seed-design/lynx-react-sortablebun add @karrotmarket/lynx-monochrome-icon @seed-design/lynx-react @seed-design/lynx-react-sortable아래 코드를 복사 후 붙여넣고 사용하세요
/**
* @file ui:attachment-display-field-reorderable
* @requires @seed-design/lynx-react@^1.0.0
* @requires @seed-design/lynx-css@^1.0.0
* @requires @karrotmarket/lynx-monochrome-icon@>=1.20.0 <2.0.0
* @requires @seed-design/lynx-react-sortable@^1.0.0
**/
import * as React from "@lynx-js/react";
import IconCameraFill from "@karrotmarket/lynx-monochrome-icon/IconCameraFill";
import {
AttachmentDisplay as SeedAttachmentDisplay,
useAttachmentDisplayContext,
} from "@seed-design/lynx-react";
import { Sortable } from "@seed-design/lynx-react-sortable";
import {
AttachmentDisplayItem,
type AttachmentDisplayItemProps,
type AttachmentDisplayProps,
} from "./attachment-display-field";
const LABEL_SELECT_FILE = "파일 선택";
const LABEL_REMOVE = "파일 제거";
const LABEL_RETRY = "재시도";
const LABEL_ITEM = "이미지";
const MOVE_ACTION_LABELS = { previous: "앞으로 이동", next: "뒤로 이동" };
const STATUS_LABELS: Partial<Record<AttachmentDisplayItemProps["entry"]["status"], string>> = {
uploading: "업로드 중",
error: "업로드 실패",
};
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}`;
const globalProps = React.useGlobalProps() as { motion?: unknown } | undefined;
return (
<SeedAttachmentDisplay.Context>
{(context: ReorderableContext) => (
<Sortable.Root
items={context.entries}
getItemKey={(entry) => entry.id}
disabled={context.disabled}
readOnly={context.readOnly}
id={reorderId}
scrollableBoundaryId={boundaryId}
scrollEdgeOffset={scrollEdgeOffset}
reducedMotion={globalProps?.motion === "reduced"}
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>
)}
</Sortable.Root>
)}
</SeedAttachmentDisplay.Context>
);
});
AttachmentDisplayReorderable.displayName = "AttachmentDisplayReorderable";
export type SortableAttachmentDisplayItemProps = AttachmentDisplayItemProps & {
index: number;
};
/**
* The whole item is one accessibility element. Screen readers move it with the
* "앞으로 이동"·"뒤로 이동" actions and reach remove·retry as actions, because iOS
* does not focus buttons inside an accessibility element.
*/
export const SortableAttachmentDisplayItem = React.forwardRef<
React.ComponentRef<typeof AttachmentDisplayItem>,
SortableAttachmentDisplayItemProps
>(({ index, entry, onRetry, ...props }, ref) => {
const { readOnly, removeEntry } = useAttachmentDisplayContext();
const actions = [
...(readOnly ? [] : [LABEL_REMOVE]),
...(onRetry && entry.status === "error" ? [LABEL_RETRY] : []),
];
return (
<Sortable.Item
itemId={entry.id}
index={index}
accessibility-element={true}
accessibility-label={entry.name ?? LABEL_ITEM}
accessibility-value={[`${index + 1}번째`, STATUS_LABELS[entry.status]]
.filter(Boolean)
.join(", ")}
moveActionLabels={MOVE_ACTION_LABELS}
accessibility-actions={actions.length > 0 ? actions : undefined}
bindaccessibilityaction={(event) => {
if (event.detail.name === LABEL_REMOVE) removeEntry(entry.id);
else if (event.detail.name === LABEL_RETRY) onRetry?.();
}}
>
{(dragging) => (
<AttachmentDisplayItem
ref={ref}
entry={entry}
onRetry={onRetry}
{...props}
dragging={dragging}
/>
)}
</Sortable.Item>
);
});
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를 호출하지 않습니다. 호스트 picker가 반환한 AttachmentDisplayEntry[]를 onTriggerTap callback의 addEntries에 전달해야 합니다. 문서의 picsum.photos URL은 demo fixture이며 실제 앱의 native bridge API를 가정하지 않습니다.
Props
AttachmentDisplayField
Prop
Type
children?React.ReactNodelabel?React.ReactNodelabelWeight?"medium" | "bold" | undefinedindicator?React.ReactNodedescription?React.ReactNodeerrorMessage?React.ReactNodeshowRequiredIndicator?boolean | undefinedAttachmentDisplay
Prop
Type
onTriggerTap(helpers: DisplayTriggerHelpers) => voidchildren?((context: UseAttachmentDisplayContext) => React.ReactNode) | undefinedonRetry?((entry: DisplayEntry, helpers: DisplayRetryHelpers) => void) | undefinedAttachmentDisplayItem
Prop
Type
entryDisplayItemEntryonRetry?(() => void) | undefinedUsage
onTriggerTap에서 호스트 미디어 picker를 호출하고 반환된 entries를 addEntries에 전달합니다.
<AttachmentDisplayField defaultEntries={[]} maxEntries={10}>
<AttachmentDisplay
onTriggerTap={async ({ addEntries }) => {
const entries = await hostMediaPicker();
addEntries(entries);
}}
/>
</AttachmentDisplayField>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}) 치환이 적용됩니다.
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 (
<VStack gap="x4" 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>
);
}Listening to Entry Changes
entries와 onEntriesChange로 목록을 외부에서 관리할 수 있습니다. 추가와 삭제 결과는 모두 onEntriesChange로 전달됩니다.
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<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 (
<VStack gap="x4" width="100%">
<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>
);
}Managing Item Status
항목의 status는 pending, uploading, success, error 중 하나입니다. 호스트 앱은 updateEntryStatus로 업로드 진행률과 최종 상태를 갱신합니다.
uploading:ProgressCircle이 표시되며progress가 있으면 해당 값(0–100)을 표시합니다.error:onRetry가 있으면 재시도 action이 표시됩니다. 재시도할 때 같은 id를uploading으로 바꾸고 업로드를 다시 시작합니다.success: 완료된 thumbnail을 표시합니다.
예제의 상태 전이는 demo fixture입니다. 실제 앱에서는 호스트 업로드 결과로 상태를 갱신합니다.
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 (
<VStack gap="x4" 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>
);
}Reordering Entries
ui:attachment-display-field-reorderable은 브라우저 dnd-kit 대신 네이티브 가로 long-press gesture로 목록 순서를 변경합니다. disabled와 readOnly에서는 정렬할 수 없습니다.
사용자 지정 항목을 렌더링할 때는 SortableAttachmentDisplayItem에 현재 index를 전달해야 합니다. 스크린 리더는 항목 이름, 순서, 업로드 상태를 읽고 앞·뒤 이동과 파일 제거 동작을 제공합니다. error 상태에 onRetry가 있으면 재시도 동작도 제공합니다. iOS에서는 항목 내부 버튼 대신 이 사용자 지정 접근성 동작을 사용합니다. readOnly에서는 이동과 제거가, disabled에서는 이동만 비활성화됩니다.
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 (
<VStack 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" },
]);
}}
/>
</AttachmentDisplayField>
</VStack>
);
}Examples
Disabled
disabled는 trigger 추가와 정렬 gesture를 막지만, 기존 항목의 remove action은 허용합니다.
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 (
<VStack gap="x4" width="100%">
<AttachmentDisplayField defaultEntries={INITIAL_ENTRIES} maxEntries={5} disabled>
<AttachmentDisplay onTriggerTap={() => {}} />
</AttachmentDisplayField>
</VStack>
);
}Read Only
readOnly는 trigger, remove, 정렬 gesture를 모두 막습니다. 외부에서 entries를 갱신하거나 초기 목록을 hydrate하는 것은 허용됩니다.
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 (
<VStack gap="x4" width="100%">
<AttachmentDisplayField defaultEntries={INITIAL_ENTRIES} maxEntries={5} readOnly>
<AttachmentDisplay onTriggerTap={() => {}} />
</AttachmentDisplayField>
</VStack>
);
}Controlled
entries와 onEntriesChange를 사용하여 외부에서 아이템 목록을 제어할 수 있습니다. 외부 reset은 setEntries([])처럼 앱 state를 갱신하여 수행합니다.
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<AttachmentDisplayEntry[]>([]);
const nextId = useRef(0);
return (
<VStack gap="x4" 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>
);
}Custom Inset
--seed-attachment-input-extend-x CSS 변수를 사용하면 가로 목록을 global gutter 바깥으로 확장할 수 있습니다.
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<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 nextId = useRef(0);
return (
<VStack
px="x6"
width="100%"
maxWidth="400px"
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>
);
}Field Integration
Field와 함께 안내 문구와 오류 상태를 표시할 수 있습니다. 예제는 목록이 비었을 때 invalid와 error footer를 표시합니다.
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<AttachmentDisplayEntry[]>([INITIAL_ENTRY]);
const nextId = useRef(0);
const invalid = entries.length < 1;
return (
<VStack gap="x4" 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>
);
}Customizing Items
기본 항목 구성을 바꾸는 방법은 다음 예제에서 확인할 수 있습니다. 첫 번째 항목에는 대표 사진 badge가 표시됩니다.
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 (
<VStack gap="x4" 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>
);
}
function CustomImageItem({ entry, isCover }: { entry: AttachmentDisplayEntry; isCover: boolean }) {
return (
<AttachmentDisplayItem 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>
</AttachmentDisplayItem>
);
}State and accessibility
기본 trigger와 remove action에는 접근성 label이 제공됩니다. 사용자 지정 action에는 의미를 설명하는 accessibility-label을 지정해야 합니다.
Lynx 미지원 기능
- 브라우저 drag-and-drop 및 React
dnd-kit은 네이티브 long-press reorder snippet으로 대체합니다. - HTML
<input type="file">,File,Blob, object URL 대신 호스트 media picker가 URL 기반AttachmentDisplayEntry를 반환해야 합니다. - HTML form 제출 및
react-hook-form대신 앱 state와 submit adapter를 사용합니다.
Last updated on