Creating a drop-down menu component with nested options in Blazor

Introduction

Blazor is a Microsoft framework that lets you build interactive web UIs using C# instead of JavaScript. This means you can leverage your existing C# knowledge for web development.

Using components in Blazor offers several advantages:

  • Reusability
    Create UI building blocks that you can use throughout your application, reducing code duplication and saving time.
  • Encapsulation
    Bundle UI logic and presentation within a component, making code easier to understand and maintain.
  • Modularity
    Break down complex pages into smaller, manageable components, promoting better organisation and scalability.
  • Testability
    Isolate components for individual testing, simplifying debugging and ensuring code quality.

A component I’ve needed recently was a drop-down menu with nested options. It turned out to be more complicated to develop than I thought, so I created a post about it. Hopefully, it can help you when making a similar component. The source code is also available on GitHub if you want to copy it. You can also see it running on a production site at https://app.groundschool.aero/Store/Products. It is used for the category’s menu.

Setting up the project

The project was created using NET 8, version 8.0.204. I’m building the component using Visual Studio Code with the C# Dev Kit extension.

Run the following command to create a new project:

dotnet new blazorwasm -o DropdownMenuComponent

Next, enter the following command to open the project in Visual Studio Code.

code DropdownMenuComponent

Open the terminal using Visual Studio code and run the dotnet watch. This will build and run the project, and the changes will automatically update in the browser without you having to rebuild manually to see them.

Remove the Counter.razor and Weather.razor files and update the NavMenu.razor to keep the project structure simple. Below is how the NavMenu.razor file looks after modification:

<div class="top-row ps-3 navbar navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="">DropdownMenuComponent</a>
        <button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
            <span class="navbar-toggler-icon"></span>
        </button>
    </div>
</div>

<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
    <nav class="flex-column">
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
            </NavLink>
        </div>
    </nav>
</div>

@code {
    private bool collapseNavMenu = true;

    private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}

Create a Components folder at the project’s root, then create a new file in the Components folder called MbDropdownMenu.razor. I’m prefixing the component with “Mb” so that the name does not conflict with the folder’s name. Populate the MbDropdownMenu.razor file with the following code:

<p>TODO: Dropdown menu</p>

@code {
   
}

The above code is just a basic skeleton that will be completed soon. Add a using statement and a reference to the component in Home.razor (inside the Pages folder). You can also remove the <h1> element  and welcome message. Home.razor will then contain the following:

@page "/"
@using DropdownMenuComponent.Components

<PageTitle>Home</PageTitle>

<MbDropdownMenu />

This will render the following to the browser:

Open index.html and add the following styles, replacing the link to the Bootstrap style that comes with the template. Then, delete the bootstrap file that comes with the template. You can also download these files into your project and reference them locally instead of via a CDN. These links will add support for Bootstrap 4.7 and Bootstrap Icons.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css" integrity="sha384-xOolHFLEh07PJGoPkLv1IbcEPTNtaed2xpHsD9ESMhqIYd0nLMwNLD69Npy4HI+N" crossorigin=" anonymous">

Towards the bottom of the index.html file, add the following JavaScript links after the link to the blazor.webassembly.js file. These links are for jQuery and Bootstrap’s JavaScript. As with the CSS, you can also download these files into your project and then reference them locally instead of via a CDN.

Creating the Dropdown Menu component

The MbDropdownMenu.razor will be a generic component that declares a type parameter of TItem.

@typeparam TItem

Below that declaration, create a <div> element. The items will be contained within this div. Also, it should be set up so that consumers can specify a custom ID for the component. If no ID is provided, the component’s name will be used instead. Within the div container, we’ll loop through a list of TItem and then create MenuItem components with them. The MenuItem component still needs to be created. We will do that soon. It will recursively call itself to create sub-menu items. Provide an OnClick event callback to the MenuItem.

<div id="@(Id ?? $"{nameof(MbDropdownMenu<TItem>)}")">
    <div class="list-group">
        @foreach (var item in Items)
        {
            <MenuItem TItemId="TItem" Item="item" SelectedItem="SelectedItem" />
        }
    </div>
</div>

Add the following parameters to the @code section:

[Parameter] public string? Id { get; set; }
[Parameter] public List<Item<TItem>> Items { get; set; }

We are going to need some CSS and  JavaScript to control setting the state of the icon and to give it a nice rotation effect when an item is selected. Create a new file in the same folder as MbDropdownMenu.razor called MbDropdownMenu.razor.css and add the following CSS to it:

.list-group-item-right-icon {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.rotated {
    transform: rotate(90deg);
}

.rotate-icon {
    transition: transform 0.3s ease-in-out;
}

Create another file in the same folder called MbDropdownMenu.razor.js with the following contents: 

export function initialize(id) {
    jQuery('#' + id + ' .list-group-item').on('click', function () {
        jQuery(this).find('.rotate-icon').toggleClass('rotated');
    });
}

Now, switch back to the MbDropdownMenu.razor file and add the following code to use the JavaScript module when initalising the component.

[Inject] private IJSRuntime _jsRuntime { get; set; }

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        if (_jsRuntime is not null)
        {
            IJSObjectReference module = await _jsRuntime.InvokeAsync<IJSObjectReference>("import", "./Components/DropdownMenu/MbDropdownMenu.razor.js");
            await module.InvokeVoidAsync("initialize", Id);
        }
    }
}

Finally, lets add a way to be able to report back to consumers when an item was clicked. Add an EventCallback parameter and a property to keep track of the currently selected item:

[Parameter] public EventCallback<TItem> OnClickCallback { get; set; }

private TItem? SelectedItem { get; set; }

Then, add a method that will assign the property and  call the EventCallback.

public async Task OnClick(TItem itemId)
{
    SelectedItem = itemId;

    await OnClickCallback.InvokeAsync(itemId);
}

We’ll also need to pass the EventCallback into the MenuItem component so that it can notify this MbDropdownMenu component when it is clicked. Change the declaration of the MenuItem component to include a parameter for the EventCallback.

<MenuItem TItemId="TItem" Item="item" SelectedItem="SelectedItem" OnClickCallback="OnClick" />

In the same folder as the component, create a generic class that will represent the items in the dropdown menu.

public class Item<Tid>
{
    public Tid? Id { get; set; }
    public string? Label { get; set; }
    public string? Url { get; set; }
    public List<Item<Tid>> Items { get; set; } = [];
}

Now, let’s go back to Home.razor and add something so that we can see the OnClickCallback working. In the HTML part of the file, add the following:

<p>Selected: @Selected</p>

While we’re here, we can also update the MbDropdownMenu component declaration with the parameters it needs:

<MbDropdownMenu
    Id="MbDropdownMenu"
    Items="Items"
    OnClickCallback="OnClick"
    TItem="Guid" />

I’ve used Guid IDs, but you could use string, int or something else if you prefer. In the code section, create a property to keep track of the selected item:

string Selected { get; set; } = string.Empty;

a list of items to show:

List<Item<Guid>> Items = new List<Item<Guid>>
{
    new Item<Guid> 
    { 
        Id = Guid.NewGuid(), 
        Label = "Item 1",
        Url = "/item1"
    },
    new Item<Guid> 
    { 
        Id = Guid.NewGuid(), 
        Label = "Item 2",
        Url = "/item2",
        Items = new List<Item<Guid>>
        {
            new Item<Guid> 
            { 
                Id = Guid.NewGuid(), 
                Label = "Item 2.1",
                Url = "/item2.1"
            },
            new Item<Guid> 
            { 
                Id = Guid.NewGuid(), 
                Label = "Item 2.2",
                Url = "/item2.2",
                Items = new List<Item<Guid>>
                {
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 2.2.1",
                        Url = "/item2.2.1"
                    },
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 2.2.2",
                        Url = "/item2.2.2"
                    },
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 2.2.3",
                        Url = "/item2.2.3"
                    },
                }
            },
            new Item<Guid> 
            { 
                Id = Guid.NewGuid(), 
                Label = "Item 2.3",
                Url = "/item2.3",
                Items = new List<Item<Guid>>
                {
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 2.3.1",
                        Url = "/item2.3.1"
                    },
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 2.3.2",
                        Url = "/item2.3.2"
                    },
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 2.3.3",
                        Url = "/item2.3.3"
                    },
                }
            },
        }
    },
    new Item<Guid> 
    { 
        Id = Guid.NewGuid(), 
        Label = "Item 3",
        Url = "/item3",
        Items = new List<Item<Guid>>
        {
            new Item<Guid> 
            { 
                Id = Guid.NewGuid(), 
                Label = "Item 3.1",
                Url = "/item3.1"
            },
            new Item<Guid> 
            { 
                Id = Guid.NewGuid(), 
                Label = "Item 3.2",
                Url = "/item3.2",
                Items = new List<Item<Guid>>
                {
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 3.2.1",
                        Url = "/item3.2.1"
                    },
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 3.2.2",
                        Url = "/item3.2.2"
                    },
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 3.2.3",
                        Url = "/item3.2.3"
                    },
                }
            },
            new Item<Guid> 
            { 
                Id = Guid.NewGuid(), 
                Label = "Item 3.3",
                Url = "/item3.3",
                Items = new List<Item<Guid>>
                {
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 3.3.1",
                        Url = "/item3.3.1"
                    },
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 3.3.2",
                        Url = "/item3.3.2"
                    },
                    new Item<Guid> 
                    { 
                        Id = Guid.NewGuid(), 
                        Label = "Item 3.3.3",
                        Url = "/item3.3.3"
                    },
            },
        }
    },
    },
    new Item<Guid> 
    { 
        Id = Guid.NewGuid(), 
        Label = "Item 4"
    },
    new Item<Guid> 
    { 
        Id = Guid.NewGuid(), 
        Label = "Item 5"
    },
};

