ASP.NET Core Blazor 表单绑定

注意

此版本不是本文的最新版本。 对于当前版本,请参阅此文的 .NET 8 版本

警告

此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 对于当前版本,请参阅此文的 .NET 8 版本

重要

此信息与预发布产品相关,相应产品在商业发布之前可能会进行重大修改。 Microsoft 对此处提供的信息不提供任何明示或暗示的保证。

对于当前版本,请参阅此文的 .NET 8 版本

本文介绍如何在表单中使用 Blazor 绑定。

EditForm/EditContext 模型

EditForm 基于分配的对象创建了 EditContext,用作窗体中其他组件的级联值EditContext 跟踪有关编辑进程的元数据,其中包括已修改的窗体字段和当前的验证消息。 分配给 EditForm.ModelEditForm.EditContext 可以将窗体绑定到数据。

模型绑定

分配给 EditForm.Model

<EditForm ... Model="Model" ...>
    ...
</EditForm>

@code {
    [SupplyParameterFromForm]
    private Starship? Model { get; set; }

    protected override void OnInitialized() => Model ??= new();
}
<EditForm ... Model="Model" ...>
    ...
</EditForm>

@code {
    public Starship? Model { get; set; }

    protected override void OnInitialized() => Model ??= new();
}

注意

本文的大部分窗体模型示例将窗体绑定到 C# 属性,但也支持 C# 字段绑定。

上下文绑定

分配给 EditForm.EditContext

<EditForm ... EditContext="editContext" ...>
    ...
</EditForm>

@code {
    private EditContext? editContext;

    [SupplyParameterFromForm]
    private Starship? Model { get; set; }

    protected override void OnInitialized()
    {
        Model ??= new();
        editContext = new(Model);
    }
}
<EditForm ... EditContext="editContext" ...>
    ...
</EditForm>

@code {
    private EditContext? editContext;

    public Starship? Model { get; set; }

    protected override void OnInitialized()
    {
        Model ??= new();
        editContext = new(Model);
    }
}

EditContextModel 分配给 EditForm。 如果同时分配了两者,则会引发运行时错误。

支持的类型

绑定支持:

  • 基元类型
  • 集合
  • 复杂类型
  • 递归类型
  • 具有构造函数的类型
  • 枚举

还可以使用 [DataMember][IgnoreDataMember] 属性来自定义模型绑定。 使用这些属性可重命名属性、忽略属性,并根据需要标记属性。

其他绑定选项

调用 AddRazorComponents 时,可从 RazorComponentsServiceOptions 获取其他模型绑定选项:

下面演示框架分配的默认值:

builder.Services.AddRazorComponents(options =>
{
    options.FormMappingUseCurrentCulture = true;
    options.MaxFormMappingCollectionSize = 1024;
    options.MaxFormMappingErrorCount = 200;
    options.MaxFormMappingKeySize = 1024 * 2;
    options.MaxFormMappingRecursionDepth = 64;
}).AddInteractiveServerComponents();

窗体名称

使用 FormName 参数分配窗体名称。 窗体名称必须是唯一的,才能绑定模型数据。 以下窗体命名为 RomulanAle

<EditForm ... FormName="RomulanAle" ...>
    ...
</EditForm>

提供窗体名称:

  • 对于静态呈现的服务器端组件提交的所有表单都是必需的。
  • 对于交互式呈现组件提交的表单(包括 Blazor WebAssembly 应用程序中的表单和具有交互式呈现模式的组件)则不是必需的。 但是,我们建议为每个表单提供唯一的表单名称,以防止表单交互性下降时出现运行时表单发布错误。

仅当表单作为来自静态呈现的服务器端组件的传统 HTTP POST 请求发布到终结点时,才会检查表单名称。 框架不会在呈现窗体时引发异常,但仅在 HTTP POST 到达且未指定窗体名称时引发异常。

应用的根组件上方有一个未命名(空字符串)表单范围,当应用中未发生表单名称冲突时,该范围就足够了。 如果可能发生表单名称冲突,例如当包含库中的表单并且无法控制库开发人员使用的表单名称时,请在 Blazor Web App' 中提供带有 FormMappingScope 组件的表单名称范围的主要项目。

在以下示例中,HelloFormFromLibrary 组件具有名为 Hello 的表单,并且位于库中。

