S-018 – Integración Tailwind CSS + Daisy UI + Felte + Zod en Svelte

Después de múltiples ejemplos, integrar las mismas soluciones que integré en PHPRunner (aplicaciones Web), me he cuestionado si estoy utilizando Svelte adecuadamente y sobre todo, con los productos más habituales que los programadores «nativos» de Svelte, utilizan.

De ahí surgió la utilización de estos productos:

  • Para interface de aplicación, UI.- He seleccionado Tailwind CSS, que prácticamente es un estándar y que es utilizado masivamente por todos (mucho más que Bootstrap) y se complemente con Daisy UI que facilita el uso de TailWind CSS y nos dota de Temas, componentes para la iteración con el usuario, etc. Muy, muy importante y excelente solución, que nos soluciona la escritura de los componentes de iteración con el usuario.
  • Para la lógica y validación de los formularios.- Los productos seleccionados no tienen tanta difusión, pero son un buen ejemplo del ecosistema de soluciones que hay en el entorno de Svelte.
    Felte, nos soluciona la arquitectura de los formularios y Zod, nos facilta la definición de esquemas de validación de los datos de entrada de los formularios.
Objetivo

Repetir el proyecto de Bootstrap Svelte, con este kit de productos más propio del ecosistema de Svelte y verificar las bondades de utilización de las soluciones más habituales de los componentes de Svelte.

Se mantiene la aplicación de Back-End desarrollada en PHP SLIM4.

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

Solución Técnica

Es muy importante seguir las instrucciones de la instalación de Tailwind CSS y Daisy UI. Ha cambiado mucho su instalación en la última versión, siendo ahora mucho más sencillo. Cuando le indiqué a Copilot que quería trabajar con estos 2 productos, me contó «una milonga» de que no estaban migradas a Svelte 5, …., tonterías o «alucinaciones». Cualquier solución que tenga menos de un año, suele «patinar», pero hay que insistir y vuelve a ayudarte en la integración.

En la página de Daisy UI te explica cómo hacer la instalación. En mi caso, trabajo con VITE en Microsoft Visual Studio y este es el resumen de la instalación.

Instalación de TailWind CSS y Daisy UI en Vite
Crear proyecto Svelte
npm create vite@latest <nombre-proyecto>
cd <nombre-proyecto>
npm install

En el diálogo que aparece al hacer el create, indicad que quieres un proyecto de Svelte y que sólo vas a trabajar con JavaScript

Para instalar un desarrollo, es necesario descargarlo en un directorio y después:
cd <directorio-app>
npm install

Instalar Tailwind CSS y Daisy UI
npm install tailwindcss@latest @tailwindcss/vite@latest daisyui@latest
Añadir Tailwind CSS a Vite config
Fichero vite.config.js

import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [svelte(), tailwindcss()],
  base: 'tu-proyecto',
  build: {
    outDir: 'dist',
    assetsDir: 'assets',
    assetsInlineLimit: 0 // Asegura que los assets no se conviertan en base64
  }
});
Configurar los CSS de Tailwind CSS y Daisy UI
En el fichero src/app.css o si lo deseas, src/style.css

@import "tailwindcss";
@plugin "daisyui" {
    themes:
        corporate --default,
        dark --prefersdark,
        light,
        business,
        cupcake,
        garden,
        synthwave,
        emerald,
        dracula;
}

Aquí se define los temas de Daisy UI que están definidos por defecto y además los que puedes usar y cambiar dinámicamente.
La información de los temas y su configuración lo puedes ver en este enlace.
Para cambiar dinámicamente de tema tienes que utilizar esta sentencia:

document.documentElement.setAttribute("data-theme", "emerald");

Ahora inicio el ejemplo en concreto que os he puesto en la demo.

A la izquierda está el conjunto de ficheros que tiene el ejemplo.
Comprobaréis que la tabla de Películas (Movies), su gestión está dividida en List y Manager. List corresponde a la visualización de la tabla, filtros, ordenación paginado, etc. y Manager corresponde a la visualización, edición y alta, de los registros.

También, en Movie se han hecho más codificación para añadir funcionalidad (incluyendo exportación a Excel) y adaptaciones de su UI.

Las tablas de Support y Theme, están en un único fichero y su gestión es muy sencilla

 

