Área de Upload
Área para arrastar e soltar ou clicar e escolher arquivos, com validação de tipo, tamanho e quantidade, lista dos escolhidos com miniatura (prévia de imagens ou ícone pelo tipo), tamanho, botão de remover e progresso de envio de cada um. Exporta também a MiniaturaDeArquivo.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<MiniaturaDeArquivo>
Miniatura de um arquivo: a própria imagem quando é imagem, senão um ícone pelo tipo (PDF, planilha, documento, vídeo, áudio, compactado). Para `File`, a prévia usa um endereço temporário (`URL.createObjectURL`) liberado ao desmontar.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| arquivoobrigatória | ArquivoExibido | — | |
| tamanho | "pequena" | "grande" | "pequena" | "pequena": quadrado de 48px para listas. "grande": ocupa a largura disponível, quadrada. |
<AreaDeUpload>
Área para escolher arquivos: arrastar e soltar ou clicar (e Enter/Espaço) para abrir o seletor do sistema. Valida tipo, tamanho e quantidade com mensagens claras, e lista os escolhidos com miniatura, nome, tamanho, botão de remover e, se informado, o progresso do envio de cada um. Controlada: recebe `arquivos` e devolve a lista nova em `aoMudar`.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| arquivosobrigatória | File[] | — | |
| aoMudarobrigatória | (arquivos: File[]) => void | — | |
| aceitar | string | — | Tipos aceitos, no formato do `accept` do HTML: ".pdf,image/*". |
| multiplos | boolean | false | Aceita vários arquivos (somando aos já escolhidos). Sem isso, um novo substitui o anterior. |
| tamanhoMaximo | number | — | Tamanho máximo de cada arquivo, em bytes (5 MB = 5 * 1024 * 1024). |
| maximoDeArquivos | number | — | Quantidade máxima de arquivos com `multiplos`. |
| progresso | ReadonlyMap<File, number> | — | Progresso do envio de cada arquivo, de 0 a 100. Arquivos fora do mapa não mostram barra. |
| exibicao | "lista" | "grade" | "lista" | "lista": linhas com miniatura pequena. "grade": cartões com miniatura grande (bom para imagens). |
| titulo | string | — | Texto principal da área. Padrão: "Arraste … ou clique para escolher". |
| descricao | string | — | Linha de apoio. Padrão: tipos aceitos e tamanho máximo ("PDF ou imagens, até 5 MB"). |
| desabilitado | boolean | false | |
| mensagens | Partial<MensagensDoUpload> | — |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, useRef, useState, type DragEvent } from "react";
import { extensao, MiniaturaDeArquivo } from "./MiniaturaDeArquivo";
/** 1536 → "1,5 KB"; 5242880 → "5 MB" (base 1024). */
export function formatarTamanho(bytes: number) {
const unidades = ["B", "KB", "MB", "GB", "TB"];
let valor = bytes;
let u = 0;
while (valor >= 1024 && u < unidades.length - 1) {
valor /= 1024;
u++;
}
return `${valor.toLocaleString("pt-BR", { maximumFractionDigits: u === 0 || valor >= 100 ? 0 : 1 })} ${unidades[u]}`;
}
/** Testa o arquivo contra a lista do `accept` (".pdf", "image/*", "application/pdf"). */
function aceito(arquivo: File, aceitar: string | undefined) {
if (!aceitar) return true;
const nome = arquivo.name.toLowerCase();
const tipo = arquivo.type.toLowerCase();
return aceitar
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
.some((t) => {
if (t.startsWith(".")) return nome.endsWith(t);
if (t.endsWith("/*")) return tipo.startsWith(t.slice(0, -1));
return tipo === t;
});
}
const NOME_DO_TIPO: Record<string, string> = {
"image/*": "imagens",
"video/*": "vídeos",
"audio/*": "áudios",
"application/pdf": "PDF",
"text/csv": "CSV",
};
/** ".pdf,image/*" → "PDF ou imagens". */
function descreverTipos(aceitar: string) {
const nomes = [
...new Set(
aceitar
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
.map((t) => NOME_DO_TIPO[t] ?? (t.startsWith(".") ? t.slice(1).toUpperCase() : t)),
),
];
return nomes.length <= 1 ? (nomes[0] ?? "") : `${nomes.slice(0, -1).join(", ")} ou ${nomes[nomes.length - 1]}`;
}
const mesmoArquivo = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
/** Mensagens de erro e textos da área; troque para o contexto. */
export type MensagensDoUpload = {
tipo: (nome: string) => string;
tamanho: (nome: string, limite: string) => string;
quantidade: (limite: number) => string;
lista: string;
remover: (nome: string) => string;
enviando: string;
enviado: string;
};
const MENSAGENS_PADRAO: MensagensDoUpload = {
tipo: (nome) => `“${nome}” não é de um tipo aceito.`,
tamanho: (nome, limite) => `“${nome}” passa do limite de ${limite}.`,
quantidade: (limite) => (limite === 1 ? "Escolha só um arquivo." : `Escolha no máximo ${limite} arquivos.`),
lista: "Arquivos escolhidos",
remover: (nome) => `Remover ${nome}`,
enviando: "Enviando",
enviado: "Enviado",
};
/**
* Área para escolher arquivos: arrastar e soltar ou clicar (e Enter/Espaço) para
* abrir o seletor do sistema. Valida tipo, tamanho e quantidade com mensagens
* claras, e lista os escolhidos com miniatura, nome, tamanho, botão de remover e,
* se informado, o progresso do envio de cada um.
*
* Controlada: recebe `arquivos` e devolve a lista nova em `aoMudar`.
*/
export function AreaDeUpload({
arquivos,
aoMudar,
aceitar,
multiplos = false,
tamanhoMaximo,
maximoDeArquivos,
progresso,
exibicao = "lista",
titulo,
descricao,
desabilitado = false,
mensagens: mensagensParciais,
}: {
arquivos: File[];
aoMudar: (arquivos: File[]) => void;
/** Tipos aceitos, no formato do `accept` do HTML: ".pdf,image/*". */
aceitar?: string;
/** Aceita vários arquivos (somando aos já escolhidos). Sem isso, um novo substitui o anterior. */
multiplos?: boolean;
/** Tamanho máximo de cada arquivo, em bytes (5 MB = 5 * 1024 * 1024). */
tamanhoMaximo?: number;
/** Quantidade máxima de arquivos com `multiplos`. */
maximoDeArquivos?: number;
/** Progresso do envio de cada arquivo, de 0 a 100. Arquivos fora do mapa não mostram barra. */
progresso?: ReadonlyMap<File, number>;
/** "lista": linhas com miniatura pequena. "grade": cartões com miniatura grande (bom para imagens). */
exibicao?: "lista" | "grade";
/** Texto principal da área. Padrão: "Arraste … ou clique para escolher". */
titulo?: string;
/** Linha de apoio. Padrão: tipos aceitos e tamanho máximo ("PDF ou imagens, até 5 MB"). */
descricao?: string;
desabilitado?: boolean;
mensagens?: Partial<MensagensDoUpload>;
}) {
const mensagens = { ...MENSAGENS_PADRAO, ...mensagensParciais };
const idErros = useId();
const seletor = useRef<HTMLInputElement>(null);
const area = useRef<HTMLButtonElement>(null);
const [sobre, setSobre] = useState(false);
const [erros, setErros] = useState<string[]>([]);
const limite = multiplos ? maximoDeArquivos : 1;
const textoTitulo =
titulo ?? (multiplos ? "Arraste arquivos até aqui ou clique para escolher" : "Arraste um arquivo até aqui ou clique para escolher");
const textoDescricao =
descricao ??
[aceitar && descreverTipos(aceitar), tamanhoMaximo !== undefined && `até ${formatarTamanho(tamanhoMaximo)}`]
.filter(Boolean)
.join(", ");
function receber(lista: FileList | null | undefined) {
if (!lista || lista.length === 0 || desabilitado) return;
const novos: File[] = [];
const problemas: string[] = [];
for (const f of Array.from(lista)) {
if (!aceito(f, aceitar)) problemas.push(mensagens.tipo(f.name));
else if (tamanhoMaximo !== undefined && f.size > tamanhoMaximo) problemas.push(mensagens.tamanho(f.name, formatarTamanho(tamanhoMaximo)));
else novos.push(f);
}
let resultado: File[];
if (!multiplos) {
if (novos.length > 1) problemas.push(mensagens.quantidade(1));
resultado = novos.length ? [novos[0]] : arquivos;
} else {
resultado = [...arquivos, ...novos.filter((n) => !arquivos.some((a) => mesmoArquivo(a, n)))];
if (limite !== undefined && resultado.length > limite) {
problemas.push(mensagens.quantidade(limite));
resultado = resultado.slice(0, limite);
}
}
setErros(problemas);
if (novos.length) aoMudar(resultado);
}
function remover(indice: number) {
aoMudar(arquivos.filter((_, i) => i !== indice));
setErros([]);
area.current?.focus();
}
const aoArrastarSobre = (e: DragEvent) => {
if (desabilitado || !e.dataTransfer.types.includes("Files")) return;
// Sem o preventDefault o navegador recusa o drop e abre o arquivo numa aba.
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
if (!sobre) setSobre(true);
};
return (
<div className="flex flex-col gap-3">
<input
ref={seletor}
type="file"
hidden
tabIndex={-1}
accept={aceitar}
multiple={multiplos}
disabled={desabilitado}
onChange={(e) => {
receber(e.target.files);
// Zera para o mesmo arquivo poder ser escolhido de novo depois de removido.
e.target.value = "";
}}
/>
<button
ref={area}
type="button"
disabled={desabilitado}
aria-describedby={erros.length ? idErros : undefined}
onClick={() => seletor.current?.click()}
onDragEnter={aoArrastarSobre}
onDragOver={aoArrastarSobre}
onDragLeave={(e) => {
// dragleave também dispara ao passar de um filho para outro: só apaga ao sair da área inteira.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setSobre(false);
}}
onDrop={(e) => {
e.preventDefault();
setSobre(false);
receber(e.dataTransfer.files);
}}
className={`flex w-full cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed px-6 py-8 text-center outline-accent focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
sobre ? "border-accent bg-accent/5" : "border-rule-strong hover:border-accent hover:bg-surface"
}`}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className={`size-7 ${sobre ? "text-accent" : "text-muted"}`}
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 15V4m-4 4 4-4 4 4" />
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
</svg>
<span className="text-sm font-medium text-ink">{textoTitulo}</span>
{textoDescricao && <span className="text-xs text-muted first-letter:uppercase">{textoDescricao}</span>}
</button>
{erros.length > 0 && (
<ul id={idErros} role="alert" className="flex flex-col gap-1 text-sm text-critical">
{erros.map((erro, i) => (
<li key={i} className="flex items-start gap-2">
<svg aria-hidden="true" viewBox="0 0 24 24" className="mt-0.5 size-4 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v5M12 16.5h.01" />
</svg>
{erro}
</li>
))}
</ul>
)}
{arquivos.length > 0 && (
<ul
aria-label={mensagens.lista}
className={exibicao === "grade" ? "grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3" : "flex flex-col gap-2"}
>
{arquivos.map((arquivo, i) => {
const pct = progresso?.get(arquivo);
const meta = [extensao(arquivo.name), formatarTamanho(arquivo.size)].filter(Boolean).join(" · ");
// O botão fica a 3 (grade) ou 4 (lista) unidades da borda do cartão: o raio acompanha essa folga
const raioDoBotao = exibicao === "grade" ? "rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*3))]" : "rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*4))]";
const botaoRemover = (
<button
type="button"
onClick={() => remover(i)}
aria-label={mensagens.remover(arquivo.name)}
title={mensagens.remover(arquivo.name)}
className={`flex size-8 shrink-0 cursor-pointer items-center justify-center ${raioDoBotao} text-muted outline-accent hover:bg-critical/10 hover:text-critical focus-visible:outline-2`}
>
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
);
const barra = pct !== undefined && (
<div className="mt-1.5 flex items-center gap-2">
<div
role="progressbar"
aria-label={`${pct >= 100 ? mensagens.enviado : mensagens.enviando}: ${arquivo.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pct)}
className="h-1.5 flex-1 overflow-hidden rounded-full bg-track"
>
<div
className={`h-full rounded-full transition-[width] duration-150 ${pct >= 100 ? "bg-success" : "bg-accent"}`}
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
/>
</div>
<span className={`text-xs tabular-nums ${pct >= 100 ? "text-success" : "text-muted"}`}>
{pct >= 100 ? mensagens.enviado : `${Math.round(pct)}%`}
</span>
</div>
);
return exibicao === "grade" ? (
<li key={`${arquivo.name}-${arquivo.lastModified}-${i}`} className="relative flex flex-col gap-2 rounded-xl border border-rule bg-page p-2">
<MiniaturaDeArquivo arquivo={arquivo} tamanho="grande" />
<div className="min-w-0 px-1 pb-1">
<p className="truncate text-sm text-ink" title={arquivo.name}>
{arquivo.name}
</p>
<p className="text-xs text-muted">{formatarTamanho(arquivo.size)}</p>
{barra}
</div>
<span className={`absolute top-3 right-3 ${raioDoBotao} bg-page/90`}>{botaoRemover}</span>
</li>
) : (
<li key={`${arquivo.name}-${arquivo.lastModified}-${i}`} className="flex items-center gap-3 rounded-xl border border-rule bg-page p-2 pr-4">
<MiniaturaDeArquivo arquivo={arquivo} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-ink" title={arquivo.name}>
{arquivo.name}
</p>
<p className="text-xs text-muted">{meta}</p>
{barra}
</div>
{botaoRemover}
</li>
);
})}
</ul>
)}
</div>
);
}
"use client";
import { useCallback, useState } from "react";
/** Um arquivo escolhido agora (`File`) ou um já enviado, descrito por nome, tipo MIME e endereço. */
export type ArquivoExibido = File | { nome: string; tipo?: string; url?: string };
type Categoria = "imagem" | "pdf" | "planilha" | "documento" | "video" | "audio" | "compactado" | "outro";
/** `instanceof File` protegido para ambientes sem `File` (renderização no servidor antiga). */
export const ehFile = (a: unknown): a is File => typeof File !== "undefined" && a instanceof File;
/** Nome e tipo MIME de qualquer forma de arquivo. */
export function dadosDoArquivo(arquivo: ArquivoExibido) {
return ehFile(arquivo)
? { nome: arquivo.name, tipo: arquivo.type, url: undefined }
: { nome: arquivo.nome, tipo: arquivo.tipo ?? "", url: arquivo.url };
}
/** Extensão em maiúsculas ("PDF", "XLSX"), ou vazio. */
export function extensao(nome: string) {
const ponto = nome.lastIndexOf(".");
return ponto > 0 ? nome.slice(ponto + 1).toUpperCase() : "";
}
// O tipo MIME às vezes vem vazio (arquivo arrastado, extensão rara): a extensão é a segunda chance.
function categoria(nome: string, tipo: string): Categoria {
const ext = extensao(nome).toLowerCase();
if (tipo.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "webp", "avif", "svg"].includes(ext)) return "imagem";
if (tipo === "application/pdf" || ext === "pdf") return "pdf";
if (tipo.startsWith("video/") || ["mp4", "mov", "webm", "mkv"].includes(ext)) return "video";
if (tipo.startsWith("audio/") || ["mp3", "wav", "ogg", "m4a"].includes(ext)) return "audio";
if (["xls", "xlsx", "csv", "ods"].includes(ext) || tipo.includes("spreadsheet") || tipo === "text/csv") return "planilha";
if (["zip", "rar", "7z", "gz", "tar"].includes(ext) || tipo.includes("zip") || tipo.includes("compressed")) return "compactado";
if (["doc", "docx", "odt", "txt", "md", "rtf"].includes(ext) || tipo.startsWith("text/") || tipo.includes("word")) return "documento";
return "outro";
}
const DESENHO: Record<Exclude<Categoria, "imagem">, string> = {
pdf: "M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-5m6 5v-5h1a2.5 2.5 0 0 1 0 5h-1",
planilha: "M8 12h8M8 16h8M12 12v6",
documento: "M8 12h8M8 15h8M8 18h5",
video: "m10 11.5 4 2.5-4 2.5z",
audio: "M10 18a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm1.5-1.5V11l3-1",
compactado: "M11 4v2m2 0v2m-2 0v2m2 0v2m-2 3h2v3h-2z",
outro: "",
};
const COR: Record<Categoria, string> = {
imagem: "text-accent",
pdf: "text-critical",
planilha: "text-success",
documento: "text-accent",
video: "text-warning",
audio: "text-warning",
compactado: "text-ink-2",
outro: "text-muted",
};
function IconeDoArquivo({ cat, className }: { cat: Categoria; className: string }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<path d="M14 3v5h5" />
{cat === "imagem" ? <path d="m7 18 3.5-4 2.5 2.5 1.5-1.5L17 18zM10 11.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2z" /> : <path d={DESENHO[cat]} />}
</svg>
);
}
/**
* Miniatura de um arquivo: a própria imagem quando é imagem, senão um ícone pelo
* tipo (PDF, planilha, documento, vídeo, áudio, compactado). Para `File`, a
* prévia usa um endereço temporário (`URL.createObjectURL`) liberado ao desmontar.
*/
export function MiniaturaDeArquivo({
arquivo,
tamanho = "pequena",
}: {
arquivo: ArquivoExibido;
/** "pequena": quadrado de 48px para listas. "grande": ocupa a largura disponível, quadrada. */
tamanho?: "pequena" | "grande";
}) {
const { nome, tipo, url } = dadosDoArquivo(arquivo);
const cat = categoria(nome, tipo);
const [falhou, setFalhou] = useState<ArquivoExibido | null>(null);
const mostrarImagem = cat === "imagem" && falhou !== arquivo && (ehFile(arquivo) || !!url);
// Ref com limpeza (React 19): cria o endereço quando a <img> monta e o revoga quando ela sai ou o arquivo muda.
const prender = useCallback(
(img: HTMLImageElement | null) => {
if (!img || !ehFile(arquivo)) return;
const endereco = URL.createObjectURL(arquivo);
img.src = endereco;
return () => URL.revokeObjectURL(endereco);
},
[arquivo],
);
const caixa =
// Dentro do cartão (rounded-xl, p-2): raio do cartão menos o espaçamento, para a curva acompanhar o contorno
`${tamanho === "grande" ? "aspect-square w-full" : "size-12"} rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*2))]`;
return (
<span className={`relative flex shrink-0 items-center justify-center overflow-hidden border border-rule bg-surface ${caixa}`}>
{mostrarImagem ? (
// eslint-disable-next-line @next/next/no-img-element -- a biblioteca não depende do next/image
<img
ref={ehFile(arquivo) ? prender : undefined}
src={ehFile(arquivo) ? undefined : url}
alt=""
onError={() => setFalhou(arquivo)}
className="size-full object-cover"
/>
) : (
<span className="flex flex-col items-center gap-1">
<IconeDoArquivo cat={cat} className={`${tamanho === "grande" ? "size-10" : "size-6"} ${COR[cat]}`} />
{tamanho === "grande" && extensao(nome) && <span className="text-xs font-semibold text-muted">{extensao(nome)}</span>}
</span>
)}
</span>
);
}
import { useEffect, useState } from "react";
import { defineStories } from "@/registry/stories";
import { AreaDeUpload } from "./AreaDeUpload";
import { MiniaturaDeArquivo } from "./MiniaturaDeArquivo";
/** Imagem de exemplo em SVG (as cores são do "conteúdo" do arquivo, não da interface). */
function imagem(nome: string, matiz: number) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><rect width="120" height="120" fill="hsl(${matiz} 55% 72%)"/><circle cx="84" cy="34" r="14" fill="hsl(${matiz + 40} 80% 88%)"/><path d="M0 120 45 62l30 34 18-18 27 42z" fill="hsl(${matiz} 45% 42%)"/></svg>`;
return new File([svg], nome, { type: "image/svg+xml", lastModified: 1 });
}
/** Arquivo de exemplo com o tamanho informado (conteúdo vazio repetido). */
function arquivo(nome: string, tipo: string, bytes: number) {
return new File([new Uint8Array(bytes)], nome, { type: tipo, lastModified: 1 });
}
let cache: File[] | null = null;
/** Criados uma vez só (o PDF de exemplo tem 1,8 MB de conteúdo vazio). */
function exemplos() {
return (cache ??= [
arquivo("relatorio-trimestral.pdf", "application/pdf", 1_843_200),
imagem("foto-da-vitrine.svg", 200),
arquivo("vendas-por-loja.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 284_000),
arquivo("contrato-assinado.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 96_500),
]);
}
function Exemplo({
multiplos,
exibicao,
aceitar,
tamanhoMaximoMb,
comProgresso,
desabilitado,
comArquivos,
}: {
multiplos: boolean;
exibicao: string;
aceitar: string;
tamanhoMaximoMb: number;
comProgresso: boolean;
desabilitado: boolean;
comArquivos: boolean;
}) {
const [arquivos, setArquivos] = useState<File[]>(() => (comArquivos ? exemplos().slice(0, multiplos ? 4 : 1) : []));
// Progresso fixo de exemplo: um enviado, um no meio, um começando.
const [progresso] = useState(() => new Map(arquivos.map((a, i) => [a, [100, 64, 12, 100][i] ?? 0])));
return (
<div className="w-full max-w-xl">
<AreaDeUpload
arquivos={arquivos}
aoMudar={setArquivos}
multiplos={multiplos}
exibicao={exibicao as "lista" | "grade"}
aceitar={aceitar || undefined}
tamanhoMaximo={tamanhoMaximoMb > 0 ? tamanhoMaximoMb * 1024 * 1024 : undefined}
progresso={comProgresso ? progresso : undefined}
desabilitado={desabilitado}
/>
</div>
);
}
/** Simula o envio: cada arquivo novo sobe de 0 a 100%. */
function ExemploComEnvio() {
const [arquivos, setArquivos] = useState<File[]>(() => exemplos().slice(0, 2));
const [progresso, setProgresso] = useState<Map<File, number>>(() => new Map());
useEffect(() => {
const t = window.setInterval(() => {
setProgresso((atual) => {
let mudou = false;
const novo = new Map(atual);
for (const a of arquivos) {
const v = novo.get(a) ?? 0;
if (v < 100) {
novo.set(a, Math.min(100, v + 6 + (a.size % 7)));
mudou = true;
}
}
return mudou ? novo : atual;
});
}, 300);
return () => window.clearInterval(t);
}, [arquivos]);
return (
<div className="w-full max-w-xl">
<AreaDeUpload
arquivos={arquivos}
aoMudar={setArquivos}
multiplos
maximoDeArquivos={5}
tamanhoMaximo={10 * 1024 * 1024}
aceitar=".pdf,.xlsx,.docx,image/*"
progresso={progresso}
/>
</div>
);
}
export default defineStories({
args: {
multiplos: true,
exibicao: "lista",
aceitar: ".pdf,.xlsx,.docx,image/*",
tamanhoMaximoMb: 5,
comProgresso: true,
desabilitado: false,
comArquivos: true,
},
controls: {
multiplos: { type: "boolean" },
exibicao: { type: "select", options: ["lista", "grade"] },
aceitar: { type: "text" },
tamanhoMaximoMb: { type: "number", label: "tamanhoMaximo (MB, 0 = sem)", min: 0, max: 50 },
comProgresso: { type: "boolean", label: "progresso (exemplo fixo)" },
desabilitado: { type: "boolean" },
comArquivos: { type: "boolean", label: "começar com arquivos" },
},
render: (a) => <Exemplo key={`${a.multiplos}-${a.comArquivos}`} {...a} />,
stories: [
{ name: "Vários arquivos com progresso" },
{ name: "Um arquivo (PDF ou imagem)", args: { multiplos: false, aceitar: ".pdf,image/*", comProgresso: false } },
{ name: "Grade", args: { exibicao: "grade", comProgresso: false } },
{ name: "Vazia", args: { comArquivos: false } },
{ name: "Envio simulado", render: () => <ExemploComEnvio /> },
{
name: "Miniaturas",
render: () => (
<div className="flex flex-wrap items-end gap-4">
{exemplos().map((a) => (
<MiniaturaDeArquivo key={a.name} arquivo={a} />
))}
<MiniaturaDeArquivo arquivo={{ nome: "backup.zip", tipo: "application/zip" }} />
<MiniaturaDeArquivo arquivo={{ nome: "apresentacao.mp4" }} />
<div className="w-32">
<MiniaturaDeArquivo arquivo={{ nome: "planilha.csv" }} tamanho="grande" />
</div>
</div>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}