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);
}