HelloFormFromLibrary.razor

<EditForm FormName="Hello" Model="this" OnSubmit="Submit">
    <InputText @bind-Value="Name" />
    <button type="submit">Submit</button>
</EditForm>

@if (submitted)
{
    <p>Hello @Name from the library's form!</p>
}

@code {
    bool submitted = false;

    [SupplyParameterFromForm]
    private string? Name { get; set; }

    private void Submit() => submitted = true;
}

以下 NamedFormsWithScope 组件使用库的 HelloFormFromLibrary 组件,并且还有一个名为 Hello 的表单。 对于 HelloFormFromLibrary 组件提供的任何表单,FormMappingScope 组件的范围名称为 ParentContext。 尽管本示例中的两个表单都具有表单名称 (Hello),但表单名称不会冲突,并且事件将路由到表单 POST 事件的正确表单。

NamedFormsWithScope.razor

@page "/named-forms-with-scope"

<div>Hello form from a library</div>

<FormMappingScope Name="ParentContext">
    <HelloFormFromLibrary />
</FormMappingScope>

<div>Hello form using the same form name</div>

<EditForm FormName="Hello" Model="this" OnSubmit="Submit">
    <InputText @bind-Value="Name" />
    <button type="submit">Submit</button>
</EditForm>

@if (submitted)
{
    <p>Hello @Name from the app form!</p>
}

@code {
    bool submitted = false;

    [SupplyParameterFromForm]
    private string? Name { get; set; }

    private void Submit() => submitted = true;
}

提供窗体中的参数 ([SupplyParameterFromForm])

[SupplyParameterFromForm] 属性指示应从窗体的窗体数据中提供关联属性的值。 与属性名称匹配的请求中的数据绑定到该属性。 基于 InputBase<TValue> 的输入生成与 Blazor 用于模型绑定的名称匹配的窗体值名称。 与组件参数属性 ([Parameter]) 不同,使用 [SupplyParameterFromForm] 注释的属性无需标记为 public

可以将以下窗体绑定参数指定到 [SupplyParameterFromForm] 属性

  • Name:获取或设置参数的名称。 该名称用于确定要用于匹配窗体数据的前缀,并确定是否需要绑定值。
  • FormName:获取或设置句柄的名称。 该名称用于按窗体名称将参数与窗体匹配,以确定是否需要绑定值。

以下示例按窗体名称单独将两个窗体绑定到其模型。

Starship6.razor

@page "/starship-6"
@inject ILogger<Starship6> Logger

<EditForm Model="Model1" OnSubmit="Submit1" FormName="Holodeck1">
    <div>
        <label>
            Holodeck 1 Identifier: 
            <InputText @bind-Value="Model1!.Id" />
        </label>
    </div>
    <div>
        <button type="submit">Submit</button>
    </div>
</EditForm>

<EditForm Model="Model2" OnSubmit="Submit2" FormName="Holodeck2">
    <div>
        <label>
            Holodeck 2 Identifier: 
            <InputText @bind-Value="Model2!.Id" />
        </label>
    </div>
    <div>
        <button type="submit">Submit</button>
    </div>
</EditForm>

@code {
    [SupplyParameterFromForm(FormName = "Holodeck1")]
    private Holodeck? Model1 { get; set; }

    [SupplyParameterFromForm(FormName = "Holodeck2")]
    private Holodeck? Model2 { get; set; }

    protected override void OnInitialized()
    {
        Model1 ??= new();
        Model2 ??= new();
    }

    private void Submit1() => Logger.LogInformation("Submit1: Id={Id}", Model1?.Id);

    private void Submit2() => Logger.LogInformation("Submit2: Id={Id}", Model2?.Id);

    public class Holodeck
    {
        public string? Id { get; set; }
    }
}

嵌套和绑定窗体

以下指南演示如何嵌套和绑定子窗体。

以下船舶详细信息类 (ShipDetails) 包含子窗体的说明和长度。

ShipDetails.cs

namespace BlazorSample;

public class ShipDetails
{
    public string? Description { get; set; }
    public int? Length { get; set; }
}

以下 Ship 类为标识符 (Id) 命名,并包括船舶详细信息。

Ship.cs

