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",