diff --git a/src/Components/Web/src/Forms/Label.cs b/src/Components/Web/src/Forms/Label.cs new file mode 100644 index 000000000000..010284b7ae6d --- /dev/null +++ b/src/Components/Web/src/Forms/Label.cs @@ -0,0 +1,92 @@ +// 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) + { + 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 if the expression changed + if (For != _previousFieldAccessor) + { + var newDisplayName = ExpressionMemberAccessor.GetDisplayName(For); + + if (newDisplayName != _displayName) + { + _displayName = newDisplayName; + _renderHandle.Render(BuildRenderTree); + } + + _previousFieldAccessor = For; + } + else if (ChildContent != previousChildContent || AdditionalAttributes != previousAdditionalAttributes) + { + // Re-render if other parameters changed + _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..20bcf101c0ca --- /dev/null +++ b/src/Components/Web/test/Forms/LabelTest.cs @@ -0,0 +1,353 @@ +// 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); + } + + [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(); + 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; } + + [Display(Name = nameof(TestResources.LocalizedDisplayName), ResourceType = typeof(TestResources))] + public string PropertyWithResourceBasedDisplay { get; set; } = string.Empty; + } + + private class TestModelWithNestedProperty + { + public AddressModel Address { get; set; } = new(); + } + + 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"; + } +}