Skip to content

React hello-pangea/dnd

React hello-pangea/dnd es una biblioteca que nos permite utilizar drag and drop interfaces en React. Esta diseñada para ser liviana, rápida y fácil de usar.

Recursos

Install

sh
# npm
npm install @hello-pangea/dnd --save

# pnpm
pnpm add @hello-pangea/dnd

# yarn
yarn add @hello-pangea/dnd

Usage

jsx
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";

DragDropContext le dará a nuestra aplicación la capacidad de usar la biblioteca. Funciona de manera similar a la API de contexto de React, donde la biblioteca ahora puede tener acceso al árbol de componentes.

Droppable es un componente que le permite a la biblioteca saber dónde se pueden soltar los elementos. Es como un contenedor que puede contener elementos que se pueden arrastrar.

Draggable es un componente que le permite a la biblioteca saber qué elementos se pueden arrastrar.

Droppable

Queremos crear un área Droppable, lo que significa que esto nos permitirá proporcionar un área específica donde nuestros artículos se pueden mover dentro.

jsx
import { useState } from "react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";

const initialTodos = [
    { id: 1, text: "Learn React", completed: true },
    { id: 2, text: "Learn Firebase", completed: true },
    { id: 3, text: "Learn GraphQL", completed: false },
];

const App = () => {
    const [todos, setTodos] = useState(initialTodos);

    return (
        <Droppable droppableId="todos">
            <h1>Todo App</h1>
            {(droppableProvider) => (
                <ul>
                    {todos.map((todo) => (
                        <li key={todo.id}>{todo.text}</li>
                    ))}
                </ul>
            )}
        </Droppable>
    );
};

export default App;
  • droppableId: Es un identificador único que se usa para identificar esta instancia específica de Droppable.
  • droppableProvider: nos permite acceder a la información de estado de la biblioteca.
  • droppableProvider.innerRef: Esto creará una referencia ( provided.innerRef) para que la biblioteca acceda al elemento HTML del elemento de la lista.
  • droppableProvider.droppableProps: referencia API
jsx
<DragDropContext>
    <h1>Todo App</h1>
    <Droppable droppableId="todos">
        {(droppableProvider) => (
            <ul
                ref={droppableProvider.innerRef}
                {...droppableProvider.droppableProps}
            >
                {todos.map((todo) => (
                    <li key={todo.id}>{todo.text}</li>
                ))}
            </ul>
        )}
    </Droppable>
</DragDropContext>
css
li {
    border: 2px solid salmon;
    padding: 0.5rem;
}

ul {
    list-style: none;
    padding: 1rem;
    border: 2px solid chocolate;
}

Draggable

Usaremos el componente Draggable , que nuevamente, similar al componente Droppable, incluirá una función en la que pasaremos los accesorios a los componentes de nuestro elemento de lista.

  • key=
  • draggableId={todo.id} : Es un identificador único que se usa para identificar esta instancia específica de Draggable.
  • index={index} : Es el índice de la lista de elementos que se está iterando. Esto se usa para determinar el orden de los elementos en la lista.
jsx
{
    todos.map((todo, index) => (
        <Draggable key={todo.id} index={index} draggableId={todo.id}>
            {(draggableProvider) => <li key={todo.id}>{todo.text}</li>}
        </Draggable>
    ));
}
  • draggableProvider.innerRef: Esto creará una referencia ( provided.innerRef) para que la biblioteca acceda al elemento HTML del elemento de la lista.
  • draggableProvider.draggableProps y draggableProvider.dragHandleProps: referencia API
jsx
{
    todos.map((todo, index) => (
        <Draggable key={todo.id} index={index} draggableId={todo.id}>
            {(draggableProvider) => (
                <li
                    ref={draggableProvider.innerRef}
                    {...draggableProvider.draggableProps}
                    {...draggableProvider.dragHandleProps}
                >
                    {todo.text}
                </li>
            )}
        </Draggable>
    ));
}

draggableId

Invariant failed: Draggable requires a [string] draggableId.

draggableId={${todo.id}}

droppableProvider.placeholder

Se puede utilizar un espacio reservado para mostrar dónde se colocará el elemento cuando se suelta.

