An MVC DropDownListFor that supports all attributes

So I need a DropDownListFor that allows me to create any attribute for the select or option tags. Obviously the attributes for each option tag should come from a property in an object that is provided in a collection. I also eliminate the need of converting a collection of your custom object to a collection of SelectListItem.

Remember a DropDownList in HTML looks like this:

<select>
  <option value="1">1</option>
  <option value="2">2</option>
  <option value="3">3</option>
</select>

The current DropDownListFor uses a list of SelectListItem objects to create the option tags. However, SelectListItem only supports the value attribute and the inner text (the text between the open option tag and the close option tag). Support for any attribute should be provided.

First, I create a model to hold the attribute data. It is a very small class with only five properties, but looks like a big class due to the extensive comments.

using System.Collections.Generic;

namespace LANDesk.Licensing.Wavelink.Models
{
    public class HtmlSelectListModel<T>
    {
        /// <summary>
        /// That collection of data elements from which to create a the select options.
        /// </summary>
        public IEnumerable<T> DataObjects { get; set; }

        /// <summary>
        /// Often there is an option on top called "--Select item--" that has an empty value.
        /// </summary>
        public string EmptyValueText { get; set; }

        /// <summary>
        /// A bool value that checks for whether EmptyValueText is used or not
        /// </summary>
        public bool ShouldUseEmptyValue
        {
            get { return !string.IsNullOrWhiteSpace(EmptyValueText); }
        }

        /// <summary>
        /// This will add attributes to the <select></select> html tag.
        /// The key is the attribute, the value is the value.
        /// For example:
        ///     SelectAttributes.add("id", "select-id-1");
        /// Would result in this html:
        ///     <select id="select-id-1"></select>
        /// </summary>
        public Dictionary<string, string> SelectAttributes
        {
            get { return _SelectAttributes ?? (_SelectAttributes = new Dictionary<string, string>()); }
            set { _SelectAttributes = value; }
        } private Dictionary<string, string> _SelectAttributes;

        /// <summary>
        /// This will add attributes to the <option></option> html tag in a select list.
        /// The key is the attribute, the value is the property on the object that contains the value.
        /// A list of objects will be used to create the options. Using reflection, the property with a
        /// name matching the value will be found. If the value is "Country" then a property name Country
        /// will be found using reflection. 
        /// 
        /// Note: If inner-text is used it will not be an attribute but will be the text between the
        ///       opening and closing tags.
        /// 
        /// For example:
        ///     OptionAttributes.add("value", "CountryTwoLetter");
        ///     OptionAttributes.add("innter-text", "Country");
        /// If the the list had to objects and the Country values were "United States" and "Canada",
        /// and the CountryTwoLetter values were "US" and "CA", then the result would be this html:
        ///     <option value="US">United States</option>
        ///     <option value="US">United States</option>
        /// 
        /// Html data-[name] attributes are supported here as well.
        /// </summary>
        public Dictionary<string, string> OptionAttributes
        {
            get { return _OptionAttributes ?? (_OptionAttributes = new Dictionary<string, string>()); }
            set { _OptionAttributes = value; }
        } private Dictionary<string, string> _OptionAttributes;
    }
}

Next add an extension method to HtmlHelper.

using LANDesk.Licensing.Wavelink.Models;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;

namespace System.Web.Mvc
{
    public static class DrownDownListHelper
    {
        /// <summary>
        /// Adds increased attributed functionality to DropDownListFor.
        /// </summary>
        /// <typeparam name="TModel"></typeparam>
        /// <typeparam name="TProperty"></typeparam>
        /// <typeparam name="T">The type of object in a list.</typeparam>
        /// <param name="htmlHelper">The object this method attaches to.</param>
        /// <param name="expression">The object the selected value binds to.</param>
        /// <param name="listModel">The Model object for creating this list.</param>
        /// <returns>MvcHtmlString - Html for Razor pages.</returns>
        public static MvcHtmlString DropDownListWithAttributesFor<TModel, TProperty, T>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, HtmlSelectListModel<T> listModel)
        {
            var dropdown = new TagBuilder("select");
            foreach (var selectAttribute in listModel.SelectAttributes)
            {
                dropdown.Attributes.Add(selectAttribute.Key, selectAttribute.Value);
            }
            string currentValue = null;
            var metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
            if (metadata != null && metadata.Model != null)
                currentValue = metadata.Model.ToString();

            var optionsBuilder = new StringBuilder();
            foreach (T item in listModel.DataObjects)
            {
                BuildOptionTags(listModel, optionsBuilder, item, currentValue);
            }
            if (string.IsNullOrWhiteSpace(currentValue) && listModel.ShouldUseEmptyValue)
            {
                optionsBuilder.Insert(0, "<option value=\"\">" + listModel.EmptyValueText + "</option>");
            }
            dropdown.InnerHtml = optionsBuilder.ToString();

            return new MvcHtmlString(dropdown.ToString(TagRenderMode.Normal));
        }