and the OnClick method, which will be called when an item is clicked.

void OnClick(Guid itemId)
{
    Selected = itemId.ToString();
}

The last part, is to implement the MenuItem component which does most of the work. Thankfully, due to recursion, it’s relatively simple, but can be used to create nested menus.

It consists of an outer <div> element with the list-group class. Within this <div> element, add an <a> element for the content of the item. Set the href attribute to :javascript so that it does not link to anything, but still shows the link cursor. You could modify the component later to make items include links by adding a Parameter that changes this value.

Give the <a> element the following style: “@($"padding-left: {Level}em;")
This will will allow us to increase the padding for each successive child element.

Give the <a> element the following class: class="@($"list-group-item list-group-item-right-icon {(SelectedItem!= null && SelectedItem.Equals(Item.Id) ? "active" : string.Empty)}")"

This will set the “active” class on the <a> element when this item is the active item.

Add the following two data- attributes which are used by Bootstrap to toggle showing and hiding the sub-items: data-toggle="collapse" data-target="@($"#Item{Item.Id}")"
This is not complicated. The Id of the item is used to build the data-target attribute.

Finally, add an @onclick handler to handle the EventCallback: @onclick="@((e) => OnClick(Item.Id))"

Within the <a> element, add the following to display the item’s label and the icon. The _isOpen field is used to control the rotated class which is used in the CSS to change the icon’s direction.

@Item.Label
@if (Item.Items.Any())
{
    <i class="@($"bi bi-caret-right-fill rotate-icon {(_isOpen ? "rotated" : string.Empty)}")"></i>
}

Then, finally, the magic of recursion. If there are any children items, create a new list-group and a new MenuItem for the children.

@if (Item.Items.Any())
{
    <div class="list-group collapse" id="@($"Item{Item.Id}")">
        @foreach (var subItem in Item.Items)
        {
            <MenuItem TItemId="TItemId" Item="subItem" SelectedItem="SelectedItem" OnClickCallback="OnClickCallback" Level="Level + 1" />
        }
    </div>
}

The code section for the MenuItem component consists mostly of parameters, the _isOpen field and the OnClick method that toggles _isOpen and calls the EventCallback.

@code {
    [Parameter] public Item<TItemId> Item { get; set; }
    [Parameter] public TItemId SelectedItem { get; set; }
    [Parameter] public EventCallback<TItemId> OnClickCallback { get; set; }
    [Parameter] public int Level { get; set; } = 1;

    private bool _isOpen;

    public async Task OnClick(TItemId itemId)
    {
        _isOpen = !_isOpen;

        await OnClickCallback.InvokeAsync(itemId);
    }
}

When running up the project, you should see the following:

I hope this helps you with developing your own components. Also, feel free to use this component as is and make modifications to it to suite your needs. If you do use it, I’d love to hear about it – please leave a comment!

Leave a comment

Your email address will not be published. Required fields are marked *

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