File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-14
08-14
08-18
08-18
08-14
08-18
08-18
08-18
08-18
File name
Commit message
Commit date
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
type DragEvent,
} from "react";
import { FoxFormLabel } from "../fox-form-label";
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)}
role="group"
aria-labelledby={label ? `${autoId}-label` : undefined}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{label && (
<FoxFormLabel as="span" id={`${autoId}-label`}>
{label}
</FoxFormLabel>
)}
{/* 파일 고르기는 네이티브 입력이 맡고 화면에는 버튼만 보인다. */}
<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>
);
}