Creating a InputSelect component for enumerations in Blazor

  • Gérald Barré

It's common to use a dropdown list to select a value from an enumeration. In ASP.NET Core MVC you can use Html.GetEnumSelectList to create the items for an enumeration. This is very convenient and very productive. However, this helper doesn't exist in Blazor. In this post we'll create something even easier to use. Yes, that possible 😃

The component InputSelect allows to bind a property of type Enum. However, you need to provide all options manually which is error prone and not very productive:

<EditForm Model="model">
    <InputSelect @bind-Value="model.Season">
        <option>Spring</option>
        <option>Summer</option>
        <option>Autumn</option>
        <option>Winter</option>
    </InputSelect>
</EditForm>

@code {
    Model model = new Model();

    class Model
    {
        public Season Season { get; set; }
    }

    enum Season
    {
        Spring,
        Summer,
        Autumn,
        Winter
    }
}

You can make this code more generic by iterating on Enum.GetValues:

<EditForm Model="model">
    <InputSelect @bind-Value="model.Season">
        @foreach (var value in Enum.GetValues(typeof(Season)))
        {
            <option>@value</option>
        }
    </InputSelect>
</EditForm>

This way you can copy/paste the code for any enumeration you want to bind to a select. However, the display text is not customizable, so not very use friendly. It is also not localized. As always in Blazor, the solution is to create a component! Components allow to encapsulate reusable behaviors. Then, you can use them in your other components and avoid duplicating code.

#InputSelectEnum Blazor component

To create this component, I checked how the InputSelect component is made on GitHub. The code is very simple. It contains 2 methods: BuildRenderTree and TryParseValueFromString. We'll change the first one to populate the option elements when creating the tree instead of using the template ChildContent. The TryParseValueFromString method converts the string value from the select element to a valid enumeration value. We'll adapt this method to support nullable types.

A few points to note in the implementation:

  • This component supports nullable types which is not the case of the InputSelect component.
  • This component read the [Display] attribute to create the option display names. If no attribute is defined, it decamelizes the enumeration member name. This attribute allows to localize the application.

In previous posts, we have created components using the razor syntax. In this case, it is easier to create the component in C# code only. You can add the code in the "Shared" folder, so the component is accessible in all the views. I've added a few comments to explain the code. But don't worry, there's nothing complicated in this component.

// file: Shared/InputSelectEnum.cs
using System;
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Rendering;

// Inherit from InputBase so the hard work is already implemented 😊
// Note that adding a constraint on TEnum (where T : Enum) doesn't work when used in the view, Razor raises an error at build time. Also, this would prevent using nullable types...
public sealed class InputSelectEnum<TEnum> : InputBase<TEnum>
{
    // Generate html when the component is rendered.
    protected override void BuildRenderTree(RenderTreeBuilder builder)
    {
        builder.OpenElement(0, "select");
        builder.AddMultipleAttributes(1, AdditionalAttributes);
        builder.AddAttribute(2, "class", CssClass);
        builder.AddAttribute(3, "value", BindConverter.FormatValue(CurrentValueAsString));
        builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder<string>(this, value => CurrentValueAsString = value, CurrentValueAsString, null));

        // Add an option element per enum value
        var i = 5;
        var enumType = GetEnumType();
        foreach (TEnum value in Enum.GetValues(enumType))
        {
            builder.OpenElement(i++, "option");
            builder.AddAttribute(i++, "value", value.ToString());
            builder.AddContent(i++, GetDisplayName(value));
            builder.CloseElement();
        }

        builder.CloseElement(); // close the select element
    }

    protected override bool TryParseValueFromString(string value, out TEnum result, out string validationErrorMessage)
    {
        // Let's Blazor convert the value for us 😊
        if (BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out TEnum parsedValue))
        {
            result = parsedValue;
            validationErrorMessage = null;
            return true;
        }

        // Map null/empty value to null if the bound object is nullable
        if (string.IsNullOrEmpty(value))
        {
            var nullableType = Nullable.GetUnderlyingType(typeof(TEnum));
            if (nullableType != null)
            {
                result = default;
                validationErrorMessage = null;
                return true;
            }
        }

        // The value is invalid => set the error message
        result = default;
        validationErrorMessage = $"The {FieldIdentifier.FieldName} field is not valid.";
        return false;
    }

    // Get the display text for an enum value:
    // - Use the DisplayAttribute if set on the enum member, so this support localization
    // - Fallback on Humanizer to decamelize the enum member name
    private string GetDisplayName(TEnum value)
    {
        // Read the Display attribute name
        var member = value.GetType().GetMember(value.ToString())[0];
        var displayAttribute = member.GetCustomAttribute<DisplayAttribute>();
        if (displayAttribute != null)
            return displayAttribute.GetName();

        // Require the NuGet package Humanizer.Core
        // <PackageReference Include = "Humanizer.Core" Version = "2.8.26" />
        return value.ToString().Humanize();
    }

    // Get the actual enum type. It unwrap Nullable<T> if needed
    // MyEnum  => MyEnum
    // MyEnum? => MyEnum
    private Type GetEnumType()
    {
        var nullableType = Nullable.GetUnderlyingType(typeof(TEnum));
        if (nullableType != null)
            return nullableType;

        return typeof(TEnum);
    }
}

You can now use this component in another Blazor component:

<EditForm Model="model">
    <div>
        @* The type of the enum (TEnum) is detected by the type of the bound property which is just awesome! *@
        <InputSelectEnum @bind-Value="model.Season" />
        <span>Selected value: @model.Season</span>
    </div>
</EditForm>

@code {
    Model model = new Model();

    class Model
    {
        public Season Season { get; set; }
    }

    enum Season
    {
        [Display(Name = "Spring", ResourceType = typeof(Resources))]
        Spring,
        [Display(Name = "Summer", ResourceType = typeof(Resources))]
        Summer,
        [Display(Name = "Autumn", ResourceType = typeof(Resources))]
        Autumn,
        [Display(Name = "Winter", ResourceType = typeof(Resources))]
        Winter,
    }
}

You can see on this screenshot that the enumeration values are localized in French thanks to the [Display] attribute. However the value in the span is not localized as it only uses ToString on the enumeration member (but this is not the point of this blog post).

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee