diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Extensions/TagHelperOutputExtensions.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Extensions/TagHelperOutputExtensions.cs index 40ca23a8f1..7b1b4372b5 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Extensions/TagHelperOutputExtensions.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Extensions/TagHelperOutputExtensions.cs @@ -1,11 +1,17 @@ using Microsoft.AspNetCore.Razor.TagHelpers; +using System; using System.IO; +using System.Linq; using System.Text.Encodings.Web; namespace Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Extensions; public static class TagHelperOutputExtensions { + // ASCII whitespace per the HTML5 spec, used to tokenize space-separated + // attribute values such as aria-describedby. + private static readonly char[] HtmlAsciiWhitespace = { ' ', '\t', '\n', '\r', '\f' }; + public static string Render(this TagHelperOutput output, HtmlEncoder htmlEncoder) { using (var writer = new StringWriter()) @@ -14,4 +20,35 @@ public static class TagHelperOutputExtensions return writer.ToString(); } } + + /// + /// Appends an id token to the space-separated aria-describedby attribute, + /// preserving any tokens that were already present (e.g. provided by the consumer) + /// and skipping the token when it is already in the list. + /// + public static void AppendAriaDescribedby(this TagHelperOutput output, string token) + { + if (string.IsNullOrEmpty(token)) + { + return; + } + + var existing = output.Attributes + .FirstOrDefault(a => string.Equals(a.Name, "aria-describedby", StringComparison.OrdinalIgnoreCase)) + ?.Value?.ToString(); + + if (string.IsNullOrWhiteSpace(existing)) + { + output.Attributes.SetAttribute("aria-describedby", token); + return; + } + + var tokens = existing.Split(HtmlAsciiWhitespace, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Any(t => t == token)) + { + return; + } + + output.Attributes.SetAttribute("aria-describedby", existing + " " + token); + } } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs index f40ff1030b..f8994a462c 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs @@ -88,10 +88,10 @@ public class AbpInputTagHelperService : AbpTagHelperService var (inputTag, isCheckBox) = await GetInputTagHelperOutputAsync(context, output); context.Items[nameof(IsOutputHidden)] = IsOutputHidden(inputTag); - var inputHtml = inputTag.Render(_encoder); var label = await GetLabelAsHtmlAsync(context, output, inputTag, isCheckBox); var info = GetInfoAsHtml(context, output, inputTag, isCheckBox); var validation = isCheckBox ? "" : await GetValidationAsHtmlAsync(context, output, inputTag); + var inputHtml = inputTag.Render(_encoder); return (GetContent(context, output, label, inputHtml, validation, info, isCheckBox), isCheckBox); } @@ -259,15 +259,14 @@ public class AbpInputTagHelperService : AbpTagHelperService } var idAttr = inputTagHelperOutput.Attributes.FirstOrDefault(a => a.Name == "id"); + var idValue = idAttr?.Value?.ToString(); - if (idAttr == null) + if (string.IsNullOrEmpty(idValue)) { return; } - var infoText = _tagHelperLocalizer.GetLocalizedText(idAttr.Value + "InfoText", TagHelper.AspFor.ModelExplorer); - - inputTagHelperOutput.Attributes.Add("aria-describedby", infoText); + inputTagHelperOutput.AppendAriaDescribedby(idValue + "InfoText"); } protected virtual bool IsInputCheckbox(TagHelperContext context, TagHelperOutput output, TagHelperAttributeList attributes) @@ -356,14 +355,18 @@ public class AbpInputTagHelperService : AbpTagHelperService } var idAttr = inputTag.Attributes.FirstOrDefault(a => a.Name == "id"); + var idValue = idAttr?.Value?.ToString(); var localizedText = _tagHelperLocalizer.GetLocalizedText(text, TagHelper.AspFor.ModelExplorer); var div = new TagBuilder("div"); - div.Attributes.Add("id", idAttr?.Value + "InfoText"); div.AddCssClass("form-text"); div.InnerHtml.Append(localizedText); - inputTag.Attributes.Add("aria-describedby", idAttr?.Value + "InfoText"); + if (!string.IsNullOrEmpty(idValue)) + { + div.Attributes.Add("id", idValue + "InfoText"); + inputTag.AppendAriaDescribedby(idValue + "InfoText"); + } return div.ToHtmlString(); } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpSelectTagHelperService.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpSelectTagHelperService.cs index c411d571f4..42d85cfb74 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpSelectTagHelperService.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpSelectTagHelperService.cs @@ -73,10 +73,10 @@ public class AbpSelectTagHelperService : AbpTagHelperService protected virtual async Task GetFormInputGroupAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperContent childContent) { var selectTag = await GetSelectTagAsync(context, output, childContent); - var selectAsHtml = selectTag.Render(_encoder); var label = await GetLabelAsHtmlAsync(context, output, selectTag); var validation = await GetValidationAsHtmlAsync(context, output, selectTag); var infoText = GetInfoAsHtml(context, output, selectTag); + var selectAsHtml = selectTag.Render(_encoder); return TagHelper.FloatingLabel ? selectAsHtml + Environment.NewLine + label + Environment.NewLine + infoText + Environment.NewLine + validation : label + Environment.NewLine + selectAsHtml + Environment.NewLine + infoText + Environment.NewLine + validation; @@ -216,15 +216,14 @@ public class AbpSelectTagHelperService : AbpTagHelperService } var idAttr = inputTagHelperOutput.Attributes.FirstOrDefault(a => a.Name == "id"); + var idValue = idAttr?.Value?.ToString(); - if (idAttr == null) + if (string.IsNullOrEmpty(idValue)) { return; } - var infoText = _tagHelperLocalizer.GetLocalizedText(idAttr.Value + "InfoText", TagHelper.AspFor.ModelExplorer); - - inputTagHelperOutput.Attributes.Add("aria-describedby", infoText); + inputTagHelperOutput.AppendAriaDescribedby(idValue + "InfoText"); } protected virtual string GetInfoAsHtml(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag) @@ -249,14 +248,20 @@ public class AbpSelectTagHelperService : AbpTagHelperService } var idAttr = inputTag.Attributes.FirstOrDefault(a => a.Name == "id"); + var idValue = idAttr?.Value?.ToString(); var localizedText = _tagHelperLocalizer.GetLocalizedText(text, TagHelper.AspFor.ModelExplorer); - var small = new TagBuilder("small"); - small.Attributes.Add("id", idAttr?.Value?.ToString() + "InfoText"); - small.AddCssClass("form-text"); - small.InnerHtml.Append(localizedText); + var div = new TagBuilder("div"); + div.AddCssClass("form-text"); + div.InnerHtml.Append(localizedText); + + if (!string.IsNullOrEmpty(idValue)) + { + div.Attributes.Add("id", idValue + "InfoText"); + inputTag.AppendAriaDescribedby(idValue + "InfoText"); + } - return small.ToHtmlString(); + return div.ToHtmlString(); } protected virtual List GetSelectItemsFromEnum(TagHelperContext context, TagHelperOutput output, ModelExplorer explorer) diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpInputTagHelperService_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpInputTagHelperService_Tests.cs index 33e2e35bec..31ced995cb 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpInputTagHelperService_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpInputTagHelperService_Tests.cs @@ -1,9 +1,14 @@ +#nullable enable + using System.Collections.Generic; +using System.Linq; +using System.Reflection; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Localization; using Shouldly; using Xunit; @@ -45,6 +50,109 @@ public class AbpInputTagHelperService_Tests service.LastGroupHtml.ShouldContain("mb-3"); } + [Fact] + public async Task Info_text_should_be_rendered_as_div_with_form_text_class() + { + var service = new TestAbpInputTagHelperServiceForInfo(); + var tagHelper = new AbpInputTagHelper(service) + { + AspFor = CreateModelExpression(), + InfoText = "Description" + }; + + var output = CreateOutput(); + + await tagHelper.ProcessAsync(CreateContext(), output); + + service.LastGroupHtml.ShouldContain("
a.Name == "aria-describedby").ToList(); + ariaDescribedby.Count.ShouldBe(1); + ariaDescribedby[0].Value.ToString().ShouldBe("TestInputInfoText"); + } + private static TagHelperContext CreateContext() { return new TagHelperContext( @@ -69,6 +177,21 @@ public class AbpInputTagHelperService_Tests metadataProvider.GetModelExplorerForType(typeof(string), null)); } + private static ModelExpression CreateModelExpressionWithInputInfoText() + { + var metadataProvider = new EmptyModelMetadataProvider(); + var modelExplorer = metadataProvider + .GetModelExplorerForType(typeof(TestModelWithInputInfoText), null) + .GetExplorerForProperty(nameof(TestModelWithInputInfoText.TestInput)); + return new ModelExpression(nameof(TestModelWithInputInfoText.TestInput), modelExplorer); + } + + private class TestModelWithInputInfoText + { + [InputInfoText("Description from attribute")] + public string TestInput { get; set; } = string.Empty; + } + private sealed class TestAbpInputTagHelperService : AbpInputTagHelperService { private readonly string _inputTypeName; @@ -119,4 +242,75 @@ public class AbpInputTagHelperService_Tests suppress = false; } } + + private sealed class TestAbpInputTagHelperServiceForInfo : AbpInputTagHelperService + { + private readonly string? _inputId; + private readonly string? _existingAriaDescribedby; + + public string LastGroupHtml { get; private set; } = string.Empty; + + public TagHelperOutput? LastInputTag { get; private set; } + + public TestAbpInputTagHelperServiceForInfo(string? inputId = "TestInput", string? existingAriaDescribedby = null) + : base(null!, HtmlEncoder.Default, new FakeTagHelperLocalizer()) + { + _inputId = inputId; + _existingAriaDescribedby = existingAriaDescribedby; + } + + protected override Task<(TagHelperOutput, bool)> GetInputTagHelperOutputAsync(TagHelperContext context, TagHelperOutput output) + { + var attributes = new TagHelperAttributeList + { + { "type", "text" }, + { "class", "form-control" } + }; + if (!string.IsNullOrEmpty(_inputId)) + { + attributes.Add("id", _inputId); + } + if (!string.IsNullOrEmpty(_existingAriaDescribedby)) + { + attributes.Add("aria-describedby", _existingAriaDescribedby); + } + + LastInputTag = new TagHelperOutput( + "input", + attributes, + (_, _) => Task.FromResult(new DefaultTagHelperContent())) + { + TagMode = TagMode.SelfClosing + }; + + AddInfoTextId(LastInputTag); + + return Task.FromResult((LastInputTag, false)); + } + + protected override Task GetLabelAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag, bool isCheckbox) + { + return Task.FromResult(string.Empty); + } + + protected override Task GetValidationAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag) + { + return Task.FromResult(string.Empty); + } + + protected override void AddGroupToFormGroupContents(TagHelperContext context, string propertyName, string html, int order, out bool suppress) + { + LastGroupHtml = html; + suppress = false; + } + } + + private sealed class FakeTagHelperLocalizer : IAbpTagHelperLocalizer + { + public string GetLocalizedText(string text, ModelExplorer explorer) => text; + + public IStringLocalizer? GetLocalizerOrNull(ModelExplorer explorer) => null; + + public IStringLocalizer? GetLocalizerOrNull(Assembly assembly) => null; + } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpSelectTagHelperService_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpSelectTagHelperService_Tests.cs new file mode 100644 index 0000000000..9c2e90ca25 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpSelectTagHelperService_Tests.cs @@ -0,0 +1,246 @@ +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Localization; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form; + +public class AbpSelectTagHelperService_Tests +{ + [Fact] + public async Task Info_text_should_be_rendered_as_div_with_form_text_class() + { + var service = new TestAbpSelectTagHelperService(); + var tagHelper = new AbpSelectTagHelper(service) + { + AspFor = CreateModelExpression(), + InfoText = "Description" + }; + + var output = CreateOutput(); + + await tagHelper.ProcessAsync(CreateContext(), output); + + service.LastGroupHtml.ShouldContain("
a.Name == "aria-describedby").ToList(); + ariaDescribedby.Count.ShouldBe(1); + ariaDescribedby[0].Value.ToString().ShouldBe("TestSelectInfoText"); + } + + private static TagHelperContext CreateContext() + { + return new TagHelperContext( + new TagHelperAttributeList(), + new Dictionary(), + "test"); + } + + private static TagHelperOutput CreateOutput() + { + return new TagHelperOutput( + "abp-select", + new TagHelperAttributeList(), + (_, _) => Task.FromResult(new DefaultTagHelperContent())); + } + + private static ModelExpression CreateModelExpression() + { + var metadataProvider = new EmptyModelMetadataProvider(); + return new ModelExpression( + "TestSelect", + metadataProvider.GetModelExplorerForType(typeof(string), null)); + } + + private static ModelExpression CreateModelExpressionWithInputInfoText() + { + var metadataProvider = new EmptyModelMetadataProvider(); + var modelExplorer = metadataProvider + .GetModelExplorerForType(typeof(TestModelWithInputInfoText), null) + .GetExplorerForProperty(nameof(TestModelWithInputInfoText.TestSelect)); + return new ModelExpression(nameof(TestModelWithInputInfoText.TestSelect), modelExplorer); + } + + private class TestModelWithInputInfoText + { + [InputInfoText("Description from attribute")] + public string TestSelect { get; set; } = string.Empty; + } + + private sealed class TestAbpSelectTagHelperService : AbpSelectTagHelperService + { + private readonly string? _selectId; + private readonly string? _existingAriaDescribedby; + + public string LastGroupHtml { get; private set; } = string.Empty; + + public TagHelperOutput? LastSelectTag { get; private set; } + + public TestAbpSelectTagHelperService(string? selectId = "TestSelect", string? existingAriaDescribedby = null) + : base(null!, HtmlEncoder.Default, new FakeTagHelperLocalizer(), null!, null!) + { + _selectId = selectId; + _existingAriaDescribedby = existingAriaDescribedby; + } + + protected override Task GetSelectTagAsync(TagHelperContext context, TagHelperOutput output, TagHelperContent childContent) + { + var attributes = new TagHelperAttributeList(); + if (!string.IsNullOrEmpty(_selectId)) + { + attributes.Add("id", _selectId); + } + if (!string.IsNullOrEmpty(_existingAriaDescribedby)) + { + attributes.Add("aria-describedby", _existingAriaDescribedby); + } + + LastSelectTag = new TagHelperOutput( + "select", + attributes, + (_, _) => Task.FromResult(new DefaultTagHelperContent())) + { + TagMode = TagMode.StartTagAndEndTag + }; + + AddInfoTextId(LastSelectTag); + + return Task.FromResult(LastSelectTag); + } + + protected override Task GetLabelAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperOutput selectTag) + { + return Task.FromResult(string.Empty); + } + + protected override Task GetValidationAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperOutput selectTag) + { + return Task.FromResult(string.Empty); + } + + protected override void AddGroupToFormGroupContents(TagHelperContext context, string propertyName, string html, int order, out bool suppress) + { + LastGroupHtml = html; + suppress = false; + } + } + + private sealed class FakeTagHelperLocalizer : IAbpTagHelperLocalizer + { + public string GetLocalizedText(string text, ModelExplorer explorer) => text; + + public IStringLocalizer? GetLocalizerOrNull(ModelExplorer explorer) => null; + + public IStringLocalizer? GetLocalizerOrNull(Assembly assembly) => null; + } +}