S-021 – Drag and Drop (Arrastrar y Soltar) en Svelte 5

Esta funcionalidad de «arrastrar y soltar» no es de las primeras cosas que pruebas en un nuevo entorno de programación, pero es importante para determinadas aplicaciones, si deseas que el interfaz de la aplicación aparezca como algo moderno.

Repasando las aplicaciones realizadas en Web con PHPRunner, vi que tenía una, de la que me sentía muy contento y que me faltaba esta pieza/solución. La aplicación es KANBAN.

La parte del interfaz que me faltaba era la de «Drag and Drop» o «Arrastrar y Soltar», para cambiar de estado a los incidentes o tareas.

Objetivo

Seleccionar una librería de DnD (Drag and Drop) para añadir esta funcionalidad a los desarrollos de Svelte 5.

DEMO:  https://fhumanes.com/my-fluid-dnd/

Solución Técnica

El producto que creí que tenía todas las características que necesitaba es Fluid DnD y el ejercicio que he hecho son prácticamente todos los ejemplos que tiene en el blog de documentación.

Indicaros que parece ser que el producto se creo inicialmente para VUE y que después se ha migrado a REACT y SVELTE.

Los ejemplos del creador funcionan perfectamente , no así los ejemplos que tiene definidos para Svelte, que faltan pasos y definiciones, por lo que creo que estos ejemplos, completos para Svelte 5 (JavaScript), pueden ser de mucha utilidad para aquellos que deseen utilizar esta funcionalidad en esta plataforma.

Para disponer en una única aplicación todos los ejemplos de fabricante, he añadido un menú y una cabecera, que nos permitirá acceder a cada uno de los ejemplos.

Los nombres de los ejemplos coinciden (más o menos) con los dados por el fabricante y espero que no tengáis problema en identificarlos.

En los 2 últimos ejemplos «Estilo Gragging» y «Lista con Handler», veréis que cuando se define una clase para el evento que corresponda, esta clase se define Global. Esto es necesario porque el producto activa y desactiva esa clase, pero con el nombre que se indica y en Svelte 5, si definimos clase de CSS que no sean globales, en fase de compilación de código, se cambian estos nombres por otros, para que no colisionen entre las distintas vista del aplicativo.

Os mostraré algunos de los ficheros más representativos de cara a facilitar el acceso a ellos y su comprensión.

(1) .- App.svelte(2) .- Header.svelte(3) .- list-on-a-scroll.svelte(4) .- map-values.svelte(5) .- dragging-styles.svelte
<script>

    import Header from './Header.svelte';

    import Sv from './single-vertical.svelte';
    import SvScroll from './list-on-a-scroll.svelte';
    import SvMixed from './list-mixed-styles.svelte';
    import Sh from './single-horizontal-list.svelte';
    import IsDragable from './is-dragable.svelte';
    import ListGroup from './list-group.svelte';
    import List_inputs from './list-with-inputs.svelte';
    import SortTables from './sort-tables.svelte';
    import MapValues from './map-values.svelte';
    import OnDrag from './onDrag.svelte';
    import DraggingStyles from './dragging-styles.svelte';
    import List_Handler from './list-with-handler.svelte';
   

  

    let basepath = "/my-my-fluid-dnd"; // Ajusta esto según tu configuración de despliegue

    // Para gestión del Menú
    let menuAbierto = false;
    let vistaActual = {};



    // Mapa de vistas → componente
    const vistas = [
      { id: 1,
        componente: Sv,
        menu: "C.Vertical",
        cabecera: "Un Columna Vertical"
      },
      { id: 2,
        componente: SvMixed,
        menu: "L.varios estilos",
        cabecera: "Lista con varios Estilos"
      },
      { id: 3,
        componente: SvScroll,
        menu: "Vertical Scroll",
        cabecera: "Una Columna Vertical con Scroll"
      },
      { id: 4,
        componente: Sh,
        menu: "F.horizontal",
        cabecera: "Una Fila Horizontal"
      },
      { id: 5,
        componente: IsDragable,
        menu: "Draggable",
        cabecera: "Lista con Elementos No Arrastrables"
      },
      { id: 6,
        componente: ListGroup,
        menu: "Grupos de Listas",
        cabecera: "Listas Agrupadas"
      },
      { id: 7,
        componente: List_inputs,
        menu: "Listas con Inputs",
        cabecera: "Listas con Campos de Entrada"
      },
      { id: 8,
        componente: SortTables,
        menu: "Tablas Ordenables",
        cabecera: "Tablas con Elementos Ordenables"
      },
      { id: 9,
        componente: MapValues,
        menu: "Mapeo de Valores",
        cabecera: "Mapeo de Valores entre Listas"
      },
      { id: 10,
        componente: OnDrag,
        menu: "Eventos onDrag",
        cabecera: "Uso de Eventos onDrag"
      },
      { id: 11,
        componente: DraggingStyles,
        menu: "Estilo Dragging",
        cabecera: "Personalización de Estilos durante el Arrastre"
      },
      { id: 12,
        componente: List_Handler,
        menu: "Lista con Handler",
        cabecera: "Lista con Manipulador de Arrastre"
      }
    ];

    function toggleMenu() {
        menuAbierto = !menuAbierto;
    }

    function cambiarVista(vista_id) {
        vistaActual = vistas.find(v => v.id === vista_id);
        menuAbierto = false;
    }
    cambiarVista(1); // Vista por defecto



