From 4c58ca165f9f447559ef780b09b194002cd0f68a Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 1 May 2026 14:12:59 +0800 Subject: [PATCH 1/7] Render abp-select info as
with aria-describedby - Replace with
to match abp-input and the Bootstrap 5 form-text guidance - Add aria-describedby on the select so screen readers announce the description --- .../TagHelpers/Form/AbpSelectTagHelperService.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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..e1ef97386e 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 @@ -251,12 +251,14 @@ public class AbpSelectTagHelperService : AbpTagHelperService var idAttr = inputTag.Attributes.FirstOrDefault(a => a.Name == "id"); 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.Attributes.Add("id", idAttr?.Value + "InfoText"); + div.AddCssClass("form-text"); + div.InnerHtml.Append(localizedText); - return small.ToHtmlString(); + inputTag.Attributes.Add("aria-describedby", idAttr?.Value + "InfoText"); + + return div.ToHtmlString(); } protected virtual List GetSelectItemsFromEnum(TagHelperContext context, TagHelperOutput output, ModelExplorer explorer) From 5d4b7a4719a74b26e60703dc35650a0dbd1c597b Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 1 May 2026 14:23:04 +0800 Subject: [PATCH 2/7] Add unit tests for AbpSelectTagHelperService info text rendering - Verify info text is rendered as
(not ) - Verify aria-describedby is set on the select tag --- .../Form/AbpSelectTagHelperService_Tests.cs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpSelectTagHelperService_Tests.cs 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..240562f929 --- /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,129 @@ +#nullable enable + +using System.Collections.Generic; +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("
(), + "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 sealed class TestAbpSelectTagHelperService : AbpSelectTagHelperService + { + public string LastGroupHtml { get; private set; } = string.Empty; + + public TagHelperOutput? LastSelectTag { get; private set; } + + public TestAbpSelectTagHelperService() + : base(null!, HtmlEncoder.Default, new FakeTagHelperLocalizer(), null!, null!) + { + } + + protected override Task GetSelectTagAsync(TagHelperContext context, TagHelperOutput output, TagHelperContent childContent) + { + LastSelectTag = new TagHelperOutput( + "select", + new TagHelperAttributeList { { "id", "TestSelect" } }, + (_, _) => Task.FromResult(new DefaultTagHelperContent())) + { + TagMode = TagMode.StartTagAndEndTag + }; + + 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; + } +} From f1df827ec9fea1a967315b160a23124d4e23b2ce Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 1 May 2026 14:30:16 +0800 Subject: [PATCH 3/7] Fix aria-describedby wiring and align abp-input/abp-select form-text rendering - Move inputTag.Render after GetInfoAsHtml so aria-describedby reaches the final HTML - Replace Attributes.Add with Attributes.SetAttribute for aria-describedby to avoid duplicates when [InputInfoText] and info="..." are both present - Apply the same fixes to AbpInputTagHelperService for consistency - Cover the [InputInfoText] attribute path with an additional test --- .../Form/AbpInputTagHelperService.cs | 6 +-- .../Form/AbpSelectTagHelperService.cs | 6 +-- .../Form/AbpSelectTagHelperService_Tests.cs | 42 +++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) 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..25fb1aff3e 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); } @@ -267,7 +267,7 @@ public class AbpInputTagHelperService : AbpTagHelperService var infoText = _tagHelperLocalizer.GetLocalizedText(idAttr.Value + "InfoText", TagHelper.AspFor.ModelExplorer); - inputTagHelperOutput.Attributes.Add("aria-describedby", infoText); + inputTagHelperOutput.Attributes.SetAttribute("aria-describedby", infoText); } protected virtual bool IsInputCheckbox(TagHelperContext context, TagHelperOutput output, TagHelperAttributeList attributes) @@ -363,7 +363,7 @@ public class AbpInputTagHelperService : AbpTagHelperService div.AddCssClass("form-text"); div.InnerHtml.Append(localizedText); - inputTag.Attributes.Add("aria-describedby", idAttr?.Value + "InfoText"); + inputTag.Attributes.SetAttribute("aria-describedby", idAttr?.Value + "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 e1ef97386e..854415f8c1 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; @@ -224,7 +224,7 @@ public class AbpSelectTagHelperService : AbpTagHelperService var infoText = _tagHelperLocalizer.GetLocalizedText(idAttr.Value + "InfoText", TagHelper.AspFor.ModelExplorer); - inputTagHelperOutput.Attributes.Add("aria-describedby", infoText); + inputTagHelperOutput.Attributes.SetAttribute("aria-describedby", infoText); } protected virtual string GetInfoAsHtml(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag) @@ -256,7 +256,7 @@ public class AbpSelectTagHelperService : AbpTagHelperService div.AddCssClass("form-text"); div.InnerHtml.Append(localizedText); - inputTag.Attributes.Add("aria-describedby", idAttr?.Value + "InfoText"); + inputTag.Attributes.SetAttribute("aria-describedby", idAttr?.Value + "InfoText"); return div.ToHtmlString(); } 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 index 240562f929..79d61ff74d 100644 --- 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 @@ -1,6 +1,7 @@ #nullable enable using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Text.Encodings.Web; using System.Threading.Tasks; @@ -51,6 +52,30 @@ public class AbpSelectTagHelperService_Tests service.LastSelectTag.ShouldNotBeNull(); service.LastSelectTag!.Attributes.ContainsName("aria-describedby").ShouldBeTrue(); service.LastSelectTag.Attributes["aria-describedby"].Value.ToString().ShouldBe("TestSelectInfoText"); + service.LastGroupHtml.ShouldContain("aria-describedby=\"TestSelectInfoText\""); + } + + [Fact] + public async Task InputInfoText_attribute_should_render_info_text_with_single_aria_describedby() + { + var service = new TestAbpSelectTagHelperService(); + var tagHelper = new AbpSelectTagHelper(service) + { + AspFor = CreateModelExpressionWithInputInfoText() + }; + + 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() @@ -77,6 +102,21 @@ public class AbpSelectTagHelperService_Tests 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 { public string LastGroupHtml { get; private set; } = string.Empty; @@ -98,6 +138,8 @@ public class AbpSelectTagHelperService_Tests TagMode = TagMode.StartTagAndEndTag }; + AddInfoTextId(LastSelectTag); + return Task.FromResult(LastSelectTag); } From a8060b9566be166ec0125fd0be9dadf22c86048d Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 1 May 2026 14:42:45 +0800 Subject: [PATCH 4/7] Guard aria-describedby and InfoText id against missing/empty input id - Stop using the localized text as the aria-describedby value in AddInfoTextId; reference the actual id directly - Skip rendering the InfoText id and aria-describedby when the input/select has no id (or an empty one) so the form never renders a non-unique "InfoText" id - Cover the no-id case with a new test --- .../Form/AbpInputTagHelperService.cs | 15 ++++---- .../Form/AbpSelectTagHelperService.cs | 15 ++++---- .../Form/AbpSelectTagHelperService_Tests.cs | 36 +++++++++++++++++-- 3 files changed, 52 insertions(+), 14 deletions(-) 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 25fb1aff3e..a3adc4eae7 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 @@ -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.SetAttribute("aria-describedby", infoText); + inputTagHelperOutput.Attributes.SetAttribute("aria-describedby", 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.SetAttribute("aria-describedby", idAttr?.Value + "InfoText"); + if (!string.IsNullOrEmpty(idValue)) + { + div.Attributes.Add("id", idValue + "InfoText"); + inputTag.Attributes.SetAttribute("aria-describedby", 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 854415f8c1..928082d6e8 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 @@ -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.SetAttribute("aria-describedby", infoText); + inputTagHelperOutput.Attributes.SetAttribute("aria-describedby", idValue + "InfoText"); } protected virtual string GetInfoAsHtml(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag) @@ -249,14 +248,18 @@ 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 div = new TagBuilder("div"); - div.Attributes.Add("id", idAttr?.Value + "InfoText"); div.AddCssClass("form-text"); div.InnerHtml.Append(localizedText); - inputTag.Attributes.SetAttribute("aria-describedby", idAttr?.Value + "InfoText"); + if (!string.IsNullOrEmpty(idValue)) + { + div.Attributes.Add("id", idValue + "InfoText"); + inputTag.Attributes.SetAttribute("aria-describedby", idValue + "InfoText"); + } return div.ToHtmlString(); } 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 index 79d61ff74d..4ec7948846 100644 --- 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 @@ -55,6 +55,29 @@ public class AbpSelectTagHelperService_Tests service.LastGroupHtml.ShouldContain("aria-describedby=\"TestSelectInfoText\""); } + [Fact] + public async Task Info_text_should_skip_id_and_aria_describedby_when_select_has_no_id() + { + var service = new TestAbpSelectTagHelperService(selectId: null); + var tagHelper = new AbpSelectTagHelper(service) + { + AspFor = CreateModelExpression(), + InfoText = "Description" + }; + + var output = CreateOutput(); + + await tagHelper.ProcessAsync(CreateContext(), output); + + service.LastGroupHtml.ShouldContain("
GetSelectTagAsync(TagHelperContext context, TagHelperOutput output, TagHelperContent childContent) { + var attributes = new TagHelperAttributeList(); + if (!string.IsNullOrEmpty(_selectId)) + { + attributes.Add("id", _selectId); + } + LastSelectTag = new TagHelperOutput( "select", - new TagHelperAttributeList { { "id", "TestSelect" } }, + attributes, (_, _) => Task.FromResult(new DefaultTagHelperContent())) { TagMode = TagMode.StartTagAndEndTag From 19c4e2989a89b72c65a025a9fe365c24ce11d9dd Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 1 May 2026 15:02:19 +0800 Subject: [PATCH 5/7] Append aria-describedby tokens instead of overwriting - Add TagHelperOutputExtensions.AppendAriaDescribedby helper that preserves caller-supplied tokens (space-separated id list) and dedupes - Replace SetAttribute calls in AddInfoTextId/GetInfoAsHtml of abp-input/abp-select with the helper - Cover the consumer-provided aria-describedby case with a new test --- .../Extensions/TagHelperOutputExtensions.cs | 32 +++++++++++++++++++ .../Form/AbpInputTagHelperService.cs | 4 +-- .../Form/AbpSelectTagHelperService.cs | 4 +-- .../Form/AbpSelectTagHelperService_Tests.cs | 27 +++++++++++++++- 4 files changed, 62 insertions(+), 5 deletions(-) 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..838d662af8 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,5 +1,7 @@ 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; @@ -14,4 +16,34 @@ 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 => a.Name == "aria-describedby")?.Value?.ToString(); + + if (string.IsNullOrEmpty(existing)) + { + output.Attributes.SetAttribute("aria-describedby", token); + return; + } + + var tokens = existing.Split(' ', 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 a3adc4eae7..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 @@ -266,7 +266,7 @@ public class AbpInputTagHelperService : AbpTagHelperService return; } - inputTagHelperOutput.Attributes.SetAttribute("aria-describedby", idValue + "InfoText"); + inputTagHelperOutput.AppendAriaDescribedby(idValue + "InfoText"); } protected virtual bool IsInputCheckbox(TagHelperContext context, TagHelperOutput output, TagHelperAttributeList attributes) @@ -365,7 +365,7 @@ public class AbpInputTagHelperService : AbpTagHelperService if (!string.IsNullOrEmpty(idValue)) { div.Attributes.Add("id", idValue + "InfoText"); - inputTag.Attributes.SetAttribute("aria-describedby", 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 928082d6e8..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 @@ -223,7 +223,7 @@ public class AbpSelectTagHelperService : AbpTagHelperService return; } - inputTagHelperOutput.Attributes.SetAttribute("aria-describedby", idValue + "InfoText"); + inputTagHelperOutput.AppendAriaDescribedby(idValue + "InfoText"); } protected virtual string GetInfoAsHtml(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag) @@ -258,7 +258,7 @@ public class AbpSelectTagHelperService : AbpTagHelperService if (!string.IsNullOrEmpty(idValue)) { div.Attributes.Add("id", idValue + "InfoText"); - inputTag.Attributes.SetAttribute("aria-describedby", idValue + "InfoText"); + inputTag.AppendAriaDescribedby(idValue + "InfoText"); } return div.ToHtmlString(); 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 index 4ec7948846..3ec4e12a2c 100644 --- 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 @@ -78,6 +78,25 @@ public class AbpSelectTagHelperService_Tests service.LastSelectTag!.Attributes.ContainsName("aria-describedby").ShouldBeFalse(); } + [Fact] + public async Task Aria_describedby_should_preserve_existing_value_set_by_caller() + { + var service = new TestAbpSelectTagHelperService(existingAriaDescribedby: "custom-id"); + var tagHelper = new AbpSelectTagHelper(service) + { + AspFor = CreateModelExpression(), + InfoText = "Description" + }; + + var output = CreateOutput(); + + await tagHelper.ProcessAsync(CreateContext(), output); + + service.LastSelectTag.ShouldNotBeNull(); + service.LastSelectTag!.Attributes["aria-describedby"].Value.ToString().ShouldBe("custom-id TestSelectInfoText"); + service.LastGroupHtml.ShouldContain("aria-describedby=\"custom-id TestSelectInfoText\""); + } + [Fact] public async Task InputInfoText_attribute_should_render_info_text_with_single_aria_describedby() { @@ -143,15 +162,17 @@ public class AbpSelectTagHelperService_Tests 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") + 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) @@ -161,6 +182,10 @@ public class AbpSelectTagHelperService_Tests { attributes.Add("id", _selectId); } + if (!string.IsNullOrEmpty(_existingAriaDescribedby)) + { + attributes.Add("aria-describedby", _existingAriaDescribedby); + } LastSelectTag = new TagHelperOutput( "select", From 72cf6bbcb8058099d9421b6134c0e4c6d26d518d Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 1 May 2026 15:12:25 +0800 Subject: [PATCH 6/7] Mirror AbpSelectTagHelperService info text tests for AbpInputTagHelperService Cover the new aria-describedby behaviour for : - form-text rendered as
- aria-describedby reaches the final HTML - no-id case skips the InfoText id and aria-describedby - caller-supplied aria-describedby is preserved (append + dedupe) - [InputInfoText] attribute path produces a single aria-describedby --- .../Form/AbpInputTagHelperService_Tests.cs | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) 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; + } } From dd3e9697d40d1faa9c338ef4e933b83c9411e918 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 1 May 2026 15:30:03 +0800 Subject: [PATCH 7/7] Make AppendAriaDescribedby case-insensitive and split on all HTML whitespace - Look up the existing aria-describedby attribute with OrdinalIgnoreCase to match the casing rules used by HTML and TagHelperAttributeList - Tokenize the existing value on all ASCII whitespace (space, tab, newline, carriage return, form feed) instead of just the literal space - Cover the whitespace-separated case with a new test --- .../Extensions/TagHelperOutputExtensions.cs | 11 ++++++++--- .../Form/AbpSelectTagHelperService_Tests.cs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) 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 838d662af8..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 @@ -8,6 +8,10 @@ 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()) @@ -30,15 +34,16 @@ public static class TagHelperOutputExtensions } var existing = output.Attributes - .FirstOrDefault(a => a.Name == "aria-describedby")?.Value?.ToString(); + .FirstOrDefault(a => string.Equals(a.Name, "aria-describedby", StringComparison.OrdinalIgnoreCase)) + ?.Value?.ToString(); - if (string.IsNullOrEmpty(existing)) + if (string.IsNullOrWhiteSpace(existing)) { output.Attributes.SetAttribute("aria-describedby", token); return; } - var tokens = existing.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var tokens = existing.Split(HtmlAsciiWhitespace, StringSplitOptions.RemoveEmptyEntries); if (tokens.Any(t => t == token)) { return; 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 index 3ec4e12a2c..9c2e90ca25 100644 --- 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 @@ -97,6 +97,24 @@ public class AbpSelectTagHelperService_Tests service.LastGroupHtml.ShouldContain("aria-describedby=\"custom-id TestSelectInfoText\""); } + [Fact] + public async Task Aria_describedby_should_split_on_html_whitespace_separators() + { + var service = new TestAbpSelectTagHelperService(existingAriaDescribedby: "id1\tid2"); + var tagHelper = new AbpSelectTagHelper(service) + { + AspFor = CreateModelExpression(), + InfoText = "Description" + }; + + var output = CreateOutput(); + + await tagHelper.ProcessAsync(CreateContext(), output); + + service.LastSelectTag.ShouldNotBeNull(); + service.LastSelectTag!.Attributes["aria-describedby"].Value.ToString().ShouldBe("id1\tid2 TestSelectInfoText"); + } + [Fact] public async Task InputInfoText_attribute_should_render_info_text_with_single_aria_describedby() {