임동욱 임동욱 08-13
feat: FoxFileUpload 추가
Figma 시안 다섯을 한 컴포넌트로 옮긴다 — file-upload(391:3677) ·
file-upload-area(392:5968) · file-upload-img(391:3732) · file-list(392:5938) ·
file-list-item(391:3509). 고르는 방식만 다르고 목록은 같아서다.

시안에서 버튼·입력이 모두 기존 컴포넌트의 인스턴스라 그대로 조합한다.
썸네일 삭제 버튼은 FoxIconButton overlay xsm이 시안과 정확히 같다(24px ·
모서리 4 · 검정 50% · 배경흐림 10 · 흰 아이콘).

업로드는 하지 않는다. 파일을 고르면 원본 File을 넘길 뿐이고 진행률·서버
오류를 목록에 어떻게 비출지는 files로 정한다. files를 안 주면 안에서 들고
있어 간단한 폼에 그대로 쓴다.

default 모드는 시안의 두 상태를 모두 갖는다 — 비었으면 흰 칸에
파일선택(secondary), 파일 하나를 목록 없이 들고 있으면 칸이 회색으로 잠기고
버튼이 삭제(default 계열 · 휴지통)로 바뀐다. 흰 칸 규칙은 FoxInput의 읽기
전용 규칙(0,6,0)보다 특이도를 높여 로드 순서와 무관하게 이기도록 적었다.

끌어다 놓기는 prop으로 켜되 area는 안내문이 그렇게 말하므로 언제나 받는다.
입력칸 폭과 썸네일 크기는 토큰에 없어 prop 기본값으로 뒀다(사용자 승인).

Co-Authored-By: Claude Opus 5 
@3c3b7a9994f5e8016e1aaf2d5572201cd9a8ea6d
@fox/README.md
--- @fox/README.md
+++ @fox/README.md
@@ -162,6 +162,9 @@
 - 전화번호는 화면만 세 칸이고 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지
   않습니다. `unit`은 앞자리가 드롭다운이라 조각을 select·input으로 조립하고, `combine`은
   상자 하나에 세 칸을 둡니다.