namespace BlazorSample
{
    public class Ship
    {
        public string? Id { get; set; }
        public ShipDetails Details { get; set; } = new();
    }
}

以下子窗体用于编辑 ShipDetails 类型的值。 这是通过继承组件顶部的 Editor<T> 来实现的。 Editor<T> 确保子组件根据模型 (T) 生成正确的窗体字段名称,以下示例中的 TShipDetails

StarshipSubform.razor

@inherits Editor<ShipDetails>

<div>
    <label>
        Description: 
        <InputText @bind-Value="Value!.Description" />
    </label>
</div>
<div>
    <label>
        Length: 
        <InputNumber @bind-Value="Value!.Length" />
    </label>
</div>

主窗体绑定到 Ship 类。 StarshipSubform 组件用于编辑作为 Model!.Details 绑定的船舶详细信息。

Starship7.razor

@page "/starship-7"
@inject ILogger<Starship7> Logger

<EditForm Model="Model" OnSubmit="Submit" FormName="Starship7">
    <div>
        <label>
            Identifier: 
            <InputText @bind-Value="Model!.Id" />
        </label>
    </div>
    <StarshipSubform @bind-Value="Model!.Details" />
    <div>
        <button type="submit">Submit</button>
    </div>
</EditForm>

@code {
    [SupplyParameterFromForm]
    private Ship? Model { get; set; }

    protected override void OnInitialized() => Model ??= new();

    private void Submit() => 
        Logger.LogInformation("Id = {Id} Desc = {Description} Length = {Length}",
            Model?.Id, Model?.Details?.Description, Model?.Details?.Length);
}

使用静态 SSR 初始化表单数据

当组件采用静态 SSR 时,OnInitialized{Async} 生命周期方法OnParametersSet{Async} 生命周期方法会在组件最初呈现和每次表单 POST 到服务器时触发。 若要初始化表单模型值,请在 OnParametersSet{Async} 中分配新模型值之前确认模型是否已包含数据,如以下示例所示。

StarshipInit.razor

@page "/starship-init"
@inject ILogger<StarshipInit> Logger

<EditForm Model="Model" OnValidSubmit="Submit" FormName="StarshipInit">
    <div>
        <label>
            Identifier:
            <InputText @bind-Value="Model!.Id" />
        </label>
    </div>
    <div>
        <button type="submit">Submit</button>
    </div>
</EditForm>

@code {
    [SupplyParameterFromForm]
    private Starship? Model { get; set; }

    protected override void OnInitialized() => Model ??= new();

    protected override void OnParametersSet()
    {
        if (Model!.Id == default)
        {
            LoadData();
        }
    }

    private void LoadData()
    {
        Model!.Id = "Set by LoadData";
    }

    private void Submit()
    {
        Logger.LogInformation("Id = {Id}", Model?.Id);
    }

    public class Starship
    {
        public string? Id { get; set; }
    }
}

高级窗体映射错误方案

框架实例化并填充窗体的 FormMappingContext,这是与给定窗体的映射操作关联的上下文。 每个映射范围(由 FormMappingScope 组件定义)实例化 FormMappingContext。 每次 [SupplyParameterFromForm] 请求值的上下文时,框架都会使用尝试的值和任何映射错误填充 FormMappingContext

开发人员不应直接与 FormMappingContext 交互,因为它主要是 InputBase<TValue>EditContext 和其他内部实现的数据源,以将映射错误显示为验证错误。 在高级自定义方案中,开发人员可以直接以 [CascadingParameter] 的形式访问 FormMappingContext,以编写使用尝试的值和映射错误的自定义代码。

自定义输入组件

对于自定义输入处理方案,以下小节演示了自定义输入组件:

建议从 InputBase<TValue> 中派生自定义输入组件,除非特定要求阻止你执行此操作。 该 InputBase<TValue> 类由 ASP.NET Core 团队积极维护,确保其最新 Blazor 功能和框架更保持最新状态。

基于 InputBase<T> 的输入组件

下面的示例组件执行以下操作:

  • 继承自 InputBase<TValue>。 继承自 InputBase<TValue> 的组件必须在 Blazor 窗体中使用 (EditForm)。
  • 从复选框获取布尔输入。
  • 绑定 (@bind:after) 后执行 AfterChange 方法时,根据复选框的状态设置其容器 <div> 的背景色。
  • 需要重写基类的 TryParseValueFromString 方法,但不处理字符串输入数据,因为复选框不提供字符串数据。 对于处理字符串输入的其他类型输入组件的 TryParseValueFromString 的示例实现,请参阅 ASP.NET Core 参考源

