upload · minimal · 灵感来源:Refactoring UI(实底虚线框)
把文件拖到这里,或
把文件拖到这里,或
把文件拖到这里,或
把文件拖到这里,或
Dropzone · 极简上传 · 上传 / Minimal
做一个 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(实底虚线框)--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
焦点 / 禁用 / 错误
(尚未接进整页场景)
/r/upload--minimal.json
免外壳的 markdown 版:/llms/components/upload--minimal.mdimport { 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>
);
}
src/components/upload/minimal/