jsx
<DragDropContext>
    <h1>Todo App</h1>
    <Droppable droppableId="todos">
        {(droppableProvider) => (
            <ul
                ref={droppableProvider.innerRef}
                {...droppableProvider.droppableProps}
            >
                {todos.map((todo, index) => (
                    <Draggable
                        key={todo.id}
                        index={index}
                        draggableId={`${todo.id}`}
                    >
                        {(draggableProvider) => (
                            <li
                                ref={draggableProvider.innerRef}
                                {...draggableProvider.draggableProps}
                                {...draggableProvider.dragHandleProps}
                            >
                                {todo.text}
                            </li>
                        )}
                    </Draggable>
                ))}
                {droppableProvider.placeholder}
            </ul>
        )}
    </Droppable>
</DragDropContext>

Persistir el orden de los elementos

Para resolver esto, DragDropContext toma un props onDragEnd que nos permitirá activar una función después de que se haya completado el arrastre.

  • splice js: El método splice() cambia el contenido de un array eliminando elementos existentes y/o agregando nuevos elementos.
  • array.splice(start[, deleteCount[, item1[, item2[, ...]]]])
  • start: Indice donde se comenzará a cambiar el array.
  • deleteCount: Número de elementos (enteros) a eliminar, comenzando por start.
    • Si es 1, se eliminará un elemento.
    • Si es 0, no se eliminarán elementos. En este caso, debe especificarse al menos un nuevo elemento.
  • item1, item2, ...: Los elementos que se agregarán al array. Si no se especifican, splice() solo eliminará elementos del array.
jsx
const handleDragEnd = (result) => {
    // console.log(result);
    if (!result.destination) return;

    console.log("origen: ", result.source.index);
    console.log("fin: ", result.destination.index);

    const startIndex = result.source.index;
    const endIndex = result.destination.index;

    const items = [...todos];
    // con splice estamos eliminando un elemento del array y devolviendo ese elemento
    const [reorderedItem] = items.splice(startIndex, 1);

    // con splice estamos insertando un elemento en el array
    items.splice(endIndex, 0, reorderedItem);

    setTodos(items);
};