        internal static void BuildOptionTags<T>(HtmlSelectListModel<T> listModel, StringBuilder optionsBuilder, T item, string selectedValue)
        {
            var optionAttributes = GetOptionAttributes(listModel, item);
            string innerText = GetOptionInnerText(optionAttributes);

            if (optionsBuilder == null) { optionsBuilder = new StringBuilder(); }
            optionsBuilder.Append("<option ");
            bool defaultValueFound = false;
            foreach (var attribute in optionAttributes)
            {
                optionsBuilder.Append(string.Format("{0}=\"{1}\" ", attribute.Key, attribute.Value));
                if (attribute.Value == selectedValue)
                {
                    optionsBuilder.Append("selected=\"selected\" ");
                    defaultValueFound = true;
                }
            }
            optionsBuilder.Append(">" + innerText + "</option>");
        }

        internal static string GetOptionInnerText(IDictionary<string, string> optionAttributes)
        {
            string innerText;
            if (optionAttributes.TryGetValue("inner-text", out innerText))
            {
                optionAttributes.Remove("inner-text");
            }
            return innerText;
        }

        internal static Dictionary<string, string> GetOptionAttributes<T>(HtmlSelectListModel<T> listModel, T item)
        {
            var properties = item.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => listModel.OptionAttributes.Values.Contains(p.Name));
            var optionAttributes = new Dictionary<string, string>();
            foreach (var p in properties)
            {
                var p1 = p;
                foreach (var oa in listModel.OptionAttributes.Where(oa => p1.Name == oa.Value))
                {
                    optionAttributes.Add(oa.Key, p.GetValue(item, null).ToString());
                }
            }
            return optionAttributes;
        }
    }
}

Now you can use this in an MVC razor cshtml page. You will need a using statement like this:
@using MyNamespace.Models

    <div class="form-group">
        @Html.LabelFor(model => model.Customer.State, new { @class = "control-label col-md-2", required = "required" })
        <div class="col-md-10">
            @Html.DropDownListFor2(model => model.Customer.State, 
                new HtmlSelectListModel<State>
                {
                    DataObjects = CountryManager.Instance.States,
                    EmptyValueText = "- Select a State -",
                    SelectAttributes = new Dictionary<string, string> { { "Id", "Customer_State" }, { "Name", "Customer.State" } },
                    OptionAttributes = new Dictionary<string, string>
                                        {
                                            { "value", "StateCode" }, { "inner-text", "StateName" }, { "data-country", "CountryCode" } 
                                        }
                }
            )
            @Html.ValidationMessageFor(model => model.Customer.State)
        </div>
    </div>

It will make some awesome html. It even allows for adding data-[name] attributes. I needed a data-country attribute, which is why I wrote this class.

<select id="Customer_State" name="Customer.State">
    <option value="" >- Select a State -</option>
    <option value="AB" data-country="CA">Alberta</option>
    <option value="AK" data-country="US">Alaska</option>
    <option value="AL" data-country="US">Alabama</option>
    <option value="AR" data-country="US">Arkansas</option>
    <option value="AZ" data-country="US">Arizona</option>
    <option value="BC" data-country="CA">British Columbia</option>
    <option value="CA" data-country="US">California</option>
    <option value="CO" data-country="US">Colorado</option>
    <option value="CT" data-country="US">Connecticut</option>
    <option value="DC" data-country="US">District of Columbia</option>
    <option value="DE" data-country="US">Delaware</option>
    <option value="FL" data-country="US">Florida</option>
    ...
</select>

If you have any feedback, I would love to hear it. Please comment.

Leave a Reply

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

*