upload · glass-apple · 灵感来源:Dropzone 的拖放模型 + Apple HIG 的虚线投放区
把文件拖到这里,或
把文件拖到这里,或
把文件拖到这里,或
把文件拖到这里,或
Dropzone · 玻璃上传 · 上传 / Glass
做一个 Dropzone(玻璃上传):
- 三个入口:拖放、点选择文件、键盘聚焦按钮后 Enter 打开选择器
- maxSizeMb 单文件上限;超限文件被拒绝并在提示行说明,其余照常进入
- 文件列表显示名称与大小,每行可移除;aria-live 播报已选数量
- 不负责上传进度与取消——那是网络层
用这些 CSS 变量:--color-accent --color-bg --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-recess --text-2xs --text-base --text-xs
灵感参考:Dropzone 的拖放模型 + Apple HIG 的虚线投放区--color-accent --color-bg --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-recess --text-2xs --text-base --text-xs
焦点 / 禁用 / 错误
/r/upload--glass-apple.json
免外壳的 markdown 版:/llms/components/upload--glass-apple.mdmaxSizeMb 单文件上限;超限文件被拒绝,提示行说明是哪个文件,其余文件照常进入aria-label="移除 文件名");aria-live 播报「已选择 n 个文件」aria-describedbyimport { 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 GlassAppleUpload({
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-xl text-center
bg-[var(--glass-bg)] border border-dashed backdrop-blur-xl
transition-[border-color,background-color] duration-[var(--dur-fast)]
${dragging
? 'border-[var(--color-accent)] bg-[var(--glass-highlight)]'
: field.invalid
? 'border-[var(--color-warning-fg)]'
: 'border-[var(--glass-border)]'}
${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/glass-apple/