Alpine.js Drag and Drop Integration with Laravel

I use Laravel to manage my projects and I wanted to integrate the drag and drop code I wrote using Alpine.js, so I built a new component. This Laravel component supports passing in variables which builds up the two sides of the drag and drop listing; the default and the selected. This means I can use the same component over and over, but with different lists.

You can create a new component using Laravel's artisan on the command line. This will create the class and Blade view for you.

php artisan make:component DragNDrop

The Class

We need the component to accept some properties and expose them to the view. We need a type and collection of $form items to populate the “from” list. Optionally we can pass a $to collection which will be used to pre-populated the “to” list and exclude them from the $from list.

The defined render() method reviews the Blade view for the component. Note this is in a sub-folder from the base components path. There is an itemView() method which we will discuss later.

<?php

namespace App\View\Components;

use Illuminate\Support\Collection;
use Illuminate\View\Component;
use Illuminate\View\View;

class DragNDrop extends Component {

    /** @var string */
    public $id;

    /** @var string */
    public $type;

    /** @var \Illuminate\Support\Collection */
    public $from;

    /** @var \Illuminate\Support\Collection */
    public $to;

    public function __construct(string $type, Collection $from, ?Collection $to = null)
    {
        $this->id = uniqid();
        $this->type = $type;
        $this->from = $from->diff($to);
        $this->to = $to;
    }

    /**
     * Look up the view to show for each item from the item model.
     * This defaults to the main component item view.
     * A model could define its own item view.
     *
     * @param mixed $item
     * @return string
     */
    public function itemView($item): string
    {
        return $item->dragItemView ?? 'components.drag-n-drop.item';
    }

    public function render(): View
    {
        return view('components.drag-n-drop.listing');
    }
}

The Blade

The HTML for the component listing should match the version from the alpine.js example with a few changes to loop through the $from and $to collections.

I have added the id property with the $id variable to the x-data attribute. This allows us to have multiple instances of this Blade component without them clashing.

@foreach() as been used to loop through the $from and $to variables. These collections provide data to the item view.

The <li> id attribute has been updated to use our Blade component scope $id and unique $item identifier; id="{{ $id }}--{{ $item->id }}"

Usually our component sits inside a form and we want to know which items are in the $to list. Each of our items has a hidden input field, which is submitted with the form. We need to make the inputs inside the from list to be disabled so they're not submitted.

We have moved the item content to a separate file and we include it using the Blade method @include(). Useful variables have been passed to the separate view.

<div x-data="{ id: '{{ $id }}', adding: false, removing: false }" {{ $attributes->merge(['class' => 'drag-and-drop']) }}>
    <div class="drag-and-drop__container drag-and-drop__container--from">
        <h3 class="drag-and-drop__title">From</h3>
        <ul
            class="drag-and-drop__items"
            :class="{ 'drag-and-drop__items--removing': removing }"
            x-on:drop="removing = false"
            x-on:drop.prevent="
                const id event.dataTransfer.getData('text/plain');
                const target = event.target.closest('ul');
                const element = document.getElementById(id);
                target.appendChild(element);
                element.querySelector('input[type=hidden]').disabled = true;
            "
            x-on:dragover.prevent="removing = true"
            x-on:dragleave.prevent="removing = false">
            @foreach($from ?? [] as $item)
                <li
                id="{{ $id }}--{{ $item->id }}"
                class="drag-and-drop__item"
                :class="{ 'drag-and-drop__item--dragging': dragging }"
                x-on:dragstart.self="
                    dragging = true;
                    event.dataTransfer.effectAllowed = 'move';
                    event.dataTransfer.setData('text/plain', event.target.id);
                "
                x-on:dragend="dragging = false"
                x-data="{ dragging: false }"
                draggable="true">
                @include($itemView($item), ['item' => $item, 'id' => $id, 'loop' => $loop, 'disabled' => true])
            </li>
            @endforeach
        </ul>
    </div>
    <div class="drag-and-drop__divider"></div>
    <div class="drag-and-drop__container drag-and-drop__container--to">
        <h3 class="drag-and-drop__title">To</h3>
        <ul
            class="drag-and-drop__items"
            :class="{ 'drag-and-drop__items--adding': adding }"
            x-on:drop="adding = false"
            x-on:drop.prevent="
                const id = event.dataTransfer.getData('text/plain');
                const target = event.target.closest('ul');
                const element = document.getElementById(id);
                target.appendChild(element);
                element.querySelector('input[type=hidden]').disabled = false;
            "
            x-on:dragover.prevent="adding = true"
            x-on:dragleave.prevent="adding = false">
            @foreach($to ?? [] as $item)
            <li
                id="{{ $id }}--{{ $item->id }}"
                class="drag-and-drop__item"
                :class="{ 'drag-and-drop__item--dragging': dragging }"
                x-on:dragstart.self="
                    dragging = true;
                    event.dataTransfer.effectAllowed = 'move';
                    event.dataTransfer.setData('text/plain', event.target.id);
                "
                x-on:dragend="dragging = false"
                x-data="{ dragging: false }"
                draggable="true">
                @include($itemView($item), ['item' => $item, 'id' => $id, 'loop' => $loop, 'disabled' => false])
            </li>
            @endforeach
        </ul>
    </div>
</div>

The item view

I have split the HTML for each item list in to a seperate Blade file. This allows us to customise the output of each list item based on the $item variable passed in to the itemView() method. The default template item.blade.php might look like this;

<input type="hidden" name="{{ $type }}[{{ $loop->index }}][id]" value="{{ $item->id }}" {{ $disabled ? 'disabled' : '' }}>
{{ $item->title }}

If the $item model has an attribute for dragItemView, then the custom template will be used. For example, I have setup a mutator method on the $roles model, which has the path to a custom view.

<?php

namespace App\Models;

class Role extends Model {

    public function getDragItemViewAttribute(): ?string
    {
        return 'role.components.drag-n-drop';
    }
}

And the custom role.components.drag-n-drop Blade template;

<input type="hidden" name="{{ $type }}[{{ $loop->index }}][id]" value="{{ $item->id }}" {{ $disabled ? 'disabled' : '' }}>
<h3>{{ $item->name }}</h3>
<p>({{ $item->level }})</p>

Using the component

We can now use the component multiple times, for example associating roles and groups to a user. This will us two unique component instances, allowing drag and dropping of items within each component and a custom view for each item within the role list.

<form action="{{ route('users.update', $user) }}" method="POST">
  <x-drag-drop type="roles" :from="$roles" :to="$user->roles" />
  <x-drag-drop type="groups" :from="$groups" :to="$user->groups" />
</form>