Search code examples
c#htmlasp.net-corerazortag-helpers

Select an option in a select element with tag helpers


This is my situation: In an ASP.NET Core application, I need to generate the following HTML code in a Razor view from a model instance:

<select name="CultureName">
    <option value="de-DE" data-summary="Deutsch (Deutschland)">* Deutsch (Deutschland)</option>
    <option value="de-AT" data-summary="Deutsch (Österreich)">* Deutsch (Österreich)</option>
    <option value="de-CH" data-summary="Deutsch (Schweiz)">* Deutsch (Schweiz)</option>
    <option value="en-GB" data-summary="Englisch (Großbritannien)" selected>Englisch (Großbritannien)</option>
                                                                   ^^^^^^^^- important!
    <option value="en-US" data-summary="Englisch (USA)">Englisch (USA)</option>
</select>

The CultureName property of the model has the value "en-GB". The model also has a list of all available options. The separate data-summary attribute is supported by the web frontend framework I use and needs to be set on each <option> element, along with other attributes left out here for brevity. One of the options is preselected by the selected attribute.

I cannot use the predefined SelectListItem class because it doesn't support the additional attributes. So I created my own SelectListOption class that looks similar but has additional properties. I have a tag helper that allows me to write this:

<select asp-for="CultureName">
    @foreach (var culture in Model.Cultures)
    {
        <option item="@culture"></option>
    }
</select>

Here's the code:

[HtmlTargetElement("option", ParentTag = "select", Attributes = "item")]
public class OptionTagHelper : TagHelper
{
    public SelectListOption Item { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Item != null)
        {
            output.Content.SetContent(Item.Text);
            output.Attributes.SetAttribute("value", Item.Value);
            if (Item.Summary != null)
                output.Attributes.SetAttribute("data-summary", Item.Summary);
            if (Item.HtmlText != null)
                output.Attributes.SetAttribute("data-html", Item.HtmlText);
            if (Item.HtmlSummary != null)
                output.Attributes.SetAttribute("data-summary-html", Item.HtmlSummary);
            if (Item.Selected)
                output.Attributes.SetAttribute("selected", true);
            if (Item.Disabled)
                output.Attributes.SetAttribute("disabled", true);
            output.Attributes.RemoveAll("item");
        }
    }
}

It fills in the other attributes of the <option> element from the model. That works nicely, but the selection is ignored when rendering the page. So the user will see a list with all options, but the current selection is missing.

I found out that what I do isn't necessarily correct, and I should do this instead:

<select asp-for="CultureName" asp-items="@Model.Cultures">
</select>

The predefined select tag helper would then fill in the options for me. But again, I cannot use this because I need additional standard attributes here. So I tried to make this myself, too.

Here's the code but I have no idea what I'm doing there really because there's not much documentation about it:

[HtmlTargetElement("select", Attributes = "options")]
public class SelectTagHelper : TagHelper
{
    public IEnumerable<SelectListOption> Options { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Options != null)
        {
            foreach (var option in Options)
            {
                // TODO: Use HtmlEncoder.HtmlEncode() on values?
                output.Content.AppendHtml($"<option value=\"{option.Value}\"");
                if (option.Summary != null)
                    output.Content.AppendHtml($" data-summary=\"{option.Summary}\"");
                if (option.HtmlText != null)
                    output.Content.AppendHtml($" data-html=\"{option.HtmlText}\"");
                if (option.HtmlSummary != null)
                    output.Content.AppendHtml($" data-summary-html=\"{option.HtmlSummary}\"");
                if (option.Selected)
                    output.Content.AppendHtml(" selected");
                if (option.Disabled)
                    output.Content.AppendHtml(" disabled");
                output.Content.AppendHtml($">{option.Text}</option>");
            }
        }
    }
}

I'm using this as:

<select asp-for="CultureName" options="@Model.Cultures">
</select>

This seems to work, I get all the options in my select box. But of course now still without the initial selection.

Now I'm stuck. How can I get the selected attribute to the correct <option> element? How can I find out what the current model value of the <select> element is? It has an asp-for attribute but that's only the name, not the value I need to match.

I'm looking for something like this:

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (Options != null)
        {
            foreach (var option in Options)
            {
                output.Content.AppendHtml($"<option value=\"{option.Value}\"");
                if (option.Value == CURRENT_SELECT_VALUE_FROM_MODEL)
                    output.Content.AppendHtml(" selected");
                // (Other attributes)
                output.Content.AppendHtml($">{option.Text}</option>");
            }
        }
    }

Next I have another requirement: In some places I need to add another page-specific attribute to all <option> elements that will be used for other parts of the form:

<select asp-for="CultureName">
    @foreach (var culture in Model.Cultures)
    {
        <option item="@culture" data-separator="@culture.ValueObject.TextInfo.ListSeparator"></option>
    }
</select>

This makes available each CultureInfo's TextInfo.ListSeparator value to the page scripts through the data-separator attribute. This obviously cannot be handled by the default case anymore, so I still need to create the <option> elements in a loop myself. Fortunately, this is already covered by my first tag helper so all the extra attributes are already there. But again, the selected attribute is missing.

While I could try to modify the list of options in the model so that one of them has the Selected property set, that is a lot of stupid work that should already be covered by something like asp-for.

Here, I also need to find out from each option tag helper whether a specific option should be selected.

Or maybe alternatively, the select tag helper (parent) could find the option element to select, after its content has been processed. So if that's possible I would have to trigger the closing </select> tag and iterate all <option> child elements to match their value attribute and add a selected attribute to one of them.

Can tag helpers even help me with that or are they far too limited for that task? How much of a help are these helpers really?


Solution

  • You're not doing anything with the asp-for. It's not magic. If you look at the code for one of the built-in tag helpers, you'll see they have property for capturing this, along the lines of:

    [HtmlAttributeName("asp-for")]
    public ModelExpression For { get; set; }
    

    Then, you must use this property to actually set the selected option by reading the value. That said, though, you'd probably be better served by simply inheriting your tag helper from SelectTagHelper, and just overriding Process to do your own logic. Remember to call base.Process as well, though.