# 极简上传（Dropzone）

- 类型 / 材质：`upload` / `minimal`（极简）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Refactoring UI（实底虚线框）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/upload/minimal` ｜ 条目：`/r/upload--minimal.json`
- 必缺状态：焦点 / 禁用 / 错误
- 整页场景：尚未接入

## 规格

- 同一份 props（accept / multiple / maxSizeMb / files / onChange）
- 实底虚线框；拖拽悬停时边框转 accent
- 入口、校验与列表行为同玻璃上传

## 参数（CSS 变量）

`--color-accent` `--color-bg` `--color-divider` `--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--color-warning-fg` `--dur-fast` `--glass-border` `--glass-recess` `--text-2xs` `--text-base` `--text-xs`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `label` | `string` | 可选 | — |
| `hint` | `string` | 可选 | — |
| `error` | `boolean` | 可选 | — |
| `errorMessage` | `string` | 可选 | — |
| `required` | `boolean` | 可选 | — |
| `accept` | `string` | 可选 | 传给原生 input 的 accept，如 image/*,.pdf |
| `multiple` | `boolean` | 可选 | — |
| `maxSizeMb` | `number` | 可选 | 单个文件上限（MB）；超出的文件被拒绝并提示 |
| `files` | `File[]` | 可选 | — |
| `defaultFiles` | `File[]` | 可选 | — |
| `onChange` | `(files: File[]) => void` | 可选 | — |
| `disabled` | `boolean` | 可选 | — |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「极简上传」— Dropzone（上传 / Minimal）
灵感来源：Refactoring UI（实底虚线框）
规格：
- 同一份 props（accept / multiple / maxSizeMb / files / onChange）
- 实底虚线框；拖拽悬停时边框转 accent
- 入口、校验与列表行为同玻璃上传
参数（CSS 变量）：--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --text-2xs --text-base --text-xs
必缺状态：焦点 / 禁用 / 错误
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/upload/minimal · 标准 interaction /design#interaction · 调研笔记 /showcase/component/upload/minimal#notes
```

## 调研笔记

import { useRef, useState, type DragEvent } from 'react';
import { Upload, X } from 'lucide-react';
import { Icon } from '@/components/ui-echo/Icon';
import { Field } from '@/components/ui-echo/Field';

interface Props {
  label?: string;
  hint?: string;
  error?: boolean;
  errorMessage?: string;
  required?: boolean;
  /** 传给原生 input 的 accept，如 image/*,.pdf */
  accept?: string;
  multiple?: boolean;
  /** 单个文件上限（MB）；超出的文件被拒绝并提示 */
  maxSizeMb?: number;
  files?: File[];
  defaultFiles?: File[];
  onChange?: (files: File[]) => void;
  disabled?: boolean;
  className?: string;
}

function formatSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

export function MinimalUpload({
  label,
  hint,
  error = false,
  errorMessage,
  required = false,
  accept,
  multiple = false,
  maxSizeMb,
  files,
  defaultFiles,
  onChange,
  disabled = false,
  className = '',
}: Props) {
  const pickerRef = useRef<HTMLInputElement>(null);
  const [inner, setInner] = useState<File[]>(defaultFiles ?? []);
  const [dragging, setDragging] = useState(false);
  const [localError, setLocalError] = useState('');
  const current = files ?? inner;

  const commit = (incoming: File[]) => {
    const limit = maxSizeMb ? maxSizeMb * 1024 * 1024 : Infinity;
    const tooBig = incoming.filter((f) => f.size > limit);
    const accepted = incoming.filter((f) => f.size <= limit);
    setLocalError(tooBig.length ? `「${tooBig[0].name}」超过 ${maxSizeMb} MB` : '');
    const next = multiple ? [...current, ...accepted] : accepted.slice(0, 1);
    if (!files) setInner(next);
    onChange?.(next);
  };

  const onDrop = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setDragging(false);
    if (disabled) return;
    commit(Array.from(e.dataTransfer.files));
  };

  const remove = (name: string) => {
    const next = current.filter((f) => f.name !== name);
    if (!files) setInner(next);
    onChange?.(next);
  };

  return (
    <Field
      label={label}
      hint={hint}
      error={error}
      errorMessage={errorMessage || localError}
      required={required}
      className={className}
    >
      {(field) => (
        <div>
          <div
            onDragOver={(e) => {
              e.preventDefault();
              if (!disabled) setDragging(true);
            }}
            onDragLeave={() => setDragging(false)}
            onDrop={onDrop}
            className={`
              flex flex-col items-center justify-center gap-1.5
              px-4 py-6 rounded-md text-center
              bg-[var(--color-surface)] border border-dashed
              transition-[border-color,background-color] duration-[var(--dur-fast)]
              ${dragging
                ? 'border-[var(--color-accent)] bg-[var(--color-bg)]'
                : field.invalid
                  ? 'border-[var(--color-warning-fg)]'
                  : 'border-[var(--color-divider)]'}
              ${disabled ? 'opacity-50' : ''}
            `}
          >
            <Icon icon={Upload} size={18} className="text-[var(--color-text-subtle)]" />
            <p className="text-xs text-[var(--color-text-muted)]">把文件拖到这里，或</p>
            <button
              type="button"
              id={field.id}
              disabled={disabled}
              aria-describedby={field.describedBy}
              onClick={() => pickerRef.current?.click()}
              className="
                press-feedback px-3 py-1.5 rounded-lg text-xs
                border border-[var(--glass-border)] bg-[var(--color-surface)]
                text-[var(--color-text-muted)] hover:text-[var(--color-text)]
                transition-colors cursor-pointer disabled:cursor-not-allowed
              "
            >
              选择文件
            </button>
            <input
              ref={pickerRef}
              type="file"
              accept={accept}
              multiple={multiple}
              disabled={disabled}
              className="sr-only"
              onChange={(e) => {
                commit(Array.from(e.target.files ?? []));
                e.target.value = '';
              }}
            />
            <span className="sr-only" aria-live="polite">
              {current.length ? `已选择 ${current.length} 个文件` : ''}
            </span>
          </div>

          {current.length > 0 && (
            <ul className="mt-2 space-y-1">
              {current.map((file) => (
                <li
                  key={file.name}
                  className="flex items-center justify-between gap-3 px-2.5 py-1.5 rounded-lg bg-[var(--color-bg)]"
                >
                  <span className="min-w-0 truncate text-xs">{file.name}</span>
                  <span className="shrink-0 text-[length:var(--text-2xs)] font-mono text-[var(--color-text-subtle)]">
                    {formatSize(file.size)}
                  </span>
                  <button
                    type="button"
                    disabled={disabled}
                    onClick={() => remove(file.name)}
                    aria-label={`移除 ${file.name}`}
                    className="press-feedback shrink-0 grid place-items-center size-5 rounded text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer disabled:cursor-not-allowed"
                  >
                    <Icon icon={X} size={11} />
                  </button>
                </li>
              ))}
            </ul>
          )}
        </div>
      )}
    </Field>
  );
}
