在 Blazor 中构建 DataList 控件





4.00/5 (2投票s)
如何在 Blazor 中构建 DataList 控件
引言
本文介绍如何在 Blazor 中构建基于 DataList
的输入控件,并使其行为类似于 Select
。 DataList
出现在 HTML5 中。一些浏览器,特别是 Safari,采纳较慢,因此在 HTML5 早期,其使用有点问题。如今,各种平台上的所有主流浏览器都支持它:您可以在 此处 查看支持列表。
我们将使用 Blazor 的 InputBase
作为基类来构建两个版本的控件,以适应现有的编辑表单框架。在此过程中,我们将深入了解 InputBase
的内部工作原理并探索控件绑定。
HTML DataList
当 Input
与 datalist
关联时,它会根据 datalist
在用户键入时提供过滤建议。开箱即用,用户可以选择一个建议或输入任何文本值。控件的基本标记如下所示
<input type="text" list="countrylist" />
<datalist id="countrylist" />
<option value="Algeria" />
<option value="Australia" />
<option value="Austria" />
<datalist>
示例站点和代码存储库
代码位于我的 Blazor.Database 存储库中,在此 Blazor.SPA/Components/FormControls 处。
您可以在我的 Blazor.Database 演示站点 上看到这些控件的实际运行情况。
探索测试控件中的绑定
在构建控件之前,让我们先探讨一下绑定的内容。如果您熟悉绑定三要素,可以跳过本节。
从一个标准的 Razor 组件和代码隐藏文件开始 - MyInput.razor 和 MyInput.Razor.cs。
将以下代码添加到 MyInput.razor.cs。
- 我们拥有所谓的“绑定属性三要素”。
Value
是要显示的实际值。ValueChanged
是一个回调,用于设置父级中的值。ValueExpression
是一个 lambda 表达式,指向父级中的源属性。它用于生成FieldIdentifier
,该FieldIdentifier
用于验证和状态管理,以唯一标识字段。CurrentValue
是控件内部的 Value。更改时,它会更新Value
并调用ValueChanged
。AdditionalAttributes
用于捕获添加到控件的类和其他属性。
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);
}
}
}
}
}
在 razor 文件中添加一个 Text input
HTML 控件。
- 添加命名空间是为了在源文件数量增长时将 Components 分割到子文件夹中。
@bind-value
指向控件的CurrentValue
属性。@attributes
将控件属性添加到input
。
@namespace MyNameSpace.Components
<input type="text" @bind-value="this.CurrentValue" @attributes="this.AdditionalAttributes" />
测试页面
将测试页面添加到 Pages - 如果您正在使用测试站点,则可以覆盖 index。我们将使用它来测试所有控件。
这不需要太多解释。Bootstrap 用于格式化,经典 EditForm
。CheckButton
为我们提供了一个易于设置断点的按钮,以便我们可以检查值和对象。
您可以在表单中看到我们的 MyInput
。
@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;
}
}
注意,当您更改 MyInput
中的文本时,值显示会更新。
在底层,Razor 编译器会将包含 MyInput
的部分编译成如下所示的组件代码
__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();
您可以在 obj 文件夹中看到编译后的 C# 文件。在我的项目中,它位于 \obj\Debug\net5.0\RazorDeclaration\Components\FormControls。
@bind-value
已转换为对 Value
、ValueChanged
和 ValueExpression
三要素的完整映射。Value
和 ValueExpression
的设置不言自明。ValueChanged
使用代码工厂生成一个运行时方法,该方法映射到 ValueChanged
并将 model.Value
设置为 ValueChanged
返回的值。
这解释了一个常见的误解 - 您可以像这样为 @onchange
附加一个事件处理程序
<input type="text" @bind-value ="model.Value" @onchange="(e) => myonchangehandler()"/>
控件上没有 @onchange
事件,内部控件上的事件已经绑定,因此无法再次绑定。您不会收到错误消息,只是没有触发。
InputBase
让我们继续 InputBase
。
首先,我们来看 InputText
来了解实现方式
- HTML input 的
value
绑定到CurrentValue
,onchange
事件绑定到CurrentValueAsString
。值中的任何更改都会调用CurrentValueASsString
的 setter。 TryParseValueFromString
只是将value
(输入的值)作为result
传递。无需进行从string
到其他类型的转换。
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;
}
}
让我们深入研究 InputBase
。
onchange
事件设置 CurrentValueAsString
。请注意,它不是虚拟的,因此不能被重写。
protected string? CurrentValueAsString
{
get => FormatValueAsString(CurrentValue);
set
{
// clear the ValidationMessageStore
_parsingValidationMessages?.Clear();
bool parsingFailed;
// Error if can't be null and value is null.
if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value))
{
parsingFailed = false;
CurrentValue = default!;
}
// Call TryParseValueFromString.
else if (TryParseValueFromString(value, out var parsedValue,
out var validationErrorMessage))
{
// If we pass complete and set CurrentValue
parsingFailed = false;
CurrentValue = parsedValue!;
}
else
{
// We reach here if we fail parsing
// set flags and make sure we have a ValidationMessageStore
parsingFailed = true;
if (_parsingValidationMessages == null)
{
_parsingValidationMessages = new ValidationMessageStore(EditContext);
}
// Add a parsing error message to the store
_parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);
// Since we're not writing to CurrentValue,
// we'll need to notify about modification from here
EditContext.NotifyFieldChanged(FieldIdentifier);
}
// skip the validation notification if we were previously valid and still are
// if we failed this time notify
// if we failed last time but are ok now we need notify
// to get the validation controls cleared
if (parsingFailed || _previousParsingAttemptFailed)
{
EditContext.NotifyValidationStateChanged();
_previousParsingAttemptFailed = parsingFailed;
}
}
}
输入 value
绑定到 CurrentValue
的 getter,而 CurrentValueAsString
设置它。再次注意,它不是 virtual
,因此不能重写。
protected TValue? CurrentValue
{
// straight getter from Value
get => Value;
set
{
// Checks for equality between submitted value and class Value
var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
// and if it's changed
if (hasChanged)
{
// sets the class Value
Value = value;
// calls the ValueChanged EventHandler to update the parent value
_ = ValueChanged.InvokeAsync(Value);
// Notifies the EditContext that the field has changed
// and passes the FieldIdentifier
EditContext.NotifyFieldChanged(FieldIdentifier);
}
}
}
最后,TryParseValueFromString
是抽象的,因此必须在继承类中实现。它的目的是验证并将提交的 string
转换为正确的 TValue
。
protected abstract bool TryParseValueFromString(string? value,
[MaybeNullWhen(false)] out TValue result,
[NotNullWhen(false)] out string? validationErrorMessage);
构建我们的 DataList 控件
首先,我们需要一个辅助类来获取国家列表。从存储库获取完整的类。
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",
};
}
}
构建控件
这是 partial
类,将 TValue
设置为 string
。有内联的解释注释。
public partial class InputDataList : InputBase<string>
{
// List of values for datalist
[Parameter] public IEnumerable<string> DataList { get; set; }
// parameter to restrict valid values to the list
[Parameter] public bool RestrictToList { get; set; }
// unique id for the datalist based on a guid - we may have more than one in a form
private string dataListId { get; set; } = Guid.NewGuid().ToString();
// instruction to CurrentStringValue that we are in RestrictToList mode
// and the user has tabbed
private bool _valueSetByTab = false;
// current typed value in the input box - kept up to date by UpdateEnteredText
private string _typedText = string.Empty;
// New method to parallel CurrentValueAsString
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 which will precipitate
// a Validation Message general update
EditContext.NotifyValidationStateChanged();
}
}
// Clear the Tab notification flag
_setValueByTab = false;
}
}
// Keep _typedText up to date with typed entry
private void UpdateEnteredText(ChangeEventArgs e)
=> _typedText = e.Value.ToString();
// Detector for Tabbing away from the input
private void OnKeyDown(KeyboardEventArgs e)
{
// Check if we have a Tab with some text already typed
// and are in RestrictToList Mode
_setValueByTab = RestrictToList && (!string.IsNullOrWhiteSpace(e.Key)) &&
e.Key.Equals("Tab") && !string.IsNullOrWhiteSpace(this._typedText);
}
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)}'.");
}
以及 Razor
- Input 使用控件生成的 CSS。
- 绑定到
CurrentValue
。 - 添加附加属性,包括控件生成的
Aria
。 - 将
list
绑定到datalist
。 - 连接
oninput
和onkeydown
的事件处理程序。 - 从控件
DataList
属性构建datalist
。
@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>
在测试页面中测试控件。
<div class="row mb-2">
<div class="col-4 form-label" for="txtcountry">
Country (Any Value)
</div>
<div class="col-4">
<InputDataList @bind-Value="model.Value" DataList="Countries.CountryArray"
class="form-control" placeholder="Select a country"></InputDataList>
</div>
</div>
<div class="row mb-2">
<div class="col-4 form-label" for="txtcountry">
Country (Strict)
</div>
<div class="col-4">
<InputDataList @bind-Value="model.StrictValue"
DataList="Countries.CountryArray" RestrictToList="true"
class="form-control" placeholder="Select a country"></InputDataList>
</div>
<div class="col-4">
<ValidationMessage For="(() => model.StrictValue)"></ValidationMessage>
</div>
</div>
<div class="row mb-2">
<div class="col-4 form-label">
Country Value
</div>
<div class="col-4 form-control">
@this.model.Value
</div>
</div>
<div class="row mb-2">
<div class="col-4 form-label">
Country Strict Value
</div>
<div class="col-4 form-control">
@this.model.StrictValue
</div>
</div>
class Model
{
public string Value { get; set; } = string.Empty;
public string StrictValue { get; set; } = string.Empty;
public int Index { get; set; } = 0;
public int TIndex { get; set; } = 0;
public int Opinion { get; set; } = 0;
}
控件不使用 CurrentValueAsString
和 TryParseValueFromString
。相反,我们构建一个并行的 CurrentStringValue
,其中包含 CurrentValueAsString
和 TryParseValueFromString
中的所有逻辑,并将 HTML 输入连接到它。我们不使用 TryParseValueFromString
,但由于它是抽象的,我们需要实现一个盲版本。
Input Search Select 控件
控件的 Select
替代版本建立在 InputDataList
的基础上。我们
- 转换为键/值对列表,其中键是泛型的。
- 在 HTML input 中添加从
TValue
到string
再转换回来的额外逻辑。 - 在类中添加泛型处理。
复制 InputDataList
并将其重命名为 InputDataListSelect
。
添加泛型声明。该控件可以与大多数明显的类型一起用作 Key
- 例如,int
、long
、string
。
public partial class InputDataListSelect<TValue> : InputBase<TValue>
将 DataList
更改为 SortedDictionary
。
[Parameter] public SortedDictionary<TValue, string> DataList { get; set; }
额外的 private
属性如下
// the EditContext ValidationMessageStore
private ValidationMessageStore? _parsingValidationMessages;
// field to manage parsing failure
private bool _previousParsingAttemptFailed = false;
CurrentValue
有所变化,以处理 K/V 对并进行 K/V 对查找。同样,内联注释提供了详细信息。
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 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
TValue val = default;
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.ContainsKey(this.Value);
// Set the value by tabbing. We need to select the first entry in the DataList
if (_setValueByTab)
{
if (!string.IsNullOrWhiteSpace(this._typedText))
{
// Check if we have at least one K/V match in the filtered list
_havevalue = DataList != null && DataList.Any
(item => item.Value.Contains(_typedText,
StringComparison.CurrentCultureIgnoreCase));
if (_havevalue)
{
// the the first K/V pair
var filteredList = DataList.Where(item => item.Value.Contains
(_typedText, StringComparison.CurrentCultureIgnoreCase)).ToList();
val = filteredList[0].Key;
}
}
}
// Normal set
else
{
// Check if we have a match and set it if we do
_havevalue = DataList != null && DataList.ContainsValue(value);
if (_havevalue)
val = DataList.First(item => item.Value.Equals(value)).Key;
}
// 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 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
_setValueByTab = false;
}
}
OnKeyDown
设置 _setValueByTab
标志。
private void UpdateEnteredText(ChangeEventArgs e)
=> _typedText = e.Value?.ToString();
private void OnKeyDown(KeyboardEventArgs e)
{
// Check if we have a Tab with some text already typed
_setValueByTab = ((!string.IsNullOrWhiteSpace(e.Key)) &&
e.Key.Equals("Tab") && !string.IsNullOrWhiteSpace(this._typedText));
}
// 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)}'.");
Razor 几乎相同
datalist
更改以适应 K/V 对列表。- 添加
@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>
通过在测试页面的编辑表中添加一行来测试它。尝试输入无效的 string
- 例如“xxxx
”。
<div class="row mb-2">
<div class="col-4 form-label" for="txtcountry">
Country T Index
</div>
<div class="col-4">
<InputDataListSelect TValue="int" @bind-Value="model.TIndex"
DataList="Countries.CountryDictionary" class="form-control"
placeholder="Select a country"></InputDataListSelect>
</div>
<div class="col-4">
<ValidationMessage For="(() => model.TIndex)"></ValidationMessage>
</div>
</div>
<div class="row mb-2">
<div class="col-4 form-label">
Country T Index
</div>
<div class="col-4 form-control">
@this.model.TIndex
</div>
</div>
class Model
{
public string Value { get; set; } = string.Empty;
public string StrictValue { get; set; } = string.Empty;
public int Index { get; set; } = 0;
public int TIndex { get; set; } = 0;
public int Opinion { get; set; } = 0;
}
总结
构建编辑组件并非易事,但也不应令人畏惧。
我构建的示例基于 InputBase
。如果您开始构建自己的控件,我强烈建议您花点时间熟悉 InputBase
及其同级控件。代码位于 此处。
历史
- 2021 年 4 月 23 日:初始版本