注意

指向 .NET 参考源的文档链接通常会加载存储库的默认分支,该分支表示针对下一个 .NET 版本的当前开发。 若要为特定版本选择标记,请使用“切换分支或标记”下拉列表。 有关详细信息,请参阅如何选择 ASP.NET Core 源代码的版本标记 (dotnet/AspNetCore.Docs #26205)

EngineeringApprovalInputDerived.razor

@using System.Diagnostics.CodeAnalysis
@inherits InputBase<bool>

<div class="@divCssClass">
    <label>
        Engineering Approval:
        <input @bind="CurrentValue" @bind:after="AfterChange" class="@CssClass" 
            type="checkbox" />
    </label>
</div>

@code {
    private string? divCssClass;

    private void AfterChange()
    {
        divCssClass = CurrentValue ? "bg-success text-white" : null;
    }

    protected override bool TryParseValueFromString(
        string? value, out bool 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)}'.");
}

要在 Starship 示例窗体 (Starship3.razor/Starship.cs) 中使用上述组件,请将工程审批字段的 <div> 块替换为与模型的 IsValidatedDesign 属性绑定的 EngineeringApprovalInputDerived 组件实例:

- <div>
-     <label>
-         Engineering Approval: 
-         <InputCheckbox @bind-Value="Model!.IsValidatedDesign" />
-     </label>
- </div>
+ <EngineeringApprovalInputDerived @bind-Value="Model!.IsValidatedDesign" />

具有开发人员完全控件的输入组件

下面的示例组件执行以下操作:

  • 不继承自 InputBase<TValue>。 该组件完全控制输入处理,包括绑定、回调和验证。 该组件可以在 Blazor 窗体内部或外部使用 (EditForm)。
  • 从复选框获取布尔输入。
  • 如果选中复选框,则更改背景色。

组件中的代码包括:

  • Value 属性与双向绑定一起使用,以获取或设置输入的值。 ValueChanged 是更新绑定值的回调。

  • 在 Blazor 窗体中使用时:

    • EditContext 是级联值
    • fieldCssClass 根据 EditContext 验证结果设置字段样式。
    • ValueExpression 是由标识绑定值的框架分配的表达式 (Expression<Func<T>>)。
    • FieldIdentifier 唯一标识可以编辑的单个字段,通常对应于模型属性。 使用标识绑定值 (ValueExpression) 的表达式创建字段标识符。
  • OnChange 事件处理程序中:

EngineeringApprovalInputStandalone.razor

@using System.Globalization
@using System.Linq.Expressions

<div class="@divCssClass">
    <label>
        Engineering Approval:
        <input class="@fieldCssClass" @onchange="OnChange" type="checkbox" 
            value="@Value" />
    </label>
</div>

@code {
    private string? divCssClass;
    private FieldIdentifier fieldIdentifier;
    private string? fieldCssClass => EditContext?.FieldCssClass(fieldIdentifier);

    [CascadingParameter]
    private EditContext? EditContext { get; set; }

    [Parameter]
    public bool? Value { get; set; }

    [Parameter]
    public EventCallback<bool> ValueChanged { get; set; }

    [Parameter]
    public Expression<Func<bool>>? ValueExpression { get; set; }

    protected override void OnInitialized()
    {
        fieldIdentifier = FieldIdentifier.Create(ValueExpression!);
    }

    private async Task OnChange(ChangeEventArgs args)
    {
        BindConverter.TryConvertToBool(args.Value, CultureInfo.CurrentCulture, 
            out var value);

        divCssClass = value ? "bg-success text-white" : null;

        await ValueChanged.InvokeAsync(value);
        EditContext?.NotifyFieldChanged(fieldIdentifier);
    }
}

要在 Starship 示例窗体 (Starship3.razor/Starship.cs) 中使用上述组件,请将工程批审批段的 <div> 块替换为与模型的 IsValidatedDesign 属性绑定的 EngineeringApprovalInputStandalone 组件实例:

- <div>
-     <label>
-         Engineering Approval: 
-         <InputCheckbox @bind-Value="Model!.IsValidatedDesign" />
-     </label>
- </div>
+ <EngineeringApprovalInputStandalone @bind-Value="Model!.IsValidatedDesign" />

EngineeringApprovalInputStandalone 组件在 EditForm 之外也可正常运行:

<EngineeringApprovalInputStandalone @bind-Value="ValidDesign" />

<div>
    <b>ValidDesign:</b> @ValidDesign
</div>

@code {
    private bool ValidDesign { get; set; }
}

单选按钮

本部分中的示例基于本文窗体示例部分的 Starfleet Starship Database 窗体(Starship3 组件)。

将以下 enum 类型添加到应用程序。 创建一个新文件用于保存这些类型,或将它们到 Starship.cs 文件中。

public class ComponentEnums
{
    public enum Manufacturer { SpaceX, NASA, ULA, VirginGalactic, Unknown }
    public enum Color { ImperialRed, SpacecruiserGreen, StarshipBlue, VoyagerOrange }
    public enum Engine { Ion, Plasma, Fusion, Warp }
}

使 ComponentEnums 类可供以下项访问:

  • Starship.cs 中的 Starship 模型(例如,using static ComponentEnums;)。
  • Starfleet Starship Database 窗体 (Starship3.razor)(例如 @using static ComponentEnums)。

结合使用 InputRadio<TValue> 组件和 InputRadioGroup<TValue> 组件以创建单选按钮组。 在以下示例中,属性被添加到输入组件一文的示例表单部分所述的 Starship 模型中:

[Required]
[Range(typeof(Manufacturer), nameof(Manufacturer.SpaceX), 
    nameof(Manufacturer.VirginGalactic), ErrorMessage = "Pick a manufacturer.")]
public Manufacturer Manufacturer { get; set; } = Manufacturer.Unknown;

[Required, EnumDataType(typeof(Color))]
public Color? Color { get; set; } = null;

[Required, EnumDataType(typeof(Engine))]
public Engine? Engine { get; set; } = null;

更新输入组件一文的示例表单部分所述的 Starfleet Starship Database 表单(Starship3 组件)。 添加组件以生成:

  • 用于选择飞船制造商的单选按钮组。
  • 用于引擎和 ship 颜色的嵌套式单选按钮组。

注意

在窗体中,嵌套的单选按钮组并不常用,因为它们可能会导致窗体控件的无序布局,而这会使用户感到困惑。 不过,在某些情况下,这些在 UI 设计中是有意义的,如以下示例所示,为两个用户输入 ship 引擎和 ship 颜色配对建议。 表单验证需要一个引擎和一种颜色。 窗体的布局使用嵌套的 InputRadioGroup<TValue> 为引擎和颜色建议配对。 但是,用户可以组合任何引擎与任何颜色以提交窗体。

注意

对于以下示例,请确保 ComponentEnums 类可供组件使用:

@using static ComponentEnums
<fieldset>
    <legend>Manufacturer</legend>
    <InputRadioGroup @bind-Value="Model!.Manufacturer">
        @foreach (var manufacturer in Enum.GetValues<Manufacturer>())
        {
            <div>
                <label>
                    <InputRadio Value="manufacturer" />
                    @manufacturer
                </label>
            </div>
        }
    </InputRadioGroup>
</fieldset>

<fieldset>
    <legend>Engine and Color</legend>
    <p>
        Engine and color pairs are recommended, but any
        combination of engine and color is allowed.
    </p>
    <InputRadioGroup Name="engine" @bind-Value="Model!.Engine">
        <InputRadioGroup Name="color" @bind-Value="Model!.Color">
            <div style="margin-bottom:5px">
                <div>
                    <label>
                        <InputRadio Name="engine" Value="Engine.Ion" />
                        Ion
                    </label>
                </div>
                <div>
                    <label>
                        <InputRadio Name="color" Value="Color.ImperialRed" />
                        Imperial Red
                    </label>
                </div>
            </div>
            <div style="margin-bottom:5px">
                <div>
                    <label>
                        <InputRadio Name="engine" Value="Engine.Plasma" />
                        Plasma
                    </label>
                </div>
                <div>
                    <label>
                        <InputRadio Name="color" Value="Color.SpacecruiserGreen" />
                        Spacecruiser Green
                    </label>
                </div>
            </div>
            <div style="margin-bottom:5px">
                <div>
                    <label>
                        <InputRadio Name="engine" Value="Engine.Fusion" />
                        Fusion
                    </label>
                </div>
                <div>
                    <label>
                        <InputRadio Name="color" Value="Color.StarshipBlue" />
                        Starship Blue
                    </label>
                </div>
            </div>
            <div style="margin-bottom:5px">
                <div>
                    <label>
                        <InputRadio Name="engine" Value="Engine.Warp" />
                        Warp
                    </label>
                </div>
                <div>
                    <label>
                        <InputRadio Name="color" Value="Color.VoyagerOrange" />
                        Voyager Orange
                    </label>
                </div>
            </div>
        </InputRadioGroup>
    </InputRadioGroup>
