diff --git a/README.md b/README.md index 6f007f2..b64c9c0 100644 --- a/README.md +++ b/README.md @@ -304,29 +304,39 @@ With HTMX 4.x selected, `HtmxRequestTagHelper` generates `hx-config` instead. HTMX 4.x additionally supports `hx-request-cache`, `hx-request-redirect`, `hx-request-referrer`, `hx-request-integrity`, and `hx-request-validate`; `hx-request-no-headers` is limited to HTMX 1.9.x and 2.x. -### Attribute Inheritance +### Attribute Modifiers HTMX 1.9.x and 2.x merge-inherit `hx-request`, `hx-vals`, and `hx-headers` automatically. HTMX 4.x requires inheritance to be enabled explicitly. Use the corresponding Tag Helper attribute to emit -the HTMX 4 `inherited` modifier: +the HTMX 4 `inherited` or `append` modifier: | Razor attribute | Generated HTMX 4 attribute | |-------------------------------|----------------------------| | `hx-request-inherited="true"` | `hx-config:inherited` | +| `hx-request-append="true"` | `hx-config:append` | | `hx-vals-inherited="true"` | `hx-vals:inherited` | +| `hx-vals-append="true"` | `hx-vals:append` | | `hx-headers-inherited="true"` | `hx-headers:inherited` | +| `hx-headers-append="true"` | `hx-headers:append` | -For example, values declared on a parent element can be inherited by its children: +Use `inherited` on a parent and `append` on a child to merge their values: ```html -
- ... +
+
``` +Setting both options on the same element emits one combined attribute, such as +`hx-vals:inherited:append`, so the merged value is also inherited by descendants. + Alternatively, set `HtmxV4Config.ImplicitInheritance` to `true` to enable inheritance globally. -For HTMX 1.9.x and 2.x, the Tag Helper attributes above do not change the generated attribute names. +For HTMX 1.9.x and 2.x, the Tag Helper modifier attributes above do not change the generated attribute names. ## Configuration diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs index 7f56aff..b5d42ad 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs @@ -20,15 +20,17 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// HTMX 4.x requires either the explicit inheritance modifier or the global /// option for inheritance. /// -/// A child declaration of a header overrides a parent declaration. +/// The append modifier merges a child declaration into inherited headers. /// /// [HtmlTargetElement(Attributes = InheritedAttributeName)] +[HtmlTargetElement(Attributes = AppendAttributeName)] [HtmlTargetElement(Attributes = HeadersDictionaryName)] [HtmlTargetElement(Attributes = HeadersPrefix + "*")] public sealed class HtmxHeaderTagHelper(IOptions options) : TagHelper { private const string InheritedAttributeName = "hx-headers-inherited"; + private const string AppendAttributeName = "hx-headers-append"; private const string HeadersPrefix = "hx-header-"; private const string HeadersDictionaryName = "hx-all-headers"; @@ -42,6 +44,16 @@ public sealed class HtmxHeaderTagHelper(IOptions options) : [HtmlAttributeName(InheritedAttributeName)] public bool Inherited { get; set; } + /// + /// Gets or sets a value indicating whether hx-headers is appended to inherited headers. + /// + /// + /// HTMX 4.x emits the append modifier when this property is . + /// The property does not change the generated attribute name for HTMX 1.x and 2.x. + /// + [HtmlAttributeName(AppendAttributeName)] + public bool Append { get; set; } + /// /// Gets or sets the hx-headers attribute values. /// @@ -57,9 +69,13 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp { if (Headers is { Count: > 0 }) { - var name = "hx-headers"; - if (Inherited && options.Value.TargetVersion == HtmxTargetVersion.V4) - name = "hx-headers:inherited"; + var name = options.Value.TargetVersion switch + { + HtmxTargetVersion.V4 when Inherited && Append => "hx-headers:inherited:append", + HtmxTargetVersion.V4 when Inherited => "hx-headers:inherited", + HtmxTargetVersion.V4 when Append => "hx-headers:append", + _ => "hx-headers" + }; var info = HtmxDictionaryJsonSerializerContext.Default.IDictionaryStringString; var json = JsonSerializer.Serialize(Headers, info); diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs index bf906ac..d5aedb5 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -15,11 +15,12 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// /// HTMX 1.x and 2.x use the merge-inherited hx-request attribute. /// -/// HTMX 4.x uses hx-config and requires either the explicit inheritance modifier -/// or the global option for inheritance. +/// HTMX 4.x uses hx-config, supports the inherited and append modifiers, +/// and can enable inheritance globally with . /// /// [HtmlTargetElement(Attributes = RequestInheritedAttributeName)] +[HtmlTargetElement(Attributes = RequestAppendAttributeName)] [HtmlTargetElement(Attributes = RequestTimeoutAttributeName)] [HtmlTargetElement(Attributes = RequestCredentialsAttributeName)] [HtmlTargetElement(Attributes = RequestNoHeadersAttributeName)] @@ -31,6 +32,7 @@ namespace Ramstack.HtmxToolkit.TagHelpers; public sealed class HtmxRequestTagHelper(IOptions options) : TagHelper { private const string RequestInheritedAttributeName = "hx-request-inherited"; + private const string RequestAppendAttributeName = "hx-request-append"; private const string RequestTimeoutAttributeName = "hx-request-timeout"; private const string RequestCredentialsAttributeName = "hx-request-credentials"; private const string RequestNoHeadersAttributeName = "hx-request-no-headers"; @@ -52,6 +54,16 @@ public sealed class HtmxRequestTagHelper(IOptions options) : [HtmlAttributeName(RequestInheritedAttributeName)] public bool Inherited { get; set; } + /// + /// Gets or sets a value indicating whether the generated attribute is appended to inherited configuration. + /// + /// + /// HTMX 4.x emits the append modifier when this property is . + /// The property does not change the generated attribute name for HTMX 1.x and 2.x. + /// + [HtmlAttributeName(RequestAppendAttributeName)] + public bool Append { get; set; } + /// /// Gets or sets the timeout for the request in milliseconds. /// @@ -166,7 +178,9 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp { var name = options.Value.TargetVersion switch { + HtmxTargetVersion.V4 when Inherited && Append => "hx-config:inherited:append", HtmxTargetVersion.V4 when Inherited => "hx-config:inherited", + HtmxTargetVersion.V4 when Append => "hx-config:append", HtmxTargetVersion.V4 => "hx-config", _ => "hx-request" }; diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs index 966399c..65b06e4 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs @@ -20,15 +20,17 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// HTMX 4.x requires either the explicit inheritance modifier or the global /// option for inheritance. /// -/// A child declaration of a value overrides a parent declaration. +/// The append modifier merges a child declaration into inherited values. /// /// [HtmlTargetElement(Attributes = InheritedAttributeName)] +[HtmlTargetElement(Attributes = AppendAttributeName)] [HtmlTargetElement(Attributes = ValuesDictionaryName)] [HtmlTargetElement(Attributes = ValuesPrefix + "*")] public sealed class HtmxValsTagHelper(IOptions options) : TagHelper { private const string InheritedAttributeName = "hx-vals-inherited"; + private const string AppendAttributeName = "hx-vals-append"; private const string ValuesPrefix = "hx-val-"; private const string ValuesDictionaryName = "hx-all-vals"; @@ -42,6 +44,16 @@ public sealed class HtmxValsTagHelper(IOptions options) : Ta [HtmlAttributeName(InheritedAttributeName)] public bool Inherited { get; set; } + /// + /// Gets or sets a value indicating whether hx-vals is appended to inherited values. + /// + /// + /// HTMX 4.x emits the append modifier when this property is . + /// The property does not change the generated attribute name for HTMX 1.x and 2.x. + /// + [HtmlAttributeName(AppendAttributeName)] + public bool Append { get; set; } + /// /// Gets or sets the hx-vals attribute values. /// @@ -57,9 +69,13 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp { if (Values is { Count: > 0 } values) { - var name = "hx-vals"; - if (Inherited && options.Value.TargetVersion == HtmxTargetVersion.V4) - name = "hx-vals:inherited"; + var name = options.Value.TargetVersion switch + { + HtmxTargetVersion.V4 when Inherited && Append => "hx-vals:inherited:append", + HtmxTargetVersion.V4 when Inherited => "hx-vals:inherited", + HtmxTargetVersion.V4 when Append => "hx-vals:append", + _ => "hx-vals" + }; var info = HtmxDictionaryJsonSerializerContext.Default.IDictionaryStringString; var json = JsonSerializer.Serialize(values, info); diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs index a33ccb2..027c87d 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs @@ -80,6 +80,41 @@ public async Task ProcessAsync_Htmx4_Inherited_UsesInheritedAttribute() Assert.That(output.Attributes["hx-headers"], Is.Null); } + [Test] + public async Task ProcessAsync_Htmx4_Append_UsesAppendAttribute() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + + helper.Append = true; + helper.Headers["X-Custom"] = "value"; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-headers:append"]; + + Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"X-Custom\":\"value\"}")); + Assert.That(output.Attributes["hx-headers"], Is.Null); + } + + [Test] + public async Task ProcessAsync_Htmx4_InheritedAndAppend_UsesCombinedAttribute() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + + helper.Inherited = true; + helper.Append = true; + helper.Headers["X-Custom"] = "value"; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-headers:inherited:append"]; + + Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"X-Custom\":\"value\"}")); + Assert.That(output.Attributes["hx-headers"], Is.Null); + Assert.That(output.Attributes["hx-headers:inherited"], Is.Null); + Assert.That(output.Attributes["hx-headers:append"], Is.Null); + } + [Test] public async Task ProcessAsync_Htmx4_NotInherited_UsesOrdinaryAttribute() { @@ -108,12 +143,13 @@ public async Task ProcessAsync_Htmx4_Inherited_WithEmptyDictionary_OmitsAttribut [TestCase(HtmxTargetVersion.V1)] [TestCase(HtmxTargetVersion.V2)] - public async Task ProcessAsync_Htmx1_Htmx2_Inherited_UsesOrdinaryAttribute(HtmxTargetVersion version) + public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTargetVersion version) { var output = TestHelper.CreateTagHelperOutput(); var helper = CreateHelper(version); helper.Inherited = true; + helper.Append = true; helper.Headers["X-Custom"] = "value"; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); @@ -121,6 +157,8 @@ public async Task ProcessAsync_Htmx1_Htmx2_Inherited_UsesOrdinaryAttribute(HtmxT Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"X-Custom\":\"value\"}")); Assert.That(output.Attributes["hx-headers:inherited"], Is.Null); + Assert.That(output.Attributes["hx-headers:append"], Is.Null); + Assert.That(output.Attributes["hx-headers:inherited:append"], Is.Null); Assert.That(output.Attributes["hx-inherit"], Is.Null); } diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs index 8b6f4a0..8ce52d5 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs @@ -193,6 +193,41 @@ public async Task ProcessAsync_Htmx4_Inherited_UsesInheritedAttribute() Assert.That(output.Attributes["hx-config"], Is.Null); } + [Test] + public async Task ProcessAsync_Htmx4_Append_UsesAppendAttribute() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + + helper.Append = true; + helper.Timeout = 500; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-config:append"]; + + Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"timeout\":500}")); + Assert.That(output.Attributes["hx-config"], Is.Null); + } + + [Test] + public async Task ProcessAsync_Htmx4_InheritedAndAppend_UsesCombinedAttribute() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + + helper.Inherited = true; + helper.Append = true; + helper.Timeout = 500; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-config:inherited:append"]; + + Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"timeout\":500}")); + Assert.That(output.Attributes["hx-config"], Is.Null); + Assert.That(output.Attributes["hx-config:inherited"], Is.Null); + Assert.That(output.Attributes["hx-config:append"], Is.Null); + } + [Test] public async Task ProcessAsync_Htmx4_Inherited_WithImplicitInheritance_StillUsesInheritedAttribute() { @@ -237,12 +272,13 @@ public async Task ProcessAsync_Inherited_WithEmptyConfiguration_OmitsAttribute() [TestCase(HtmxTargetVersion.V1)] [TestCase(HtmxTargetVersion.V2)] - public async Task ProcessAsync_Htmx1_Htmx2_Inherited_UsesOrdinaryAttribute(HtmxTargetVersion version) + public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTargetVersion version) { var output = TestHelper.CreateTagHelperOutput(); var helper = CreateHelper(version); helper.Inherited = true; + helper.Append = true; helper.Timeout = 500; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); @@ -250,6 +286,8 @@ public async Task ProcessAsync_Htmx1_Htmx2_Inherited_UsesOrdinaryAttribute(HtmxT Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"timeout\":500}")); Assert.That(output.Attributes["hx-request:inherited"], Is.Null); + Assert.That(output.Attributes["hx-request:append"], Is.Null); + Assert.That(output.Attributes["hx-request:inherited:append"], Is.Null); Assert.That(output.Attributes["hx-inherit"], Is.Null); } diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs index f57ffcf..ea6119d 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs @@ -75,6 +75,41 @@ public async Task ProcessAsync_Htmx4_Inherited_UsesInheritedAttribute() Assert.That(output.Attributes["hx-vals"], Is.Null); } + [Test] + public async Task ProcessAsync_Htmx4_Append_UsesAppendAttribute() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + + helper.Append = true; + helper.Values["sort"] = "title"; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-vals:append"]; + + Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"sort\":\"title\"}")); + Assert.That(output.Attributes["hx-vals"], Is.Null); + } + + [Test] + public async Task ProcessAsync_Htmx4_InheritedAndAppend_UsesCombinedAttribute() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + + helper.Inherited = true; + helper.Append = true; + helper.Values["sort"] = "title"; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-vals:inherited:append"]; + + Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"sort\":\"title\"}")); + Assert.That(output.Attributes["hx-vals"], Is.Null); + Assert.That(output.Attributes["hx-vals:inherited"], Is.Null); + Assert.That(output.Attributes["hx-vals:append"], Is.Null); + } + [Test] public async Task ProcessAsync_Htmx4_NotInherited_UsesOrdinaryAttribute() { @@ -103,12 +138,13 @@ public async Task ProcessAsync_Htmx4_Inherited_WithEmptyDictionary_OmitsAttribut [TestCase(HtmxTargetVersion.V1)] [TestCase(HtmxTargetVersion.V2)] - public async Task ProcessAsync_Htmx1_Htmx2_Inherited_UsesOrdinaryAttribute(HtmxTargetVersion version) + public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTargetVersion version) { var output = TestHelper.CreateTagHelperOutput(); var helper = CreateHelper(version); helper.Inherited = true; + helper.Append = true; helper.Values["sort"] = "title"; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); @@ -116,6 +152,8 @@ public async Task ProcessAsync_Htmx1_Htmx2_Inherited_UsesOrdinaryAttribute(HtmxT Assert.That(attribute!.Value.ToString(), Is.EqualTo("{\"sort\":\"title\"}")); Assert.That(output.Attributes["hx-vals:inherited"], Is.Null); + Assert.That(output.Attributes["hx-vals:append"], Is.Null); + Assert.That(output.Attributes["hx-vals:inherited:append"], Is.Null); Assert.That(output.Attributes["hx-inherit"], Is.Null); }