</script>

  <Header {menuAbierto} {vistaActual} {toggleMenu} {cambiarVista} {vistas}/> 

    <div class="app-container">
        <main class="content-area">
          {#if vistaActual.componente}
            <h3 style="text-align: center">{vistaActual.cabecera} </h3>
            <!-- Renderiza el componente según el valor de vistaActual -->
            <svelte:component this={vistaActual.componente} {cambiarVista} />
            <!-- <{vistas[vistaActual]} {cambiarVista} /> -->
 
          {:else}
            <p>🚨------- Vista no encontrada ---------🚨</p>
          {/if}
        </main> 
    </div>

<style>
  main {
    position: fixed;
    top: 55px;      /* debajo de la cabecera fija // 55 desktop || 80 movil */
    left: 5px;        /* pegado al borde izquierdo */
    width: calc(100% - 10px); /* ocupa todo el ancho si quieres */
     /* min-height: calc(99vh - 55px); resto de la ventana */
    height: calc(100dvh - 55px); 
    overflow-y: auto;
    padding: 1rem;
  }
   @media (max-width: 768px) {
    main {
      top: 80px;      /* debajo de la cabecera fija // 55 desktop || 80 movil */
      /* min-height: calc(99vh - 80px);  resto de la ventana */
      height: calc(99dvh - 80px); 
      overflow-y: auto;
    }
  }

  .app-container {  
    display: flex;
    flex-direction: column;
    /* min-height: 97vh;  */
  } 

  .content-area {
    /* margin-top: 15px;  /* Ajusta según la altura de tu encabezado */
    flex-grow: 1; /* Permite que el área de contenido ocupe el espacio restante */
    /*  padding: 30px; */
    padding-top: 0px;
    padding-right: 30px;
    padding-bottom:30px;
    padding-left: 30px;
    background-color: #ffffff; /* Fondo similar al de tus modales */
  }
  @media (max-width: 768px) {
    .content-area {
      /* margin-top: 15px;  /* Ajusta según la altura de tu encabezado */
      flex-grow: 1; /* Permite que el área de contenido ocupe el espacio restante */
      /*  padding: 30px; */
      padding-top: 0px;
      padding-right: 3px;
      padding-bottom:3px;
      padding-left: 3px;
      background-color: #ffffff; /* Fondo similar al de tus modales */
    }
  }

  </style>
<script>
  
  let { menuAbierto, vistaActual, toggleMenu, cambiarVista, vistas } = $props();


  function irAVista(vista) {
    cambiarVista(vista);
  }
</script>

<nav>
  <div class="logo">Fluid-dnd</div>

  <button class="menu-toggle" onclick={toggleMenu} aria-label="Abrir menú" type="button">
    ☰
  </button>

  <ul class:abierto={menuAbierto}>

    {#each vistas as vista}
      <li>
        <button
          class="nav-btn"
          class:activo={vistaActual.id === vista.id}
          onclick={() => irAVista(vista.id)}
        >
          {vista.menu}
        </button>
      </li>
    {/each}
    
  </ul>
</nav>

<style>
  * {
    box-sizing: border-box;
  }

  nav {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    background-color: #333;
    color: white;
    padding: 0.5rem 0.5rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
    z-index: 1000;
  }

  .logo {
    font-weight: bold;
    font-size: 1.2rem;
  }

  .menu-toggle {
    font-size: 1.5rem;
    cursor: pointer;
    background: none;
    border: none;
    color: white;
  }

  ul {
    list-style: none;
    display: flex;
    gap: 1rem;
    margin: 0;
    padding: 0;
  }

  li {
    position: relative;     /* clave para posicionar el submenú */
    display: flex;
    align-items: center;
  }

  button.nav-btn {
    background: none;
    border: none;
    color: inherit;
    font: inherit;
    cursor: pointer;
    padding: 0.3rem 0.6rem;
    border-radius: 4px;
    text-align: left;
  }

  button.nav-btn:hover {
    background-color: #555;
  }

  button.nav-btn.activo {
    background-color: #007acc;
  }

  @media (max-width: 768px) {
    ul {
      flex-direction: column;
      align-items: flex-start;
      padding-left: 1rem;
      position: absolute;
      top: 100%;
      left: 0;
      width: 100%;
      background-color: #444;
      display: none;
    }

    ul.abierto {
      display: flex;
    }

    li {
      width: 100%;
      flex-direction: column;
      align-items: flex-start;
    }

  }

  .menu-toggle {
    display: none;
  }

  @media (max-width: 768px) {
    .menu-toggle {
      display: block;
    }
  }
</style>
<script>
    import { useDragAndDrop } from "fluid-dnd/svelte";

    const list = $state([...Array(20).keys()]);
    const [ parent ] = useDragAndDrop(list);
</script>


<ul use:parent class="number-list">
    {#each list as element, index (element)}
        <li class="number" data-index={index}>
            {element}
        </li>
    {/each}
</ul>

<style>
  .number {
    border-style: solid;
    padding-left: 5px;
    margin-top: 0.25rem;
    border-width: 2px;
  }
.number-list {
    display: block;
    padding-inline: 20px;
    overflow: auto;
    height: 300px;
    width: 450px;
}
</style>
<script>
  import { useDragAndDrop } from "fluid-dnd/svelte";

  const list = $state([
    { id: "1", value: "Carcu", is_visible: true },
    { id: "2", value: "Rodri", is_visible: true },
    { id: "3", value: "Mbappe", is_visible: true }
  ]);

  const list2 = $state([
    { id: "11", label: "Cholo" },
    { id: "ab", label: "Popi" }
  ]);

  const MapItemToMinifiedItem = (item) => ({
    id: item.id,
    label: item.value
  });

  const MapMinifiedItemToItem = (item) => ({
    id: item.id,
    value: item.label,
    is_visible: true
  });

  const [parent1] = useDragAndDrop(list, {
    droppableGroup: "group1",
    mapFrom: (item) => MapItemToMinifiedItem(item)
  });

  const [parent2] = useDragAndDrop(list2, {
    droppableGroup: "group1",
    mapFrom: (item) => MapMinifiedItemToItem(item)
  });


  const handleClick = (item) => {
    alert(`ID: ${item.id}\nValor: ${item.value ?? item.label}`);
};

</script>

<div class="container">
  <div class="list-box" use:parent1>
    <h3>Lista 1</h3>
    {#each list as item, index (item.id)}
        <!-- Se muestra el valor y el estado de visibilidad del item . Warning porque se definio DIV, con Button se quita error-->
        <!-- svelte-ignore a11y_click_events_have_key_events -->
        <div
            class="item"
            role="button"
            tabindex="0"
            onclick={() => {handleClick(item)}}
            data-index={index}
            >
            {item.value} - {item.is_visible ? "Visible" : "Hidden"}
      </div>
    {/each}
  </div>

  <div class="list-box" use:parent2>
    <h3>Lista 2</h3>
    {#each list2 as item, index (item.id)}
        <!-- svelte-ignore a11y_click_events_have_key_events -->
        <div
            class="item"
            role="button"
            tabindex="0"
            onclick={() => {handleClick(item)}}
            data-index={index}
            >
            {item.label}
        </div>
    {/each}
  </div>
</div>

<style>
  .container {
    display: flex;
    gap: 20px;
    justify-content: center;
    margin-top: 20px;
  }

  .list-box {
    background: #1e1e1e;
    padding: 20px;
    border-radius: 10px;
    width: 220px;
    border: 1px solid #444;
  }

  .list-box h3 {
    color: #fff;
    text-align: center;
    margin-bottom: 15px;
  }

    .item {
    all: unset; /* elimina estilo nativo del botón */
    
    width: 180px;     /* ancho fijo */
    height: 40px;     

    display: block;
    background: #2a2a2a;
    color: #fff;
    padding: 10px;
    margin-bottom: 10px ; /* centrado horizontal */
    border-radius: 6px;
    border: 1px solid #555;
    cursor:grab;

    user-select: none;
    -webkit-user-select: none;
    -ms-user-select: none;
    }

    .item:active {
        cursor: grabbing;
    }
    .item:focus {
      outline: 2px solid #888; /* opcional, accesible */
    }
</style>
<script>
import { useDragAndDrop } from "fluid-dnd/svelte";

const list = $state([1, 2, 3,4,5,6,7,8,9,10]);
const [ parent ] = useDragAndDrop(list,{
    draggingClass: 'dragging',
});

</script>
<h3> Estilos de arrastre</h3>

<ul use:parent class="number-list">
    {#each list as element, index (element)}
        <li class="number5" data-index={index}>
            {element}
        </li>
    {/each}
</ul>

<style>
  .number5 {
    display: block;  /* muy importante para que el draggingClass funcione correctamente */
    border-style: solid;
    padding-left: 5px;
    margin-top: 0.25rem;
    border-width: 2px;
  }
  .number-list {
    display: block;
    padding: 20px;
    width: 450px;
  }
  :global(.number5.dragging) { /* el selector debe ser global para que se aplique al elemento arrastrado, que se mueve al body durante el arrastre */
    transition: background-color 150ms ease-in, color 150ms ease-in;
    background-color: black;
    color: white;
}
</style>

(1) Es la vista contenedora de la aplicación Dispone de una array que contiene el título del menú y el nombre del fichero del código, que previamente se ha importado. Es una forma muy sencilla de construir una App con un menú que muestra diferentes ejemplos.

(2) Es el que construye el interfaz del menú. Sencillo y práctico.

(3) Es el ejercicio básico. Observar qué poco hay que programar para disponer de esta fucnionalidad

(4) Lo he elegido porque dispone de casi todo lo que se requiere para construir el KANBAN. Observar línea 48. Explico que Svelte muestra un mensaje warning porque estoy definiendo un evente «onclick» en un <DIV>. Con este mensaje de línea 48 se bloquea el warning, para que no muestre en error warning en compilación.

(5) En este ejemplo muestro la definición de CSS globales, para que cambie el interfaz del registro que se arrastra.

Como siempre os dejo los fuentes para que lo descarguéis y probéis en vuestros PC’s.

Para cualqueir duda, poneros en contacto conmigo a través del email.

Adjuntos

Archivo Tamaño de archivo Descargas
zip my-fluid-dnd 43 KB 0

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