S-025 – Creación componente DataGrid con Daisy UI

Una de las cosas que se repiten muchísimas veces es disponer de un «DataGrid», para mostrar y navegar por los elementos de una tabla.

En los ejemplos hasta ahora mostrados, he utilizados soluciones hechas 100% con código Svelte 5 y en otras ocasiones el DataGrid de alguna librería como las de SVAR. He intentado utilizar ApexGrid, como DataGrid general y reutilizable para «todo», pero me he encontrado que sólo me funcionaba en inglés y no he sido capaz de ponerlo en otros idiomas.

Objetivo

Partiendo del DataGrid hecho con código Svelte 5 y utilizando Daisy UI, realizar un componente de DataGrid que pueda reutilizar en los proyectos de cara a simplificar,  optimizar y minimizar el código necesario para disponer de DataGrid para las tablas de un proyecto.

DEMO: https://fhumanes.com/datagrid-svelte/

Solución Técnica

Estoy muy contento con el resultado y me ha abierto la mente para lo que muchos desarrolladores de Svelte 5 indican, que es , no es necesario utilizar librerías de terceros, se puede diseñar las tuyas y siempre las podrás ajustar mucho más y mejor, a las necesidades de tus proyectos.

Como es habitual desde hace algunos meses, he trabajado con Copilot. No sé si el producto va mejorando de forma general o mejora en mi caso por el tiempo que he trabajado con él, lo que sí está ocurriendo es que cada vez conoce más de Svelte 5 con Runas, que es lo que yo utilizo.

Como expliqué, estuve intentando utilizar ApexGrid, que por documentación tenía todo lo que necesitaba, pero me encontré que no soportaba multi-idioma (al menos esa es mi impresión), por lo que busqué otras soluciones y al final, pensé que lo mejor era «componetizar» la solución de DataGrid «manual» que he estado utilizando en diferentes ejercicios.

Lo primero que hicimos es definir la estructura de las columnas del Grid. Ejemplo de Películas:

// ============================
//   DEFINICIÓN DE COLUMNAS
// ============================

const columns = [
  { key: "actions", label: "Acciones", component: RowActions },
  { key: "id", label: "ID", sortable: true },
  { key: "name", label: "Nombre", sortable: true },
  { key: "price", label: "Precio", sortable: true, format: formatPrice },
  {
    key: "date",
    label: "Fecha",
    sortable: true,
    format: (d) => 
        d.toLocaleDateString("es-ES", {
          day: "2-digit",
          month: "2-digit",
          year: "numeric"
        })
  },
  { key: "rating", label: "Rating", sortable: true, component: RatingStars },
  { key: "theme", label: "Tema", sortable: true },
  { key: "support", label: "Soporte", sortable: true }
];

Se pueden observar que las columnas pueden tener los atributos:

  • key – El nombre real del campo (Obligatorio).
  • label – Nombre que se le va a dar al campo en el Grid (Obligatorio).
  • sortable – para indicar que queremos que sea un campo para posible ordenación o no. Por defecto «false»
  • format – Para definir el formato de presentación, siempre que no sea un string. No obligatorio, por defecto es «string».
  • class – Clases de TailWind CSS a aplicar a las columnas. Alineación, fuente, etc.
  • component – Componentes para la presentación de la información de la columnas, En el ejemplo está definido «RatingStarts», para las estrellas de valoración y «RowActions» que es para los botones de VIEW, EDIT o DELETE, de las filas del GRID. Es muy sencillos construir nuevos componentes.

Después, se definió que objetos del Grid iban en el componentes y cuáles no.
Van todos, menos el campo de filtro que se deja fuera para dar mayores posibilidades en este punto.

Al Componente podrás adaptarlo a tu gusto y hacer que funcione como tú desees

También, en todas las páginas del ejemplo, por cada una de las tablas, he utilizado un «DataGrid» y un «Drawer», que es una ventana de visualización «popup» que en mi caso aparece por la derecha y que visualmente me gusta mucho.

Como podrás ver a la izquierda el proyecto, tiene el directorio «src», y debajo de él:

  • Directorio «components», que son los ficheros que forman el «DataGrid».
  • Directorio «lib», que en este caso es el API de acceso a los datos del Back-End.
  • Directorio «pages», que son los «DataGrid» y los «Drawer», de las 3 tablas del proyecto.

En «Apps.svelte», tenemos «menú» de la aplicación y el «router» para la ejecución de las distintas páginas.

