I'm just now starting to learn ASP.NET MVC. How would I go about creating a reusable tri-state checbox? In WebForms this would be a control, but I don't know the MVC equivalent.
Limitations:
View Rendering - When rendering HTML content, there is no attribute you can possibly place on an
<input type="checkbox" />
that will give it the propertyindeterminate
.
At some point, you'll have to use JavaScript to grab the element and set the indeterminate property:
<!-- language: lang-js -->// vanilla js
document.getElementById("myChk").indeterminate = true;
// jQuery
$("#myCheck).prop("indeterminate", true);
Form Data - model binding will always be limited to what values are actually sent in the request, either from the url or the data payload (on a POST).
In this simplified example, both unchecked and indeterminate checkboxes are treated identically:
And you can confirm that for yourself in this Stack Snippet:
<!-- begin snippet: js hide: true console: true babel: false --> <!-- language: lang-css -->label {
display: block;
margin-bottom: 3px;
}
<!-- language: lang-html -->
<form action="#" method="post">
<label >
<input type="checkbox" name="chkEmpty">
Checkbox
</label>
<label >
<input type="checkbox" name="chkChecked" checked>
Checkbox with Checked
</label>
<label >
<input type="checkbox" name="chkIndeterminate" id="chkIndeterminate">
<script> document.getElementById("chkIndeterminate").indeterminate = true; </script>
Checkbox with Indeterminate
</label>
<label >
<input name="RegularBool" type="checkbox" value="true">
<input name="RegularBool" type="hidden" value="false">
RegularBool
</label>
<input type="submit" value="submit"/>
</form>
<!-- end snippet -->
Model Binding - Further, model binding will only occur on properties that are actually sent. This actually poses a problem even for regular checkboxes, since they won't post a value when unchecked. Value types do always have a default value, however, if that's the only property in your model, MVC won't new up an entire class if it doesn't see any properties.
ASP.NET solves this problem by emitting two inputs per checkbox:
Note: The hidden input guarantees that a 'false' value will be sent even when the checkbox is not checked
. When the checkbox is checked, HTTP is allowed to submit multiple values with the same name, but ASP.NET MVC will only take the first instance, so it will return true
like we'd expect.
Render Only Solution
We can render a checkbox for a nullable boolean, however this really only works to guarantee a bool by converting null
→ false
when rendering. It is still difficult to share the indeterminate state across server and client. If you don't need to ever post back indeterminate, this is probably the cleanest / easiest implementation.
Roundtrip Solution
As there are serious limitations to using a HTML checkbox to capture and post all 3 visible states, let's separate out the view of the control (checkbox) with the tri-state values that we want to persist, and then keep them synchronized via JavsScript. Since we already need JS anyway, this isn't really increasing our dependency chain.
Start with an Enum that will hold our value:
<!-- language: lang-cs --> <pre><code>/// <summary> Specifies the state of a control, such as a check box, that can be checked, unchecked, or set to an indeterminate state.</summary> /// <remarks> Adapted from <a href="https://msdn.microsoft.com/en-us/library/System.Windows.Forms.CheckState"><b>System.Windows.Forms.CheckState</b></a>, but duplicated to remove dependency on Forms.dll</remarks> public enum CheckState { Checked, Indeterminate, Unchecked }</code></pre>Then add the following property to your Model instead of a boolean:
<!-- language: lang-cs -->public CheckState OpenTasks { get; set; }
Then create an EditorTemplate for the property that will render the actual property we want to persist inside of a hidden input PLUS a checkbox control that we'll use to update that property
<code>Views/Shared/EditorTemplates/<b>CheckState.cshtml</b></code>:
<!-- language: lang-cs -->@model CheckState
@Html.HiddenFor(model => model, new { @class = "tri-state-hidden" })
@Html.CheckBox(name: "",
isChecked: (Model == CheckState.Checked),
htmlAttributes: new { @class = "tri-state-box" })
Note: We're using the same hack as ASP.NET MVC to submit two fields with the same name, and placing the HiddenFor
value that we want to persist first so it wins. This just makes it easy to traverse the DOM and find the corresponding value, but you could use different names to prevent any possible overlap.
Then, in your view, you can render both the property + checkbox using the editor template the same way you would have used a checkbox, since it renders both. So just add this to your view:
<!-- language: lang-cs -->@Html.EditorFor(model => model.OpenTasks)
The finally piece is to keep them synchronized via JavaScript on load and whenever the checkbox changes like this:
<!-- language: lang-js -->// on load, set indeterminate
$(".tri-state-hidden").each(function() {
var isIndeterminate = this.value === "@CheckState.Indeterminate";
if (isIndeterminate) {
var $box = $(".tri-state-box[name='" + this.name + "'][type='checkbox']");
$box.prop("indeterminate", true);
}
});
// on change, keep synchronized
$(".tri-state-box").change(function () {
var newValue = this.indeterminate ? "@CheckState.Indeterminate"
: this.checked ? "@CheckState.Checked"
: "@CheckState.Unchecked";
var $hidden = $(".tri-state-hidden[name='" + this.name + "'][type='hidden']");
$hidden.val(newValue);
});
Then you can use however you'd like in your business model. For example, if you wanted to map to a nullable boolean, you could use the CheckState
property as a backing value and expose/modify via getters/setters in a bool?
like this:
public bool? OpenTasksBool
{
get
{
if (OpenTasks == CheckState.Indeterminate) return null;
return OpenTasks == CheckState.Checked;
}
set
{
switch (value)
{
case null: OpenTasks = CheckState.Indeterminate; break;
case true: OpenTasks = CheckState.Checked; break;
case false: OpenTasks = CheckState.Unchecked; break;
}
}
}
Alternative Solution
Also, depending on your domain model, you could just use Yes, No, ⁿ/ₐ radio buttons