Drag and drop with Blazor

drag and drop wallpaper

In this new post, I show how to implement drag and drop with Blazor WebAssembly and Blazor Server. It’s common to find drag and drop interfaces in productivity tools, great examples of this is Azure DevOps. As well as being an intuitive interface for the user, it can definitely add a bit of “eye-candy” to an application.

As result of this post, I want to create a simple Kanban board like in the following screenshot.

Drag and drop with Blazor - Kanban board
Drag and drop with Blazor – Kanban board

Before that,

Before that, I look at a simple example with a bullet list.

Drag and drop with Blazor - Reorder list
Drag and drop with Blazor – Reorder list

And then a little bit complex example to play around with.

The source code of both projects is on GitHub.

Drag and drop API

The drag and drop API is part of the HTML5 spec and has been around for a long time now. The API defines a set of events and interfaces. We can use them to build a drag and drop interface.

Events

  • drag
    Fires when a dragged item (element or text selection) is dragged.
  • dragend
    Fires when a drag operation ends, such as releasing a mouse button or hitting the Esc key.
  • dragenter
    Fires when a dragged item enters a valid drop target.
  • dragexit
    Fires when an element is no longer the drag operation’s immediate selection target.
  • dragleave
    Fires when a dragged item leaves a valid drop target.
  • dragover
    Fires when a dragged item is being dragged over a valid drop target, every few hundred milliseconds.
  • dragstart
    Fires when the user starts dragging an item.
  • drop
    Fires when an item is dropped on a valid drop target.

Certain events will only fire once during a drag-and-drop interaction such as dragstart and dragend. However, others will fire repeatedly such as drag and dragover.

Interfaces

There are a few interfaces for drag and drop interactions but the key ones are the DragEvent interface and the DataTransfer interface.

The DragEvent interface is a DOM event which represents a drag and drop interaction. It contains a single property, dataTransfer, which is a DataTransfer object.

The DataTransfer interface has several properties and methods available. It contains information about the data being transferred by the interaction as well as methods to add or remove data from it.

Properties

  • dropEffect
    Gets the type of drag-and-drop operation currently selected or sets the operation to a new type. The value must be nonecopylink or move.
  • effectAllowed
    Provides all of the types of operations that are possible. Must be one of nonecopycopyLinkcopyMovelinklinkMovemoveall or uninitialized.
  • files
    Contains a list of all the local files available on the data transfer. If the drag operation doesn’t involve dragging files, this property is an empty list.
  • items
    Gives a DataTransferItemList object which is a list of all of the drag data.
  • types
    An array of strings giving the formats that were set in the dragstart event.

Methods

  • DataTransfer.clearData()
    Remove the data associated with a given type. The type argument is optional. If the type is empty or not specified, the data associated with all types is removed. If data for the specified type does not exist, or the data transfer contains no data, this method will have no effect.
  • DataTransfer.getData()
    Retrieves the data for a given type, or an empty string if data for that type does not exist or the data transfer contains no data.
  • DataTransfer.setData()
    Set the data for a given type. If data for the type does not exist, it is added at the end, such that the last item in the types list will be the new format. If data for the type already exists, the existing data is replaced in the same position.
  • DataTransfer.setDragImage()
    Set the image to be used for dragging if a custom one is desired.

Reorder list project

So, based on the drag and drop API from HTML5, I am going to create a first basic example. Open the Index.razor page and add the following HTML code

@page "/"
<ul ondragover="event.preventDefault();"
    ondragstart="event.dataTransfer.setData('', event.target.id);">
    @foreach (var item in Models.OrderBy(x => x.Order))
    {
        <li @ondrop="()=>HandleDrop(item)" @key="item">
            <div @ondragleave="@(()=> {item.IsDragOver = false;})"
                @ondragenter="@(()=>{item.IsDragOver = true;})"
                style="@(item.IsDragOver?"border-style: solid none none none; border-color:red;":"")"
                @ondragstart="() => draggingModel = item"
                @ondragend="()=> draggingModel = null" draggable="true">@item.Name</div>
        </li>
    }
</ul>

How you can see in the code, for each API event I added a specific function for Blazor. The tag ul is the generic container of the drag and drop actions: here I defined to call ondragover and ondragstart. I connect the other API events at the li level because those are the elements that are changing their status.

