From dd3e9697d40d1faa9c338ef4e933b83c9411e918 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 1 May 2026 15:30:03 +0800 Subject: [PATCH] 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() {