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?
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.