The model

Now, in the code section, I defined a simple model for each element I want to display.

public List<Model> Models { get; set; } = new();

public class Model
{
    public int Order { get; set; }
    public string Name { get; set; } = "";
    public bool IsDragOver{ get; set; } 
}

// the model that is being dragged
private Model? draggingModel;

So, in the OnInitialized I’m going to create at runtime 10 random elements

protected override void OnInitialized()
{
    // fill names with "random" string
    for (var i = 0; i < 10; i++)
    {
        Model m = new() { Order = i, Name = $"Item {i}" };
        Models.Add(m);
    }

    base.OnInitialized();
}

Handle the drop

The last part is to manage the drop in Blazor and for this reason there is a function called HandleDrop that it is called from li ondrop. This is the C# function

private void HandleDrop(Model landingModel)
{
    // landing model -> where the drop happened
    if (draggingModel is null) return;

    // keep the original order for later
    int originalOrderLanding = landingModel.Order;

    // increase model under landing one by 1
    Models.Where(x => x.Order >= landingModel.Order).ToList().ForEach(x => x.Order++);

    // replace landing model
    draggingModel.Order = originalOrderLanding;

    int ii = 0;
    foreach (var model in Models.OrderBy(x=>x.Order).ToList())
    {
        // keep the numbers from 0 to size-1
        model.Order = ii++;

        // remove drag over. 
        model.IsDragOver = false;
    }
}

This function receives as a parameter, the item the user moved in the new position. When the drag starts, the variable draggingModel has the full item from the Model.

If the new item position is a valid one, I keep the original order in originalOrderLanding and I increse the Order value for all the elements from the position in advance.

Then, I order the item list again and update the order.

Reorder list with complex elements

Based on the example we have just seen, we can change it with a bit more complex element to drag. Also, I want to display a red line to show the user the exact position of the drop of the element.

<ul ondragover="event.preventDefault();"
    ondragstart="event.dataTransfer.setData('', event.target.id);">
    @foreach (var item in Models.OrderBy(x => x.Order))
    {
        <li @key="item" class="pb-2 position-relative"
        @ondragstart="() => draggingModel = item"
        @ondragend="()=> draggingModel = null" draggable="true">
            <div>
                <div>@item.Name</div>
                <div>Child elem. to demonstrate the issue @item.Name</div>
            </div>

            @if (draggingModel is not null)
            {
                <div class="position-absolute w-100 h-100 " style="top:0px;left:0px;
                     @(item.IsDragOver?"border-top-color:red;border-top-style: solid;border-top-width:thick;":"")"
                     @ondrop="()=>HandleDrop(item)"
                     @ondragenter="@(()=>{item.IsDragOver = true;})"
                     @ondragleave="@(()=> {item.IsDragOver = false;})">
                </div>
            }
        </li>
    }
</ul>

For that, in the div I added an instant if: if the user is dragging the item, a red line appears under the item the mouse is passing over.

Drag and drop with Blazor - Reorder list
Drag and drop with Blazor – Reorder list

Simple Kanban board

Now, we talked about drag and drop with simple elements, we can head to create a more complex example, like a simple Kanban board.

Build the project

As you have seen from the gif at the start of this post, the prototype is a highly original todo list. I set myself some goals I wanted to achieve from the exercise, they were:

  • Be able to track an item being dragged
  • Control where items could be dropped
  • Give a visual indicator to the user where items could be dropped or not dropped
  • Update an item on drop
  • Feedback when an item has been updated

Overview

My solution ended up with three components, JobsContainerJobList and Job which are used to manipulate a list of JobModels.

public class JobModel
{
    public int Id { get; set; }
    public JobStatuses Status { get; set; }
    public string Description { get; set; }
    public DateTime LastUpdated { get; set; }
}

Then, we define the enum for the statues of the jobs.

public enum JobStatuses
{
    Todo,
    Started,
    Completed
}

The JobsContainer is responsible for overall list of jobs, keeping track of the job being dragged and raising an event whenever a job is updated.

So, the JobsList component represents a single job status, it creates a drop-zone where jobs can be dropped and renders any jobs which have its status.