+- 파일 첨부는 세 모드(`default`·`area`·`image`)를 한 컴포넌트로 둡니다. 업로드는 하지
+  않고 고른 원본 `File`만 넘기며, 진행률·서버 오류를 목록에 어떻게 비출지는 `files`로
+  정합니다. `area`는 끌어다 놓기가 항상 켜져 있고 `image`에는 목록이 없습니다.
 - 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다.
 
 `core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도
 
@fox/core/components/fox-file-upload/fox-file-upload.tsx (added)
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
@@ -0,0 +1,460 @@
+"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>
+  );
+}
 
@fox/core/components/fox-file-upload/index.ts (added)
+++ @fox/core/components/fox-file-upload/index.ts
@@ -0,0 +1,6 @@
+export {
+  FoxFileUpload,
+  type FoxFileUploadProps,
+  type FoxFileUploadMode,
+  type FoxFileItem,
+} from "./fox-file-upload";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -4,6 +4,7 @@
 export * from "./fox-button-panel";
 export * from "./fox-certified";
 export * from "./fox-email";
+export * from "./fox-file-upload";
 export * from "./fox-icon-button";
 export * from "./fox-input";
 export * from "./fox-link-button";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -10,6 +10,7 @@
 import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
 import { FoxCertified } from "../core/components/fox-certified";
 import { FoxEmail } from "../core/components/fox-email";
+import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
 import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
 import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
 import { FoxTextButton } from "../core/components/fox-text-button";
@@ -205,6 +206,41 @@
       </InputStage>
       <pre className={styles.code}>{JSON.stringify(value, null, 2)}</pre>
     </>
+  );
+}
+
+// 목록은 업로드 결과에 따라 바뀌므로, 오류 줄까지 보려면 예제가 값을 들고 있어야 한다.
+const SAMPLE_FILES: FoxFileItem[] = [
+  { id: "a", name: "사업자등록증", meta: ".PDF (20KB)" },
+  { id: "b", name: "아주 긴 파일 이름이라 가운데에서 말줄임되어야 하는 첨부물", meta: ".XLSX (1.2MB)" },
+  {
+    id: "c",
+    name: "용량초과파일",
+    meta: ".ZIP (32MB)",
+    error: true,
+    message: "등록 가능한 파일 용량을 초과하였습니다. 20MB 미만의 파일만 등록할 수 있습니다.",
+  },
+];
+
+function FileUploadLiveDemo({ mode }: { mode: "default" | "area" }) {
+  const [files, setFiles] = useState<FoxFileItem[]>(SAMPLE_FILES);
+  return (
+    <InputStage wide>
+      <FoxFileUpload
+        mode={mode}
+        label={mode === "area" ? "끌어다 놓거나 버튼으로 고릅니다" : "고른 파일이 칸에 적힙니다"}
+        files={files}
+        onSelect={(picked) =>
+          setFiles((prev) => [
+            ...prev,
+            ...picked.map((file) => ({ id: file.name, name: file.name, meta: `(${file.size}B)` })),
+          ])
+        }
+        onRemove={(id) => setFiles((prev) => prev.filter((item) => item.id !== id))}
+        description={mode === "default" ? "JPG, PNG 파일만 가능 (최대 5MB)" : undefined}
+        multiple
+      />
+    </InputStage>
   );
 }
 
@@ -1268,4 +1304,92 @@
       },
     ],
   },
+  {
+    id: "fox-file-upload",
+    name: "FoxFileUpload",
+    description:
+      "Figma 시안 다섯(file-upload 391:3677 · file-upload-area 392:5968 · file-upload-img 391:3732 · file-list 392:5938 · file-list-item 391:3509)을 한 컴포넌트로 옮겼습니다. 버튼·입력이 모두 기존 컴포넌트의 인스턴스라 FoxButton·FoxTextButton·FoxIconButton·FoxInput을 그대로 조합했습니다. 업로드는 하지 않고 고른 원본 File만 넘깁니다 — 목록은 files로 주면 제어, 안 주면 안에서 들고 있습니다. default·area는 목록을 붙일 수 있고 image는 목록이 없습니다.",
+    variants: [
+      {
+        label: "default 모드 — 읽기 전용 칸 + 파일선택, 목록 지원",
+        node: <FileUploadLiveDemo mode="default" />,
+      },
+      {
+        label: "area 모드 — 끌어다 놓기가 항상 켜져 있고 목록을 지원합니다",
+        node: <FileUploadLiveDemo mode="area" />,
+      },
+      {
+        label: "image 모드 — 목록이 없고 썸네일 하나입니다",
+        node: (
+          <>
+            <InputStage wide>
+              <FoxFileUpload
+                mode="image"
+                label="비었을 때"
+                accept="image/*"
+                description="JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)"
+              />
+            </InputStage>
+            <InputStage wide>
+              <FoxFileUpload
+                mode="image"
+                label="채워졌을 때 — 가운데 버튼으로 지웁니다"
+                accept="image/*"
+                defaultFiles={[
+                  { id: "p", name: "미리보기", previewUrl: "https://picsum.photos/seed/picsum/426/240" },
+                ]}
+                description="JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)"
+              />
+            </InputStage>
+          </>
+        ),
+      },
+      {
+        label: "default 모드 · 파일 하나 (시안 upload=true) — 칸이 잠기고 버튼이 삭제로 바뀝니다",
+        node: (
+          <>
+            <InputStage wide>
+              <FoxFileUpload label="비었을 때" description="JPG, PNG 파일만 가능 (최대 5MB)" />
+            </InputStage>
+            <InputStage wide>
+              <FoxFileUpload
+                label="파일이 있을 때"
+                defaultFiles={[{ id: "one", name: "파일명", meta: ".PDF (20KB)" }]}
+                description="JPG, PNG 파일만 가능 (최대 5MB)"
+              />
+            </InputStage>
+          </>
+        ),
+      },
+      {
+        label: "목록 없이 · 크기 바꾸기",
+        node: (
+          <>
+            <InputStage wide>
+              <FoxFileUpload label="showList={false}" defaultFiles={SAMPLE_FILES} showList={false} multiple />
+            </InputStage>
+            <InputStage wide>
+              <FoxFileUpload label="inputWidth=440" inputWidth={440} />
+            </InputStage>
+            <InputStage wide>
+              <FoxFileUpload
+                mode="image"
+                label="thumbnail 160×90"
+                thumbnailWidth={160}
+                thumbnailHeight={90}
+              />
+            </InputStage>
+          </>
+        ),
+      },
+      {
+        label: "비활성",
+        node: (
+          <InputStage wide>
+            <FoxFileUpload label="disabled" defaultFiles={SAMPLE_FILES} multiple disabled />
+          </InputStage>
+        ),
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-file-upload.scss (added)
+++ @fox/styles/_fox-file-upload.scss
@@ -0,0 +1,286 @@
+// FoxFileUpload — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//   file-upload (391:3677) · file-upload-area (392:5968) · file-upload-img (391:3732)
+//   file-list (392:5938) · file-list-item (391:3509)
+//
+// 세 모드가 한 컴포넌트다.
+//   - default: [읽기 전용 입력 + 파일선택(secondary)] — 목록을 붙일 수 있다.
+//   - area:    점선 상자에 안내문과 파일선택(primary) — 목록을 붙일 수 있고 끌어다 놓기가
+//              항상 켜져 있다(안내문이 그렇게 말한다).
+//   - image:   썸네일 + 파일선택(secondary) + 설명 — 목록이 없다.
+//
+// 시안에서 버튼·입력이 모두 기존 컴포넌트의 인스턴스라 그대로 조합한다.
+//   파일선택 = FoxButton(secondary/primary md) · 삭제 = FoxTextButton(md secondary)
+//   썸네일 삭제 = FoxIconButton(overlay xsm — 24px·radius 4·배경흐림·흰 아이콘이 시안과 같다)
+//   default 모드 입력칸 = FoxInput(md, 읽기 전용)
+// 그래서 이 파일은 배치와 목록·썸네일·점선 상자만 갖는다.
+//
+// 아이콘 색은 에셋에서 확인해 토큰과 맞췄다 — 클립 #8A949E(icon-neutral-subtler),
+// 오류 #BD2C0F(icon-system-danger-strong), 빈 썸네일은 검정 25%(icon-neutral-fill).
+// 버튼 안 아이콘은 FoxButton·FoxTextButton이 `currentColor`로 칠하므로 여기서 정하지 않는다.
+//
+// 토큰이 아닌 값:
+//   - 입력칸 폭과 썸네일 크기는 인라인 스타일로 들어온다 — 시안 값(320px · 213×120px)이
+//     토큰에 없어 prop 기본값으로 두었다(사용자 승인).
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - object-fit / overflow / position: 구조 규칙.
+
+@use "abstracts" as fox;
+
+.fox-file-upload {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+  inline-size: 100%;
+
+  // area 모드는 상자와 목록 사이가 한 단계 넓다.
+  &--area {
+    gap: fox.gap(7);
+  }
+
+  &__label {
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-md);
+    font-weight: fox.font-weight(medium);
+    line-height: 1;
+    letter-spacing: -0.025em;
+  }
+
+  &__description {
+    margin: 0;
+    color: fox.color(font-neutral-subtle);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // ── default 모드 ──────────────────────────────────────────────────────────
+  &__row {
+    display: flex;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    inline-size: 100%;
+  }
+
+  // 폭은 prop이 인라인 스타일로 정한다.
+  &__field {
+    flex-shrink: 0;
+  }
+
+
+  // ── area 모드 ─────────────────────────────────────────────────────────────
+  &__area {
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: fox.gap(7);
+    inline-size: 100%;
+    padding: fox.padding(10);
+    border: fox.border(2) dashed fox.color(border-neutral-subtle);
+    border-radius: fox.radius(6);
+    background: fox.color(surface-neutral-gray);
+  }
+
+  // 끌어오는 동안. 시안에 없는 상태라 기존 토큰으로 최소한만 표시한다.
+  &__area--over {
+    border-color: fox.color(border-theme-primary);
+    background: fox.color(surface-theme-primary);
+  }
+
+  &__area-text {
+    margin: 0;
+    inline-size: 100%;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-lg);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+    text-align: center;
+  }
+
+  // ── image 모드 ────────────────────────────────────────────────────────────
+  &__image {
+    display: flex;
+    align-items: center;
+    gap: fox.gap(6);
+    inline-size: 100%;
+  }
+
+  &__thumb {
+    position: relative;
+    box-sizing: border-box;
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+    border: fox.border(1) dashed fox.color(border-neutral-subtle);
+    border-radius: fox.radius(3);
+    background: fox.color(surface-neutral-gray);
+    color: fox.color(icon-neutral-fill);
+  }
+
+  // 그림이 들어오면 점선이 실선이 되고 바탕은 그림이 덮는다.
+  &__thumb--filled {
+    border-style: solid;
+    background: none;
+  }
+
+  &__thumb-icon > * {
+    inline-size: fox.icon(6);
+    block-size: fox.icon(6);
+  }
+
+  &__thumb-image {
+    inline-size: 100%;
+    block-size: 100%;
+    object-fit: cover;
+  }
+
+  &__thumb-remove {
+    position: absolute;
+    inset-block-start: 50%;
+    inset-inline-start: 50%;
+    transform: translate(-50%, -50%);
+  }
+
+  &__image-side {
+    display: flex;
+    flex: 1 0 0;
+    flex-direction: column;
+    gap: fox.gap(4);
+    min-inline-size: 0;
+  }
+
+  // ── 목록 ──────────────────────────────────────────────────────────────────
+  // 줄이 많아지면 상자 안에서만 스크롤한다.
+  &__list {
+    display: flex;
+    flex-direction: column;
+    inline-size: 100%;
+    max-block-size: 32rem;
+    overflow-x: clip;
+    overflow-y: auto;
+    border: fox.border(1) solid fox.color(border-neutral-subtle);
+    border-radius: fox.radius(3);
+  }
+
+  &__item {
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    gap: fox.gap(4);
+    justify-content: center;
+    inline-size: 100%;
+    padding-inline: fox.padding(6);
+    padding-block: fox.padding(4);
+    border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
+    background: fox.color(surface-neutral-default);
+  }
+
+  // 오류 줄은 네 변 모두 굵은 테두리를 갖는다.
+  &__item--error {
+    border: fox.border(2) solid fox.color(border-system-danger);
+    background: fox.color(surface-system-danger);
+  }
+
+  &__item-main {
+    display: flex;
+    align-items: center;
+    gap: fox.gap(3);
+    inline-size: 100%;
+  }
+
+  &__item-title {
+    display: flex;
+    flex: 1 0 0;
+    align-items: center;
+    gap: fox.gap(2);
+    min-inline-size: 0;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+    white-space: nowrap;
+  }
+
+  &__item-icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    color: fox.color(icon-neutral-subtler);
+
+    > * {
+      inline-size: fox.icon(4);
+      block-size: fox.icon(4);
+    }
+  }
+
+  // 이름만 줄어들고 확장자·용량은 그대로 남는다.
+  &__item-name {
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  &__item-meta {
+    flex-shrink: 0;
+  }
+
+  &__item-actions {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    gap: fox.gap(4);
+  }
+
+  // 오류 문구는 줄 안에서 옅은 선으로 한 번 끊고 붙는다.
+  &__item-message {
+    display: flex;
+    gap: fox.number(3);
+    align-items: flex-start;
+    inline-size: 100%;
+    margin: 0;
+    padding-block-start: fox.padding(5);
+    border-block-start: fox.border(1) solid fox.color(border-neutral-overlay);
+    color: fox.color(font-system-danger);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__item-message-icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: flex-start;
+    padding-block-start: fox.padding(1);
+    color: fox.color(icon-system-danger-strong);
+
+    > * {
+      inline-size: fox.icon(3);
+      block-size: fox.icon(3);
+    }
+  }
+}
+
+// 칸은 손으로 고칠 수 없으므로 언제나 읽기 전용이지만, 시안은 비었을 때 흰 상자를 보여준다
+// (파일이 들어오면 읽기 전용 본래의 회색 상자가 그대로 나온다 — 시안 upload=true).
+// FoxInput의 읽기 전용 규칙이 특이도 0,6,0이라 그보다 높게 적어 로드 순서와 무관하게 이긴다.
+.fox-file-upload
+  .fox-file-upload__field--empty
+  .fox-input:not([data-state])
+  .fox-input__box:has(.fox-file-upload__field--empty .fox-input__field:read-only:not(:disabled)),
+.fox-file-upload
+  .fox-file-upload__field--empty
+  .fox-input:not([data-state])
+  .fox-input__box:has(.fox-input__field:read-only:not(:disabled)) {
+  border-color: fox.color(input-border);
+  background: fox.color(input-surface);
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -10,6 +10,7 @@
 @use "fox-address";
 @use "fox-certified";
 @use "fox-email";
+@use "fox-file-upload";
 @use "fox-input";
 @use "fox-phone-number";
 @use "fox-select";
Add a comment
List