Carga y vista previa de CSV
Selector por arrastre, validación y vista previa de filas para React.
Pruébalo con un archivo
La vista previa muestra hasta seis filas. El componente devuelve los encabezados y las filas para que los conectes a tu flujo.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useId, useRef, useState } from "react";
import { FileUp, FileSpreadsheet, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
export type CsvDataset = {
fileName: string;
headers: string[];
rows: string[][];
irregularRows: number;
};
export function parseCsvText(text: string, fileName = "archivo.csv"): CsvDataset {
const source = text.replace(/^\uFEFF/, "");
const records: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
if (quoted) {
if (char === '"' && source[index + 1] === '"') {
field += '"';
index += 1;
} else if (char === '"') {
quoted = false;
} else {
field += char;
}
continue;
}
if (char === '"' && field.length === 0) {
quoted = true;
} else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n" || char === "\r") {
row.push(field);
records.push(row);
row = [];
field = "";
if (char === "\r" && source[index + 1] === "\n") index += 1;
} else {
field += char;
}
}
if (quoted) throw new Error("Hay una comilla sin cerrar. Revisa el archivo e inténtalo de nuevo.");
row.push(field);
if (field.length > 0 || row.length > 1 || (records.length === 0 && source.length > 0)) records.push(row);
if (!records.length || (records.length === 1 && records[0].every((value) => !value.trim()))) {
throw new Error("El archivo está vacío o no contiene filas con datos.");
}
const rawHeaders = records[0].map((header) => header.trim());
const widestRow = Math.max(rawHeaders.length, ...records.slice(1).map((record) => record.length));
const headers = [...rawHeaders];
for (let column = headers.length; column < widestRow; column += 1) {
headers.push(`Columna adicional ${column - rawHeaders.length + 1}`);
}
const dataRecords = records.slice(1);
const irregularRows = dataRecords.filter((record) => record.length !== rawHeaders.length).length;
const rows = dataRecords.map((record) =>
Array.from({ length: widestRow }, (_, column) => record[column] ?? ""),
);
return { fileName, headers, rows, irregularRows };
}
type CsvUploadPreviewProps = {
onDatasetChange?: (dataset: CsvDataset | null) => void;
onError?: (message: string | null) => void;
maxFileSizeMb?: number;
previewRows?: number;
showPreview?: boolean;
};
export function CsvUploadPreview({
onDatasetChange,
onError,
maxFileSizeMb = 10,
previewRows = 6,
showPreview = true,
}: CsvUploadPreviewProps) {
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [dataset, setDataset] = useState<CsvDataset | null>(null);
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
async function readFile(file?: File) {
if (!file) return;
const maxBytes = maxFileSizeMb * 1024 * 1024;
if (!file.name.toLocaleLowerCase().endsWith(".csv")) {
setError("Selecciona un archivo .csv.");
setDataset(null);
onDatasetChange?.(null);
onError?.("Selecciona un archivo .csv.");
return;
}
if (file.size > maxBytes) {
const message = `El archivo supera el límite de ${maxFileSizeMb} MB.`;
setError(message);
setDataset(null);
onDatasetChange?.(null);
onError?.(message);
return;
}
try {
const result = parseCsvText(await file.text(), file.name);
setDataset(result);
setError(null);
onDatasetChange?.(result);
onError?.(null);
} catch (cause) {
const message = cause instanceof Error ? cause.message : "No se pudo leer el archivo.";
setDataset(null);
setError(message);
onDatasetChange?.(null);
onError?.(message);
}
}
function clearFile() {
setDataset(null);
setError(null);
onDatasetChange?.(null);
onError?.(null);
if (inputRef.current) inputRef.current.value = "";
}
return (
<div className="csv-upload-component">
<input
ref={inputRef}
id={inputId}
className="sr-only"
type="file"
accept=".csv,text/csv"
onChange={(event) => void readFile(event.target.files?.[0])}
/>
{!dataset ? (
<label
className={`csv-upload-dropzone${isDragging ? " is-dragging" : ""}`}
htmlFor={inputId}
onDragEnter={(event) => { event.preventDefault(); setIsDragging(true); }}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsDragging(false);
}}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
void readFile(event.dataTransfer.files[0]);
}}
>
<span className="csv-upload-icon"><FileUp size={19} strokeWidth={1.7} aria-hidden="true" /></span>
<span className="csv-upload-title">Arrastra tu archivo CSV aquí</span>
<span className="csv-upload-copy">o selecciónalo desde tu dispositivo · máximo {maxFileSizeMb} MB</span>
<span className="csv-upload-select">Elegir archivo</span>
</label>
) : (
<div className="csv-upload-loaded">
<div className="csv-upload-file-icon"><FileSpreadsheet size={18} aria-hidden="true" /></div>
<div className="csv-upload-file-copy">
<strong>{dataset.fileName}</strong>
<span>{dataset.rows.length.toLocaleString("es-MX")} filas · {dataset.headers.length} columnas</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={clearFile}>
<RotateCcw size={15} aria-hidden="true" /> Cambiar archivo
</Button>
</div>
)}
{error ? <p className="csv-upload-error" role="alert">{error}</p> : null}
{dataset && showPreview ? (
<div className="csv-upload-preview-table-wrap">
<table className="csv-upload-preview-table">
<thead><tr>{dataset.headers.map((header, index) => <th key={`${header}-${index}`} scope="col">{header || `Columna ${index + 1}`}</th>)}</tr></thead>
<tbody>
{dataset.rows.slice(0, previewRows).map((row, rowIndex) => (
<tr key={rowIndex}>{dataset.headers.map((_, columnIndex) => <td key={columnIndex}>{row[columnIndex] || <span aria-label="Vacío">—</span>}</td>)}</tr>
))}
</tbody>
</table>
{dataset.rows.length > previewRows ? <p className="csv-upload-preview-note">Mostrando {previewRows} de {dataset.rows.length.toLocaleString("es-MX")} filas.</p> : null}
</div>
) : null}
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/carga-csv-previewEjemplo de uso de Carga y vista previa de CSV
Después de copiar csv-upload-preview.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { CsvUploadPreview } from "./csv-upload-preview";
export default function Ejemplo() {
return <CsvUploadPreview />;
}Accesibilidad y rendimiento
El archivo tiene 201 líneas (7 KB) y no necesita @keyframes. Esto es lo que hace Carga y vista previa de CSV por quien lo usa:
- Declara roles ARIA para lectores de pantalla: alert.
- Es un componente de cliente de Next.js: incluye la directiva "use client".
Acerca del producto
Un componente React + TypeScript para cargar un CSV, mostrar un resumen y revisar las primeras filas antes de importar. Incluye soporte para comillas escapadas y saltos de línea dentro de campos.
Incluye
El archivo de origen contiene el parser y el componente; no requiere una biblioteca para leer CSV.
- Arrastrar o seleccionar archivos .csv
- Límite configurable de tamaño
- Estado de error accesible
- Vista previa configurable o callback con los datos
Requisitos
Usa React, lucide-react y los componentes Button de shadcn/ui. Cambia el alias de importación si tu proyecto usa otra estructura.