</fieldset>

注意

如果省略 Name,则 InputRadio<TValue> 组件按其最新上级进行分组。

如果在输入组件一文的示例表单部分所述的 Starship3 组件中实现了上述 Razor 标记,请更新 Submit 方法的日志记录:

Logger.LogInformation("Id = {Id} Description = {Description} " +
    "Classification = {Classification} MaximumAccommodation = " +
    "{MaximumAccommodation} IsValidatedDesign = " +
    "{IsValidatedDesign} ProductionDate = {ProductionDate} " +
    "Manufacturer = {Manufacturer}, Engine = {Engine}, " +
    "Color = {Color}",
    Model?.Id, Model?.Description, Model?.Classification,
    Model?.MaximumAccommodation, Model?.IsValidatedDesign,
    Model?.ProductionDate, Model?.Manufacturer, Model?.Engine, 
    Model?.Color);

使用窗体中的单选按钮时,数据绑定的处理方式与其他元素不同,因为单选按钮是作为一个组进行计算的。 每个单选按钮的值是固定的,但单选按钮组的值是所选单选按钮的值。 以下示例介绍如何:

  • 处理单选按钮组的数据绑定。
  • 使用自定义 InputRadio<TValue> 组件支持验证。

InputRadio.razor

@using System.Globalization
@inherits InputBase<TValue>
@typeparam TValue

<input @attributes="AdditionalAttributes" type="radio" value="@SelectedValue" 
       checked="@(SelectedValue.Equals(Value))" @onchange="OnChange" />

@code {
    [Parameter]
    public TValue SelectedValue { get; set; }

    private void OnChange(ChangeEventArgs args)
    {
        CurrentValueAsString = args.Value.ToString();
    }

    protected override bool TryParseValueFromString(string value, 
        out TValue result, out string errorMessage)
    {
        var success = BindConverter.TryConvertTo<TValue>(
            value, CultureInfo.CurrentCulture, out var parsedValue);
        if (success)
        {
            result = parsedValue;
            errorMessage = null;

            return true;
        }
        else
        {
            result = default;
            errorMessage = "The field isn't valid.";

            return false;
        }
    }
}

如需详细了解泛型类型参数 (@typeparam),请参阅以下文章:

使用以下示例模型。

StarshipModel.cs

using System.ComponentModel.DataAnnotations;

namespace BlazorServer80
{
    public class Model
    {
        [Range(1, 5)]
        public int Rating { get; set; }
    }
}

以下 RadioButtonExample 组件使用上述的 InputRadio 组件获取和验证用户的评级:

RadioButtonExample.razor

@page "/radio-button-example"
@using System.ComponentModel.DataAnnotations
@using Microsoft.Extensions.Logging
@inject ILogger<RadioButtonExample> Logger

<h1>Radio Button Example</h1>

<EditForm Model="Model" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    @for (int i = 1; i <= 5; i++)
    {
        <div>
            <label>
                <InputRadio name="rate" SelectedValue="i" 
                    @bind-Value="Model.Rating" />
                @i
            </label>
        </div>
    }

    <div>
        <button type="submit">Submit</button>
    </div>
</EditForm>

<div>@Model.Rating</div>

@code {
    public StarshipModel Model { get; set; }

    protected override void OnInitialized() => Model ??= new();

    private void HandleValidSubmit()
    {
        Logger.LogInformation("HandleValidSubmit called");
    }
}