极简上传

upload · minimal · 灵感来源:Refactoring UI(实底虚线框)

把文件拖到这里,或

拖进来或点按钮选择;超过 5 MB 会被拒绝

把文件拖到这里,或

可多选,列表里逐个移除

把文件拖到这里,或

缺少签名,审核无法通过

把文件拖到这里,或

disabled 态:可见但不可改

复用卡

Dropzone · 极简上传 · 上传 / Minimal

规格 · 术语提示词(给 AI 用)

做一个 Dropzone(极简上传):
- 同一份 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
灵感参考:Refactoring UI(实底虚线框)

参数(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(accept / multiple / maxSizeMb / files / onChange)
  • 实底虚线框 + 发丝边;拖拽悬停时边框转 accent、底色换页面底
  • 三种入口、超限拒绝、文件列表与 aria 播报同玻璃上传

实现

upload/minimal
import { useRef, useState, type DragEvent } from 'react';
import { Upload, X } from 'lucide-react';
import { Icon } from '~/components/Icon';
import { Field } from '~/components/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>
  );
}

元信息

标识
upload/minimal
状态
done
最后提交
2026-11-01
风格轴
minimal
依赖 token
--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
在工作台打开 源码与 notes 都在 src/components/upload/minimal/