From b7347ca2f9aa4bcfe0550fe0ddae3afb138fae73 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 19 Dec 2025 13:40:58 +0100 Subject: [PATCH 01/10] Implement nested label rendering. --- src/Components/Web/src/Forms/Label.cs | 79 +++++ .../Web/src/PublicAPI.Unshipped.txt | 8 + src/Components/Web/test/Forms/LabelTest.cs | 325 ++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 src/Components/Web/src/Forms/Label.cs create mode 100644 src/Components/Web/test/Forms/LabelTest.cs diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs new file mode 100644 index 000000000000..43e8d6c1a66b --- /dev/null +++ b/src/Components/Web/src/Forms/Label.cs @@ -0,0 +1,79 @@ +// 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 label wraps its child content (typically an input component), providing implicit association +/// without requiring matching for/id attributes. +/// +/// The type of the field. +public class Label : IComponent +{ + private RenderHandle _renderHandle; + private Expression>? _previousFieldAccessor; + private string? _displayName; + + /// + /// 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) + { + parameters.SetParameterProperties(this); + + if (For is null) + { + throw new InvalidOperationException($"{GetType()} requires a value for the " + + $"{nameof(For)} parameter."); + } + + // Only recalculate if the expression changed + if (For != _previousFieldAccessor) + { + _displayName = ExpressionMemberAccessor.GetDisplayName(For); + _previousFieldAccessor = For; + } + + _renderHandle.Render(BuildRenderTree); + + return Task.CompletedTask; + } + + private void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "label"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + builder.AddContent(2, _displayName); + builder.AddContent(3, ChildContent); + builder.CloseElement(); + } +} diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 96e56989f2a9..33e9c4bcbda5 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -74,3 +74,11 @@ 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.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/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs new file mode 100644 index 000000000000..b1fdfe5d7041 --- /dev/null +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -0,0 +1,325 @@ +// 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 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>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var labelElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "label"); + Assert.Equal("label", labelElement.ElementName); + } + + [Fact] + public async Task DisplaysDisplayAttributeNameAsContent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithDisplayAttribute)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Custom Display Name", textFrame.TextContent); + } + + [Fact] + public async Task DisplaysPropertyNameWhenNoAttributePresent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("PlainProperty", textFrame.TextContent); + } + + [Fact] + public async Task DisplaysDisplayNameAttributeName() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithDisplayNameAttribute)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Custom DisplayName", textFrame.TextContent); + } + + [Fact] + public async Task DisplayAttributeTakesPrecedenceOverDisplayNameAttribute() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithBothAttributes)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Display Takes Precedence", textFrame.TextContent); + } + + [Fact] + public async Task AppliesAdditionalAttributes() + { + var model = new TestModel(); + var additionalAttributes = new Dictionary + { + { "class", "form-label" }, + { "data-testid", "my-label" } + }; + + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var classAttribute = frames.FirstOrDefault(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "class"); + Assert.NotNull(classAttribute.AttributeName); + Assert.Equal("form-label", classAttribute.AttributeValue); + + var dataAttribute = frames.FirstOrDefault(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "data-testid"); + Assert.NotNull(dataAttribute.AttributeName); + Assert.Equal("my-label", dataAttribute.AttributeValue); + } + + [Fact] + public async Task RendersChildContent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "ChildContent", (RenderFragment)(childBuilder => + { + childBuilder.OpenElement(0, "input"); + childBuilder.AddAttribute(1, "type", "text"); + childBuilder.CloseElement(); + })); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var labelElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "label"); + Assert.Equal("label", labelElement.ElementName); + + var inputElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "input"); + Assert.Equal("input", inputElement.ElementName); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("PlainProperty", textFrame.TextContent); + } + + [Fact] + public async Task WorksWithDifferentPropertyTypes() + { + var model = new TestModel(); + var intComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.IntProperty)); + builder.CloseComponent(); + } + }; + var dateComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.DateProperty)); + builder.CloseComponent(); + } + }; + + var intFrames = await RenderAndGetFrames(intComponent); + var dateFrames = await RenderAndGetFrames(dateComponent); + + var intText = intFrames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + var dateText = dateFrames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Integer Value", intText.TextContent); + Assert.Equal("Date Value", dateText.TextContent); + } + + [Fact] + public async Task ThrowsWhenForIsNull() + { + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + // Not setting For parameter + builder.CloseComponent(); + } + }; + + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + var exception = await Assert.ThrowsAsync( + () => testRenderer.RenderRootComponentAsync(componentId)); + + Assert.Contains("For", exception.Message); + } + + [Fact] + public async Task RendersWithoutChildContent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var labelElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "label"); + Assert.Equal("label", labelElement.ElementName); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("PlainProperty", textFrame.TextContent); + } + + [Fact] + public async Task WorksWithNestedProperties() + { + var model = new TestModelWithNestedProperty(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.Address.Street)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Street Address", textFrame.TextContent); + } + + private static async Task RenderAndGetFrames(TestHostComponent rootComponent) + { + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + var batch = testRenderer.Batches.Single(); + return batch.ReferenceFrames; + } + + private class TestHostComponent : ComponentBase + { + public RenderFragment InnerContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + InnerContent(builder); + } + } + + private class TestModel + { + public string PlainProperty { get; set; } = string.Empty; + + [Display(Name = "Custom Display Name")] + public string PropertyWithDisplayAttribute { get; set; } = string.Empty; + + [DisplayName("Custom DisplayName")] + public string PropertyWithDisplayNameAttribute { get; set; } = string.Empty; + + [Display(Name = "Display Takes Precedence")] + [DisplayName("This Should Not Be Used")] + public string PropertyWithBothAttributes { get; set; } = string.Empty; + + [Display(Name = "Integer Value")] + public int IntProperty { get; set; } + + [Display(Name = "Date Value")] + public DateTime DateProperty { get; set; } + } + + private class TestModelWithNestedProperty + { + public AddressModel Address { get; set; } = new(); + } + + private class AddressModel + { + [Display(Name = "Street Address")] + public string Street { get; set; } = string.Empty; + } +} From adeacbe5d2f5fe3ff3589a1519e6347240873e43 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 19 Dec 2025 14:05:40 +0100 Subject: [PATCH 02/10] Feedback: condition rendering more strictly. --- src/Components/Web/src/Forms/Label.cs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs index 43e8d6c1a66b..010284b7ae6d 100644 --- a/src/Components/Web/src/Forms/Label.cs +++ b/src/Components/Web/src/Forms/Label.cs @@ -48,6 +48,9 @@ void IComponent.Attach(RenderHandle renderHandle) /// Task IComponent.SetParametersAsync(ParameterView parameters) { + var previousChildContent = ChildContent; + var previousAdditionalAttributes = AdditionalAttributes; + parameters.SetParameterProperties(this); if (For is null) @@ -56,14 +59,24 @@ Task IComponent.SetParametersAsync(ParameterView parameters) $"{nameof(For)} parameter."); } - // Only recalculate if the expression changed + // Only recalculate display name if the expression changed if (For != _previousFieldAccessor) { - _displayName = ExpressionMemberAccessor.GetDisplayName(For); + var newDisplayName = ExpressionMemberAccessor.GetDisplayName(For); + + if (newDisplayName != _displayName) + { + _displayName = newDisplayName; + _renderHandle.Render(BuildRenderTree); + } + _previousFieldAccessor = For; } - - _renderHandle.Render(BuildRenderTree); + else if (ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes) + { + // Re-render if other parameters changed + _renderHandle.Render(BuildRenderTree); + } return Task.CompletedTask; } From 41c8ab702e6cf17df87589fad4ba387a4351357f Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 19 Dec 2025 14:09:23 +0100 Subject: [PATCH 03/10] Feedback: localization test. --- src/Components/Web/test/Forms/LabelTest.cs | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Components/Web/test/Forms/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs index b1fdfe5d7041..20bcf101c0ca 100644 --- a/src/Components/Web/test/Forms/LabelTest.cs +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -271,6 +271,26 @@ public async Task WorksWithNestedProperties() Assert.Equal("Street Address", textFrame.TextContent); } + [Fact] + public async Task SupportsLocalizationWithResourceType() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithResourceBasedDisplay)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var textFrame = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + Assert.Equal("Localized Display Name", textFrame.TextContent); + } + private static async Task RenderAndGetFrames(TestHostComponent rootComponent) { var testRenderer = new TestRenderer(); @@ -310,6 +330,9 @@ private class TestModel [Display(Name = "Date Value")] public DateTime DateProperty { get; set; } + + [Display(Name = nameof(TestResources.LocalizedDisplayName), ResourceType = typeof(TestResources))] + public string PropertyWithResourceBasedDisplay { get; set; } = string.Empty; } private class TestModelWithNestedProperty @@ -322,4 +345,9 @@ private class AddressModel [Display(Name = "Street Address")] public string Street { get; set; } = string.Empty; } + + public static class TestResources + { + public static string LocalizedDisplayName => "Localized Display Name"; + } } From 21e3415fa04602b6451dabe10b4957d4f0896f02 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 2 Jan 2026 13:21:29 +0100 Subject: [PATCH 04/10] Feedback: explicit `for` should not break implicit `For` association. --- src/Components/Web/src/Forms/Label.cs | 7 ++++++ src/Components/Web/test/Forms/LabelTest.cs | 29 ++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs index 010284b7ae6d..1015aacf470c 100644 --- a/src/Components/Web/src/Forms/Label.cs +++ b/src/Components/Web/src/Forms/Label.cs @@ -59,6 +59,13 @@ Task IComponent.SetParametersAsync(ParameterView parameters) $"{nameof(For)} parameter."); } + if (AdditionalAttributes?.ContainsKey("for") == true) + { + throw new InvalidOperationException($"{GetType()} does not support the 'for' attribute " + + $"because it uses implicit label association by nesting the input inside the label element. " + + $"Remove the 'for' attribute."); + } + // Only recalculate display name if the expression changed if (For != _previousFieldAccessor) { diff --git a/src/Components/Web/test/Forms/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs index 20bcf101c0ca..3ac12a48194e 100644 --- a/src/Components/Web/test/Forms/LabelTest.cs +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -228,6 +228,35 @@ public async Task ThrowsWhenForIsNull() Assert.Contains("For", exception.Message); } + [Fact] + public async Task ThrowsWhenForAttributeIsPassedInAdditionalAttributes() + { + var model = new TestModel(); + var additionalAttributes = new Dictionary + { + { "for", "some-id" } + }; + + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); + builder.CloseComponent(); + } + }; + + var testRenderer = new TestRenderer(); + var componentId = testRenderer.AssignRootComponentId(rootComponent); + var exception = await Assert.ThrowsAsync( + () => testRenderer.RenderRootComponentAsync(componentId)); + + Assert.Contains("for", exception.Message); + Assert.Contains("implicit label association", exception.Message); + } + [Fact] public async Task RendersWithoutChildContent() { From 06b798ee9f907177ae17d23b034f80b89295b146 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 7 Jan 2026 09:09:22 +0100 Subject: [PATCH 05/10] Feedback: unify previous value storage types. --- src/Components/Web/src/Forms/Label.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs index 1015aacf470c..fd9b5ab81b52 100644 --- a/src/Components/Web/src/Forms/Label.cs +++ b/src/Components/Web/src/Forms/Label.cs @@ -17,7 +17,6 @@ namespace Microsoft.AspNetCore.Components.Forms; public class Label : IComponent { private RenderHandle _renderHandle; - private Expression>? _previousFieldAccessor; private string? _displayName; /// @@ -48,6 +47,7 @@ void IComponent.Attach(RenderHandle renderHandle) /// Task IComponent.SetParametersAsync(ParameterView parameters) { + var previousFor = For; var previousChildContent = ChildContent; var previousAdditionalAttributes = AdditionalAttributes; @@ -67,7 +67,7 @@ Task IComponent.SetParametersAsync(ParameterView parameters) } // Only recalculate display name if the expression changed - if (For != _previousFieldAccessor) + if (For != previousFor) { var newDisplayName = ExpressionMemberAccessor.GetDisplayName(For); @@ -76,8 +76,6 @@ Task IComponent.SetParametersAsync(ParameterView parameters) _displayName = newDisplayName; _renderHandle.Render(BuildRenderTree); } - - _previousFieldAccessor = For; } else if (ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes) { From d4ce0f12dcbee7740bc10ce947283562186690d2 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 7 Jan 2026 09:18:57 +0100 Subject: [PATCH 06/10] Feedback: check for all params change, not only display name + unit test for that case. --- src/Components/Web/src/Forms/Label.cs | 9 ++-- src/Components/Web/test/Forms/LabelTest.cs | 59 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs index fd9b5ab81b52..4c802e12b51e 100644 --- a/src/Components/Web/src/Forms/Label.cs +++ b/src/Components/Web/src/Forms/Label.cs @@ -67,19 +67,20 @@ Task IComponent.SetParametersAsync(ParameterView parameters) } // Only recalculate display name if the expression changed + var displayNameChanged = false; if (For != previousFor) { var newDisplayName = ExpressionMemberAccessor.GetDisplayName(For); - if (newDisplayName != _displayName) { _displayName = newDisplayName; - _renderHandle.Render(BuildRenderTree); + displayNameChanged = true; } } - else if (ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes) + + var otherParamsChanged = ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes; + if (displayNameChanged || otherParamsChanged) { - // Re-render if other parameters changed _renderHandle.Render(BuildRenderTree); } diff --git a/src/Components/Web/test/Forms/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs index 3ac12a48194e..aea7c19617ca 100644 --- a/src/Components/Web/test/Forms/LabelTest.cs +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -320,6 +320,60 @@ public async Task SupportsLocalizationWithResourceType() Assert.Equal("Localized Display Name", textFrame.TextContent); } + [Fact] + public async Task ReRendersWhenForChangesWithSameDisplayNameButAttributesChange() + { + var model = new TestModel(); + System.Linq.Expressions.Expression> forExpression1 = () => model.PlainProperty; + System.Linq.Expressions.Expression> forExpression2 = () => model.PlainProperty; + + var attributes1 = new Dictionary { { "class", "label-1" } }; + var attributes2 = new Dictionary { { "class", "label-2" } }; + + var currentFor = forExpression1; + var currentAttributes = attributes1; + + var testRenderer = new TestRenderer(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", currentFor); + builder.AddComponentParameter(2, "AdditionalAttributes", currentAttributes); + builder.CloseComponent(); + } + }; + + var componentId = testRenderer.AssignRootComponentId(rootComponent); + await testRenderer.RenderRootComponentAsync(componentId); + + // Verify initial render has class="label-1" + var initialFrames = testRenderer.Batches.Last().ReferenceFrames; + var initialClassAttr = initialFrames.First(f => + f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "class"); + Assert.Equal("label-1", initialClassAttr.AttributeValue); + + // Change both For (different object, same display name) and AdditionalAttributes + currentFor = forExpression2; + currentAttributes = attributes2; + rootComponent.InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", currentFor); + builder.AddComponentParameter(2, "AdditionalAttributes", currentAttributes); + builder.CloseComponent(); + }; + + await testRenderer.Dispatcher.InvokeAsync(() => rootComponent.TriggerRender()); + + // Should have re-rendered with the new attributes + var updatedFrames = testRenderer.Batches.Last().ReferenceFrames; + var updatedClassAttr = updatedFrames.First(f => + f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "class"); + Assert.Equal("label-2", updatedClassAttr.AttributeValue); + } + private static async Task RenderAndGetFrames(TestHostComponent rootComponent) { var testRenderer = new TestRenderer(); @@ -338,6 +392,11 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { InnerContent(builder); } + + public void TriggerRender() + { + StateHasChanged(); + } } private class TestModel From ccd918a49556681d067939c111134341a6e5c94a Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 12 Jan 2026 15:19:41 +0100 Subject: [PATCH 07/10] Unify the behavior: no throwing. --- src/Components/Web/src/Forms/Label.cs | 7 ------- src/Components/Web/test/Forms/LabelTest.cs | 14 +++++++------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs index 4c802e12b51e..839e2e5469e0 100644 --- a/src/Components/Web/src/Forms/Label.cs +++ b/src/Components/Web/src/Forms/Label.cs @@ -59,13 +59,6 @@ Task IComponent.SetParametersAsync(ParameterView parameters) $"{nameof(For)} parameter."); } - if (AdditionalAttributes?.ContainsKey("for") == true) - { - throw new InvalidOperationException($"{GetType()} does not support the 'for' attribute " + - $"because it uses implicit label association by nesting the input inside the label element. " + - $"Remove the 'for' attribute."); - } - // Only recalculate display name if the expression changed var displayNameChanged = false; if (For != previousFor) diff --git a/src/Components/Web/test/Forms/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs index aea7c19617ca..da55f1e71991 100644 --- a/src/Components/Web/test/Forms/LabelTest.cs +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -229,7 +229,7 @@ public async Task ThrowsWhenForIsNull() } [Fact] - public async Task ThrowsWhenForAttributeIsPassedInAdditionalAttributes() + public async Task AllowsForAttributeInAdditionalAttributes() { var model = new TestModel(); var additionalAttributes = new Dictionary @@ -248,13 +248,13 @@ public async Task ThrowsWhenForAttributeIsPassedInAdditionalAttributes() } }; - var testRenderer = new TestRenderer(); - var componentId = testRenderer.AssignRootComponentId(rootComponent); - var exception = await Assert.ThrowsAsync( - () => testRenderer.RenderRootComponentAsync(componentId)); + var frames = await RenderAndGetFrames(rootComponent); + + var labelElement = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Element && f.ElementName == "label"); + Assert.Equal("label", labelElement.ElementName); - Assert.Contains("for", exception.Message); - Assert.Contains("implicit label association", exception.Message); + var forAttribute = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); + Assert.Equal("some-id", forAttribute.AttributeValue); } [Fact] From b274683f68a47d81feb98fb941fc6946be86c2d6 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 12 Jan 2026 16:34:05 +0100 Subject: [PATCH 08/10] Feedback: non-nested approach. --- src/Components/Web/src/Forms/InputBase.cs | 20 +++++ src/Components/Web/src/Forms/InputCheckbox.cs | 13 ++-- src/Components/Web/src/Forms/InputDate.cs | 11 +-- src/Components/Web/src/Forms/InputHidden.cs | 11 +-- src/Components/Web/src/Forms/InputNumber.cs | 11 +-- src/Components/Web/src/Forms/InputSelect.cs | 19 ++--- src/Components/Web/src/Forms/InputText.cs | 11 +-- src/Components/Web/src/Forms/InputTextArea.cs | 11 +-- src/Components/Web/src/Forms/Label.cs | 35 +++++++-- .../Web/src/PublicAPI.Unshipped.txt | 1 + .../Web/test/Forms/InputCheckboxTest.cs | 75 +++++++++++++++++++ .../Web/test/Forms/InputDateTest.cs | 48 ++++++++++++ .../Web/test/Forms/InputHiddenTest.cs | 51 ++++++++++++- .../Web/test/Forms/InputNumberTest.cs | 35 +++++++++ .../Web/test/Forms/InputRenderer.cs | 18 +++++ .../Web/test/Forms/InputSelectTest.cs | 48 ++++++++++++ .../Web/test/Forms/InputTextAreaTest.cs | 51 ++++++++++++- .../Web/test/Forms/InputTextTest.cs | 48 ++++++++++++ src/Components/Web/test/Forms/LabelTest.cs | 70 +++++++++++++++++ 19 files changed, 535 insertions(+), 52 deletions(-) create mode 100644 src/Components/Web/test/Forms/InputCheckboxTest.cs diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index c745da39cb82..acd64158375e 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 defaults to the same value as . + /// + protected string IdAttributeValue + { + get + { + if (AdditionalAttributes?.TryGetValue("id", out var idAttributeValue) ?? false) + { + return Convert.ToString(idAttributeValue, CultureInfo.InvariantCulture) ?? string.Empty; + } + + return 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 index 839e2e5469e0..0fa7c52b750d 100644 --- a/src/Components/Web/src/Forms/Label.cs +++ b/src/Components/Web/src/Forms/Label.cs @@ -10,14 +10,28 @@ 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 label wraps its child content (typically an input component), providing implicit association -/// without requiring matching for/id attributes. /// +/// +/// +/// 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; /// /// Specifies the field for which the label should be rendered. @@ -59,7 +73,7 @@ Task IComponent.SetParametersAsync(ParameterView parameters) $"{nameof(For)} parameter."); } - // Only recalculate display name if the expression changed + // Only recalculate display name and field id if the expression changed var displayNameChanged = false; if (For != previousFor) { @@ -69,6 +83,7 @@ Task IComponent.SetParametersAsync(ParameterView parameters) _displayName = newDisplayName; displayNameChanged = true; } + _fieldId = ExpressionFormatter.FormatLambda(For); } var otherParamsChanged = ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes; @@ -83,9 +98,17 @@ Task IComponent.SetParametersAsync(ParameterView parameters) private void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "label"); - builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddContent(2, _displayName); - builder.AddContent(3, ChildContent); + + // For non-nested usage (no ChildContent), add 'for' attribute to associate with input by id + // Only add if not already provided via AdditionalAttributes + if (ChildContent is null && !(AdditionalAttributes?.ContainsKey("for") ?? false)) + { + 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 33e9c4bcbda5..e5bc7df25502 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -74,6 +74,7 @@ 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 diff --git a/src/Components/Web/test/Forms/InputCheckboxTest.cs b/src/Components/Web/test/Forms/InputCheckboxTest.cs new file mode 100644 index 000000000000..1872343ac3a6 --- /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..374f95124680 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..d6fa8d4ecc04 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..7526a96b1cb7 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..fe9f0a2ac0c9 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..c15e83886c48 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..4c44779974b8 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 index da55f1e71991..3108e2f588f0 100644 --- a/src/Components/Web/test/Forms/LabelTest.cs +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -280,6 +280,76 @@ public async Task RendersWithoutChildContent() Assert.Equal("PlainProperty", textFrame.TextContent); } + [Fact] + public async Task RendersForAttributeWhenNoChildContent() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var forAttribute = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); + Assert.Equal("model.PlainProperty", forAttribute.AttributeValue); + } + + [Fact] + public async Task DoesNotRenderForAttributeWhenChildContentProvided() + { + var model = new TestModel(); + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "ChildContent", (RenderFragment)(childBuilder => + { + childBuilder.AddContent(0, "Input goes here"); + })); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var forAttributes = frames.Where(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); + Assert.Empty(forAttributes); + } + + [Fact] + public async Task NonNestedLabel_ExplicitForOverridesGenerated() + { + var model = new TestModel(); + var additionalAttributes = new Dictionary + { + { "for", "custom-input-id" } + }; + + var rootComponent = new TestHostComponent + { + InnerContent = builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); + builder.CloseComponent(); + } + }; + + var frames = await RenderAndGetFrames(rootComponent); + + var forAttribute = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); + Assert.Equal("custom-input-id", forAttribute.AttributeValue); + } + [Fact] public async Task WorksWithNestedProperties() { From b88187e990893338541fe9b9adc3837944e584e4 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 14 Jan 2026 13:31:18 +0100 Subject: [PATCH 09/10] Feedback: small updates. --- src/Components/Web/src/Forms/Label.cs | 9 ++++-- src/Components/Web/test/Forms/LabelTest.cs | 37 +++++++++++----------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs index 0fa7c52b750d..f37d54b3b165 100644 --- a/src/Components/Web/src/Forms/Label.cs +++ b/src/Components/Web/src/Forms/Label.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Linq.Expressions; +using Microsoft.AspNetCore.Components.Forms.Mapping; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Forms; @@ -33,6 +34,8 @@ public class Label : IComponent private string? _displayName; private string? _fieldId; + [CascadingParameter] private HtmlFieldPrefix FieldPrefix { get; set; } = default!; + /// /// Specifies the field for which the label should be rendered. /// @@ -83,7 +86,8 @@ Task IComponent.SetParametersAsync(ParameterView parameters) _displayName = newDisplayName; displayNameChanged = true; } - _fieldId = ExpressionFormatter.FormatLambda(For); + _fieldId = FieldPrefix != null ? FieldPrefix.GetFieldName(For) : + ExpressionFormatter.FormatLambda(For); } var otherParamsChanged = ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes; @@ -100,8 +104,7 @@ private void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "label"); // For non-nested usage (no ChildContent), add 'for' attribute to associate with input by id - // Only add if not already provided via AdditionalAttributes - if (ChildContent is null && !(AdditionalAttributes?.ContainsKey("for") ?? false)) + if (ChildContent is null) { builder.AddAttribute(1, "for", _fieldId); } diff --git a/src/Components/Web/test/Forms/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs index 3108e2f588f0..a4ff8305402f 100644 --- a/src/Components/Web/test/Forms/LabelTest.cs +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -3,6 +3,7 @@ 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; @@ -20,7 +21,7 @@ public async Task RendersLabelElement() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.CloseComponent(); } }; @@ -40,7 +41,7 @@ public async Task DisplaysDisplayAttributeNameAsContent() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithDisplayAttribute)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PropertyWithDisplayAttribute)); builder.CloseComponent(); } }; @@ -60,7 +61,7 @@ public async Task DisplaysPropertyNameWhenNoAttributePresent() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.CloseComponent(); } }; @@ -80,7 +81,7 @@ public async Task DisplaysDisplayNameAttributeName() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithDisplayNameAttribute)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PropertyWithDisplayNameAttribute)); builder.CloseComponent(); } }; @@ -100,7 +101,7 @@ public async Task DisplayAttributeTakesPrecedenceOverDisplayNameAttribute() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithBothAttributes)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PropertyWithBothAttributes)); builder.CloseComponent(); } }; @@ -126,7 +127,7 @@ public async Task AppliesAdditionalAttributes() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); builder.CloseComponent(); } @@ -152,7 +153,7 @@ public async Task RendersChildContent() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.AddComponentParameter(2, "ChildContent", (RenderFragment)(childBuilder => { childBuilder.OpenElement(0, "input"); @@ -184,7 +185,7 @@ public async Task WorksWithDifferentPropertyTypes() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.IntProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.IntProperty)); builder.CloseComponent(); } }; @@ -193,7 +194,7 @@ public async Task WorksWithDifferentPropertyTypes() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.DateProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.DateProperty)); builder.CloseComponent(); } }; @@ -242,7 +243,7 @@ public async Task AllowsForAttributeInAdditionalAttributes() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); builder.CloseComponent(); } @@ -266,7 +267,7 @@ public async Task RendersWithoutChildContent() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.CloseComponent(); } }; @@ -289,7 +290,7 @@ public async Task RendersForAttributeWhenNoChildContent() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.CloseComponent(); } }; @@ -309,7 +310,7 @@ public async Task DoesNotRenderForAttributeWhenChildContentProvided() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.AddComponentParameter(2, "ChildContent", (RenderFragment)(childBuilder => { childBuilder.AddContent(0, "Input goes here"); @@ -338,7 +339,7 @@ public async Task NonNestedLabel_ExplicitForOverridesGenerated() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PlainProperty)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PlainProperty)); builder.AddComponentParameter(2, "AdditionalAttributes", additionalAttributes); builder.CloseComponent(); } @@ -359,7 +360,7 @@ public async Task WorksWithNestedProperties() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.Address.Street)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.Address.Street)); builder.CloseComponent(); } }; @@ -379,7 +380,7 @@ public async Task SupportsLocalizationWithResourceType() InnerContent = builder => { builder.OpenComponent>(0); - builder.AddComponentParameter(1, "For", (System.Linq.Expressions.Expression>)(() => model.PropertyWithResourceBasedDisplay)); + builder.AddComponentParameter(1, "For", (Expression>)(() => model.PropertyWithResourceBasedDisplay)); builder.CloseComponent(); } }; @@ -394,8 +395,8 @@ public async Task SupportsLocalizationWithResourceType() public async Task ReRendersWhenForChangesWithSameDisplayNameButAttributesChange() { var model = new TestModel(); - System.Linq.Expressions.Expression> forExpression1 = () => model.PlainProperty; - System.Linq.Expressions.Expression> forExpression2 = () => model.PlainProperty; + Expression> forExpression1 = () => model.PlainProperty; + Expression> forExpression2 = () => model.PlainProperty; var attributes1 = new Dictionary { { "class", "label-1" } }; var attributes2 = new Dictionary { { "class", "label-2" } }; From 60b65426ecde4467fe0e7dda2df6e329e6f9f207 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 14 Jan 2026 13:48:23 +0100 Subject: [PATCH 10/10] Feedback: Sanitize `id` like MVC does. --- .../Web/src/Forms/FieldIdGenerator.cs | 80 ++++++++++++++++++ src/Components/Web/src/Forms/InputBase.cs | 4 +- src/Components/Web/src/Forms/Label.cs | 6 +- .../Web/test/Forms/FieldIdGeneratorTest.cs | 82 +++++++++++++++++++ .../Web/test/Forms/InputCheckboxTest.cs | 2 +- .../Web/test/Forms/InputDateTest.cs | 2 +- .../Web/test/Forms/InputHiddenTest.cs | 2 +- .../Web/test/Forms/InputNumberTest.cs | 2 +- .../Web/test/Forms/InputSelectTest.cs | 2 +- .../Web/test/Forms/InputTextAreaTest.cs | 2 +- .../Web/test/Forms/InputTextTest.cs | 2 +- src/Components/Web/test/Forms/LabelTest.cs | 2 +- 12 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 src/Components/Web/src/Forms/FieldIdGenerator.cs create mode 100644 src/Components/Web/test/Forms/FieldIdGeneratorTest.cs 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 acd64158375e..cd8d7ee8ac50 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -225,7 +225,7 @@ protected string NameAttributeValue /// /// /// If an explicit "id" is provided via , that value takes precedence. - /// Otherwise, the id defaults to the same value as . + /// Otherwise, the id is derived from with invalid characters sanitized. /// protected string IdAttributeValue { @@ -236,7 +236,7 @@ protected string IdAttributeValue return Convert.ToString(idAttributeValue, CultureInfo.InvariantCulture) ?? string.Empty; } - return NameAttributeValue; + return FieldIdGenerator.SanitizeHtmlId(NameAttributeValue); } } diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs index f37d54b3b165..3c3f5b5af5b3 100644 --- a/src/Components/Web/src/Forms/Label.cs +++ b/src/Components/Web/src/Forms/Label.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Linq.Expressions; -using Microsoft.AspNetCore.Components.Forms.Mapping; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Forms; @@ -86,8 +85,9 @@ Task IComponent.SetParametersAsync(ParameterView parameters) _displayName = newDisplayName; displayNameChanged = true; } - _fieldId = FieldPrefix != null ? FieldPrefix.GetFieldName(For) : - ExpressionFormatter.FormatLambda(For); + _fieldId = FieldIdGenerator.SanitizeHtmlId( + FieldPrefix != null ? FieldPrefix.GetFieldName(For) : + ExpressionFormatter.FormatLambda(For)); } var otherParamsChanged = ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes; 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 index 1872343ac3a6..c35a95c08d20 100644 --- a/src/Components/Web/test/Forms/InputCheckboxTest.cs +++ b/src/Components/Web/test/Forms/InputCheckboxTest.cs @@ -39,7 +39,7 @@ public async Task RendersIdAttribute() var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); - Assert.Equal("model.BoolProperty", idAttribute.AttributeValue); + Assert.Equal("model_BoolProperty", idAttribute.AttributeValue); } [Fact] diff --git a/src/Components/Web/test/Forms/InputDateTest.cs b/src/Components/Web/test/Forms/InputDateTest.cs index 374f95124680..4192bf1f218b 100644 --- a/src/Components/Web/test/Forms/InputDateTest.cs +++ b/src/Components/Web/test/Forms/InputDateTest.cs @@ -68,7 +68,7 @@ public async Task RendersIdAttribute() var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); - Assert.Equal("model.DateProperty", idAttribute.AttributeValue); + Assert.Equal("model_DateProperty", idAttribute.AttributeValue); } [Fact] diff --git a/src/Components/Web/test/Forms/InputHiddenTest.cs b/src/Components/Web/test/Forms/InputHiddenTest.cs index d6fa8d4ecc04..f643dc4b1db7 100644 --- a/src/Components/Web/test/Forms/InputHiddenTest.cs +++ b/src/Components/Web/test/Forms/InputHiddenTest.cs @@ -39,7 +39,7 @@ public async Task RendersIdAttribute() var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); - Assert.Equal("model.StringProperty", idAttribute.AttributeValue); + Assert.Equal("model_StringProperty", idAttribute.AttributeValue); } [Fact] diff --git a/src/Components/Web/test/Forms/InputNumberTest.cs b/src/Components/Web/test/Forms/InputNumberTest.cs index 7526a96b1cb7..dc4f76ce4acc 100644 --- a/src/Components/Web/test/Forms/InputNumberTest.cs +++ b/src/Components/Web/test/Forms/InputNumberTest.cs @@ -107,7 +107,7 @@ public async Task RendersIdAttribute() var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); - Assert.Equal("model.SomeNumber", idAttribute.AttributeValue); + Assert.Equal("model_SomeNumber", idAttribute.AttributeValue); } [Fact] diff --git a/src/Components/Web/test/Forms/InputSelectTest.cs b/src/Components/Web/test/Forms/InputSelectTest.cs index fe9f0a2ac0c9..a2d6d0112138 100644 --- a/src/Components/Web/test/Forms/InputSelectTest.cs +++ b/src/Components/Web/test/Forms/InputSelectTest.cs @@ -227,7 +227,7 @@ public async Task RendersIdAttribute() var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); - Assert.Equal("model.NotNullableEnum", idAttribute.AttributeValue); + Assert.Equal("model_NotNullableEnum", idAttribute.AttributeValue); } [Fact] diff --git a/src/Components/Web/test/Forms/InputTextAreaTest.cs b/src/Components/Web/test/Forms/InputTextAreaTest.cs index c15e83886c48..a63ac99697c4 100644 --- a/src/Components/Web/test/Forms/InputTextAreaTest.cs +++ b/src/Components/Web/test/Forms/InputTextAreaTest.cs @@ -39,7 +39,7 @@ public async Task RendersIdAttribute() var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); - Assert.Equal("model.StringProperty", idAttribute.AttributeValue); + Assert.Equal("model_StringProperty", idAttribute.AttributeValue); } [Fact] diff --git a/src/Components/Web/test/Forms/InputTextTest.cs b/src/Components/Web/test/Forms/InputTextTest.cs index 4c44779974b8..f5850029183f 100644 --- a/src/Components/Web/test/Forms/InputTextTest.cs +++ b/src/Components/Web/test/Forms/InputTextTest.cs @@ -42,7 +42,7 @@ public async Task RendersIdAttribute() var frames = _testRenderer.GetCurrentRenderTreeFrames(componentId); var idAttribute = frames.Array.Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "id"); - Assert.Equal("model.StringProperty", idAttribute.AttributeValue); + Assert.Equal("model_StringProperty", idAttribute.AttributeValue); } [Fact] diff --git a/src/Components/Web/test/Forms/LabelTest.cs b/src/Components/Web/test/Forms/LabelTest.cs index a4ff8305402f..96d529e71f07 100644 --- a/src/Components/Web/test/Forms/LabelTest.cs +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -298,7 +298,7 @@ public async Task RendersForAttributeWhenNoChildContent() var frames = await RenderAndGetFrames(rootComponent); var forAttribute = frames.First(f => f.FrameType == RenderTree.RenderTreeFrameType.Attribute && f.AttributeName == "for"); - Assert.Equal("model.PlainProperty", forAttribute.AttributeValue); + Assert.Equal("model_PlainProperty", forAttribute.AttributeValue); } [Fact]