At the end, the Job component renders a JobModel instance. If the instance is dragged, then it lets the JobsContainer know so it can be tracked.

JobsContainer Component

<div class="jobs-container">
    <CascadingValue Value="this">
        @ChildContent
    </CascadingValue>
</div>

@code {
    [Parameter] public List<JobModel> Jobs { get; set; }
    [Parameter] public RenderFragment ChildContent { get; set; }
    [Parameter] public EventCallback<JobModel> OnStatusUpdated { get; set; }

    public JobModel Payload { get; set; }

    public async Task UpdateJobAsync(JobStatuses newStatus)
    {
        var task = Jobs.SingleOrDefault(x => x.Id == Payload.Id);

        if (task != null)
        {
            task.Status = newStatus;
            task.LastUpdated = DateTime.Now;
            await OnStatusUpdated.InvokeAsync(Payload);
        }
    }
}
Code explained

The job of JobsContainer job is to coordinate updates to jobs as they are moved about the various statuses. It takes a list of JobModel as a parameter as well as exposing an event which consuming components can handle to know when a job gets updated.

It passes itself as a CascadingValue to the various JobsList components, which are child components. This allows them access to the list of jobs as well as the UpdateJobAsync method, which is called when a job is dropped onto a new status.

JobsList Component

<div class="job-status">
    <h3>@ListStatus (@Jobs.Count())</h3>

    <ul class="dropzone @dropClass" 
        ondragover="event.preventDefault();"
        ondragstart="event.dataTransfer.setData('', event.target.id);"
        @ondrop="HandleDrop"
        @ondragenter="HandleDragEnter"
        @ondragleave="HandleDragLeave">

        @foreach (var job in Jobs)
        {
            <Job JobModel="job" />
        }

    </ul>
</div>

@code {
    [CascadingParameter] JobsContainer Container { get; set; }
    [Parameter] public JobStatuses ListStatus { get; set; }
    [Parameter] public JobStatuses[] AllowedStatuses { get; set; }

    List<JobModel> Jobs = new List<JobModel>();
    string dropClass = "";

    protected override void OnParametersSet()
    {
        Jobs.Clear();
        Jobs.AddRange(Container.Jobs.Where(x => x.Status == ListStatus));
    }

    private void HandleDragEnter()
    {
        if (ListStatus == Container.Payload.Status) return;

        if (AllowedStatuses != null && !AllowedStatuses.Contains(Container.Payload.Status))
        {
            dropClass = "no-drop";
        }
        else
        {
            dropClass = "can-drop";
        }
    }

    private void HandleDragLeave()
    {
        dropClass = "";
    }

    private async Task HandleDrop()
    {
        dropClass = "";

        if (AllowedStatuses != null && !AllowedStatuses.Contains(Container.Payload.Status)) return;

        await Container.UpdateJobAsync(ListStatus);
    }
}
Code explained

There is quite a bit of code so let’s break it down.

[Parameter] JobStatuses ListStatus { get; set; }
[Parameter] JobStatuses[] AllowedStatuses { get; set; }

The component takes a ListStatus and array of AllowedStatuses. The AllowedStatuses are used by the HandleDrop method to decide if a job can be dropped or not.

Then, the ListStatus is the job status that the component instance is responsible for. It’s used to fetch the jobs from the JobsContainer component which match that status so the component can render them in its list.

This is performed using the OnParametersSet lifecycle method, making sure to clear out the list each time to avoid duplicates.

protected override void OnParametersSet()
{
    Jobs.Clear();
    Jobs.AddRange(Container.Jobs.Where(x => x.Status == ListStatus));
}
Ordering the list

I’m using an unordered list to display the jobs. The list is also a drop-zone for jobs, meaning you can drop other elements onto it. This is achieved by defining the ondragover event but note there’s no @ symbol in-front of it.

<ul class="dropzone @dropClass" 
    ondragover="event.preventDefault();"
    ondragstart="event.dataTransfer.setData('', event.target.id);"
    @ondrop="HandleDrop"
    @ondragenter="HandleDragEnter"
    @ondragleave="HandleDragLeave">

    @foreach (var job in Jobs)
    {
        <Job JobModel="job" />
    }
</ul>
Prevent default