Para instalar este proyecto es con el comando «npm install«, no obstante si deseas crear un proyecto nuevo con TailWind CSS + Daisy UI, se explican los pasos en el artículo S-018, aunque siempre podrás instalar este y sobre él poder hacer los cambios que requiera tu proyecto.

Todos los productos que he instalado están definidos en «package.json»

 

 

{
  "name": "datagrid-svelte",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "@sveltejs/vite-plugin-svelte": "^7.1.2",
    "svelte": "^5.55.5",
    "vite": "^8.0.12",
    "vite-plugin-compression": "^0.5.1"
  },
  "dependencies": {
    "@tailwindcss/vite": "^4.3.1",
    "axios": "^1.18.0",
    "daisyui": "^5.5.23",
    "svelte-spa-router": "^4.0.1",
    "sweetalert2": "^11.26.25",
    "tailwindcss": "^4.3.1",
    "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
  }
}

Paso a mostraros algunos de los ficheros del proyecto que ayuden a explicar el uso y funcionamiento de este componente de «DataGrid».

(1) ThemeManager.svelte(2) ThemeDrawer.svelte(3) DataGrid.svelte(4) RowActions.svelte(5) RatingStars.svelte
<script>
  import { onMount } from "svelte";
  import Swal from "sweetalert2";
  import api from "../lib/api.js";
  import ThemeDrawer from "./ThemeDrawer.svelte";


  // Components DataGrid
  import DataGrid from "../components/DataGrid.svelte";
  import RowActions from "../components/RowActions.svelte";
  // import RatingStars from "../components/RatingStars.svelte";

  let themes = $state([]);
  let loading = $state(true);
  let errorMessage = $state(null);

  let filteredData = $state([]);

  let filter = $state("");
  let drawerOpen = $state(false);   // ← ahora igual que en Support
  let drawerMode = $state("add");
  let selectedTheme = $state({});

  function normalize(str) {
    return String(str)
      .trim()                           // ← elimina espacios al inicio y final
      .normalize("NFD")                 // ← separa acentos
      .replace(/[\u0300-\u036f]/g, "")  // ← elimina acentos
      .toLowerCase();                   // ← pasa a minúsculas
  }

  function filterData() {
    if (!filter) return themes;

    const normalizedFilter = normalize(filter);

    return themes.filter((row) => {
      return (
        normalize(row.theme).includes(normalizedFilter)
      );
    });
  }

  async function fetchThemes() {
    // loading = true;
    try {
      const res = await api.get("/themes");
      themes = res.data;

      filteredData = filterData();
    } catch (err) {
      errorMessage = "No se pudieron cargar los temas.";
    } finally {
      if (loading) loading = false;
    }
  }

  onMount(fetchThemes);

  function handleAdd() {
    drawerMode = "add";
    selectedTheme = { id_theme: null, theme: "" };
    drawerOpen = true;
  }

  function handleEdit(row) {
    drawerMode = "edit";
    selectedTheme = { ...row };
    drawerOpen = true;
  }

  async function handleDelete(row) {
    const id = row.id_theme;
    const name = row.theme;
    const result = await Swal.fire({
      title: "¿Eliminar tema?",
      text: `Se eliminará "${name}".`,
      icon: "warning",
      showCancelButton: true,
      confirmButtonText: "Eliminar",
      cancelButtonText: "Cancelar"
    });

    if (!result.isConfirmed) return;

    try {
      await api.delete(`/themes/${id}`);
      Swal.fire("Eliminado", "Tema eliminado correctamente", "success");
      fetchThemes();
    } catch (err) {
      Swal.fire("Error", "No se pudo eliminar el tema", "error");
    }
  }

  function closeDrawer() {
    drawerOpen = false;
    selectedTheme = {};
  }

  async function saveTheme(values) {
    const name = values.theme.trim().toLowerCase();
    const currentId = Number(selectedTheme.id_theme);

    const exists = themes.some(
      t =>
        t.theme.trim().toLowerCase() === name &&
        t.id_theme !== currentId
    );

    if (exists) {
      Swal.fire("Duplicado", "Ya existe un tema con ese nombre", "warning");
      return;
    }

    try {
      if (drawerMode === "add") {
        await api.post("/themes", values);
        Swal.fire("Guardado", "Tema añadido correctamente", "success");
      } else {
        await api.put(`/themes/${selectedTheme.id_theme}`, values);
        Swal.fire("Actualizado", "Tema actualizado correctamente", "success");
      }

      closeDrawer();
      fetchThemes();
    } catch (err) {
      Swal.fire("No actualizado", "El tema no ha sido actualizado", "info");
    }
  }

  // ============================
  //   DEFINICIÓN DE COLUMNAS
  // ============================

  const columns = [
    { key: "actions", label: "Acciones", class: "w-[50px] px-1 text-center", component: RowActions },
    { key: "id_theme", label: "ID", sortable: true, class: "w-[23px] px-1 text-center" },
    { key: "theme", label: "Nombre", sortable: true, class: "w-[300px] px-1" }
  ];