return (
    <DragDropContext onDragEnd={handleDragEnd}>
    ...

Optimizado:

jsx
//https://github.com/ymulenll/react-beautiful-dnd-demo/blob/master/src/App.js
const reorder = (list, startIndex, endIndex) => {
    const result = [...list];
    const [removed] = result.splice(startIndex, 1);
    result.splice(endIndex, 0, removed);

    return result;
};

const App = () => {
    const [todos, setTodos] = useState(initialTodos);

    const handleDragEnd = (result) => {
        const { destination, source } = result;
        if (!destination) return;
        if (
            source.index === destination.index &&
            source.droppableId === destination.droppableId
        )
            return;

        setTodos((prevTasks) =>
            reorder(prevTasks, source.index, destination.index)
        );
    };

TODO app FrontenMentor

TodoList.jsx

jsx
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import TodoItem from "./TodoItem";

const TodoList = ({ todos, removeTodo, updateTodo, handleDragEnd }) => {
    return (
        <DragDropContext onDragEnd={handleDragEnd}>
            <Droppable droppableId="todos">
                {(droppableProvided) => (
                    <div
                        className="mt-8 overflow-hidden rounded-t-md bg-white transition-all duration-1000 dark:bg-gray-800 [&>article]:p-4"
                        {...droppableProvided.droppableProps}
                        ref={droppableProvided.innerRef}
                    >
                        {todos.map((todo, index) => (
                            <Draggable
                                key={todo.id}
                                draggableId={`${todo.id}`}
                                index={index}
                            >
                                {(provided) => (
                                    <TodoItem
                                        {...provided.draggableProps}
                                        ref={provided.innerRef}
                                        {...provided.dragHandleProps}
                                        todo={todo}
                                        removeTodo={removeTodo}
                                        updateTodo={updateTodo}
                                    />
                                )}
                            </Draggable>
                        ))}
                        {droppableProvided.placeholder}
                    </div>
                )}
            </Droppable>
        </DragDropContext>
    );
};

export default TodoList;

TodoItem.jsx (no olvidar: forwardRef)

jsx
import IconCross from "./icons/IconCross";
import IconCheck from "./icons/IconCheck";
import React from "react";

const TodoItem = React.forwardRef(
    ({ todo, removeTodo, updateTodo, ...props }, ref) => {
        const { id, title, completed } = todo;

        return (
            <article
                className="flex gap-4 border-b border-b-gray-400 "
                ref={ref}
                {...props}
            >
                <button
                    className={`h-5 w-5 flex-none rounded-full border-2 ${
                        completed
                            ? "grid place-items-center bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500"
                            : "inline-block"
                    }`}
                    onClick={() => updateTodo(id)}
                >
                    {completed && <IconCheck />}
                </button>
                <p
                    className={`grow text-gray-600 transition-all duration-1000 dark:text-gray-400 ${
                        completed && "line-through"
                    }`}
                >
                    {title}
                </p>
                <button className="flex-none" onClick={() => removeTodo(id)}>
                    <IconCross />
                </button>
            </article>
        );
    }
);

export default TodoItem;

App.jsx

jsx
import { useEffect, useState } from "react";
import Header from "./components/Header";
import TodoComputed from "./components/TodoComputed";
import TodoCreate from "./components/TodoCreate";
import TodoFilter from "./components/TodoFilter";
import TodoList from "./components/TodoList";

const initialStateTodos = JSON.parse(localStorage.getItem("todos")) || [];

//https://github.com/ymulenll/react-beautiful-dnd-demo/blob/master/src/App.js
const reorder = (list, startIndex, endIndex) => {
    const result = [...list];
    const [removed] = result.splice(startIndex, 1);
    result.splice(endIndex, 0, removed);

    return result;
};

const App = () => {
    const [todos, setTodos] = useState(initialStateTodos);

    useEffect(() => {
        localStorage.setItem("todos", JSON.stringify(todos));
    }, [todos]);

    const handleDragEnd = (result) => {
        const { destination, source } = result;
        if (!destination) return;
        if (
            source.index === destination.index &&
            source.droppableId === destination.droppableId
        )
            return;

        setTodos((prevTasks) =>
            reorder(prevTasks, source.index, destination.index)
        );
    };

    const createTodo = (title) => {
        const newTodo = {
            id: `${Date.now()}`,
            title: title.trim(),
            completed: false,
        };

        setTodos([...todos, newTodo]);
    };

    const removeTodo = (id) => {
        setTodos(todos.filter((todo) => todo.id !== id));
    };

    const updateTodo = (id) => {
        setTodos(
            todos.map((todo) =>
                todo.id === id ? { ...todo, completed: !todo.completed } : todo
            )
        );
    };

    const computedItemsLeft = todos.filter((todo) => !todo.completed).length;

    const clearCompleted = () => {
        setTodos(todos.filter((todo) => !todo.completed));
    };

    const [filter, setFilter] = useState("all");

    const changeFilter = (filter) => setFilter(filter);

    const filteredTodos = () => {
        switch (filter) {
            case "all":
                return todos;
            case "active":
                return todos.filter((todo) => !todo.completed);
            case "completed":
                return todos.filter((todo) => todo.completed);
            default:
                return todos;
        }
    };

    return (
        <div className="min-h-screen bg-gray-300 bg-mobile-light bg-contain bg-no-repeat transition-all duration-1000 dark:bg-gray-900 dark:bg-mobile-dark md:bg-desktop-light md:dark:bg-desktop-dark">
            <Header />

            <main className="container mx-auto mt-8 px-4 md:max-w-xl">
                <TodoCreate createTodo={createTodo} />

                {todos.length > 0 ? (
                    <TodoList
                        todos={filteredTodos()}
                        removeTodo={removeTodo}
                        updateTodo={updateTodo}
                        handleDragEnd={handleDragEnd}
                    />
                ) : (
                    <p>Cargando...</p>
                )}

                <TodoComputed
                    computedItemsLeft={computedItemsLeft}
                    clearCompleted={clearCompleted}
                />

                <TodoFilter changeFilter={changeFilter} filter={filter} />
            </main>

            <footer className="mt-8 text-center dark:text-gray-400">
                Drag and drop to reorder list
            </footer>
        </div>
    );
};

export default App;