Attachment Field
호스트 네이티브 파일 선택 결과를 받아 첨부 항목을 표시하고 관리하는 컴포넌트입니다.
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
// 문서 고정 fixture입니다. 실제 앱에서는 호스트가 제공하는 파일 선택 adapter를 전달하세요.
const FIXTURE_FILES: NativeFile[] = [
{
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
];
export default function AttachmentFieldPreview() {
return (
<VStack gap="x4" width="100%">
<AttachmentField
maxFiles={3}
label="파일 업로드"
description="최대 3개까지 업로드할 수 있습니다"
onSelectFiles={() => FIXTURE_FILES}
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Installation
Default
순서 변경이 필요하지 않은 경우 기본 snippet을 설치합니다.
npx @seed-design/cli@latest add ui:attachment-fieldpnpm dlx @seed-design/cli@latest add ui:attachment-fieldyarn dlx @seed-design/cli@latest add ui:attachment-fieldbun x @seed-design/cli@latest add ui:attachment-field의존성 설치
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-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 IconPaperclipFill from "@karrotmarket/lynx-monochrome-icon/IconPaperclipFill";
import IconXmarkFill from "@karrotmarket/lynx-monochrome-icon/IconXmarkFill";
import {
AttachmentInput as SeedAttachmentInput,
Field as SeedField,
Icon,
HStack,
useAttachmentInputContext,
} from "@seed-design/lynx-react";
import { ProgressCircle } from "./progress-circle";
import type {
AttachmentFileEntry,
AttachmentFileStatusDetails,
AttachmentInputRootProps,
NativeFile,
} from "@seed-design/lynx-react";
const LABEL_SELECT_FILE = "파일 선택";
const LABEL_RETRY = "재시도";
const LABEL_REMOVE_FILE = "파일 제거";
type FieldRootRef = React.ComponentRef<typeof SeedField.Root>;
type FileEntry = AttachmentFileEntry;
type AttachmentInputContext = React.ComponentProps<typeof SeedAttachmentInput.Context>;
type RetryHelpers = {
updateFileEntryStatus: (id: string, details: AttachmentFileStatusDetails) => void;
};
export interface AttachmentFieldProps extends Omit<AttachmentInputRootProps, "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-field
*/
export const AttachmentField = React.forwardRef<FieldRootRef, AttachmentFieldProps>(
(
{
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}
<SeedAttachmentInput.Root
{...props}
disabled={disabled}
required={required}
invalid={invalid}
readOnly={readOnly}
>
{children}
</SeedAttachmentInput.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>
);
},
);
AttachmentField.displayName = "AttachmentField";
export type AttachmentInputProps =
| { children: AttachmentInputContext["children"]; onRetry?: never }
| {
children?: undefined;
onRetry?: (fileEntry: FileEntry, helpers: RetryHelpers) => void;
};
/**
* @see https://seed-design.io/lynx/components/attachment-field
*/
export const AttachmentInput = React.forwardRef<
React.ComponentRef<typeof SeedAttachmentInput.Container>,
AttachmentInputProps
>(({ children, onRetry }, ref) => {
return (
<SeedAttachmentInput.Container ref={ref}>
<SeedAttachmentInput.Trigger accessibility-label={LABEL_SELECT_FILE}>
<SeedAttachmentInput.TriggerIcon
image={<IconCameraFill />}
general={<IconPaperclipFill />}
/>
<SeedAttachmentInput.TriggerItemCount />
</SeedAttachmentInput.Trigger>
<SeedAttachmentInput.ItemGroup>
<SeedAttachmentInput.Context>
{typeof children === "function"
? children
: ({ acceptedFileEntries, updateFileEntryStatus }) =>
acceptedFileEntries.map((fileEntry) => (
<AttachmentInputItem
key={fileEntry.id}
fileEntry={fileEntry}
{...(onRetry
? { onRetry: () => onRetry(fileEntry, { updateFileEntryStatus }) }
: {})}
/>
))}
</SeedAttachmentInput.Context>
</SeedAttachmentInput.ItemGroup>
</SeedAttachmentInput.Container>
);
});
AttachmentInput.displayName = "AttachmentInput";
type AttachmentInputItemNativeProps = Omit<
React.ComponentProps<typeof SeedAttachmentInput.Item>,
"children" | "fileEntry"
>;
export interface AttachmentInputItemProps extends AttachmentInputItemNativeProps {
fileEntry: FileEntry;
onRetry?: () => void;
}
/**
* @see https://seed-design.io/lynx/components/attachment-field
*/
export const AttachmentInputItem = React.forwardRef<
React.ComponentRef<typeof SeedAttachmentInput.Item>,
AttachmentInputItemProps
>(({ fileEntry, onRetry, ...itemProps }, ref) => {
const { acceptType } = useAttachmentInputContext();
return (
<SeedAttachmentInput.Item ref={ref} fileEntry={fileEntry} {...itemProps}>
<SeedAttachmentInput.ItemSurface>
<SeedAttachmentInput.ItemImage />
<SeedAttachmentInput.ItemThumbnail>
<Icon icon={<IconPaperclipFill />} />
</SeedAttachmentInput.ItemThumbnail>
<SeedAttachmentInput.ItemMetadata>
<SeedAttachmentInput.ItemName />
<SeedAttachmentInput.ItemSize />
</SeedAttachmentInput.ItemMetadata>
<SeedAttachmentInput.ItemBackdrop status="uploading">
{(entry) => (
<ProgressCircle
size="24"
tone={acceptType === "image" ? "staticWhite" : "neutral"}
{...("progress" in entry ? { value: entry.progress } : {})}
/>
)}
</SeedAttachmentInput.ItemBackdrop>
{onRetry ? (
<SeedAttachmentInput.ItemBackdrop status="error">
<SeedAttachmentInput.ItemActionButton bindtap={onRetry}>
<Icon icon={<IconArrowClockwiseCircularFill />} />
{LABEL_RETRY}
</SeedAttachmentInput.ItemActionButton>
</SeedAttachmentInput.ItemBackdrop>
) : null}
</SeedAttachmentInput.ItemSurface>
<SeedAttachmentInput.ItemRemoveButton accessibility-label={LABEL_REMOVE_FILE}>
<Icon icon={<IconXmarkFill />} />
</SeedAttachmentInput.ItemRemoveButton>
</SeedAttachmentInput.Item>
);
});
AttachmentInputItem.displayName = "AttachmentInputItem";
export type { NativeFile };
/**
* 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을 설치합니다. 기본 snippet에는 reorder gesture가 포함되지 않습니다. 프로젝트에 @seed-design/lynx-react-sortable 의존성이 추가됩니다.
npx @seed-design/cli@latest add ui:attachment-field-reorderablepnpm dlx @seed-design/cli@latest add ui:attachment-field-reorderableyarn dlx @seed-design/cli@latest add ui:attachment-field-reorderablebun x @seed-design/cli@latest add ui:attachment-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-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 {
AttachmentInput as SeedAttachmentInput,
useAttachmentInputContext,
} from "@seed-design/lynx-react";
import { Sortable } from "@seed-design/lynx-react-sortable";
import IconCameraFill from "@karrotmarket/lynx-monochrome-icon/IconCameraFill";
import IconPaperclipFill from "@karrotmarket/lynx-monochrome-icon/IconPaperclipFill";
import {
AttachmentInputItem,
type AttachmentInputItemProps,
type AttachmentInputProps,
} from "./attachment-field";
const LABEL_REMOVE_FILE = "파일 제거";
const LABEL_RETRY = "재시도";
const MOVE_ACTION_LABELS = { previous: "앞으로 이동", next: "뒤로 이동" };
const STATUS_LABELS: Partial<Record<AttachmentInputItemProps["fileEntry"]["status"], string>> = {
uploading: "업로드 중",
error: "업로드 실패",
};
type AttachmentInputContext = React.ComponentProps<typeof SeedAttachmentInput.Context>;
let nextAttachmentReorderId = 0;
export type AttachmentInputReorderableProps = {
id?: string;
scrollEdgeOffset?: number;
onDragStateChange?: (dragging: boolean) => void;
} & (
| { children: AttachmentInputContext["children"]; onRetry?: never }
| { children?: undefined; onRetry?: AttachmentInputProps["onRetry"] }
);
/**
* Horizontal native long-press reorder for AttachmentInput.
*
* The gesture only emits a reorder intent; AttachmentInput remains the source
* of truth for entries and applies disabled/readOnly guards.
*
* @see https://seed-design.io/lynx/components/attachment-field
*/
export const AttachmentInputReorderable = React.forwardRef<
React.ComponentRef<typeof SeedAttachmentInput.Container>,
AttachmentInputReorderableProps
>(({ children, onRetry, id, scrollEdgeOffset, onDragStateChange }, ref) => {
const [generatedId] = React.useState(() => `instance-${nextAttachmentReorderId++}`);
const boundaryId = id ? `${id}-container` : `attachment-input-reorder-container-${generatedId}`;
const reorderId = id ? `${id}-list` : `attachment-input-reorder-list-${generatedId}`;
const globalProps = React.useGlobalProps() as { motion?: unknown } | undefined;
return (
<SeedAttachmentInput.Context>
{(context) => {
const { acceptedFileEntries, reorderFileEntry, disabled, readOnly, updateFileEntryStatus } =
context;
return (
<Sortable.Root
items={acceptedFileEntries}
getItemKey={(entry) => entry.id}
disabled={disabled}
readOnly={readOnly}
id={reorderId}
scrollableBoundaryId={boundaryId}
scrollEdgeOffset={scrollEdgeOffset}
reducedMotion={globalProps?.motion === "reduced"}
onReorder={reorderFileEntry}
onDragStateChange={onDragStateChange}
>
{({ onScroll, dragging }) => (
<SeedAttachmentInput.Container
ref={ref}
id={boundaryId}
enable-scroll={!dragging}
main-thread:bindscroll={onScroll}
>
<SeedAttachmentInput.Trigger accessibility-label="파일 선택">
<SeedAttachmentInput.TriggerIcon
image={<IconCameraFill />}
general={<IconPaperclipFill />}
/>
<SeedAttachmentInput.TriggerItemCount />
</SeedAttachmentInput.Trigger>
<SeedAttachmentInput.ItemGroup>
<SeedAttachmentInput.Context>
{typeof children === "function"
? children
: ({ acceptedFileEntries: entries }) =>
entries.map((fileEntry, index) => (
<SortableAttachmentInputItem
key={fileEntry.id}
fileEntry={fileEntry}
index={index}
{...(onRetry
? {
onRetry: () => onRetry(fileEntry, { updateFileEntryStatus }),
}
: {})}
/>
))}
</SeedAttachmentInput.Context>
</SeedAttachmentInput.ItemGroup>
</SeedAttachmentInput.Container>
)}
</Sortable.Root>
);
}}
</SeedAttachmentInput.Context>
);
});
AttachmentInputReorderable.displayName = "AttachmentInputReorderable";
export type SortableAttachmentInputItemProps = AttachmentInputItemProps & {
index: number;
children?: (dragging: boolean) => React.ReactNode;
};
/**
* 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 SortableAttachmentInputItem = React.forwardRef<
React.ComponentRef<typeof AttachmentInputItem>,
SortableAttachmentInputItemProps
>(({ index, fileEntry, onRetry, children, ...props }, ref) => {
const { readOnly, removeFileEntry } = useAttachmentInputContext();
const actions = [
...(readOnly ? [] : [LABEL_REMOVE_FILE]),
...(onRetry && fileEntry.status === "error" ? [LABEL_RETRY] : []),
];
return (
<Sortable.Item
itemId={fileEntry.id}
index={index}
accessibility-element={true}
accessibility-label={fileEntry.file.name}
accessibility-value={[`${index + 1}번째`, STATUS_LABELS[fileEntry.status]]
.filter(Boolean)
.join(", ")}
moveActionLabels={MOVE_ACTION_LABELS}
accessibility-actions={actions.length > 0 ? actions : undefined}
bindaccessibilityaction={(event) => {
if (event.detail.name === LABEL_REMOVE_FILE) removeFileEntry(fileEntry.id);
else if (event.detail.name === LABEL_RETRY) onRetry?.();
}}
>
{(dragging) =>
children ? (
children(dragging)
) : (
<AttachmentInputItem
ref={ref}
fileEntry={fileEntry}
dragging={dragging}
onRetry={onRetry}
{...props}
/>
)
}
</Sortable.Item>
);
});
SortableAttachmentInputItem.displayName = "SortableAttachmentInputItem";
/**
* 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 AttachmentField는 브라우저 파일 input을 만들지 않습니다. 호스트가 제공하는 네이티브 파일 선택 adapter를 onSelectFiles로 전달해야 합니다. 이 문서의 fixture:// URI와 고정 이미지는 실행 가능한 picker가 아닌 문서 fixture입니다.
Props
AttachmentField
Prop
Type
children?React.ReactNodelabel?React.ReactNodelabelWeight?"medium" | "bold" | undefinedindicator?React.ReactNodedescription?React.ReactNodeerrorMessage?React.ReactNodeshowRequiredIndicator?boolean | undefinedonSelectFiles?(() => NativeFile[] | Promise<NativeFile[]>) | undefinedonSelectError?((error: unknown) => void) | undefinedonAcceptedFileEntriesChange?((entries: AttachmentFileEntry[]) => void) | undefinedAttachmentInput
Prop
Type
children?((context: UseFileUploadContext) => React.ReactNode) | undefinedonRetry?((fileEntry: FileEntry, helpers: RetryHelpers) => void) | undefinedAttachmentInputItem
Prop
Type
fileEntryAttachmentFileEntryonRetry?(() => void) | undefinedUsage
onSelectFiles의 호스트 adapter는 NativeFile[] 또는 Promise<NativeFile[]>를 반환합니다. 선택을 취소하면 빈 배열을 반환해야 하며, adapter 오류는 onSelectError로 전달됩니다.
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
<AttachmentField
maxFiles={3}
label="파일 업로드"
onSelectFiles={openNativeFilePicker}
onSelectError={handleFilePickerError}
>
<AttachmentInput />
</AttachmentField>;NativeFile은 uri, name, type, size와 선택적인 previewUrl로 구성됩니다. File, Blob, HTML <input type="file">, object URL은 사용할 수 없습니다. 이미지 모드에서 previewUrl이 없으면 uri를 사용하며, 일반 파일 모드에서는 파일 아이콘과 이름·크기를 표시합니다.
AttachmentInput은 호스트 adapter가 반환한 파일을 accept, maxFiles, minFileSize, maxFileSize, validate 조건으로 검증합니다. picker의 MIME filter는 UI를 위한 보조 수단이므로 앱과 업로드 서버에서도 검증해야 합니다. adapter가 빈 배열을 반환하면 목록은 변경되지 않습니다.
Uploading Files
Trigger
기본 AttachmentInput은 파일 선택 trigger와 첨부 항목 목록을 함께 제공합니다. trigger를 탭하면 호스트 adapter가 호출되고 반환된 파일이 목록에 추가됩니다.
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const DEFAULT_FILES: AttachmentFileEntry[] = [
{
id: "document",
file: {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
status: "success",
},
];
const PICKED_FILES: NativeFile[] = [
{
uri: "fixture://attachment-field/photo.png",
name: "photo.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-trigger/200/200",
},
];
export default function AttachmentFieldTrigger() {
return (
<VStack gap="x4" width="100%">
<AttachmentField
maxFiles={3}
label="파일 업로드"
defaultAcceptedFileEntries={DEFAULT_FILES}
onSelectFiles={() => PICKED_FILES}
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Listening to Accepted File Changes
acceptedFileEntries는 검증을 통과한 파일 목록입니다. onAcceptedFileEntriesChange는 추가·삭제·상태·순서 변경을 포함한 목록 변화를 전달합니다.
import { useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import {
AttachmentField,
AttachmentInput,
AttachmentInputItem,
} from "@/components/ui/attachment-field";
import { Text, VStack } from "@seed-design/lynx-react";
const PICKED_FILES: NativeFile[] = [
{
uri: "fixture://attachment-field/value-changes.txt",
name: "value-changes.txt",
type: "text/plain",
size: 12,
},
];
export default function AttachmentFieldValueChanges() {
const [logs, setLogs] = useState<string[]>([]);
const addLog = (message: string) => setLogs((previous) => [...previous, message]);
return (
<VStack gap="x4" width="100%">
<VStack gap="x1">
{logs.length === 0 ? (
<Text color="fg.neutralMuted">파일을 추가하거나 삭제하면 로그가 표시됩니다.</Text>
) : (
logs.map((log, index) => (
<Text key={index} fontSize="fontSize.caption1">
{log}
</Text>
))
)}
</VStack>
<AttachmentField
maxFiles={3}
label="파일 업로드"
description="콜백 호출 로그를 확인하세요"
onSelectFiles={() => PICKED_FILES}
onFileAccept={(entries, { updateFileEntryStatus }) => {
addLog(`onFileAccept: ${entries.map((entry) => entry.file.name).join(", ")}`);
for (const entry of entries) {
updateFileEntryStatus(entry.id, { status: "uploading", progress: 0 });
setTimeout(
() => updateFileEntryStatus(entry.id, { status: "uploading", progress: 50 }),
250,
);
setTimeout(() => updateFileEntryStatus(entry.id, { status: "success" }), 500);
}
}}
onAcceptedFileEntriesChange={(entries) =>
addLog(
`onAcceptedFileEntriesChange: ${entries.map((entry) => `${entry.file.name} (${entry.status})`).join(", ")}`,
)
}
onFileReject={(files) =>
addLog(
`onFileReject: ${files.map((file) => `${file.file.name} (${file.errors.join(", ")})`).join(", ")}`,
)
}
>
<AttachmentInput>
{({ acceptedFileEntries }) =>
acceptedFileEntries.map((fileEntry) => (
<AttachmentInputItem key={fileEntry.id} fileEntry={fileEntry} />
))
}
</AttachmentInput>
</AttachmentField>
</VStack>
);
}Validating Files
Max Files
maxFiles는 최종 파일 개수를 제한합니다. picker에 상한이나 filter를 전달해도 초과 결과는 TOO_MANY_FILES로 reject됩니다.
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const DEFAULT_FILES: AttachmentFileEntry[] = [
{
id: "document",
file: {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
status: "success",
},
];
const PICKED_FILES: NativeFile[] = [
{ uri: "fixture://attachment-field/one.txt", name: "one.txt", type: "text/plain", size: 1 },
{ uri: "fixture://attachment-field/two.txt", name: "two.txt", type: "text/plain", size: 1 },
{ uri: "fixture://attachment-field/three.txt", name: "three.txt", type: "text/plain", size: 1 },
];
export default function AttachmentFieldMaxFiles() {
return (
<VStack gap="x4" width="100%">
<AttachmentField
maxFiles={3}
label="파일 업로드"
description="최대 3개까지 업로드할 수 있습니다"
defaultAcceptedFileEntries={DEFAULT_FILES}
onSelectFiles={() => PICKED_FILES}
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Invalid File Type
accept는 MIME type(image/png, image/*) 또는 확장자 패턴을 받을 수 있습니다. 호스트 picker가 제한을 우회해 반환한 파일도 검증하며, 유효하지 않으면 INVALID_TYPE과 함께 onFileReject가 호출됩니다.
import { useRef, useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const INVALID_FILE: NativeFile = {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
};
const VALID_FILE: NativeFile = {
uri: "fixture://attachment-field/photo.jpg",
name: "photo.jpg",
type: "image/jpeg",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-invalid/200/200",
};
export default function AttachmentFieldInvalidFileType() {
const pickCount = useRef(0);
const [errorMessage, setErrorMessage] = useState<string>();
return (
<VStack gap="x4" width="100%">
<AttachmentField
accept={["image/png", "image/jpeg"]}
maxFiles={3}
invalid={!!errorMessage}
errorMessage={errorMessage}
label="이미지 업로드"
description="PNG, JPEG 파일만 업로드할 수 있습니다"
onSelectFiles={() => [pickCount.current++ === 0 ? INVALID_FILE : VALID_FILE]}
onAcceptedFileEntriesChange={() => setErrorMessage(undefined)}
onFileReject={(files) =>
setErrorMessage(
files
.map(
({ file, errors }) =>
`"${file.name}": ${errors.includes("INVALID_TYPE") ? "지원하지 않는 파일 형식입니다" : "업로드에 실패했습니다"}`,
)
.join("\n"),
)
}
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}File Size
minFileSize, maxFileSize는 NativeFile.size(bytes)를 검증합니다. 범위를 벗어나면 각각 FILE_TOO_SMALL, FILE_TOO_LARGE로 reject됩니다.
import { useRef, useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const MIN_FILE_SIZE = 1024;
const MAX_FILE_SIZE = 10 * 1024;
const SMALL_FILE: NativeFile = {
uri: "fixture://attachment-field/small.txt",
name: "small.txt",
type: "text/plain",
size: 1,
};
const VALID_FILE: NativeFile = {
uri: "fixture://attachment-field/valid.txt",
name: "valid.txt",
type: "text/plain",
size: 2048,
};
export default function AttachmentFieldValidation() {
const pickCount = useRef(0);
const [errorMessage, setErrorMessage] = useState<string>();
return (
<VStack gap="x4" width="100%">
<AttachmentField
maxFiles={3}
minFileSize={MIN_FILE_SIZE}
maxFileSize={MAX_FILE_SIZE}
invalid={!!errorMessage}
errorMessage={errorMessage}
label="파일 업로드"
description="1KB ~ 10KB 크기의 파일만 업로드할 수 있습니다"
onSelectFiles={() => [pickCount.current++ === 0 ? SMALL_FILE : VALID_FILE]}
onAcceptedFileEntriesChange={() => setErrorMessage(undefined)}
onFileReject={(files) =>
setErrorMessage(
files
.map(
({ file, errors }) =>
`"${file.name}": ${errors.includes("FILE_TOO_SMALL") ? "크기가 1KB 미만입니다" : errors.includes("FILE_TOO_LARGE") ? "크기가 10KB를 초과합니다" : "업로드에 실패했습니다"}`,
)
.join("\n"),
)
}
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Custom Validation
validate는 각 NativeFile에 대해 custom error code 배열 또는 null을 반환합니다. 앱은 onFileReject에서 이 값을 오류 메시지로 변환합니다.
import { useRef, useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const LONG_NAME_FILE: NativeFile = {
uri: "fixture://attachment-field/filename-too-long.txt",
name: "filename-too-long.txt",
type: "text/plain",
size: 5,
};
const VALID_FILE: NativeFile = {
uri: "fixture://attachment-field/short.txt",
name: "short.txt",
type: "text/plain",
size: 5,
};
export default function AttachmentFieldCustomValidation() {
const pickCount = useRef(0);
const [errorMessage, setErrorMessage] = useState<string>();
return (
<VStack gap="x4" width="100%">
<AttachmentField
maxFiles={5}
validate={(file) =>
file.name.replace(/\.[^.]+$/, "").length > 8 ? ["FILENAME_TOO_LONG"] : null
}
invalid={!!errorMessage}
errorMessage={errorMessage}
label="파일 업로드"
description="파일 이름은 확장자를 제외하고 8자 이하여야 합니다"
onSelectFiles={() => [pickCount.current++ === 0 ? LONG_NAME_FILE : VALID_FILE]}
onAcceptedFileEntriesChange={() => setErrorMessage(undefined)}
onFileReject={(files) => {
if (files.every((file) => file.errors.includes("FILENAME_TOO_LONG")))
setErrorMessage(
`"${files.map((file) => file.file.name).join(", ")}"은(는) 파일 이름이 8자를 초과합니다.`,
);
}}
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Managing File Status
각 항목은 pending, uploading, success, error 상태를 갖습니다. 새 항목은 pending으로 시작합니다. 앱은 onFileAccept에서 업로드를 시작하고 helper로 진행률과 상태를 갱신합니다. 실제 업로드 API와 재시도 정책은 호스트 앱이 소유합니다.
import { useRef } from "@lynx-js/react";
import type { AttachmentFileStatusDetails, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const PICKED_FILE: NativeFile = {
uri: "fixture://attachment-field/status.png",
name: "status.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-status/200/200",
};
export default function AttachmentFieldStatus() {
const attempts = useRef<Record<string, number>>({});
const startUpload = (
id: string,
update: (entryId: string, details: AttachmentFileStatusDetails) => void,
) => {
attempts.current[id] = (attempts.current[id] ?? 0) + 1;
update(id, { status: "uploading", progress: 0 });
setTimeout(() => update(id, { status: "uploading", progress: 50 }), 250);
setTimeout(() => update(id, { status: attempts.current[id] === 1 ? "error" : "success" }), 500);
};
return (
<VStack gap="x4" width="100%">
<AttachmentField
accept="image/*"
maxFiles={5}
label="파일 업로드"
description="업로드 상태 시뮬레이션"
onSelectFiles={() => [PICKED_FILE]}
onFileAccept={(entries, { updateFileEntryStatus }) => {
for (const entry of entries) startUpload(entry.id, updateFileEntryStatus);
}}
>
<AttachmentInput
onRetry={(entry, { updateFileEntryStatus }) =>
startUpload(entry.id, updateFileEntryStatus)
}
/>
</AttachmentField>
</VStack>
);
}uploading:ProgressCircle이 표시됩니다. progress가 없으면 indeterminate 상태입니다.error:onRetry가 있으면 재시도 action이 표시됩니다. 재시도할 때 같은 id를uploading으로 바꾸고 업로드를 다시 시작합니다.- picker가 동기 throw 또는 rejected Promise를 반환하면
onSelectError가 호출되고 기존 목록은 유지됩니다.
Reordering Files
ui:attachment-field-reorderable은 브라우저 dnd-kit 대신 네이티브 가로 long-press gesture로 목록 순서를 변경합니다. disabled와 readOnly에서는 정렬할 수 없습니다.
사용자 지정 항목을 렌더링할 때는 SortableAttachmentInputItem에 현재 index를 전달해야 합니다. 스크린 리더는 항목의 파일 이름, 순서, 업로드 상태를 읽고 앞·뒤 이동과 파일 제거 동작을 제공합니다. error 상태에 onRetry가 있으면 재시도 동작도 제공합니다. iOS에서는 항목 내부 버튼 대신 이 사용자 지정 접근성 동작을 사용합니다. readOnly에서는 이동과 제거가, disabled에서는 이동만 비활성화됩니다.
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { VStack } from "@seed-design/lynx-react";
import { AttachmentField } from "@/components/ui/attachment-field";
import { AttachmentInputReorderable } from "@/components/ui/attachment-field-reorderable";
const PIXEL_SUNSET =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGN4FcEDAAN+AU+hW/ICAAAAAElFTkSuQmCC";
const PIXEL_CITY =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOYbPwKAAMNAbHKe2UaAAAAAElFTkSuQmCC";
const PIXEL_COFFEE =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGN4tZkDAAQwAaYlKXDxAAAAAElFTkSuQmCC";
function createFixture(name: string, data: string): NativeFile {
return {
uri: `fixture://attachment-field/${name}`,
name,
type: "image/png",
size: 1,
previewUrl: `data:image/png;base64,${data}`,
};
}
const DEFAULT_FILES: AttachmentFileEntry[] = [
{ id: "1", file: createFixture("sunset-landscape.png", PIXEL_SUNSET), status: "success" },
{ id: "2", file: createFixture("city-night.png", PIXEL_CITY), status: "success" },
{ id: "3", file: createFixture("morning-coffee.png", PIXEL_COFFEE), status: "success" },
];
export default function AttachmentFieldReorderable() {
return (
<VStack width="100%">
<AttachmentField
accept="image/*"
maxFiles={5}
label="이미지 업로드"
description="길게 눌러 드래그하여 순서를 변경할 수 있습니다"
defaultAcceptedFileEntries={DEFAULT_FILES}
>
<AttachmentInputReorderable />
</AttachmentField>
</VStack>
);
}Examples
Showing Thumbnails
accept="image/*"으로 이미지 파일만 허용하고, 호스트가 반환한 previewUrl로 썸네일을 표시합니다.
import { useRef, useState } from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const IMAGE_FILE: NativeFile = {
uri: "fixture://attachment-field/photo.png",
name: "photo.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-image/200/200",
};
const OTHER_FILE: NativeFile = {
uri: "fixture://attachment-field/readme.pdf",
name: "readme.pdf",
type: "application/pdf",
size: 5,
};
export default function AttachmentFieldAcceptImage() {
const pickCount = useRef(0);
const [errorMessage, setErrorMessage] = useState<string>();
return (
<VStack gap="x4" width="100%">
<AttachmentField
accept="image/*"
maxFiles={5}
label="이미지 업로드"
description="이미지 파일만 허용됩니다"
invalid={!!errorMessage}
errorMessage={errorMessage}
onSelectFiles={() => [pickCount.current++ === 0 ? IMAGE_FILE : OTHER_FILE]}
onAcceptedFileEntriesChange={() => setErrorMessage(undefined)}
onFileReject={() => setErrorMessage("지원하지 않는 파일 형식입니다")}
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Disabled
disabled는 trigger와 추가·정렬을 막지만 기존 항목 삭제는 허용합니다. 삭제까지 막으려면 readOnly 또는 앱 정책을 사용해야 합니다.
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const DEFAULT_FILES: AttachmentFileEntry[] = [
{
id: "document",
file: {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
status: "success",
},
{
id: "image",
file: {
uri: "fixture://attachment-field/image.png",
name: "image.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-disabled/200/200",
},
status: "success",
},
];
export default function AttachmentFieldDisabled() {
return (
<VStack gap="x4" width="100%">
<AttachmentField
disabled
maxFiles={5}
defaultAcceptedFileEntries={DEFAULT_FILES}
label="파일 업로드"
description="비활성화된 AttachmentField"
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Read Only
readOnly는 trigger, 추가, 삭제, clear, 정렬을 모두 막습니다. 외부에서 controlled 상태를 hydrate하거나 갱신하는 것은 허용됩니다.
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { VStack } from "@seed-design/lynx-react";
const DEFAULT_FILES: AttachmentFileEntry[] = [
{
id: "document",
file: {
uri: "fixture://attachment-field/document.pdf",
name: "document.pdf",
type: "application/pdf",
size: 5,
},
status: "success",
},
{
id: "image",
file: {
uri: "fixture://attachment-field/image.png",
name: "image.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-readonly/200/200",
},
status: "success",
},
];
export default function AttachmentFieldReadOnly() {
return (
<VStack gap="x4" width="100%">
<AttachmentField
readOnly
maxFiles={5}
defaultAcceptedFileEntries={DEFAULT_FILES}
label="첨부 파일"
description="읽기 전용 상태"
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Controlled
acceptedFileEntries와 onAcceptedFileEntriesChange로 목록을 외부에서 제어할 수 있습니다.
import { useState } from "@lynx-js/react";
import type { AttachmentFileEntry, NativeFile } from "@seed-design/lynx-react";
import { ActionButton, HStack, Text, VStack } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
const PICKED_FILE: NativeFile = {
uri: "fixture://attachment-field/controlled.txt",
name: "controlled.txt",
type: "text/plain",
size: 5,
};
export default function AttachmentFieldControlled() {
const [acceptedFileEntries, setAcceptedFileEntries] = useState<AttachmentFileEntry[]>([]);
function clearFiles() {
"background only";
setAcceptedFileEntries([]);
}
return (
<VStack gap="x4" width="100%">
<AttachmentField
maxFiles={5}
label="Controlled"
description="최대 5개까지 업로드할 수 있습니다"
acceptedFileEntries={acceptedFileEntries}
onAcceptedFileEntriesChange={setAcceptedFileEntries}
onSelectFiles={() => [PICKED_FILE]}
>
<AttachmentInput />
</AttachmentField>
<Text>현재 파일: {JSON.stringify(acceptedFileEntries.map((entry) => entry.file.name))}</Text>
<HStack gap="x2">
<ActionButton bindtap={clearFiles} variant="neutralWeak">
전체 삭제
</ActionButton>
</HStack>
</VStack>
);
}Custom Inset
--seed-attachment-input-extend-x style 변수를 사용하면 항목 가로 목록이 global gutter 바깥까지 확장됩니다. 실제 파일 metadata와 nested field를 함께 사용하는 예제입니다.
import * as React from "@lynx-js/react";
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { TextField, TextFieldInput } from "@/components/ui/text-field";
import { VStack } from "@seed-design/lynx-react";
type AttachmentFieldStyle = NonNullable<React.ComponentProps<typeof AttachmentField>["style"]> & {
"--seed-attachment-input-extend-x": string;
};
const INSET_STYLE: AttachmentFieldStyle = {
"--seed-attachment-input-extend-x": "var(--seed-dimension-spacing-x-global-gutter)",
};
const MOCKED_FILES: NativeFile[] = Array.from({ length: 8 }, (_, index) => ({
uri: `fixture://attachment-field/file${index + 1}.txt`,
name: `file${index + 1}.txt`,
type: "text/plain",
size: 1,
}));
export default function AttachmentFieldCustomInset() {
return (
<VStack
px="spacingX.globalGutter"
width="100%"
maxWidth="400px"
bg="palette.gray300"
borderWidth={1}
borderColor="stroke.neutralMuted"
>
<VStack gap="spacingY.componentDefault" bg="bg.layerDefault">
<TextField label="이름">
<TextFieldInput placeholder="홍길동" />
</TextField>
<AttachmentField
maxFiles={8}
label="첨부파일"
defaultAcceptedFileEntries={MOCKED_FILES.map((file, index) => ({
id: `${index}`,
file,
status: "pending",
}))}
style={INSET_STYLE}
onSelectFiles={() => [MOCKED_FILES[0]]}
>
<AttachmentInput />
</AttachmentField>
</VStack>
</VStack>
);
}Field Integration
Field와 함께 안내 문구와 오류 상태를 표시할 수 있습니다. HTML form submit 대신 앱의 validation 결과로 invalid와 errorMessage를 관리합니다.
import type { NativeFile } from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
import { Divider, VStack } from "@seed-design/lynx-react";
const PICKED_FILE: NativeFile = {
uri: "fixture://attachment-field/field.txt",
name: "field.txt",
type: "text/plain",
size: 5,
};
export default function AttachmentFieldField() {
return (
<VStack gap="x8" width="100%">
<AttachmentField
maxFiles={3}
label="첨부파일"
labelWeight="bold"
required
showRequiredIndicator
invalid
errorMessage="필수 항목입니다."
>
<AttachmentInput />
</AttachmentField>
<Divider />
<AttachmentField
maxFiles={3}
label="첨부파일"
description="파일을 선택하거나 호스트 파일 선택기를 여세요."
indicator="선택"
onSelectFiles={() => [PICKED_FILE]}
>
<AttachmentInput />
</AttachmentField>
</VStack>
);
}Customizing Items
기본 항목 구성을 바꾸는 방법은 다음 예제에서 확인할 수 있습니다. 대표 사진 배지는 앱의 순서 정책에 맞춰 표시합니다.
import IconArrowClockwiseCircularFill from "@karrotmarket/lynx-monochrome-icon/IconArrowClockwiseCircularFill";
import IconXmarkFill from "@karrotmarket/lynx-monochrome-icon/IconXmarkFill";
import { AttachmentInput as SeedAttachmentInput, Icon, VStack } from "@seed-design/lynx-react";
import { ProgressCircle } from "@/components/ui/progress-circle";
import type {
AttachmentFileEntry,
AttachmentFileStatusDetails,
NativeFile,
} from "@seed-design/lynx-react";
import { AttachmentField, AttachmentInput } from "@/components/ui/attachment-field";
const PICKED_FILE: NativeFile = {
uri: "fixture://attachment-field/custom.png",
name: "custom.png",
type: "image/png",
size: 8,
previewUrl: "https://picsum.photos/seed/attachment-custom/200/200",
};
function CustomImageItem({
fileEntry,
isCover,
onRetry,
}: {
fileEntry: AttachmentFileEntry;
isCover: boolean;
onRetry?: () => void;
}) {
return (
<SeedAttachmentInput.Item fileEntry={fileEntry}>
<SeedAttachmentInput.ItemSurface>
<SeedAttachmentInput.ItemImage />
{isCover ? <SeedAttachmentInput.ItemBadge>대표사진</SeedAttachmentInput.ItemBadge> : null}
<SeedAttachmentInput.ItemBackdrop status="uploading">
{(entry) => (
<ProgressCircle
size="24"
tone="staticWhite"
{...("progress" in entry ? { value: entry.progress } : {})}
/>
)}
</SeedAttachmentInput.ItemBackdrop>
{onRetry ? (
<SeedAttachmentInput.ItemBackdrop status="error">
<SeedAttachmentInput.ItemActionButton bindtap={onRetry}>
<Icon icon={<IconArrowClockwiseCircularFill />} />
재시도
</SeedAttachmentInput.ItemActionButton>
</SeedAttachmentInput.ItemBackdrop>
) : null}
</SeedAttachmentInput.ItemSurface>
<SeedAttachmentInput.ItemRemoveButton accessibility-label="파일 제거">
<Icon icon={<IconXmarkFill />} />
</SeedAttachmentInput.ItemRemoveButton>
</SeedAttachmentInput.Item>
);
}
export default function AttachmentFieldCustomizingItems() {
return (
<VStack gap="x4" width="100%">
<AttachmentField
accept="image/*"
maxFiles={10}
label="이미지 업로드"
description="첫 번째 이미지가 대표사진으로 설정됩니다"
onSelectFiles={() => [PICKED_FILE]}
onFileAccept={(entries, { updateFileEntryStatus }) => {
for (const entry of entries) {
updateFileEntryStatus(entry.id, { status: "uploading", progress: 0 });
setTimeout(() => updateFileEntryStatus(entry.id, { status: "success" }), 500);
}
}}
>
<AttachmentInput>
{({ acceptedFileEntries, updateFileEntryStatus }) =>
acceptedFileEntries.map((fileEntry, index) => (
<CustomImageItem
key={fileEntry.id}
fileEntry={fileEntry}
isCover={index === 0}
onRetry={() => {
updateFileEntryStatus(fileEntry.id, {
status: "uploading",
progress: 0,
} satisfies AttachmentFileStatusDetails);
setTimeout(() => updateFileEntryStatus(fileEntry.id, { status: "success" }), 500);
}}
/>
))
}
</AttachmentInput>
</AttachmentField>
</VStack>
);
}Lynx 미지원 기능과 앱 대안
Dropzone
Lynx는 React AttachmentDropzone의 브라우저 drag-and-drop 이벤트와 HTML file input을 지원하지 않습니다. 네이티브 파일 picker는 onSelectFiles에 연결하며, 목록 순서 변경에는 ui:attachment-field-reorderable을 사용합니다.
HTML form
Lynx에는 브라우저 form, FormData, input[type=file] 제출 경로가 없습니다. 앱은 onAcceptedFileEntriesChange 또는 onFileAccept로 상태를 유지하고, submit action에서 NativeFile[]를 호스트 upload adapter에 전달합니다. 서버 검증 오류는 Field의 invalid와 errorMessage에 반영합니다.
react-hook-form
react-hook-form을 사용하는 React 예제는 브라우저 파일 입력과 form 제출 경로에 의존하므로 그대로 실행할 수 없습니다. Lynx에서는 앱 state에 acceptedFileEntries와 onAcceptedFileEntriesChange를 연결하고, 제출할 때 목록 길이와 업로드 상태를 검증한 뒤 Field 오류 상태를 갱신합니다.
Browser file APIs
File, Blob, FileList, object URL, window.alert는 Native 예제에서 사용할 수 없습니다. 호스트 adapter가 NativeFile의 uri, metadata, 선택적인 previewUrl을 제공해야 합니다. 취소하면 []를 반환하며 선택기 또는 권한 오류는 onSelectError로 전달됩니다.
Last updated on