title | oneliner | precis | date | published |
---|---|---|---|---|
Builing Blazor Editor Input Controls |
This article describes how to build custom editor Input controls. |
The third article in a series describing how to build Blazor edit forms/controls with state management, validation and form locking. This article focuses on form locking. |
2021-03-17 |
2021-03-22 |
This article describes how to build an input control based on a DataList in Blazor, and make it behave like a Select. DataList apppeared in HTML5. Some browsers, particularly Safari were slow on the uptake, so using was a bit problematic in the early days of HTML5. Today, all the major browsers on various platforms support it: you can see the support list here.
We'll build two versions of the control using Blazor's InputBase
as the base class. Along the way we delve into the inner workings of InputBase
and explore control binding.
When Input
is linked to a datalist
, it makes filtered suggestions based on the datalist
. Out-of-the-box, the user can select a suggestion or enter any text value. The basic markup for the control is shown below. Try it in a page.
<input type="text" list="countrylist" />
<datalist id="countrylist" />
<option value="Algeria" />
<option value="Australia" />
<option value="Austria" />
<datalist>
Before we build our controls, let's explore what's going on in bindings. You can skip this section if you know your bind triumvirate.
Start with a standard Razor component and code behind file - MyInput.razor and MyInput.Razor.cs.
Add the following code to MyInput.razor.cs.
- We have what is known as the "Triumverate" of bind properties.
Value
is the actual value to display.ValueChanged
is a Callback that gets wired up to set the value in the parent.ValueExpression
is a lambda expression that points back to the source property in the parent. It's used to generate aFieldIdentifier
used in validation and state management to uniquely identify the field.
CurrentValue
is the control internal Value. It updatesValue
and invokesValueChanged
when changed.AdditionalAttributes
is used to capture the class and other attributes added to the control.
namespace MyNameSpace.Components
{
public partial class MyInput
{
[Parameter] public string Value { get; set; }
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public Expression<Func<string>> ValueExpression { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; }
protected virtual string CurrentValue
{
get => Value;
set
{
if (!value.Equals(this.Value))
{
Value = value;
if (ValueChanged.HasDelegate)
_ = ValueChanged.InvokeAsync(value);
}
}
}
}
}
Add a Text input
html control to the razor file.
- Namespace is added so Components can be divided into subfolders as the number of source files grow.
@bind-value
points to the controlsCurrentValue
property.@attributes
adds the control attributes toinput
.
@namespace MyNameSpace.Components
<input type="text" @bind-value="this.CurrentValue" @attributes="this.AdditionalAttributes" />
Add a Test page to Pages - or overwrite index if you're using a test site. We'll use this for testing all the controls.
This doesn't need much explanation. Bootstrap for formatting, classic EditForm
. CheckButton
gives us a easy breakpoint we can hit to check values and objects.
You can see our MyInput
in the form.
@page "/"
@using MyNameSpace.Components
<EditForm Model="this.model" OnValidSubmit="this.ValidSubmit">
<div class="container m-5 p-4 border border-secondary">
<div class="row mb-2">
<div class="col-12">
<h2>Test Editor</h2>
</div>
</div>
<div class="row mb-2">
<div class="col-4 form-label" for="txtcountry">
Country
</div>
<div class="col-4">
<MyInput id="txtcountry" @bind-Value="model.Value" class="form-control"></MyInput>
</div>
</div>
<div class="row mb-2">
<div class="col-6">
</div>
<div class="col-6 text-right">
<button class="btn btn-secondary" @onclick="(e) => this.CheckButton()">Check</button>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div>
</div>
</EditForm>
<div class="container">
<div class="row mb-2">
<div class="col-4 form-label">
Test Value
</div>
<div class="col-4 form-control">
@this.model.Value
</div>
</div>
<div class="row mb-2">
<div class="col-4 form-label">
Test Index
</div>
<div class="col-4 form-control">
@this.model.index
</div>
</div>
</div>
@code {
Model model = new Model() { Value = "Australia", index = 2 };
private void CheckButton()
{
var x = true;
}
private void ValidSubmit()
{
var x = true;
}
class Model
{
public string Value { get; set; } = string.Empty;
public int index { get; set; } = 0;
}
}
Note the value display update as you change the text in MyInput
.
Under the hood the Razor compiler builds the section containing MyInput
into component code like this:
__builder2.OpenComponent<TestBlazorServer.Components.MyInput>(12);
__builder2.AddAttribute(13, "id", "txtcountry");
__builder2.AddAttribute(14, "class", "form-control");
__builder2.AddAttribute(15, "Value", Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.TypeCheck<System.String>(model.Value));
__builder2.AddAttribute(16, "ValueChanged", Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.TypeCheck<Microsoft.AspNetCore.Components.EventCallback<System.String>>(Microsoft.AspNetCore.Components.EventCallback.Factory.Create<System.String>(this, Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this, __value => model.Value = __value, model.Value))));
__builder2.AddAttribute(17, "ValueExpression", Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.TypeCheck<System.Linq.Expressions.Expression<System.Func<System.String>>>(() => model.Value));
__builder2.CloseComponent();
You can see the compiled c# file in the obj folder. On my project this is \obj\Debug\net5.0\RazorDeclaration\Components\FormControls.
@bind-value
has translated into a full mapping to the Value
, ValueChanged
and ValueExpression
triumvirate. The setting of Value
and ValueExpression
are easy to understand. ValueChanged
uses a code factory to effectively build a runtime method that is mapped to ValueChanged
and sets model.Value to the value returned by ValueChanged
.
This explains a common issue raised by many - why can't attach an event handler to @onchange
like this:
<input type="text" @bind-value ="model.Value" @onchange="(e) => myonchangehandler()"/>
There's no @onchange
event on the control, and the one on the inner control is already bound so can't be bound a second time. You get no error message, just no triggering of the event.
Let's move on to InputBase
.
First we'll look at InputText
to see an implementation.
- The Html input
value
is bound toCurrentValue
andonchange
event toCurrentValueAsString
. Any change in the value calls the setter forCurrentValueASsString
. TryParseValueFromString
just passes onvalue
(the entered value) asresult
. There's no string to other type conversion to do.
public class InputText : InputBase<string?>
{
[DisallowNull] public ElementReference? Element { get; protected set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "input");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
builder.AddAttribute(3, "value", BindConverter.FormatValue(CurrentValue));
builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.AddElementReferenceCapture(5, __inputReference => Element = __inputReference);
builder.CloseElement();
}
protected override bool TryParseValueFromString(string? value, out string? result, [NotNullWhen(false)] out string? validationErrorMessage)
{
result = value;
validationErrorMessage = null;
return true;
}
}
Let's delve into InputBase
This calls TryParseValueFromString
and either sets CurrentValue
to the returned result
, or logs any parsing errors to the message store. In InputText
it's pretty simple, set the value and return true - we're only dealing with a string.
First we need a helper class to get a country list. Get the full list from the Repo.
using System.Collections.Generic;
namespace MyNameSpace.Data
{
public static class Countries
{
public static List<KeyValuePair<int, string>> CountryList
{
get
{
List<KeyValuePair<int, string>> list = new List<KeyValuePair<int, string>>();
var x = 1;
foreach (var v in CountryArray)
{
list.Add(new KeyValuePair<int, string>(x, v));
x++;
}
return list;
}
}
public static SortedDictionary<int, string> CountryDictionary
{
get
{
SortedDictionary<int, string> list = new SortedDictionary<int, string>();
var x = 1;
foreach (var v in CountryArray)
{
list.Add(x, v);
x++;
}
return list;
}
}
public static string[] CountryArray = new string[]
{
"Afghanistan",
"Albania",
"Algeria",
.....
"Zimbabwe",
};
}
}
The partial class:
public partial class InputDataList : InputBase<string>
{
[Parameter] public IEnumerable<string> DataList { get; set; }
[Parameter] public bool RestrictToList { get; set; }
private string dataListId { get; set; } = Guid.NewGuid().ToString();
private bool _valueSetByTab = false;
private string _typedText = string.Empty;
protected string CurrentStringValue
{
get
{
// check if we have a match to the datalist and get the value from the list
if (DataList != null && DataList.Any(item => item == this.Value))
return DataList.First(item => item == this.Value);
// if not return an empty string
else if (RestrictToList)
return string.Empty;
else
return _typedText;
}
set
{
// Check if we have a ValidationMessageStore
// Either get one or clear the existing one
if (_parsingValidationMessages == null)
_parsingValidationMessages = new ValidationMessageStore(EditContext);
else
_parsingValidationMessages?.Clear(FieldIdentifier);
// Set defaults
string val = string.Empty;
var _havevalue = false;
// check if we have a previous valid value - we'll stick with this is the current attempt to set the value is invalid
var _havepreviousvalue = DataList != null && DataList.Contains(value);
// Set the value by tabbing in Strict mode. We need to select the first entry in the DataList
if (_setValueByTab)
{
if (!string.IsNullOrWhiteSpace(this._typedText))
{
// Check if we have at least one match in the filtered list
_havevalue = DataList != null && DataList.Any(item => item.Contains(_typedText, StringComparison.CurrentCultureIgnoreCase));
if (_havevalue)
{
// the the first value
var filteredList = DataList.Where(item => item.Contains(_typedText, StringComparison.CurrentCultureIgnoreCase)).ToList();
val = filteredList[0];
}
}
}
// Normal set
else if (this.RestrictToList)
{
// Check if we have a match and set it if we do
_havevalue = DataList != null && DataList.Contains(value);
if (_havevalue)
val = DataList.First(item => item.Equals(value));
}
else
{
_havevalue = true;
val = value;
}
// check if we have a valid value
if (_havevalue)
{
// assign it to current value - this will kick off a ValueChanged notification on the EditContext
this.CurrentValue = val;
// Check if the last entry failed validation. If so notify the EditContext that validation has changed i.e. it's now clear
if (_previousParsingAttemptFailed)
{
EditContext.NotifyValidationStateChanged();
_previousParsingAttemptFailed = false;
}
}
// We don't have a valid value
else
{
// check if we're reverting to the last entry. If we don't have one the generate error message
if (!_havepreviousvalue)
{
// No match so add a message to the message store
_parsingValidationMessages?.Add(FieldIdentifier, "You must choose a valid selection");
// keep track of validation state for the next iteration
_previousParsingAttemptFailed = true;
// notify the EditContext whick will precipitate a Validation Message general update
EditContext.NotifyValidationStateChanged();
}
}
// Clear the Tab notification flag
_setValueByTab = false;
}
}
private void UpdateEnteredText(ChangeEventArgs e)
=> _typedText = e.Value.ToString();
private void OnKeyDown(KeyboardEventArgs e)
{
Debug.WriteLine($"Key: {e.Key}");
if (RestrictToList && (!string.IsNullOrWhiteSpace(e.Key)) && e.Key.Equals("Tab") && !string.IsNullOrWhiteSpace(this._typedText))
{
if (DataList.Any(item => item.Contains(_typedText, StringComparison.CurrentCultureIgnoreCase)))
{
var filteredList = DataList.Where(item => item.Contains(_typedText, StringComparison.CurrentCultureIgnoreCase)).ToList();
this.CurrentValue = filteredList[0];
_valueSetByTab = true;
}
}
}
protected override bool TryParseValueFromString(string value, [MaybeNullWhen(false)] out string result, [NotNullWhen(false)] out string validationErrorMessage)
=> throw new NotSupportedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'.");
}
And the Razor:
@namespace MyNameSpace.Components
@inherits InputBase<string>
<input class="@CssClass" type="text" @bind-value="this.CurrentStringValue" @attributes="this.AdditionalAttributes" list="@dataListId" @oninput="UpdateEnteredText" @onkeydown="OnKeyDown" />
<datalist id="@dataListId">
@foreach (var option in this.DataList)
{
<option value="@option" />
}
</datalist>
The code:
- Inherits from
InputBase
. - Declares a
DataList
Parameter. - Creates a unique name using GUIDs for the html
DataList
control - we may have several on a form. - Adds
UpdateEnteredText
event handler wired into@oninput
. It updates_typedText
on any keyboard entry. - Adds
OnKeyDown
event handler wired into@onkeydpwn
. It monitors keyboard entry and acts on a Tab, settingCurrentValue
to the first entry in the list that matches the typed text. _valueSetByTab
controlsCurrentValue
. When triggered by a Tab, it gets called twice. Initially byOnKeyDown
and then by@onchange
through binding when the control loses focus (through tabbing to the next control).OnKeyDown
sets_valueSetByTab
, and stops the second update.
The Razor markup looks like this.
- Input uses the CSS generated by the control.
- Binds to
CurrentValue
. - Adds the additional Attributes, including the
Aria
generated by the control. - Binds
list
to thedatalist
. - Hooks up event handlers to
oninput
andonkeydown
. - Builds the
datalist
from the controlDataList
property.
Test the control in the test page
<div class="col-4">
<InputDataList @bind-Value="model.Value" DataList="Countries.CountryArray" class="form-control" placeholder="Select a country"></InputDataList>
</div>
The control doesn't use CurrentValueAsString
and TryParseValueFromString
. We need to add logic to CurrentValueAsString
, but can't - there's no override. We set up an equivalent property CurrentStringValue
, and wire the html input to it. We're only dealing with strings, so there's no parsing error checking to do.
The second control builds on InputDataList
. We convert it to a select type control by adding key/value pair handling, such as you get from a database, and restricting the selection to only datalist values.
Copy InputSearchControl
and rename it to InputSearchSelectControl
.
Add the generic declaration. The control will work with most obvious types as the Key - e.g. int, long, string.
public partial class InputDataListSelectT<TValue> : InputBase<TValue>
{
#nullable enable
...
#nullable disable
}
Change DataList to a SortedDictionary
.
[Parameter] public SortedDictionary<TValue, string> DataList { get; set; }
At this point we need some cleverer logic to deal with K/V pair and returning TValue
.
The extra private properties are as follows.
- Access to the ValidationMessageStore to log parsing errors - strings entered with no match.
- A control boolean for parsing errors.
private ValidationMessageStore? _parsingValidationMessages;
private bool _previousParsingAttemptFailed = false;
CurrentValue
has grown in complexity. The inline comments provide a detailed explanation of purpose. We now log parsing errors to the EditContext and notify on error.
protected string CurrentStringValue
{
get
{
// check if we have a match to the datalist and get the value from the K/V pair
if (DataList != null && DataList.Any(item => item.Key.Equals(this.Value)))
return DataList.First(item => item.Key.Equals(this.Value)).Value;
// if not return an empty string
return string.Empty;
}
set
{
// Check if the value has already been set by tabbing in OnKeyDown
if (!_valueSetByTab)
{
// Check if we have a ValidationMessageStore
// Either get one or clear the existing one
if (_parsingValidationMessages == null)
_parsingValidationMessages = new ValidationMessageStore(EditContext);
else
_parsingValidationMessages?.Clear(FieldIdentifier);
// Check if we have a K/V match for the value
TValue val;
if (DataList != null && DataList.ContainsValue(value))
{
// get the key
val = DataList.First(item => item.Value.Equals(value)).Key;
// assign it to current value - this will kick off a ValueChanged notification on the EditContext
this.CurrentValue = val;
//var hasChanged = !val.Equals(Value);
// Check if the last entry failed validation. If so notify the EditContext that validation has changed i.e. it's now clear
if (_previousParsingAttemptFailed)
{
EditContext.NotifyValidationStateChanged();
_previousParsingAttemptFailed = false;
}
}
else
{
// No K/V match so add a message to the message store
_parsingValidationMessages?.Add(FieldIdentifier, "You must choose a valid selection");
// keep track of validation state for the next iteration
_previousParsingAttemptFailed = true;
// notify the EditContext whick will precipitate a Validation Message general update
EditContext.NotifyValidationStateChanged();
}
}
// Clear the Tab notification flag
_valueSetByTab = false;
}
}
OnKeyDown
changes a little to adopt to a key/value pair rather then a string array. We also reset the parsing errors if needed.
private void UpdateEnteredText(ChangeEventArgs e)
=> _typedText = e.Value?.ToString();
private void OnKeyDown(KeyboardEventArgs e)
{
// Debug.WriteLine($"Key: {e.Key}");
// Check if we have a Tab with some text already typed
if ((!string.IsNullOrWhiteSpace(e.Key)) && e.Key.Equals("Tab") && !string.IsNullOrWhiteSpace(this._typedText))
{
// Check if we have at least one K/V match in the filtered list
if (DataList != null && DataList.Any(item => item.Value.Contains(_typedText, StringComparison.CurrentCultureIgnoreCase)))
{
// the the first K/V pair
var filteredList = DataList.Where(item => item.Value.Contains(_typedText, StringComparison.CurrentCultureIgnoreCase)).ToList();
// Set CurrentValue to the key - this will precipitate a ValueChanged notification on the EditContext
this.CurrentValue = filteredList[0].Key;
// tell the currentstringvalue setter we've already set the value
_valueSetByTab = true;
// Check if the last entry failed validation. If so notify the EditContext that validation has changed i.e. it's now clear
if (_previousParsingAttemptFailed)
{
EditContext.NotifyValidationStateChanged();
_previousParsingAttemptFailed = false;
}
}
}
}
// set as blind
protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string validationErrorMessage)
=> throw new NotSupportedException($"This component does not parse normal string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'.");
The Razor is almost the same:
datalist
changes to accommodate a K/V pair list.- Add the
@typeparam
.
@namespace Blazor.Database.Components
@inherits InputBase<TValue>
@typeparam TValue
<input class="@CssClass" type="text" @bind-value="this.CurrentStringValue" @attributes="this.AdditionalAttributes" list="@dataListId" @oninput="UpdateEnteredText" @onkeydown="OnKeyDown" />
<datalist id="@dataListId">
@foreach (var kv in this.DataList)
{
<option value="@kv.Value" />
}
</datalist>
Test it by adding a row to the edit table in the test page. Try entering an invalid string - something like "xxxx".
<div class="row mb-2">
<div class="col-4 form-label" for="txtcountry">
Country T Index
</div>
<div class="col-4">
<InputDataListSelectT TValue="int" @bind-Value="model.Index" DataList="Countries.CountryDictionary" class="form-control" placeholder="Select a country"></InputDataListSelectT>
</div>
<div class="col-4">
<ValidationMessage For="(() => model.Index)"></ValidationMessage>
</div>
</div>
Building edit components is not trivial, but also should not be feared.
The examples I've built leverage off InputBase
. If you're going to start building your own I thoroughly recommend taking a little time and getting familiar with InputBase
and it's siblings. The code is here.