Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

Carga y vista previa de CSV

Selector por arrastre, validación y vista previa de filas para React.

DEMO INTERACTIVA

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.

csv-upload-preview.tsx201 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/carga-csv-preview

Ejemplo 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.

ejemplo.tsx5 líneas
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".
Licencia
MIT
Dependencias
React >=18, lucide-react, shadcn/ui: Button
Compatibilidad
React · Next.js (quita la directiva use client fuera de Next.js) · shadcn/ui + Tailwind

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.