En la lista de la izquierda podéis leer los productos instalados y sus versiones. He mantenido, del proyecto de Bootstrap el Sweetalert2, aunque en este caso, sería más aconsejable utilizar el que facilita Daisy UI para esta función.

 

 

 

 

 

 

Paso a mostraros los ficheros más relevantes del proyecto, para posteriormente explicar algunos detalles de ellos.

main.jsapp.cssApp.svelteMovieList.svelteMovieManager.svelteSupportManager.svelte
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'

document.documentElement.setAttribute("data-theme", "emerald");  // Establece el tema al cargar la aplicación

const app = mount(App, {
  target: document.getElementById('app'),
})

export default app

Importamos el fichero app.css que tiene todos los estilos de Tailwind CSS y de Daisy UI.

También, se define dinámicamente el tema de Daisy que vamos a utilizar, en vez de que se ha definido por defecto en app.css

@import "tailwindcss";

@plugin "daisyui" { 
    themes:
        corporate --default, 
        dark --prefersdark, 
        light,
        business, 
        cupcake,
        garden,
        synthwave,
        emerald,
        dracula; 
}

Se define la importanción de los estilos de Tailwind CSS y Daisy CSS.

En Daisy se definen los temas por defectos y todos los temas que podremos cargar dinámicamente en nuestro aplicativo.

Para acceder a la información de temas, acceder a esta página.

<script>
  import Router from 'svelte-spa-router';

  import MovieList from './MovieList.svelte';
  import ThemaManager from './ThemaManager.svelte';
  import SupportManager from './SupportManager.svelte';

  const routes = {
    '/': MovieList,
    '/movies': MovieList,
    '/themes': ThemaManager,
    '/supports': SupportManager
  };
</script>

<!-- NAVBAR DAISYUI -->
<div class="navbar bg-neutral text-neutral-content px-2 lg:px-4">
  <div class="flex-1">
    <a href="#/" class="text-xl font-bold">Gestión de Cine</a>
  </div>

  <div class="flex gap-4">
    <a href="#/movies" class="btn btn-ghost btn-sm">Películas</a>
    <a href="#/themes" class="btn btn-ghost btn-sm">Temas</a>
    <a href="#/supports" class="btn btn-ghost btn-sm">Soportes</a>
  </div>
</div>


<!-- CONTENIDO PRINCIPAL -->
<div class="p-1  bg-gray-50 min-h-screen">
  <Router {routes} />
</div>

Define los distintos códigos de las páginas asociadas al menú del aplicativo utilizando Router para carga dinámica.

El menú es muy básico y también se podría utilizar la funcionalidad de Daisy. Se ha dejado para así ser más sencilla la comparación con la versión del ejemplo de Bootstrap.

En línea 31, se define el panel y las características de este, para todas las páginas.

