diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs
index 75849c6923..f51ad6bac7 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs
@@ -164,6 +164,20 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
internal static string FormatFormActionTagHelper_CannotOverrideFormAction(object p0, object p1, object p2, object p3, object p4, object p5, object p6, object p7, object p8, object p9)
=> string.Format(CultureInfo.CurrentCulture, GetString("FormActionTagHelper_CannotOverrideFormAction"), p0, p1, p2, p3, p4, p5, p6, p7, p8, p9);
+ ///
+ /// Value cannot contain whitespace.
+ ///
+ internal static string ArgumentCannotContainHtmlSpace
+ {
+ get => GetString("ArgumentCannotContainHtmlSpace");
+ }
+
+ ///
+ /// Value cannot contain whitespace.
+ ///
+ internal static string FormatArgumentCannotContainHtmlSpace()
+ => GetString("ArgumentCannotContainHtmlSpace");
+
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx
index 9ef50b9953..7242ee001b 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx
@@ -150,4 +150,7 @@
Cannot override the '{0}' attribute for <{1}>. <{1}> elements with a specified '{0}' must not have attributes starting with '{2}' or an '{3}', '{4}', '{5}', '{6}', '{7}', '{8}' or '{9}' attribute.
+
+ Value cannot contain HTML space characters.
+
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs
index f9cc053ae8..8075b5bf0e 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/TagHelperOutputExtensions.cs
@@ -18,6 +18,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
///
public static class TagHelperOutputExtensions
{
+ private static readonly char[] SpaceChars = { '\u0020', '\u0009', '\u000A', '\u000C', '\u000D' };
+
///
/// Copies a user-provided attribute from 's
/// to 's
@@ -154,6 +156,167 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
}
}
+ ///
+ /// Adds the given to the 's
+ /// .
+ ///
+ /// The this method extends.
+ /// The class value to add.
+ /// The current HTML encoder.
+ public static void AddClass(
+ this TagHelperOutput tagHelperOutput,
+ string classValue,
+ HtmlEncoder htmlEncoder)
+ {
+ if (tagHelperOutput == null)
+ {
+ throw new ArgumentNullException(nameof(tagHelperOutput));
+ }
+
+ if (string.IsNullOrEmpty(classValue))
+ {
+ return;
+ }
+
+ var encodedSpaceChars = SpaceChars.Where(x => !x.Equals('\u0020')).Select(x => htmlEncoder.Encode(x.ToString())).ToArray();
+
+ if (SpaceChars.Any(classValue.Contains) || encodedSpaceChars.Any(value => classValue.IndexOf(value, StringComparison.Ordinal) >= 0))
+ {
+ throw new ArgumentException(Resources.ArgumentCannotContainHtmlSpace, nameof(classValue));
+ }
+
+ if (!tagHelperOutput.Attributes.TryGetAttribute("class", out TagHelperAttribute classAttribute))
+ {
+ tagHelperOutput.Attributes.Add("class", classValue);
+ }
+ else
+ {
+ var currentClassValue = ExtractClassValue(classAttribute, htmlEncoder);
+
+ var encodedClassValue = htmlEncoder.Encode(classValue);
+
+ if (string.Equals(currentClassValue, encodedClassValue, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ var arrayOfClasses = currentClassValue.Split(SpaceChars, StringSplitOptions.RemoveEmptyEntries)
+ .SelectMany(perhapsEncoded => perhapsEncoded.Split(encodedSpaceChars, StringSplitOptions.RemoveEmptyEntries))
+ .ToArray();
+
+ if (arrayOfClasses.Contains(encodedClassValue, StringComparer.Ordinal))
+ {
+ return;
+ }
+
+ var newClassAttribute = new TagHelperAttribute(
+ classAttribute.Name,
+ new HtmlString($"{currentClassValue} {encodedClassValue}"),
+ classAttribute.ValueStyle);
+
+ tagHelperOutput.Attributes.SetAttribute(newClassAttribute);
+ }
+ }
+
+ ///
+ /// Removes the given from the 's
+ /// .
+ ///
+ /// The this method extends.
+ /// The class value to remove.
+ /// The current HTML encoder.
+ public static void RemoveClass(
+ this TagHelperOutput tagHelperOutput,
+ string classValue,
+ HtmlEncoder htmlEncoder)
+ {
+ if (tagHelperOutput == null)
+ {
+ throw new ArgumentNullException(nameof(tagHelperOutput));
+ }
+
+ var encodedSpaceChars = SpaceChars.Where(x => !x.Equals('\u0020')).Select(x => htmlEncoder.Encode(x.ToString())).ToArray();
+
+ if (SpaceChars.Any(classValue.Contains) || encodedSpaceChars.Any(value => classValue.IndexOf(value, StringComparison.Ordinal) >= 0))
+ {
+ throw new ArgumentException(Resources.ArgumentCannotContainHtmlSpace, nameof(classValue));
+ }
+
+ if (!tagHelperOutput.Attributes.TryGetAttribute("class", out TagHelperAttribute classAttribute))
+ {
+ return;
+ }
+
+ var currentClassValue = ExtractClassValue(classAttribute, htmlEncoder);
+
+ if (string.IsNullOrEmpty(currentClassValue))
+ {
+ return;
+ }
+
+ var encodedClassValue = htmlEncoder.Encode(classValue);
+
+ if (string.Equals(currentClassValue, encodedClassValue, StringComparison.Ordinal))
+ {
+ tagHelperOutput.Attributes.Remove(tagHelperOutput.Attributes["class"]);
+ return;
+ }
+
+ if (!currentClassValue.Contains(encodedClassValue))
+ {
+ return;
+ }
+
+ var listOfClasses = currentClassValue.Split(SpaceChars, StringSplitOptions.RemoveEmptyEntries)
+ .SelectMany(perhapsEncoded => perhapsEncoded.Split(encodedSpaceChars, StringSplitOptions.RemoveEmptyEntries))
+ .ToList();
+
+ if (!listOfClasses.Contains(encodedClassValue))
+ {
+ return;
+ }
+
+ listOfClasses.RemoveAll(x => x.Equals(encodedClassValue));
+
+ if (listOfClasses.Any())
+ {
+ var joinedClasses = new HtmlString(string.Join(" ", listOfClasses));
+ tagHelperOutput.Attributes.SetAttribute(classAttribute.Name, joinedClasses);
+ }
+ else
+ {
+ tagHelperOutput.Attributes.Remove(tagHelperOutput.Attributes["class"]);
+ }
+ }
+
+ private static string ExtractClassValue(
+ TagHelperAttribute classAttribute,
+ HtmlEncoder htmlEncoder)
+ {
+ string extractedClassValue;
+ switch (classAttribute.Value)
+ {
+ case string valueAsString:
+ extractedClassValue = htmlEncoder.Encode(valueAsString);
+ break;
+ case HtmlString valueAsHtmlString:
+ extractedClassValue = valueAsHtmlString.Value;
+ break;
+ case IHtmlContent htmlContent:
+ using (var stringWriter = new StringWriter())
+ {
+ htmlContent.WriteTo(stringWriter, htmlEncoder);
+ extractedClassValue = stringWriter.ToString();
+ }
+ break;
+ default:
+ extractedClassValue = htmlEncoder.Encode(classAttribute.Value?.ToString());
+ break;
+ }
+ var currentClassValue = extractedClassValue ?? string.Empty;
+ return currentClassValue;
+ }
+
private static void CopyHtmlAttribute(
int allAttributeIndex,
TagHelperOutput tagHelperOutput,
diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs
index d5ee84f4fc..2c1cc87c07 100644
--- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TagHelperOutputExtensionsTest.cs
@@ -6,9 +6,12 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.Razor.TagHelpers.Testing;
using Microsoft.AspNetCore.Testing;
+using Microsoft.Extensions.WebEncoders.Testing;
+using Microsoft.AspNetCore.Mvc.TestCommon;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.TagHelpers
@@ -968,5 +971,184 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
attribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("for"));
Assert.Equal(expectedBuilderAttribute.Value, attribute.Value);
}
+
+ [Fact]
+ public void Single_AddClass()
+ {
+ // Arrange
+ var expectedValue = "class=\"HtmlEncode[[btn]]\"";
+ var htmlEncoder = new HtmlTestEncoder();
+
+ var tagHelperOutput = new TagHelperOutput(
+ tagName: "p",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(
+ new DefaultTagHelperContent()));
+
+ // Act
+ tagHelperOutput.AddClass("btn", htmlEncoder);
+
+ // Assert
+ var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class"));
+ Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute));
+ }
+
+ [Fact]
+ public void Multiple_AddClass()
+ {
+ // Arrange
+ var expectedValue = "class=\"HtmlEncode[[btn]] HtmlEncode[[btn-primary]]\"";
+ var htmlEncoder = new HtmlTestEncoder();
+
+ var tagHelperOutput = new TagHelperOutput(
+ tagName: "p",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(
+ new DefaultTagHelperContent()));
+
+ // Act
+ tagHelperOutput.AddClass("btn", htmlEncoder);
+ tagHelperOutput.AddClass("btn-primary", htmlEncoder);
+
+ // Assert
+ var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class"));
+ Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute));
+ }
+
+ [Fact]
+ public void Multiple_AddClass_RemoveClass_RemovesAllButOne()
+ {
+ // Arrange
+ var expectedValue = "class=\"HtmlEncode[[btn]]\"";
+ var htmlEncoder = new HtmlTestEncoder();
+
+ var tagHelperOutput = new TagHelperOutput(
+ tagName: "p",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(
+ new DefaultTagHelperContent()));
+
+ tagHelperOutput.AddClass("btn", htmlEncoder);
+ tagHelperOutput.AddClass("btn-success", htmlEncoder);
+ tagHelperOutput.AddClass("btn-primary", htmlEncoder);
+
+ // Act
+ tagHelperOutput.RemoveClass("btn-success", htmlEncoder);
+ tagHelperOutput.RemoveClass("btn-primary", htmlEncoder);
+
+ // Assert
+ var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class"));
+ Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute));
+ }
+
+ [Fact]
+ public void AddClass_RemoveClass_ContainsSpace()
+ {
+ // Arrange
+ var classValue = "btn btn-success";
+ var expected = new ArgumentException(Resources.ArgumentCannotContainHtmlSpace, nameof(classValue)).Message;
+ var htmlEncoder = new HtmlTestEncoder();
+
+ var tagHelperOutput = new TagHelperOutput(
+ tagName: "p",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(
+ new DefaultTagHelperContent()));
+
+ // Act and Assert
+ var exceptionAdd = Assert.Throws(() => tagHelperOutput.AddClass(classValue, htmlEncoder));
+ var exceptionRemove = Assert.Throws(() => tagHelperOutput.RemoveClass(classValue, htmlEncoder));
+ Assert.Equal(expected, exceptionAdd.Message);
+ Assert.Equal(expected, exceptionRemove.Message);
+ }
+
+ [Fact]
+ public void Single_RemoveClass_RemovesDuplicates_RemovesEntirely()
+ {
+ // Arrange
+ var htmlEncoder = new HtmlTestEncoder();
+
+ var tagHelperOutput = new TagHelperOutput(
+ tagName: "p",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(
+ new DefaultTagHelperContent()));
+
+ tagHelperOutput.Attributes.SetAttribute("class", new HtmlString("HtmlEncode[[btn]] HtmlEncode[[btn]]"));
+
+ // Act
+ tagHelperOutput.RemoveClass("btn", htmlEncoder);
+
+ // Assert
+ var classAttribute = tagHelperOutput.Attributes["class"];
+ Assert.Null(classAttribute);
+ }
+
+ [Fact]
+ public void Single_RemoveClass_RemovesDuplicates()
+ {
+ // Arrange
+ var expectedValue = "class=\"HtmlEncode[[btn-primary]]\"";
+ var htmlEncoder = new HtmlTestEncoder();
+
+ var tagHelperOutput = new TagHelperOutput(
+ tagName: "p",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(
+ new DefaultTagHelperContent()));
+
+ tagHelperOutput.Attributes.SetAttribute("class", new HtmlString("HtmlEncode[[btn]] HtmlEncode[[btn-primary]] HtmlEncode[[btn]]"));
+
+ // Act
+ tagHelperOutput.RemoveClass("btn", htmlEncoder);
+
+ // Assert
+ var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class"));
+ Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute));
+ }
+
+ [Fact]
+ public void Single_RemoveClass_RemovesEntirely()
+ {
+ var htmlEncoder = new HtmlTestEncoder();
+
+ var tagHelperOutput = new TagHelperOutput(
+ tagName: "p",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(
+ new DefaultTagHelperContent()));
+
+ tagHelperOutput.Attributes.SetAttribute("class", new HtmlString("HtmlEncode[[btn]]"));
+
+ // Act
+ tagHelperOutput.RemoveClass("btn", htmlEncoder);
+
+ // Assert
+ var classAttribute = tagHelperOutput.Attributes["class"];
+ Assert.Null(classAttribute);
+ }
+
+ [Fact]
+ public void Single_RemoveClass()
+ {
+ // Arrange
+ var expectedValue = "class=\"HtmlEncode[[btn]]\"";
+ var htmlEncoder = new HtmlTestEncoder();
+
+ var tagHelperOutput = new TagHelperOutput(
+ tagName: "p",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) => Task.FromResult(
+ new DefaultTagHelperContent()));
+
+ tagHelperOutput.Attributes.SetAttribute("class", new HtmlString("HtmlEncode[[btn]] HtmlEncode[[btn-primary]]"));
+
+ // Act
+ tagHelperOutput.RemoveClass("btn-primary", htmlEncoder);
+
+ // Assert
+ var classAttribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("class"));
+ Assert.Equal(expectedValue, HtmlContentUtilities.HtmlContentToString(classAttribute));
+ }
}
}