diff --git a/src/Components/Web/src/Forms/FieldIdGenerator.cs b/src/Components/Web/src/Forms/FieldIdGenerator.cs
new file mode 100644
index 000000000000..f20b2e23dd2f
--- /dev/null
+++ b/src/Components/Web/src/Forms/FieldIdGenerator.cs
@@ -0,0 +1,80 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Buffers;
+using System.Text;
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+///
+/// Provides methods for generating valid HTML id attribute values from field names.
+///
+internal static class FieldIdGenerator
+{
+ // Valid characters for HTML 4.01 id attributes (excluding '.' to avoid CSS selector conflicts)
+ // See: https://www.w3.org/TR/html401/types.html#type-id
+ private static readonly SearchValues ValidIdChars =
+ SearchValues.Create("-0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz");
+
+ ///
+ /// Sanitizes a field name to create a valid HTML id attribute value.
+ ///
+ /// The field name to sanitize.
+ /// A valid HTML id attribute value, or an empty string if the input is null or empty.
+ ///
+ /// This method follows HTML 4.01 id attribute rules:
+ /// - The first character must be a letter (A-Z, a-z)
+ /// - Subsequent characters can be letters, digits, hyphens, underscores, colons, or periods
+ /// - Periods are replaced with underscores to avoid CSS selector conflicts
+ ///
+ public static string SanitizeHtmlId(string? fieldName)
+ {
+ if (string.IsNullOrEmpty(fieldName))
+ {
+ return string.Empty;
+ }
+
+ // Fast path: check if sanitization is needed
+ var firstChar = fieldName[0];
+ var startsWithLetter = char.IsAsciiLetter(firstChar);
+ var indexOfInvalidChar = fieldName.AsSpan(1).IndexOfAnyExcept(ValidIdChars);
+
+ if (startsWithLetter && indexOfInvalidChar < 0)
+ {
+ return fieldName;
+ }
+
+ // Slow path: build sanitized string
+ var result = new StringBuilder(fieldName.Length);
+
+ // First character must be a letter
+ if (startsWithLetter)
+ {
+ result.Append(firstChar);
+ }
+ else
+ {
+ result.Append('z');
+ if (IsValidIdChar(firstChar))
+ {
+ result.Append(firstChar);
+ }
+ else
+ {
+ result.Append('_');
+ }
+ }
+
+ // Process remaining characters
+ for (var i = 1; i < fieldName.Length; i++)
+ {
+ var c = fieldName[i];
+ result.Append(IsValidIdChar(c) ? c : '_');
+ }
+
+ return result.ToString();
+ }
+
+ private static bool IsValidIdChar(char c)
+ => ValidIdChars.Contains(c);
+}
diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs
index c745da39cb82..cd8d7ee8ac50 100644
--- a/src/Components/Web/src/Forms/InputBase.cs
+++ b/src/Components/Web/src/Forms/InputBase.cs
@@ -220,6 +220,26 @@ protected string NameAttributeValue
}
}
+ ///
+ /// Gets the value to be used for the input's "id" attribute.
+ ///
+ ///
+ /// If an explicit "id" is provided via , that value takes precedence.
+ /// Otherwise, the id is derived from with invalid characters sanitized.
+ ///
+ protected string IdAttributeValue
+ {
+ get
+ {
+ if (AdditionalAttributes?.TryGetValue("id", out var idAttributeValue) ?? false)
+ {
+ return Convert.ToString(idAttributeValue, CultureInfo.InvariantCulture) ?? string.Empty;
+ }
+
+ return FieldIdGenerator.SanitizeHtmlId(NameAttributeValue);
+ }
+ }
+
///
public override Task SetParametersAsync(ParameterView parameters)
{
diff --git a/src/Components/Web/src/Forms/InputCheckbox.cs b/src/Components/Web/src/Forms/InputCheckbox.cs
index 9538b031960e..fe5c31f89ff0 100644
--- a/src/Components/Web/src/Forms/InputCheckbox.cs
+++ b/src/Components/Web/src/Forms/InputCheckbox.cs
@@ -34,17 +34,18 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.OpenElement(0, "input");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "type", "checkbox");
- builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue);
- builder.AddAttribute(4, "class", CssClass);
- builder.AddAttribute(5, "checked", BindConverter.FormatValue(CurrentValue));
+ builder.AddAttributeIfNotNullOrEmpty(3, "id", IdAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue);
+ builder.AddAttribute(5, "class", CssClass);
+ builder.AddAttribute(6, "checked", BindConverter.FormatValue(CurrentValue));
// Include the "value" attribute so that when this is posted by a form, "true"
// is included in the form fields. That's how works normally.
// It sends the "on" value when the checkbox is checked, and nothing otherwise.
- builder.AddAttribute(6, "value", bool.TrueString);
+ builder.AddAttribute(7, "value", bool.TrueString);
- builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue));
+ builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue));
builder.SetUpdatesAttributeName("checked");
- builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference);
+ builder.AddElementReferenceCapture(9, __inputReference => Element = __inputReference);
builder.CloseElement();
}
diff --git a/src/Components/Web/src/Forms/InputDate.cs b/src/Components/Web/src/Forms/InputDate.cs
index 4498dd539b48..3a7b1ba960ca 100644
--- a/src/Components/Web/src/Forms/InputDate.cs
+++ b/src/Components/Web/src/Forms/InputDate.cs
@@ -86,12 +86,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.OpenElement(0, "input");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "type", _typeAttributeValue);
- builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue);
- builder.AddAttribute(4, "class", CssClass);
- builder.AddAttribute(5, "value", CurrentValueAsString);
- builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
+ builder.AddAttributeIfNotNullOrEmpty(3, "id", IdAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue);
+ builder.AddAttribute(5, "class", CssClass);
+ builder.AddAttribute(6, "value", CurrentValueAsString);
+ builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
- builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference);
+ builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference);
builder.CloseElement();
}
diff --git a/src/Components/Web/src/Forms/InputHidden.cs b/src/Components/Web/src/Forms/InputHidden.cs
index d2f95d1b6272..9e5cac1b030c 100644
--- a/src/Components/Web/src/Forms/InputHidden.cs
+++ b/src/Components/Web/src/Forms/InputHidden.cs
@@ -25,12 +25,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.OpenElement(0, "input");
builder.AddAttribute(1, "type", "hidden");
builder.AddMultipleAttributes(2, AdditionalAttributes);
- builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue);
- builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass);
- builder.AddAttribute(5, "value", CurrentValueAsString);
- builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
+ builder.AddAttributeIfNotNullOrEmpty(3, "id", IdAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(5, "class", CssClass);
+ builder.AddAttribute(6, "value", CurrentValueAsString);
+ builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
- builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference);
+ builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference);
builder.CloseElement();
}
diff --git a/src/Components/Web/src/Forms/InputNumber.cs b/src/Components/Web/src/Forms/InputNumber.cs
index b346b4b39772..c2e9a1ebef50 100644
--- a/src/Components/Web/src/Forms/InputNumber.cs
+++ b/src/Components/Web/src/Forms/InputNumber.cs
@@ -55,12 +55,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttribute(1, "step", _stepAttributeValue);
builder.AddAttribute(2, "type", "number");
builder.AddMultipleAttributes(3, AdditionalAttributes);
- builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue);
- builder.AddAttributeIfNotNullOrEmpty(5, "class", CssClass);
- builder.AddAttribute(6, "value", CurrentValueAsString);
- builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
+ builder.AddAttributeIfNotNullOrEmpty(4, "id", IdAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(5, "name", NameAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(6, "class", CssClass);
+ builder.AddAttribute(7, "value", CurrentValueAsString);
+ builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
- builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference);
+ builder.AddElementReferenceCapture(9, __inputReference => Element = __inputReference);
builder.CloseElement();
}
diff --git a/src/Components/Web/src/Forms/InputSelect.cs b/src/Components/Web/src/Forms/InputSelect.cs
index 1fa8ecb1df72..f5f3f82a5c40 100644
--- a/src/Components/Web/src/Forms/InputSelect.cs
+++ b/src/Components/Web/src/Forms/InputSelect.cs
@@ -40,25 +40,26 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "select");
builder.AddMultipleAttributes(1, AdditionalAttributes);
- builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue);
- builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass);
- builder.AddAttribute(4, "multiple", _isMultipleSelect);
+ builder.AddAttributeIfNotNullOrEmpty(2, "id", IdAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass);
+ builder.AddAttribute(5, "multiple", _isMultipleSelect);
if (_isMultipleSelect)
{
- builder.AddAttribute(5, "value", BindConverter.FormatValue(CurrentValue)?.ToString());
- builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, SetCurrentValueAsStringArray, default));
+ builder.AddAttribute(6, "value", BindConverter.FormatValue(CurrentValue)?.ToString());
+ builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, SetCurrentValueAsStringArray, default));
builder.SetUpdatesAttributeName("value");
}
else
{
- builder.AddAttribute(7, "value", CurrentValueAsString);
- builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, default));
+ builder.AddAttribute(8, "value", CurrentValueAsString);
+ builder.AddAttribute(9, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, default));
builder.SetUpdatesAttributeName("value");
}
- builder.AddElementReferenceCapture(9, __selectReference => Element = __selectReference);
- builder.AddContent(10, ChildContent);
+ builder.AddElementReferenceCapture(10, __selectReference => Element = __selectReference);
+ builder.AddContent(11, ChildContent);
builder.CloseElement();
}
diff --git a/src/Components/Web/src/Forms/InputText.cs b/src/Components/Web/src/Forms/InputText.cs
index c3eb9b5f1d89..83784626308f 100644
--- a/src/Components/Web/src/Forms/InputText.cs
+++ b/src/Components/Web/src/Forms/InputText.cs
@@ -33,12 +33,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "input");
builder.AddMultipleAttributes(1, AdditionalAttributes);
- builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue);
- builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass);
- builder.AddAttribute(4, "value", CurrentValueAsString);
- builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
+ builder.AddAttributeIfNotNullOrEmpty(2, "id", IdAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass);
+ builder.AddAttribute(5, "value", CurrentValueAsString);
+ builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
- builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference);
+ builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference);
builder.CloseElement();
}
diff --git a/src/Components/Web/src/Forms/InputTextArea.cs b/src/Components/Web/src/Forms/InputTextArea.cs
index 2495ce3d07f7..5d402ca84742 100644
--- a/src/Components/Web/src/Forms/InputTextArea.cs
+++ b/src/Components/Web/src/Forms/InputTextArea.cs
@@ -33,12 +33,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "textarea");
builder.AddMultipleAttributes(1, AdditionalAttributes);
- builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue);
- builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass);
- builder.AddAttribute(4, "value", CurrentValueAsString);
- builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
+ builder.AddAttributeIfNotNullOrEmpty(2, "id", IdAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue);
+ builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass);
+ builder.AddAttribute(5, "value", CurrentValueAsString);
+ builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
- builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference);
+ builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference);
builder.CloseElement();
}
diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs
new file mode 100644
index 000000000000..3c3f5b5af5b3
--- /dev/null
+++ b/src/Components/Web/src/Forms/Label.cs
@@ -0,0 +1,117 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq.Expressions;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+///
+/// Renders a <label> element for a specified field, reading the display name from
+/// or
+/// if present, or falling back to the property name.
+///
+///
+///
+/// The component supports two usage patterns:
+///
+///
+/// Nested (wrapping) pattern: When is provided, the label wraps
+/// the input component, providing implicit HTML association without requiring matching for/id attributes.
+///
+///
+/// Non-nested pattern: When is not provided, the label renders
+/// with a for attribute matching the field expression. The corresponding input component must have
+/// a matching id attribute (which is automatically generated by input components derived from
+/// ).
+///
+///
+/// The type of the field.
+public class Label : IComponent
+{
+ private RenderHandle _renderHandle;
+ private string? _displayName;
+ private string? _fieldId;
+
+ [CascadingParameter] private HtmlFieldPrefix FieldPrefix { get; set; } = default!;
+
+ ///
+ /// Specifies the field for which the label should be rendered.
+ ///
+ [Parameter, EditorRequired]
+ public Expression>? For { get; set; }
+
+ ///
+ /// Gets or sets the child content to be rendered inside the label element.
+ /// Typically this contains an input component that will be implicitly associated with the label.
+ ///
+ [Parameter]
+ public RenderFragment? ChildContent { get; set; }
+
+ ///
+ /// Gets or sets a collection of additional attributes that will be applied to the label element.
+ ///
+ [Parameter(CaptureUnmatchedValues = true)]
+ public IReadOnlyDictionary? AdditionalAttributes { get; set; }
+
+ ///
+ void IComponent.Attach(RenderHandle renderHandle)
+ {
+ _renderHandle = renderHandle;
+ }
+
+ ///
+ Task IComponent.SetParametersAsync(ParameterView parameters)
+ {
+ var previousFor = For;
+ var previousChildContent = ChildContent;
+ var previousAdditionalAttributes = AdditionalAttributes;
+
+ parameters.SetParameterProperties(this);
+
+ if (For is null)
+ {
+ throw new InvalidOperationException($"{GetType()} requires a value for the " +
+ $"{nameof(For)} parameter.");
+ }
+
+ // Only recalculate display name and field id if the expression changed
+ var displayNameChanged = false;
+ if (For != previousFor)
+ {
+ var newDisplayName = ExpressionMemberAccessor.GetDisplayName(For);
+ if (newDisplayName != _displayName)
+ {
+ _displayName = newDisplayName;
+ displayNameChanged = true;
+ }
+ _fieldId = FieldIdGenerator.SanitizeHtmlId(
+ FieldPrefix != null ? FieldPrefix.GetFieldName(For) :
+ ExpressionFormatter.FormatLambda(For));
+ }
+
+ var otherParamsChanged = ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes;
+ if (displayNameChanged || otherParamsChanged)
+ {
+ _renderHandle.Render(BuildRenderTree);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "label");
+
+ // For non-nested usage (no ChildContent), add 'for' attribute to associate with input by id
+ if (ChildContent is null)
+ {
+ builder.AddAttribute(1, "for", _fieldId);
+ }
+
+ builder.AddMultipleAttributes(2, AdditionalAttributes);
+ builder.AddContent(3, _displayName);
+ builder.AddContent(4, ChildContent);
+ builder.CloseElement();
+ }
+}
diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt
index 96e56989f2a9..e5bc7df25502 100644
--- a/src/Components/Web/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Web/src/PublicAPI.Unshipped.txt
@@ -74,3 +74,12 @@ Microsoft.AspNetCore.Components.Forms.DisplayName
Microsoft.AspNetCore.Components.Forms.DisplayName.DisplayName() -> void
Microsoft.AspNetCore.Components.Forms.DisplayName.For.get -> System.Linq.Expressions.Expression!>?
Microsoft.AspNetCore.Components.Forms.DisplayName.For.set -> void
+Microsoft.AspNetCore.Components.Forms.InputBase.IdAttributeValue.get -> string!
+Microsoft.AspNetCore.Components.Forms.Label
+Microsoft.AspNetCore.Components.Forms.Label.AdditionalAttributes.get -> System.Collections.Generic.IReadOnlyDictionary?
+Microsoft.AspNetCore.Components.Forms.Label.AdditionalAttributes.set -> void
+Microsoft.AspNetCore.Components.Forms.Label.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment?
+Microsoft.AspNetCore.Components.Forms.Label.ChildContent.set -> void
+Microsoft.AspNetCore.Components.Forms.Label.For.get -> System.Linq.Expressions.Expression!>?
+Microsoft.AspNetCore.Components.Forms.Label.For.set -> void
+Microsoft.AspNetCore.Components.Forms.Label.Label() -> void
diff --git a/src/Components/Web/test/Forms/FieldIdGeneratorTest.cs b/src/Components/Web/test/Forms/FieldIdGeneratorTest.cs
new file mode 100644
index 000000000000..abdf6c0f8140
--- /dev/null
+++ b/src/Components/Web/test/Forms/FieldIdGeneratorTest.cs
@@ -0,0 +1,82 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+public class FieldIdGeneratorTest
+{
+ [Theory]
+ [InlineData(null, "")]
+ [InlineData("", "")]
+ [InlineData("Name", "Name")]
+ [InlineData("name", "name")]
+ [InlineData("Model.Property", "Model_Property")]
+ [InlineData("Model.Address.Street", "Model_Address_Street")]
+ [InlineData("Items[0]", "Items_0_")]
+ [InlineData("Items[0].Name", "Items_0__Name")]
+ [InlineData("Model.Items[0].Name", "Model_Items_0__Name")]
+ public void SanitizeHtmlId_ProducesValidId(string? input, string expected)
+ {
+ // Act
+ var result = FieldIdGenerator.SanitizeHtmlId(input);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizeHtmlId_StartsWithNonLetter_PrependsZ()
+ {
+ // Arrange
+ var input = "123Name";
+
+ // Act
+ var result = FieldIdGenerator.SanitizeHtmlId(input);
+
+ // Assert
+ Assert.StartsWith("z", result);
+ Assert.Equal("z123Name", result);
+ }
+
+ [Fact]
+ public void SanitizeHtmlId_StartsWithInvalidChar_PrependsZAndReplaces()
+ {
+ // Arrange
+ var input = ".Property";
+
+ // Act
+ var result = FieldIdGenerator.SanitizeHtmlId(input);
+
+ // Assert
+ Assert.StartsWith("z", result);
+ Assert.Equal("z_Property", result);
+ }
+
+ [Fact]
+ public void SanitizeHtmlId_AllowsHyphensUnderscoresColons()
+ {
+ // Arrange
+ var input = "my-field_name:value";
+
+ // Act
+ var result = FieldIdGenerator.SanitizeHtmlId(input);
+
+ // Assert
+ Assert.Equal("my-field_name:value", result);
+ }
+
+ [Fact]
+ public void SanitizeHtmlId_ReplacesSpacesWithUnderscores()
+ {
+ // Arrange
+ var input = "Field Name";
+
+ // Act
+ var result = FieldIdGenerator.SanitizeHtmlId(input);
+
+ // Assert
+ Assert.Equal("Field_Name", result);
+ }
+}
diff --git a/src/Components/Web/test/Forms/InputCheckboxTest.cs b/src/Components/Web/test/Forms/InputCheckboxTest.cs
new file mode 100644
index 000000000000..c35a95c08d20
--- /dev/null
+++ b/src/Components/Web/test/Forms/InputCheckboxTest.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+public class InputCheckboxTest
+{
+ private readonly TestRenderer _testRenderer = new TestRenderer();
+
+ [Fact]
+ public async Task InputElementIsAssignedSuccessfully()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.BoolProperty,
+ };
+
+ var inputCheckboxComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
+
+ Assert.NotNull(inputCheckboxComponent.Element);
+ }
+
+ [Fact]
+ public async Task RendersIdAttribute()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.BoolProperty,
+ };
+
+ var componentId = await RenderAndGetInputCheckboxComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("model_BoolProperty", idAttribute.AttributeValue);
+ }
+
+ [Fact]
+ public async Task ExplicitIdOverridesGenerated()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.BoolProperty,
+ AdditionalAttributes = new Dictionary { { "id", "custom-checkbox-id" } }
+ };
+
+ var componentId = await RenderAndGetInputCheckboxComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("custom-checkbox-id", idAttribute.AttributeValue);
+ }
+
+ private async Task RenderAndGetInputCheckboxComponentIdAsync(TestInputHostComponent hostComponent)
+ {
+ var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent);
+ await _testRenderer.RenderRootComponentAsync(hostComponentId);
+ var batch = _testRenderer.Batches.Single();
+ return batch.GetComponentFrames().Single().ComponentId;
+ }
+
+ private class TestModel
+ {
+ public bool BoolProperty { get; set; }
+ }
+}
diff --git a/src/Components/Web/test/Forms/InputDateTest.cs b/src/Components/Web/test/Forms/InputDateTest.cs
index a698e550be1a..4192bf1f218b 100644
--- a/src/Components/Web/test/Forms/InputDateTest.cs
+++ b/src/Components/Web/test/Forms/InputDateTest.cs
@@ -1,10 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
namespace Microsoft.AspNetCore.Components.Forms;
public class InputDateTest
{
+ private readonly TestRenderer _testRenderer = new TestRenderer();
+
[Fact]
public async Task ValidationErrorUsesDisplayAttributeName()
{
@@ -49,6 +54,49 @@ public async Task InputElementIsAssignedSuccessfully()
Assert.NotNull(inputSelectComponent.Element);
}
+ [Fact]
+ public async Task RendersIdAttribute()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.DateProperty,
+ };
+
+ var componentId = await RenderAndGetInputDateComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("model_DateProperty", idAttribute.AttributeValue);
+ }
+
+ [Fact]
+ public async Task ExplicitIdOverridesGenerated()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.DateProperty,
+ AdditionalAttributes = new Dictionary { { "id", "custom-date-id" } }
+ };
+
+ var componentId = await RenderAndGetInputDateComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("custom-date-id", idAttribute.AttributeValue);
+ }
+
+ private async Task RenderAndGetInputDateComponentIdAsync(TestInputHostComponent hostComponent)
+ {
+ var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent);
+ await _testRenderer.RenderRootComponentAsync(hostComponentId);
+ var batch = _testRenderer.Batches.Single();
+ return batch.GetComponentFrames().Single().ComponentId;
+ }
+
private class TestModel
{
public DateTime DateProperty { get; set; }
diff --git a/src/Components/Web/test/Forms/InputHiddenTest.cs b/src/Components/Web/test/Forms/InputHiddenTest.cs
index 73cf7d5414f8..f643dc4b1db7 100644
--- a/src/Components/Web/test/Forms/InputHiddenTest.cs
+++ b/src/Components/Web/test/Forms/InputHiddenTest.cs
@@ -1,14 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
namespace Microsoft.AspNetCore.Components.Forms;
public class InputHiddenTest
{
+ private readonly TestRenderer _testRenderer = new TestRenderer();
+
[Fact]
public async Task InputElementIsAssignedSuccessfully()
{
- // Arrange
var model = new TestModel();
var rootComponent = new TestInputHostComponent
{
@@ -16,13 +20,54 @@ public async Task InputElementIsAssignedSuccessfully()
ValueExpression = () => model.StringProperty,
};
- // Act
var inputHiddenComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
- // Assert
Assert.NotNull(inputHiddenComponent.Element);
}
+ [Fact]
+ public async Task RendersIdAttribute()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.StringProperty,
+ };
+
+ var componentId = await RenderAndGetInputHiddenComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("model_StringProperty", idAttribute.AttributeValue);
+ }
+
+ [Fact]
+ public async Task ExplicitIdOverridesGenerated()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.StringProperty,
+ AdditionalAttributes = new Dictionary { { "id", "custom-hidden-id" } }
+ };
+
+ var componentId = await RenderAndGetInputHiddenComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("custom-hidden-id", idAttribute.AttributeValue);
+ }
+
+ private async Task RenderAndGetInputHiddenComponentIdAsync(TestInputHostComponent hostComponent)
+ {
+ var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent);
+ await _testRenderer.RenderRootComponentAsync(hostComponentId);
+ var batch = _testRenderer.Batches.Single();
+ return batch.GetComponentFrames().Single().ComponentId;
+ }
+
private class TestModel
{
public string StringProperty { get; set; }
diff --git a/src/Components/Web/test/Forms/InputNumberTest.cs b/src/Components/Web/test/Forms/InputNumberTest.cs
index 12a7891b4bb4..dc4f76ce4acc 100644
--- a/src/Components/Web/test/Forms/InputNumberTest.cs
+++ b/src/Components/Web/test/Forms/InputNumberTest.cs
@@ -93,6 +93,41 @@ public async Task UserDefinedTypeAttributeOverridesDefault()
Assert.Equal("range", typeAttributeFrame.AttributeValue);
}
+ [Fact]
+ public async Task RendersIdAttribute()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.SomeNumber,
+ };
+
+ var componentId = await RenderAndGetTestInputNumberComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("model_SomeNumber", idAttribute.AttributeValue);
+ }
+
+ [Fact]
+ public async Task ExplicitIdOverridesGenerated()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.SomeNumber,
+ AdditionalAttributes = new Dictionary { { "id", "custom-number-id" } }
+ };
+
+ var componentId = await RenderAndGetTestInputNumberComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("custom-number-id", idAttribute.AttributeValue);
+ }
+
private async Task RenderAndGetTestInputNumberComponentIdAsync(TestInputHostComponent hostComponent)
{
var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent);
diff --git a/src/Components/Web/test/Forms/InputRenderer.cs b/src/Components/Web/test/Forms/InputRenderer.cs
index 6645beabe012..20e6f804c3fd 100644
--- a/src/Components/Web/test/Forms/InputRenderer.cs
+++ b/src/Components/Web/test/Forms/InputRenderer.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+#nullable enable
+
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Test.Helpers;
@@ -8,6 +10,8 @@ namespace Microsoft.AspNetCore.Components.Forms;
internal static class InputRenderer
{
+ private static TestRenderer? _testRenderer;
+
public static async Task RenderAndGetComponent(TestInputHostComponent hostComponent)
where TComponent : InputBase
{
@@ -17,6 +21,20 @@ public static async Task RenderAndGetComponent(T
return FindComponent(testRenderer.Batches.Single());
}
+ public static async Task RenderAndGetId(TestInputHostComponent hostComponent)
+ where TComponent : InputBase
+ {
+ _testRenderer = new TestRenderer();
+ var componentId = _testRenderer.AssignRootComponentId(hostComponent);
+ await _testRenderer.RenderRootComponentAsync(componentId);
+ return componentId;
+ }
+
+ public static ArrayRange GetCurrentRenderTreeFrames(int componentId)
+ {
+ return _testRenderer!.GetCurrentRenderTreeFrames(componentId);
+ }
+
private static TComponent FindComponent(CapturedBatch batch)
=> batch.ReferenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Component)
diff --git a/src/Components/Web/test/Forms/InputSelectTest.cs b/src/Components/Web/test/Forms/InputSelectTest.cs
index 8cd1b712e1cd..a2d6d0112138 100644
--- a/src/Components/Web/test/Forms/InputSelectTest.cs
+++ b/src/Components/Web/test/Forms/InputSelectTest.cs
@@ -1,10 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
namespace Microsoft.AspNetCore.Components.Forms;
public class InputSelectTest
{
+ private readonly TestRenderer _testRenderer = new TestRenderer();
+
[Fact]
public async Task ParsesCurrentValueWhenUsingNotNullableEnumWithNotEmptyValue()
{
@@ -208,6 +213,49 @@ public async Task InputElementIsAssignedSuccessfully()
Assert.NotNull(inputSelectComponent.Element);
}
+ [Fact]
+ public async Task RendersIdAttribute()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent>
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.NotNullableEnum,
+ };
+
+ var componentId = await RenderAndGetInputSelectComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("model_NotNullableEnum", idAttribute.AttributeValue);
+ }
+
+ [Fact]
+ public async Task ExplicitIdOverridesGenerated()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent>
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.NotNullableEnum,
+ AdditionalAttributes = new Dictionary { { "id", "custom-select-id" } }
+ };
+
+ var componentId = await RenderAndGetInputSelectComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("custom-select-id", idAttribute.AttributeValue);
+ }
+
+ private async Task RenderAndGetInputSelectComponentIdAsync(TestInputHostComponent> hostComponent)
+ {
+ var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent);
+ await _testRenderer.RenderRootComponentAsync(hostComponentId);
+ var batch = _testRenderer.Batches.Single();
+ return batch.GetComponentFrames>().Single().ComponentId;
+ }
+
enum TestEnum
{
One,
diff --git a/src/Components/Web/test/Forms/InputTextAreaTest.cs b/src/Components/Web/test/Forms/InputTextAreaTest.cs
index 9337fa03e073..a63ac99697c4 100644
--- a/src/Components/Web/test/Forms/InputTextAreaTest.cs
+++ b/src/Components/Web/test/Forms/InputTextAreaTest.cs
@@ -1,14 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
namespace Microsoft.AspNetCore.Components.Forms;
public class InputTextAreaTest
{
+ private readonly TestRenderer _testRenderer = new TestRenderer();
+
[Fact]
public async Task InputElementIsAssignedSuccessfully()
{
- // Arrange
var model = new TestModel();
var rootComponent = new TestInputHostComponent
{
@@ -16,13 +20,54 @@ public async Task InputElementIsAssignedSuccessfully()
ValueExpression = () => model.StringProperty,
};
- // Act
var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
- // Assert
Assert.NotNull(inputSelectComponent.Element);
}
+ [Fact]
+ public async Task RendersIdAttribute()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.StringProperty,
+ };
+
+ var componentId = await RenderAndGetInputTextAreaComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("model_StringProperty", idAttribute.AttributeValue);
+ }
+
+ [Fact]
+ public async Task ExplicitIdOverridesGenerated()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.StringProperty,
+ AdditionalAttributes = new Dictionary { { "id", "custom-textarea-id" } }
+ };
+
+ var componentId = await RenderAndGetInputTextAreaComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("custom-textarea-id", idAttribute.AttributeValue);
+ }
+
+ private async Task RenderAndGetInputTextAreaComponentIdAsync(TestInputHostComponent hostComponent)
+ {
+ var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent);
+ await _testRenderer.RenderRootComponentAsync(hostComponentId);
+ var batch = _testRenderer.Batches.Single();
+ return batch.GetComponentFrames().Single().ComponentId;
+ }
+
private class TestModel
{
public string StringProperty { get; set; }
diff --git a/src/Components/Web/test/Forms/InputTextTest.cs b/src/Components/Web/test/Forms/InputTextTest.cs
index de5dd3754220..f5850029183f 100644
--- a/src/Components/Web/test/Forms/InputTextTest.cs
+++ b/src/Components/Web/test/Forms/InputTextTest.cs
@@ -1,10 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
namespace Microsoft.AspNetCore.Components.Forms;
public class InputTextTest
{
+ private readonly TestRenderer _testRenderer = new TestRenderer();
+
[Fact]
public async Task InputElementIsAssignedSuccessfully()
{
@@ -23,6 +28,49 @@ public async Task InputElementIsAssignedSuccessfully()
Assert.NotNull(inputSelectComponent.Element);
}
+ [Fact]
+ public async Task RendersIdAttribute()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.StringProperty,
+ };
+
+ var componentId = await RenderAndGetInputTextComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("model_StringProperty", idAttribute.AttributeValue);
+ }
+
+ [Fact]
+ public async Task ExplicitIdOverridesGenerated()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestInputHostComponent
+ {
+ EditContext = new EditContext(model),
+ ValueExpression = () => model.StringProperty,
+ AdditionalAttributes = new Dictionary { { "id", "custom-id" } }
+ };
+
+ var componentId = await RenderAndGetInputTextComponentIdAsync(rootComponent);
+ var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId);
+
+ var idAttribute = frames.Array.First(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id");
+ Assert.Equal("custom-id", idAttribute.AttributeValue);
+ }
+
+ private async Task RenderAndGetInputTextComponentIdAsync(TestInputHostComponent hostComponent)
+ {
+ var hostComponentId = _testRenderer.AssignRootComponentId(hostComponent);
+ await _testRenderer.RenderRootComponentAsync(hostComponentId);
+ var batch = _testRenderer.Batches.Single();
+ return batch.GetComponentFrames().Single().ComponentId;
+ }
+
private class TestModel
{
public string StringProperty { get; set; }
diff --git a/src/Components/Web/test/Forms/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs
new file mode 100644
index 000000000000..96d529e71f07
--- /dev/null
+++ b/src/Components/Web/test/Forms/LabelTest.cs
@@ -0,0 +1,512 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Linq.Expressions;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+public class LabelTest
+{
+ [Fact]
+ public async Task RendersLabelElement()
+ {
+ var model = new TestModel();
+ var rootComponent = new TestHostComponent
+ {
+ InnerContent = builder =>
+ {
+ builder.OpenComponent