<script>
  import { onMount } from "svelte";
  import * as XLSX from "xlsx";
  import Swal from "sweetalert2";
  import api from "./lib/api.js";
  import MovieManager from "./MovieManager.svelte";

  // ============================
  // ESTADO (runes)
  // ============================
  let movies = $state([]);
  let themes = $state([]);
  let supports = $state([]);

  let loading = $state(true);
  let errorMessage = $state(null);

  let filter = $state("");
  let sortColumn = $state(null);
  let sortDirection = $state(1);

  let page = $state(1);
  let pageSize = $state(3);
  const pageSizes = [3, 5, 10];

  let selectedMovie = $state({});
  let showModal = $state(false);
  let modalMode = $state("view");

  // ============================
  // AUXILIARES
  // ============================
  function normalize(str) {
    return String(str)
      .normalize("NFD")
      .replace(/[\u0300-\u036f]/g, "")
      .toLowerCase();
  }

  function formatPrice(price) {
    return new Intl.NumberFormat("es-ES", {
      style: "currency",
      currency: "EUR",
      minimumFractionDigits: 2,
      maximumFractionDigits: 2
    }).format(price);
  }

  // ============================
  // DERIVADOS
  // ============================
  let filteredData = $state([]);
  let totalPages = $derived(1);
  let pagedData = $state([]);

  // ============================
  // CARGA INICIAL
  // ============================
  onMount(() => {
    fetchAllData();
  });

  async function fetchAllData() {
    loading = true;
    errorMessage = null;

    try {
      const [moviesRes, themesRes, supportsRes] = await Promise.all([
        api.get("/movies"),
        api.get("/themes"),
        api.get("/supports")
      ]);

      movies = moviesRes.data.map((m) => ({
        id: m.id_movie,
        name: m.name,
        price: parseFloat(m.price),
        date: new Date(m.startDate),
        rating: parseInt(m.rating, 10),
        theme: m.theme,
        theme_id: String(m.theme_id),
        support: m.support,
        support_id: String(m.support_id)
      }));

      themes = themesRes.data;
      supports = supportsRes.data;

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

  // ============================
  // FILTRADO + ORDENACIÓN
  // ============================
  function filterData() {
    const f = normalize(filter);

    const filtered = movies.filter((row) => {
      return (
        normalize(row.name).includes(f) ||
        normalize(row.id).includes(f) ||
        normalize(row.price).includes(f) ||
        normalize(row.rating).includes(f) ||
        normalize(row.theme).includes(f) ||
        normalize(row.support).includes(f) ||
        normalize(row.date.toLocaleDateString()).includes(f)
      );
    });

    if (!sortColumn) return filtered;

    return filtered.slice().sort((a, b) => {
      const col = sortColumn;
      const dir = sortDirection;

      if (["theme", "support", "name"].includes(col)) {
        return a[col].localeCompare(b[col]) * dir;
      }
      if (col === "date") {
        return (new Date(a.date) - new Date(b.date)) * dir;
      }

      if (a[col] < b[col]) return -1 * dir;
      if (a[col] > b[col]) return 1 * dir;
      return 0;
    });
  }

  function calculationPage() {
    totalPages = Math.ceil(filteredData.length / pageSize);
    pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize);

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

  // ============================
  // MODAL: VIEW / ADD / EDIT
  // ============================
  function handleView(movie) {
    modalMode = "view";
    selectedMovie = {
      ...movie,
      dateText: movie.date.toLocaleDateString("es-ES")
    };
    showModal = true;
  }

  function handleAdd() {
    modalMode = "add";
    selectedMovie = {
      id: null,
      name: "",
      price: "",
      date: "",
      rating: "1",
      theme_id: themes.length ? String(themes[0].id_theme) : "",
      support_id: supports.length ? String(supports[0].id_support) : ""
    };
    showModal = true;
  }

  function handleEdit(movie) {
    modalMode = "edit";
    selectedMovie = {
      ...movie,
      price: String(movie.price),
      date: movie.date.toISOString().split("T")[0],
      rating: String(movie.rating),
      theme_id: String(movie.theme_id),
      support_id: String(movie.support_id)
    };
    showModal = true;
  }

  async function handleDelete(id, name) {
    const result = await Swal.fire({
      title: "¿Estás seguro?",
      text: `Eliminar "${name}" es irreversible.`,
      icon: "warning",
      showCancelButton: true
    });

    if (!result.isConfirmed) return;

    try {
      await api.delete(`/movies/${id}`);
      Swal.fire("Eliminada", `"${name}" eliminada.`, "success");
      fetchAllData();
    } catch (err) {
      Swal.fire("Error", "No se pudo eliminar.", "error");
    }
  }

  function closeModal() {
    showModal = false;
    selectedMovie = {};
  }

  // ============================
  // GUARDAR (ADD / EDIT)
  // ============================
  async function saveMovie(values) {
    const normalizedName = values.name.trim().toLowerCase();
    const currentId = selectedMovie?.id != null ? Number(selectedMovie.id) : null;  // ID actual (null si es nuevo)

      // Buscar si hay otra película con el mismo nombre
    const duplicate = movies.find((m) => {
        const sameName = m.name.trim().toLowerCase() === normalizedName;
        const differentId = currentId == null ? true : m.id !== currentId;
        return sameName && differentId;
    });

    if (duplicate) {
        Swal.fire(
        "Duplicado",
        `Ya existe una película con ese nombre (ID ${duplicate.id})`,
        "warning"
        );
        return;
    }

    const movieData = {
      name: values.name,
      price: parseFloat(values.price.replace(",", ".")),
      startDate: values.date,
      rating: values.rating,
      theme_id: parseInt(values.theme_id),
      support_id: parseInt(values.support_id)
    };

    try {
      if (modalMode === "add") {
        await api.post("/movies", movieData);
        Swal.fire({
          title: "Guardada",
          text: "Película añadida correctamente",
          icon: "success",
          timer: 1500
        });
      } else {
        await api.put(`/movies/${selectedMovie.id}`, movieData);
        Swal.fire({
          title: "Actualizada",
          text: "Película actualizada correctamente",
          icon: "success",
          timer: 1500
        });
      }

      closeModal();
      fetchAllData();
    } catch (err) {
      Swal.fire({
        title: "Sin cambios",
        text: "No se ha variado ningún dato",
        icon: "info",
        timer: 1500
      });
    }
  }

  // ============================
  // ORDENACIÓN + PAGINACIÓN
  // ============================
  function toggleSort(col) {
    if (sortColumn === col) {
      sortDirection = sortDirection * -1;
    } else {
      sortColumn = col;
      sortDirection = 1;
    }
    page = 1;
    filteredData = filterData();
    calculationPage();
  }

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

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

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

  // ============================
  // EXPORTACIONES
  // ============================
  function exportToCSV() {
    if (!filteredData.length) return;

    const rows = [Object.keys(filteredData[0])].concat(
      filteredData.map((r) => Object.values(r))
    );

    const csv = rows.map((r) => r.join(",")).join("\n");
    const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });

    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = "peliculas.csv";
    link.click();
  }

  function exportToExcel() {
    if (!filteredData.length) return;

    const ws = XLSX.utils.json_to_sheet(filteredData);
    const wb = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(wb, ws, "Datos");
    XLSX.writeFile(wb, "peliculas.xlsx");
  }
