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.
Before that,
Before that, I look at a simple example with a bullet 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 benone
,copy
,link
ormove
.effectAllowed
Provides all of the types of operations that are possible. Must be one ofnone
,copy
,copyLink
,copyMove
,link
,linkMove
,move
,all
oruninitialized
.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 aDataTransferItemList
object which is a list of all of the drag data.types
An array ofstrings
giving the formats that were set in thedragstart
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.
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, JobsContainer
, JobList
and Job
which are used to manipulate a list of JobModel
s.
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 JobsContainer
s Payload
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);
}
}