</script>

<!-- ========================================================= -->
<!--   DRAWER DAISYUI ENVOLVIENDO TODO EL MANAGER              -->
<!-- ========================================================= -->

<div class="drawer drawer-end">
  <input
    id="theme-drawer"
    type="checkbox"
    class="drawer-toggle"
    bind:checked={drawerOpen}
  />

  <!-- ===================== -->
  <!--   CONTENIDO PRINCIPAL -->
  <!-- ===================== -->
  <div class="drawer-content">

    <div class="max-w-xl mt-6 px-4">
      <h2 class="text-2xl font-bold mb-4">Gestión de Temas</h2>

      <div class="flex justify-between items-center mb-4">
        <div class="relative w-1/2">
          <input
            type="text"
            placeholder="🔎 Filtrar..."
            class="input input-bordered w-full pr-10"
            bind:value={filter}
            oninput={() => {
              filteredData = filterData();
              // console.log("Filtro actualizado:", filter, "=> Filas filtradas:", filteredData.length);
            }}
          />

          {#if filter}
            <button
              type="button"
              class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
              onclick={() => {
                filter = "";
                filteredData = filterData();
              }}
            >
              ✖
            </button>
          {/if}
        </div>

        <button class="btn btn-outline btn-success" onclick={handleAdd}>➕ Añadir Tema</button>
      </div>

      {#if loading}
        <p class="text-center py-6">Cargando temas...</p>
      {:else if errorMessage}
        <div class="alert alert-error">{errorMessage}</div>
      {:else}
        <DataGrid
          rows={filteredData}
          columns={columns}
          keyField="id_theme"
          pageSizes = {[5, 10, 20]}
          onEdit={handleEdit}
          onDelete={handleDelete}
        />
      {/if}
    </div>

  </div>

  <!-- ===================== -->
  <!--   PANEL LATERAL       -->
  <!-- ===================== -->
  <div class="drawer-side">
    <label for="theme-drawer" class="drawer-overlay"></label>

    <ThemeDrawer
      mode={drawerMode}
      theme={selectedTheme}
      onSave={saveTheme}
      onClose={closeDrawer}
    />
  </div>
</div>
<script>
  const {
    mode = "view", // "add" | "edit" | "view"
    theme = {},
    onSave,
    onClose
  } = $props();

  let formValues = $state({
    theme: ""
  });

  let errors = $state({});

  // Cargar datos cuando se abre en modo add/edit
  $effect(() => {
    if (mode !== "view" && theme) {
      formValues.theme = theme.theme ?? "";
      errors = {};
    }
  });

  function validate(values) {
    const e = {};
    if (!values.theme.trim()) e.theme = "El nombre es obligatorio";
    return e;
  }

  function handleSubmit(e) {
    e.preventDefault();
    errors = validate(formValues);
    if (Object.keys(errors).length) return;
    if (onSave) onSave(formValues);
  }
</script>

<!-- ========================================================= -->
<!--   SOLO CONTENIDO DEL PANEL LATERAL (SIN drawer, SIN input) -->
<!-- ========================================================= -->

<div class="menu bg-base-200 text-base-content w-full max-w-md p-6">

  <h3 class="font-bold text-lg mb-4">
    {mode === "add"
      ? "Añadir Tema"
      : mode === "edit"
      ? "Editar Tema"
      : "Detalles del Tema"}
  </h3>

  {#if mode === "view"}

    <input
      class="input input-bordered w-full mb-4"
      value={theme.theme}
      readonly
    />

    <div class="flex justify-end gap-2 mt-4">
      <button class="btn" type="button" onclick={onClose}>Cerrar</button>
    </div>

  {:else}

    <form onsubmit={handleSubmit}>
      <label class="label" for="theme">Nombre del tema</label>

      <input
        id="theme"
        class="input input-bordered w-full mb-1"
        placeholder="Nombre del tema"
        bind:value={formValues.theme}
      />

      {#if errors.theme}
        <p class="text-red-500 text-sm mb-2">{errors.theme}</p>
      {/if}

      <div class="flex justify-end gap-2 mt-4">
        <button class="btn btn-primary" type="submit">
          {mode === "add" ? "Guardar" : "Actualizar"}
        </button>

        <button class="btn" type="button" onclick={onClose}>
          Cerrar
        </button>
      </div>
    </form>

  {/if}
</div>
<script>
import { onMount } from "svelte";
import RatingStart from  "./RatingStars.svelte";
import RowActions from "./RowActions.svelte";

  const {
    rows = [],
    columns = [],
    pageSizes = [3, 5, 10, 20, 50, 100],
    keyField = "id",
    onView,
    onEdit,
    onDelete
  } = $props();

  // console.log("DataGrid.svelte - Props recibidas:", { rows, columns, pageSizes, onView, onEdit, onDelete });

  // Estado interno (runes)
  let sortColumn = $state();
  let sortDirection = $state(); // 1 = ascendente, -1 = descendente
  let page = $state();
  // svelte-ignore state_referenced_locally
  let pageSize = $state();
  let pagedData = $state([]);

  let totalPages = $state();

  onMount(() => {
    // Inicialización solo al montar el componente
    // console.log("DataGrid.svelte - onMount: Inicializando paginación y ordenamiento");
    page = 1;
    pageSize = pageSizes[0] || 10;
    sortColumn = null;
    sortDirection = 1;
    calculationPage();
  });

 $effect(() => {
  // Dependencia explícita: si rows cambia, se ejecuta
  rows;

  calculationPage();  // Cáluclos de paginación y actualización de pagedData
});


  function sortBy(col) {
    // console.log("DataGrid.svelte - sortBy:", col);
    if (!col.sortable || !col.key) return;

    if (sortColumn === col.key) {
      sortDirection = -sortDirection;
    } else {
      sortColumn = col.key;
      sortDirection = 1;
    }

    page = 1;
  }

  function getSortedRows() {
    if (!sortColumn) return rows;

    return [...rows].sort((a, b) => {
      const va = a[sortColumn];
      const vb = b[sortColumn];

      if (typeof va === "string") {
        return va.localeCompare(vb) * sortDirection;
      }

      if (va < vb) return -1 * sortDirection;
      if (va > vb) return 1 * sortDirection;
      return 0;
    });
  }

  function getPagedRows() {
    const sorted = getSortedRows();
    const start = (page - 1) * pageSize;
    return sorted.slice(start, start + pageSize);
  }

  function prevPage() {
    if (page > 1) {
      page--;
    }
  }

  function nextPage() {
    if (page < totalPages) {
      page++;
    }
  }

  function goToPage(n) {
    if (n >= 1 && n <= totalPages) {
      page = n;
    }
  }

  function changePageSize(value) {
    pageSize = parseInt(value);
    page = 1;
    calculationPage();
  }

  function calculationPage() {
    totalPages = Math.ceil(rows.length / pageSize);
    // console.log("DataGrid.svelte - calculationPage, TotalPages:", totalPages,"pagesize: ", pageSize, "page: ", page, "rows.length: ", rows.length);

    // Si el offset queda fuera del rango → volver a página 1
    if (pageSize * (page - 1) >= rows.length) {
      // console.log("DataGrid.svelte - calculationPage: offset fuera de rango, volviendo a página 1", pageSize * (page - 1));
      page = 1;
    }
    pagedData = rows.slice((page - 1) * pageSize, page * pageSize);

    if (totalPages === 0) page = 1;
    if (page > totalPages && totalPages > 0) page = totalPages;
  }

</script>

<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
  <div class="flex items-center gap-2">
    <label class="font-medium" for="page-size-select">Registros:</label>
    <select
      id="page-size-select"
      class="select select-bordered w-16"
      value={pageSize}
      onchange={(e) => changePageSize(e.target.value)}
    >
      {#each pageSizes as size}
        <option value={size}>{size}</option>
      {/each}
    </select>

    <span class="font-semibold ml-2">Total: {rows.length}</span>
  </div>
</div>

<div class="overflow-x-auto bg-base-100 rounded-xl shadow">
  <table class="table table-zebra w-full">
    <thead class="bg-gray-200">
      <tr>
        {#each columns as col}
          <th
            class={col.class || ""}
            onclick={() => col.sortable ? sortBy(col) : null}
          >
            {col.label}
            {#if col.sortable && sortColumn === col.key}
              {sortDirection === 1 ? " ▲" : " ▼"}
            {/if}
          </th>
        {/each}
      </tr>
    </thead>

    <tbody>
      {#if getPagedRows().length === 0}
        <tr>
          <td colspan={columns.length} class="text-center py-4">
            No hay resultados.
          </td>
        </tr>
      {:else}
       {#each getPagedRows() as row (row[keyField])}
       <tr>
        {#each columns as col}
        <td class={col.class || ""}>
            {#if col.key === "actions" || col.component === "RowActions" }
                <RowActions
                  row={row}
                  onView={onView}
                  onEdit={onEdit}
                  onDelete={onDelete}
                />
            {:else if col.component}
                <col.component row={row} field={col.key} />
            {:else if col.format}
                {col.format(row[col.key])}
            {:else}
                {row[col.key]}
            {/if}
        </td>
        {/each}
    </tr>
    {/each}

      {/if}
    </tbody>
  </table>
</div>

{#if totalPages > 1}
  <div class="flex justify-center mt-6">
    <div class="join shadow">
      <button class="join-item btn btn-sm" disabled={page === 1} onclick={prevPage}>«</button>

      {#each Array(totalPages) as _, i}
        <button
          class="join-item btn btn-sm {page === i + 1 ? 'btn-primary btn-active text-white' : ''}"
          onclick={() => goToPage(i + 1)}
        >
          {i + 1}
        </button>
      {/each}

      <button class="join-item btn btn-sm" disabled={page === totalPages} onclick={nextPage}>»</button>
    </div>
  </div>
{/if}

<style>
  .btn-soft {
    opacity: 0.8;
  }
  .btn-soft:hover {
    opacity: 1;
  }
</style>
<script>
  const {
    row,
    onView = null,
    onEdit = null,
    onDelete = null
  } = $props();

  // console.log("RowActions.svelte - Props recibidas:", { row, onView, onEdit, onDelete });
</script>

<div class="flex gap-2 justify-center items-center">

    {#if onView}
        <button
        class="btn btn-info btn-xs btn-soft"
        onclick={() =>  onView(row)}
        >
        👁
        </button>
    {/if}

    {#if onEdit}
        <button
        class="btn btn-warning btn-xs btn-soft"
        onclick={() => { // console.log("Edit: ",row);
                            onEdit(row)}
                        }
        >
        ✏️
        </button>
    {/if}

    {#if onDelete}
        <button
        class="btn btn-error btn-xs btn-soft"
        onclick={() => { // console.log("Delete: ",row);
                            onDelete(row)}
                        }
        >
        🗑
        </button>
    {/if}

</div>
<script>
  const {
    row,
    field
  } = $props();
  // svelte-ignore state_referenced_locally
  // console.log("RatingStars.svelte - Props recibidas:", { row, field });

   const stars = Array(5);
</script>

<div class="flex justify-center gap-1 text-yellow-400 text-2xl">
  {#each stars as _, i}
    <span>{i < row[field] ? "★" : "☆"}</span>
  {/each}
</div>

En el fichero (1) se muestra cómo se ha hecho la página del «DataGrid» de Temas y las llamadas al componente «DataGrid» y al «Drawer» de edición de la tabla. En este caso no hay visualización.

En fichero (2) está una definición «estándar» de un Drawer general. En este caso el de Temas.

En fichero (3) está el componente de «DataGrid» que la recibir los parámetros define el Grid de los datos y cómo se deben mostrar. Creo que sabiendo los parámetros que recibe, se puede seguir y entender y a partir de ese momento, cambiar la lógica que necesites.

En fichero (4) está en componentes de las acciones. En estos momentos, pinta los botones de View, Edit y Delete, si recoben las funciones para hacer estas acciones.

En fichero (5) está el componente de pintar las estrellas de valoración, pero de igual forma podría se una imagen, un gráfico o cualquier otro objeto visual que necesites. Estos componentes reciben el total de campos del registro que está tratando, por lo que a través de ellos, puedes acceder a cualqueir otro recurso.

La aplicación de back-end es la misma que he utilizado en el resto de ejemplos (PHP, Slim 4 y Mysql)  y está explicada en el articulo S-002.

Os dejo todos los fuentes para que lo podáis descargar e instalar en vuestros PC’s, y así hagáis los cambios que consideréis oportunos.

Si necesitáis cualqueir cosa sobre este u otro ejemplo, contactar conmigo a través de mi email.

Adjuntos

Archivo Tamaño de archivo Descargas
zip datagrid-svelte 36 KB 0
zip PHP - SLIM 4.0 - Backend 2 MB 150
zip Backup de los Datos 1 KB 172

Blog personal para facilitar soporte gratuito a usuarios de React y PHPRunner