</script>

<!-- ========================= -->
<!--  CONTENIDO PRINCIPAL     -->
<!-- ========================= -->

<div class="max-w-6xl mt-6 px-2">

  <h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
    <span class="text-primary">🎬</span> Gestión de Películas
  </h2>

  <!-- FILTRO + AÑADIR -->
  <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">

    <div class="w-full md:w-1/3 relative">
      <input
        class="input input-bordered w-full pr-8"
        placeholder="🔎 Filtrar..."
        value={filter}
        oninput={(e) => {
          filter = e.target.value;
          page = 1;
          filteredData = filterData();
          calculationPage();
        }}
      />

      {#if filter}
        <button
          class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
          onclick={() => {
            filter = "";
            filteredData = filterData();
            calculationPage();
          }}
        >
          ✖
        </button>
      {/if}
    </div>
    
    <div class=" gap-2 flex align-items-end">
      <button class="btn btn-outline btn-success" onclick={handleAdd}>
        ➕ Añadir Película
      </button>
      <button class="btn btn-outline btn-neutral" onclick={exportToCSV}>CSV</button>
      <button class="btn btn-outline btn-success" onclick={exportToExcel}>Excel</button>    
    </div>
  </div>

  <!-- PAGE SIZE + EXPORT -->
  <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) => {
          pageSize = parseInt(e.target.value);
          page = 1;
          filteredData = filterData();
          calculationPage();
        }}
      >
        {#each pageSizes as size}
          <option value={size}>{size}</option>
        {/each}
      </select>

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

    </div>

  </div>

  <!-- ESTADOS -->
  {#if loading}
    <p class="text-center py-8">Cargando...</p>

  {:else if errorMessage}
    <div class="alert alert-error justify-center">{errorMessage}</div>

  {:else}
    <div class="overflow-x-auto bg-base-100 rounded-xl shadow">
      <table class="table table-zebra w-full">
        <thead class="bg-gray-200" >
          <tr>
            <th>Acciones</th>

            <th class="cursor-pointer text-right" onclick={() => toggleSort("id")}>
              ID {sortColumn === "id" ? (sortDirection === 1 ? "▲" : "▼") : ""}
            </th>

            <th class="cursor-pointer" onclick={() => toggleSort("name")}>
              Nombre {sortColumn === "name" ? (sortDirection === 1 ? "▲" : "▼") : ""}
            </th>

            <th class="cursor-pointer text-right" onclick={() => toggleSort("price")}>
              Precio {sortColumn === "price" ? (sortDirection === 1 ? "▲" : "▼") : ""}
            </th>

            <th class="cursor-pointer text-center" onclick={() => toggleSort("date")}>
              Fecha {sortColumn === "date" ? (sortDirection === 1 ? "▲" : "▼") : ""}
            </th>

            <th class="cursor-pointer text-center" onclick={() => toggleSort("rating")}>
              Rating {sortColumn === "rating" ? (sortDirection === 1 ? "▲" : "▼") : ""}
            </th>

            <th class="cursor-pointer" onclick={() => toggleSort("theme")}>
              Tema {sortColumn === "theme" ? (sortDirection === 1 ? "▲" : "▼") : ""}
            </th>

            <th class="cursor-pointer" onclick={() => toggleSort("support")}>
              Soporte {sortColumn === "support" ? (sortDirection === 1 ? "▲" : "▼") : ""}
            </th>
          </tr>
        </thead>

        <tbody>
          {#if pagedData.length === 0}
            <tr>
              <td colspan="8" class="text-center py-4">
                No hay resultados.
              </td>
            </tr>

          {:else}
            {#each pagedData as row (row.id)}
              <tr>
                <td>
                  <div class="flex gap-2">
                    <button class="btn btn-info btn-xs btn-soft" onclick={() => handleView(row)}>
                      👁
                    </button>
                    <button class="btn btn-warning btn-xs btn-soft" onclick={() => handleEdit(row)}>
                      ✏️
                    </button>
                    <button class="btn btn-error btn-xs btn-soft" onclick={() => handleDelete(row.id, row.name)}>
                      🗑
                    </button>
                  </div>
                </td>

                <td class="text-right">{row.id}</td>
                <td>{row.name}</td>
                <td class="text-right">{formatPrice(row.price)}</td>
                <td class="text-center">{row.date.toLocaleDateString()}</td>

                <td class="text-center">
                  <div class="flex justify-center gap-1 text-yellow-400">
                    {#each Array(5) as _, i}
                      <span>{i < row.rating ? "★" : "☆"}</span>
                    {/each}
                  </div>
                </td>

                <td>{row.theme}</td>
                <td>{row.support}</td>
              </tr>
            {/each}
          {/if}
        </tbody>
      </table>
    </div>
  {/if}

  <!-- PAGINACIÓN -->
  {#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}

  <!-- MODAL (componente separado) -->
  <MovieManager
    open={showModal}
    mode={modalMode}
    movie={selectedMovie}
    {themes}
    {supports}
    onSave={saveMovie}
    onClose={closeModal}
  />
</div>

<style>
  .btn-soft {
    opacity: 0.8;
  }
  .btn-soft:hover {
    opacity: 1;
  }
</style>

Aquí hay mucho código y mucho detalle. De lo más importante es que trabajamos con 3 conjutnos de datos de Movie (Todos, los Filtrados y los de la Página).
En Todos, es el conjunto total de películas que hay en el Back-End.
En Filtrados, es el conjunto de registros en donde a Todos, se le ha aplicado los filtros que haya definido el usuario.
En Página, es el conjunto de registros de Filtrados que se presentan en pantalla de acuerdo al número de registros por página.

Muy relevante es el algoritmo de filtrado, porque tiene en cuenta el tipo de dato de cada columna.

También, es importante ver que las soluciones de Data Grid, como las vista en los componentes de SVAR, hacen muchas cosas complejas, pero para valorarlas, hay que saber cúales son y lo que conlleva programarlas.

<script>
  import { createForm } from "felte";
  import { validator } from "@felte/validator-zod";
  import { z } from "zod";

  const {
    open = false,
    mode = "view",  // "add" | "edit" | "view"
    movie = {},
    themes = [],
    supports = [],
    onSave,
    onClose
  } = $props();


  // ============================
  // ZOD: VALIDACIÓN
  // ============================
  const movieSchema = z.object({
    name: z.string().min(1, "El nombre es obligatorio"),
    price: z
      .string()
      .min(1, "El precio es obligatorio")
      .regex(/^\d+([.,]\d{1,2})?$/, "Formato de precio inválido"),
    date: z
      .string()
      .min(1, "La fecha es obligatoria")
      .refine((v) => !isNaN(Date.parse(v)), "Fecha inválida"),
    rating: z
      .string()
      .refine(v => ["1","2","3","4","5"].includes(v), "Valoración inválida")
      .transform(v => Number(v)),
    theme_id: z.string().min(1, "Tema obligatorio"),
    support_id: z.string().min(1, "Soporte obligatorio")
  });

  // ============================
  // FELTE
  // ============================
  const {
    form,
    errors,
    setInitialValues,
    setTouched
  } = createForm({
    initialValues: {
      name: "",
      price: "",
      date: "",
      rating: 1,
      theme_id: "",
      support_id: ""
    },
    extend: validator({ schema: movieSchema }),
    onSubmit: (values) => {
      if (onSave) onSave(values);
    }
  });

  // Sincronizar valores iniciales con la película seleccionada
  // ✅✅✅✅ Necesario para cargar los datos al abrir el modal en modo "edit" o "view"

  $effect.pre(() => {
    // console.log("Pre-efecto de sincronización ejecutado. open:", open, "mode:", mode, "movie:", movie);  
    if (open && mode !== "view" && movie) {
      setInitialValues(movie);
      setTouched({});
    }
  });


  function formatPrice(price) {
    return new Intl.NumberFormat("es-ES", {
      style: "currency",
      currency: "EUR",
      minimumFractionDigits: 2,
      maximumFractionDigits: 2
    }).format(price);
  }
</script>

{#if open}
  <dialog class="modal modal-open">
    <div class="modal-box w-full">

      <h3 class="font-bold text-lg mb-4">
        {mode === "add"
          ? "Añadir Película"
          : mode === "edit"
          ? "Editar Película"
          : "Detalles de la Película"}
      </h3>

      <!-- FORMULARIO ADD/EDIT -->
      {#if mode !== "view"}

        <form id="felte-form" use:form class="grid grid-cols-1 md:grid-cols-2 gap-4">

          <div class="form-control">
            <label class="label" for="name">Nombre</label>
            <input name="name" id="name" class="input input-bordered" />
            {#if $errors.name}
              <p class="text-red-500 text-sm">{$errors.name[0]}</p>
            {/if}
          </div>

          <div class="form-control">
            <label class="label" for="price">Precio</label>
            <input name="price" class="input input-bordered" />
            {#if $errors.price}
              <p class="text-red-500 text-sm">{$errors.price[0]}</p>
            {/if}
          </div>

          <div class="form-control">
            <label class="label" for="date">Fecha</label>
            <input name="date" id="date" type="date" class="input input-bordered" />
            {#if $errors.date}
              <p class="text-red-500 text-sm">{$errors.date[0]}</p>
            {/if}
          </div>

          <div class="form-control">
            <label class="label" for="rating">Valoración</label>
            <select name="rating" id="rating" class="select select-bordered">
              {#each [1, 2, 3, 4, 5] as r}
                <option value={r}>{r}</option>
              {/each}
            </select>
            {#if $errors.rating}
              <p class="text-red-500 text-sm">{$errors.rating[0]}</p>
            {/if}
          </div>

          <div class="form-control">
            <label class="label" for="theme_id">Tema</label>
            <select name="theme_id" class="select select-bordered">
              <option value="">-- Selecciona tema --</option>
              {#each themes as t}
                <option value={String(t.id_theme)}>{t.theme}</option>
              {/each}
            </select>
            {#if $errors.theme_id}
              <p class="text-red-500 text-sm">{$errors.theme_id[0]}</p>
            {/if}
          </div>

          <div class="form-control">
            <label class="label" for="support_id">Soporte</label>
            <select name="support_id" id="support_id" class="select select-bordered">
              <option value="">-- Selecciona soporte --</option>
              {#each supports as s}
                <option value={String(s.id_support)}>{s.support}</option>
              {/each}
            </select>
            {#if $errors.support_id}
              <p class="text-red-500 text-sm">{$errors.support_id[0]}</p>
            {/if}
          </div>

        </form>


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

      {:else}
        <!-- MODO SOLO LECTURA -->
        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">

          <div class="form-control">
            <label class="font-semibold" for="selectedMovieName">Nombre</label>
            <input class="input input-bordered w-full" value={movie.name} readonly disabled />
          </div>

          <div class="form-control">
            <label class="font-semibold" for="selectedMoviePrice">Precio</label>
            <input class="input input-bordered w-full" value={formatPrice(movie.price)} readonly disabled />
          </div>

          <div>
            <label class="font-semibold" for="selectedMovieDate">Fecha</label>
            <input class="input input-bordered w-full" value={movie.dateText} readonly disabled />
          </div>

          <div class="form-control">
            <label class="font-semibold" for="selectedMovieRating">Valoración</label>
            <div class=" input input-bordered 	bg-star w-full">
              <div class="flex gap-1 text-yellow-400 mt-1">
                {#each Array(5) as _, i}
                  <span>{i < movie.rating ? "★" : "☆"}</span>
                {/each}
              </div>
            </div>
          </div>

          <div class="form-control">
            <label class="font-semibold" for="selectedMovieTheme">Tema</label>
            <input
              class="input input-bordered w-full"
              value={themes.find(t => t.id_theme == movie.theme_id)?.theme}
              readonly
              disabled
            />
          </div>

          <div class="form-control">
            <label class="font-semibold" for="selectedMovieSupport">Soporte</label>
            <input
              class="input input-bordered w-full"
              value={supports.find(s => s.id_support == movie.support_id)?.support}
              readonly
              disabled
            />
          </div>
      </div>
      <div class="modal-action  flex justify-end gap-2 mt-4">
        <button class="btn" type="button" onclick={onClose}>Cerrar</button>
      </div>

        
      {/if}

    </div>
  </dialog>
{/if}

<style>
  input[disabled] {
    background-color: #eef2f6 !important; /* un gris muy claro */
    color: #1f2937; /* texto gris oscuro para buena lectura */
    opacity: 1 !important; /* evita que DaisyUI lo haga más tenue */
  }
  .bg-star {
    background-color: #eef2f6; /* un gris muy claro */
    border:#e7e9eb
  }

</style>

Aquí es donde entran los productos Felte y Zod. Estos productos tiene su documentación en Internet y creo que si os interesa los mismos, deberías, además de ver el ejemplo, consultar estas páginas.

Un aspecto relevante es la línea 64 que lo que hace es indicar a Felte que se ha cambiado el registro de Movie y que debe recogerlo antes de renderizar la página. Esto es necesario al utilizar una ventana modal, ya que la ventana se crea cuando renderiza la página de List, lo que pasa es que no se vé. Para refrescar los datos cuando abrimos la ventana, nos es necesarias estas línea.

<script>
  import { onMount } from "svelte";
  import Swal from "sweetalert2";
  import api from "./lib/api.js";

  // Felte + Zod
  import { createForm } from "felte";
  import { validator } from "@felte/validator-zod";
  import { z } from "zod";

  let supports = [];
  let loading = true;
  let errorMessage = null;

  let filter = "";
  let showModal = false;
  let modalMode = "add";
  let selectedSupport = {};

  async function fetchSupports() {
    loading = true;
    try {
      const res = await api.get("/supports");
      supports = res.data;
    } catch (err) {
      errorMessage = "No se pudieron cargar los soportes.";
    } finally {
      loading = false;
    }
  }

  onMount(fetchSupports);

  // -----------------------------------------------------
  //  ZOD: schema estático (solo campo obligatorio)
  // -----------------------------------------------------
  const supportSchema = z.object({
    support: z.string().min(1, "El nombre es obligatorio")
  });

  // -----------------------------------------------------
  //  FELTE: inicialización (solo una vez)
  // -----------------------------------------------------
  const {
    form,
    errors,
    data,
    setInitialValues,
    setTouched
  } = createForm({
    initialValues: { support: "" },
    extend: validator({ schema: supportSchema }),
    onSubmit: saveSupport
  });

  // -----------------------------------------------------
  //  Acciones del modal
  // -----------------------------------------------------
  function handleAdd() {
    modalMode = "add";
    selectedSupport = { id_support: null, support: "" };

    setInitialValues({ support: "" });
    setTouched({});

    showModal = true;
  }

  function handleEdit(item) {
    modalMode = "edit";
    selectedSupport = { ...item };

    setInitialValues({ support: item.support });
    setTouched({});

    showModal = true;
  }

  function handleView(item) {
    modalMode = "view";
    selectedSupport = { ...item };
    showModal = true;
  }

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

    if (!result.isConfirmed) return;

    try {
      await api.delete(`/supports/${id}`);
      Swal.fire("Eliminado", "Soporte eliminado correctamente", "success");
      fetchSupports();
    } catch (err) {
      Swal.fire("Error", "No se pudo eliminar el soporte", "error");
    }
  }

  // -----------------------------------------------------
  //  Guardar (Felte llama a esta función)
  // -----------------------------------------------------
  async function saveSupport(values) {
    const name = values.support.trim().toLowerCase();
    const currentId = Number(selectedSupport.id_support);

    // Validación de duplicados
    const exists = supports.some(
      s =>
        s.support.trim().toLowerCase() === name &&
        s.id_support !== currentId
    );

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

    try {
      if (modalMode === "add") {
        await api.post("/supports", values);
        Swal.fire("Guardado", "Soporte añadido correctamente", "success");
      } else {
        await api.put(`/supports/${selectedSupport.id_support}`, values);
        Swal.fire("Actualizado", "Soporte actualizado correctamente", "success");
      }

      showModal = false;
      fetchSupports();
    } catch (err) {
      Swal.fire("Error", "No se pudo guardar el soporte", "error");
    }
  }
</script>

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

  <div class="flex justify-between items-center mb-4">

    <!-- Input con botón X -->
    <div class="relative w-1/3">
      <input
        type="text"
        placeholder="🔎 Filtrar..."
        class="input input-bordered w-full pr-10"
        bind:value={filter}
      />

      {#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 = "")}
        >
          ✖
        </button>
      {/if}
    </div>

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

  {#if loading}
    <p class="text-center py-6">Cargando soportes...</p>
  {:else if errorMessage}
    <div class="alert alert-error">{errorMessage}</div>
  {:else}
    <div class="overflow-x-auto bg-base-100 rounded-xl shadow">
      <table class="table table-zebra w-full">
        <thead>
          <tr>
            <th>Acciones</th>
            <th>ID</th>
            <th>Nombre</th>
          </tr>
        </thead>
        <tbody>
          {#each supports.filter(s => s.support.toLowerCase().includes(filter.toLowerCase())) as s}
            <tr>
              <td>
                <div class="flex gap-2">
                  <button class="btn btn-info btn-xs" onclick={() => handleView(s)}>👁</button>
                  <button class="btn btn-warning btn-xs" onclick={() => handleEdit(s)}>✏️</button>
                  <button class="btn btn-error btn-xs" onclick={() => handleDelete(s.id_support, s.support)}>🗑</button>
                </div>
              </td>
              <td>{s.id_support}</td>
              <td>{s.support}</td>
            </tr>
          {/each}
        </tbody>
      </table>
    </div>
  {/if}

  <!-- -----------------------------------------------------
       MODAL
  ------------------------------------------------------ -->
  {#if showModal}
    <dialog class="modal modal-open">
      <div class="modal-box">
        <h3 class="font-bold text-lg mb-4">
          {modalMode === "add"
            ? "Añadir Soporte"
            : modalMode === "edit"
            ? "Editar Soporte"
            : "Detalles del Soporte"}
        </h3>

        {#if modalMode === "view"}
          <input
            class="input input-bordered w-full mb-4"
            value={selectedSupport.support}
            readonly
            disabled
          />
        {:else}
          <form id="felte-form" use:form>
            <label class="label" for="support">Nombre del soporte</label>
            <input
              name="support"
              class="input input-bordered w-full mb-1"
              placeholder="Nombre del soporte"
            />

            {#if $errors.support}
              <p class="text-red-500 text-sm mb-2">{$errors.support[0]}</p>
            {/if}
          </form>
        {/if}

        <div class="modal-action">
          {#if modalMode !== "view"}
            <button class="btn btn-success" form="felte-form" type="submit">
              {modalMode === "add" ? "Guardar" : "Actualizar"}
            </button>
          {/if}

          <button class="btn" type="button" onclick={() => (showModal = false)}>
            Cerrar
          </button>
        </div>
      </div>
    </dialog>
  {/if}
</div>

Es un ejemplo muy básico y muy útil, si tu nivel de codificación no es alto.

En la Guía S-002, con utilización de Bootstrap, veréis que su codificación difiere bastante, es porque esa codificación mantenia aspectos de Svelte 4. En esta, se ha actualizado todo a vesión 5 con RUNAS.

Como siempre, os dejo los fuentes del proyecto para que lo descarguéis y podáis hacer todas las modificaciones que creáis oportunas.

Os recuerdo que la aplicación del Back-End, con su base de datos está en la guía S-002

 

Adjuntos

Archivo Tamaño de archivo Descargas
zip felte-svelte 37 KB 0

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