File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
type DragEvent,
} from "react";
import { cx } from "../../utils";
import { FoxButton } from "../fox-button";
import { FoxIconButton } from "../fox-icon-button";
import { FoxInput } from "../fox-input";
import { FoxTextButton } from "../fox-text-button";
export type FoxFileUploadMode = "default" | "area" | "image";
/** 목록에 그릴 파일 한 줄. 업로드 상태는 호출부가 이 모양으로 바꿔서 넘긴다. */
export interface FoxFileItem {
/** 목록에서 구분하는 키. */
id: string;
/** 확장자를 뺀 이름. 길면 말줄임된다. */
name: string;
/** 이름 뒤에 붙는 표기 — 시안은 `.PDF (20KB)`. 줄어들지 않는다. */
meta?: string;
/** 참이면 줄이 붉어지고 `message`가 아래에 붙는다. */
error?: boolean;
message?: string;
/** image 모드에서 썸네일로 쓸 주소. */
previewUrl?: string;
}
export interface FoxFileUploadProps {
mode?: FoxFileUploadMode;
/** 주면 제어 컴포넌트가 된다. 없으면 고른 파일을 안에서 들고 있는다. */
files?: FoxFileItem[];
defaultFiles?: FoxFileItem[];
/** 목록이 바뀔 때(고르기·삭제) 부른다. */
onFilesChange?: (files: FoxFileItem[]) => void;
/** 파일을 고르면 원본 `File`을 그대로 넘긴다 — 업로드는 호출부가 한다. */
onSelect?: (picked: File[]) => void;
onRemove?: (id: string) => void;
accept?: string;
multiple?: boolean;
/**
* `default`·`area`에서 목록을 보여준다. `image`에는 목록이 없다.
* 넘기지 않으면 `area`이거나 여러 개를 받을 때만 켜진다 — 파일 하나는 칸이 곧 목록이다.
*/
showList?: boolean;
/** 끌어다 놓기. `area`는 안내문이 그렇게 말하므로 이 값과 무관하게 항상 켜져 있다. */
droppable?: boolean;
label?: string;
description?: string;
placeholder?: string;
areaText?: string;
selectLabel?: string;
removeLabel?: string;
/** `default` 모드 입력칸 폭. 시안은 320px이고 토큰에 없어 여기서 받는다. */
inputWidth?: string | number;
/** `image` 모드 썸네일 크기. 시안은 213×120px(16:9)이고 토큰에 없어 여기서 받는다. */
thumbnailWidth?: string | number;
thumbnailHeight?: string | number;
disabled?: boolean;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
}
const DEFAULT_AREA_TEXT = "첨부할 파일을 여기에 끌어다 놓거나 파일 선택 버튼을 직접 선택해주세요.";
/* ── 아이콘 ────────────────────────────────────────────────────────────────
@fox에 아이콘 세트가 없어 시안의 모양을 직접 그린다. 세트가 들어오면 교체할 자리다.
전부 `currentColor`라 감싼 쪽이 색을 정한다. */
function UploadIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<path d="M8 10.5V2.4M4.8 5.4L8 2.2l3.2 3.2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M2.4 10.2v2.6h11.2v-2.6" strokeLinecap="round" />
</svg>
);
}
function PaperclipIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<path
d="M12.6 7.6l-4.7 4.7a3 3 0 0 1-4.2-4.2l5.4-5.4a2 2 0 0 1 2.8 2.8l-5.4 5.4a1 1 0 0 1-1.4-1.4l4.7-4.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function XCircleIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<circle cx="8" cy="8" r="6.3" />
<path d="M6 6l4 4M10 6l-4 4" strokeLinecap="round" />
</svg>
);
}
function ProhibitIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<circle cx="8" cy="8" r="6.3" />
<path d="M3.5 3.5l9 9" strokeLinecap="round" />
</svg>
);
}
function CameraPlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
<path d="M1.8 5.4h2.6l1-1.6h3.2l1 1.6h4.6v7.4H1.8z" strokeLinejoin="round" />
<circle cx="8" cy="9.1" r="2.2" />
<path d="M12.6 1.6v2.6M11.3 2.9h2.6" strokeLinecap="round" />
</svg>
);
}
function TrashIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<path d="M2.6 4.2h10.8M6.4 4.2V2.8h3.2v1.4M4 4.2l.6 8.4h6.8l.6-8.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/* ── 값 다루기 ─────────────────────────────────────────────────────────────── */
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes}B`;
}
if (bytes < 1024 * 1024) {
return `${Math.round(bytes / 1024)}KB`;
}
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
/** 비제어로 쓸 때 원본 `File`을 목록 한 줄로 바꾼다. 시안의 `이름` + `.PDF (20KB)` 모양이다. */
function toItem(file: File, previewUrl?: string): FoxFileItem {
const dot = file.name.lastIndexOf(".");
const base = dot > 0 ? file.name.slice(0, dot) : file.name;
const ext = dot > 0 ? file.name.slice(dot + 1).toUpperCase() : "";
return {
id: `${file.name}:${file.size}:${file.lastModified}`,
name: base,
meta: ext ? `.${ext} (${formatBytes(file.size)})` : `(${formatBytes(file.size)})`,
previewUrl,
};
}
function toSize(value: string | number | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
return typeof value === "number" ? `${value}px` : value;
}
/**
* @fox 파일 첨부. 세 모드를 한 컴포넌트로 둔다 — 고르는 방식만 다르고 목록은 같기 때문이다.
*
* 업로드는 하지 않는다. 파일을 고르면 원본 `File`을 넘길 뿐이고, 진행률·서버 오류를 목록에
* 어떻게 비출지는 호출부가 `files`로 정한다. `files`를 넘기지 않으면 고른 파일을 안에서
* 들고 있어 간단한 폼에서는 그대로 쓸 수 있다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxFileUpload({
mode = "default",
files,
defaultFiles,
onFilesChange,
onSelect,
onRemove,
accept,
multiple = false,
showList,
droppable = false,
label,
description,
placeholder = "파일을 선택해 주세요.",
areaText = DEFAULT_AREA_TEXT,
selectLabel = "파일선택",
removeLabel = "삭제",
inputWidth = 320,
thumbnailWidth = 213,
thumbnailHeight = 120,
disabled = false,
hidden = false,
className,
}: FoxFileUploadProps) {
const autoId = useId();
const pickerRef = useRef<HTMLInputElement>(null);
const objectUrls = useRef<string[]>([]);
const [inner, setInner] = useState<FoxFileItem[]>(defaultFiles ?? []);
const [over, setOver] = useState(false);
// 비제어로 만든 미리보기 주소는 사라질 때 돌려준다.
useEffect(
() => () => {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
},
[]
);
if (hidden) {
return null;
}
const current = files ?? inner;
// area는 안내문이 끌어다 놓으라고 말하므로 언제나 받는다.
const dropEnabled = !disabled && (mode === "area" || droppable);
const listVisible = (showList ?? (mode === "area" || multiple)) && mode !== "image";
const preview = current[0];
// 시안의 upload=true 모양 — 목록 없이 파일 하나를 들고 있으면 칸이 잠기고 버튼이 삭제로 바뀐다.
const singleFilled = mode === "default" && !listVisible && current.length === 1;
const openPicker = () => {
pickerRef.current?.click();
};
const takeFiles = (picked: File[]) => {
if (!picked.length) {
return;
}
onSelect?.(picked);
if (files !== undefined) {
return;
}
const made = picked.map((file) => {
if (!file.type.startsWith("image/")) {
return toItem(file);
}
const url = URL.createObjectURL(file);
objectUrls.current.push(url);
return toItem(file, url);
});
const next = multiple ? [...inner, ...made] : made.slice(0, 1);
setInner(next);
onFilesChange?.(next);
};
const handlePicked = (event: ChangeEvent<HTMLInputElement>) => {
takeFiles(Array.from(event.target.files ?? []));
// 같은 파일을 다시 골라도 이벤트가 오게 비운다.
event.target.value = "";
};
const handleRemove = (id: string) => {
onRemove?.(id);
if (files !== undefined) {
return;
}
const next = inner.filter((item) => item.id !== id);
setInner(next);
onFilesChange?.(next);
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
if (!dropEnabled) {
return;
}
event.preventDefault();
setOver(true);
};
const handleDragLeave = () => {
setOver(false);
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
if (!dropEnabled) {
return;
}
event.preventDefault();
setOver(false);
takeFiles(Array.from(event.dataTransfer.files ?? []));
};
const thumbStyle: CSSProperties = {
inlineSize: toSize(thumbnailWidth),
blockSize: toSize(thumbnailHeight),
};
const selectButton = (type: "primary" | "secondary") => (
<FoxButton
size="md"
type={type}
label={selectLabel}
leadingIcon={<UploadIcon />}
disabled={disabled}
onAction={openPicker}
/>
);
return (
<div
className={cx("fox-file-upload", mode === "area" && "fox-file-upload--area", className)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{label && (
<span className="fox-file-upload__label" id={`${autoId}-label`}>
{label}
</span>
)}
{/* 파일 고르기는 네이티브 입력이 맡고 화면에는 버튼만 보인다. */}
<input
ref={pickerRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
hidden
onChange={handlePicked}
/>
{mode === "default" && (
<div className="fox-file-upload__row">
<div
className={cx(
"fox-file-upload__field",
!singleFilled && "fox-file-upload__field--empty"
)}
style={{ inlineSize: toSize(inputWidth) }}
>
<FoxInput
size="md"
aria-label={placeholder}
value={singleFilled ? `${preview.name}${preview.meta ?? ""}` : ""}
placeholder={placeholder}
message={description}
readOnly
disabled={disabled}
/>
</div>
{singleFilled ? (
<FoxButton
size="md"
type="default"
label={removeLabel}
leadingIcon={<TrashIcon />}
disabled={disabled}
onAction={() => handleRemove(preview.id)}
/>
) : (
selectButton("secondary")
)}
</div>
)}
{mode === "area" && (
<div className={cx("fox-file-upload__area", over && "fox-file-upload__area--over")}>
<p className="fox-file-upload__area-text">{areaText}</p>
{selectButton("primary")}
</div>
)}
{mode === "image" && (
<div className="fox-file-upload__image">
<div
className={cx(
"fox-file-upload__thumb",
preview?.previewUrl && "fox-file-upload__thumb--filled"
)}
style={thumbStyle}
>
{preview?.previewUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element -- next/image를 쓰면 이
폴더가 Next 전용이 된다. */}
<img className="fox-file-upload__thumb-image" src={preview.previewUrl} alt="" />
<span className="fox-file-upload__thumb-remove">
<FoxIconButton
size="xsm"
type="overlay"
label={removeLabel}
icon={<TrashIcon />}
disabled={disabled}
onAction={() => handleRemove(preview.id)}
/>
</span>
</>
) : (
<span className="fox-file-upload__thumb-icon" aria-hidden="true">
<CameraPlusIcon />
</span>
)}
</div>
<div className="fox-file-upload__image-side">
{selectButton("secondary")}
{description && <p className="fox-file-upload__description">{description}</p>}
</div>
</div>
)}
{listVisible && current.length > 0 && (
<ul className="fox-file-upload__list">
{current.map((item) => (
<li
key={item.id}
className={cx(
"fox-file-upload__item",
item.error && "fox-file-upload__item--error"
)}
>
<div className="fox-file-upload__item-main">
<span className="fox-file-upload__item-title">
<span className="fox-file-upload__item-icon" aria-hidden="true">
<PaperclipIcon />
</span>
<span className="fox-file-upload__item-name">{item.name}</span>
{item.meta && <span className="fox-file-upload__item-meta">{item.meta}</span>}
</span>
<span className="fox-file-upload__item-actions">
<FoxTextButton
size="md"
type="secondary"
label={removeLabel}
trailingIcon={<XCircleIcon />}
disabled={disabled}
onAction={() => handleRemove(item.id)}
/>
</span>
</div>
{item.error && item.message && (
<p className="fox-file-upload__item-message">
<span className="fox-file-upload__item-message-icon" aria-hidden="true">
<ProhibitIcon />
</span>
{item.message}
</p>
)}
</li>
))}
</ul>
)}
</div>
);
}