The event is just a normal JavaScript event, not a Blazor version, calling preventDefault. The reason for this is that by default you can’t drop elements onto each other. By calling preventDefault it stops this default behaviour from occurring.

I’ve also defined the ondragstart JavaScript event as well, this is there to satisfy FireFoxs requirements to enable drag and drop and doesn’t do anything else.

Handle the drag

The rest of the events are all Blazor versions. OnDragEnter and OnDragLeave are both used to set the CSS of for the drop-zone.

private void HandleDragEnter()
{
    if (ListStatus == Container.Payload.Status) return;

    if (AllowedStatuses != null && !AllowedStatuses.Contains(Container.Payload.Status))
    {
        dropClass = "no-drop";
    }
    else
    {
        dropClass = "can-drop";
    }
}

private void HandleDragLeave()
{
    dropClass = "";
}

HandleDragEnter manages the border of the drop-zone to give the user visual feedback.

If the job being dragged has the same status as the drop-zone it’s over then nothing happens. If a job is dragged over the drop-zone, and it’s a valid target, then a green border is added via the can-drop CSS class. If it’s not a valid target then a red border is added via the no-drop CSS class.

The HandleDragLeave method just resets the class once the job has been dragged away.

private async Task HandleDrop()
{
    dropClass = "";

    if (AllowedStatuses != null && !AllowedStatuses.Contains(Container.Payload.Status)) return;

    await Container.UpdateJobAsync(ListStatus);
}

Finally, HandleDrop is responsible for making sure a job is allowed to be dropped, and if so, updating its status via the JobsContainer.

Job Component

<li class="draggable" draggable="true" title="@JobModel.Description" 
    @ondragstart="@(() => HandleDragStart(JobModel))">
    <p class="description">@JobModel.Description</p>
    <p class="last-updated"><small>Last Updated</small> 
        @JobModel.LastUpdated.ToString("HH:mm.ss tt")
    </p>
</li>

@code {
    [CascadingParameter] JobsContainer Container { get; set; }
    [Parameter] public JobModel JobModel { get; set; }

    private void HandleDragStart(JobModel selectedJob)
    {
        Container.Payload = selectedJob;
    }
}
Code explained

It’s responsible for displaying a JobModel and for making it draggable. Elements are made draggable by adding the draggable="true" attribute. The component is also responsible for handling the ondragstart event.

When ondragstart fires the component assigns the job to the JobsContainerPayload property. This keeps track of the job being dragged which is used when handling drop events, as we saw in the JobsList component.

Usage

Now we’ve gone through each component let’s see what it looks like all together.

<JobsContainer Jobs="Jobs" OnStatusUpdated="HandleStatusUpdated">
    <JobList ListStatus="JobStatuses.Todo" AllowedStatuses="@(new JobStatuses[] { JobStatuses.Started})" />
    <JobList ListStatus="JobStatuses.Started" AllowedStatuses="@(new JobStatuses[] { JobStatuses.Todo})" />
    <JobList ListStatus="JobStatuses.Completed" AllowedStatuses="@(new JobStatuses[] { JobStatuses.Started })" />
</JobsContainer>

@code {
    List<JobModel> Jobs = new List<JobModel>();

    protected override void OnInitialized()
    {
        Jobs.Add(new JobModel { Id = 1, Description = "Install certicate for the website", 
            Status = JobStatuses.Todo, LastUpdated = DateTime.Now });
        Jobs.Add(new JobModel { Id = 2, Description = "Fix bug in the drag and drop project", 
            Status = JobStatuses.Todo, LastUpdated = DateTime.Now });
        Jobs.Add(new JobModel { Id = 3, Description = "Update NuGet packages", 
            Status = JobStatuses.Todo, LastUpdated = DateTime.Now });
        Jobs.Add(new JobModel { Id = 4, Description = "Generate graphs", 
            Status = JobStatuses.Todo, LastUpdated = DateTime.Now });
        Jobs.Add(new JobModel { Id = 5, Description = "Finish blog post", 
            Status = JobStatuses.Started, LastUpdated = DateTime.Now });
    }

    void HandleStatusUpdated(JobModel updatedJob)
    {
        Console.WriteLine(updatedJob.Description);
    }
}

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.