Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div hx-request-inherited="true"
hx-request-timeout="2000">
...
<div hx-vals-inherited="true"
hx-val-category="books">
<button hx-post="/search"
hx-vals-append="true"
hx-val-sort="title">
Search
</button>
</div>
```

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

Expand Down
24 changes: 20 additions & 4 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// HTMX 4.x requires either the explicit inheritance modifier or the global
/// <see cref="HtmxV4Config.ImplicitInheritance" /> option for inheritance.
/// </item>
/// <item>A child declaration of a header overrides a parent declaration.</item>
/// <item>The <c>append</c> modifier merges a child declaration into inherited headers.</item>
/// </list>
/// </remarks>
[HtmlTargetElement(Attributes = InheritedAttributeName)]
[HtmlTargetElement(Attributes = AppendAttributeName)]
[HtmlTargetElement(Attributes = HeadersDictionaryName)]
[HtmlTargetElement(Attributes = HeadersPrefix + "*")]
public sealed class HtmxHeaderTagHelper(IOptions<HtmxToolkitOptions> 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";

Expand All @@ -42,6 +44,16 @@ public sealed class HtmxHeaderTagHelper(IOptions<HtmxToolkitOptions> options) :
[HtmlAttributeName(InheritedAttributeName)]
public bool Inherited { get; set; }

/// <summary>
/// Gets or sets a value indicating whether <c>hx-headers</c> is appended to inherited headers.
/// </summary>
/// <remarks>
/// HTMX 4.x emits the <c>append</c> modifier when this property is <see langword="true" />.
/// The property does not change the generated attribute name for HTMX 1.x and 2.x.
/// </remarks>
[HtmlAttributeName(AppendAttributeName)]
public bool Append { get; set; }

/// <summary>
/// Gets or sets the <c>hx-headers</c> attribute values.
/// </summary>
Expand All @@ -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);
Expand Down
18 changes: 16 additions & 2 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// <remarks>
/// <para>HTMX 1.x and 2.x use the merge-inherited <c>hx-request</c> attribute.</para>
/// <para>
/// HTMX 4.x uses <c>hx-config</c> and requires either the explicit inheritance modifier
/// or the global <see cref="HtmxV4Config.ImplicitInheritance" /> option for inheritance.
/// HTMX 4.x uses <c>hx-config</c>, supports the <c>inherited</c> and <c>append</c> modifiers,
/// and can enable inheritance globally with <see cref="HtmxV4Config.ImplicitInheritance" />.
/// </para>
/// </remarks>
[HtmlTargetElement(Attributes = RequestInheritedAttributeName)]
[HtmlTargetElement(Attributes = RequestAppendAttributeName)]
[HtmlTargetElement(Attributes = RequestTimeoutAttributeName)]
[HtmlTargetElement(Attributes = RequestCredentialsAttributeName)]
[HtmlTargetElement(Attributes = RequestNoHeadersAttributeName)]
Expand All @@ -31,6 +32,7 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
public sealed class HtmxRequestTagHelper(IOptions<HtmxToolkitOptions> 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";
Expand All @@ -52,6 +54,16 @@ public sealed class HtmxRequestTagHelper(IOptions<HtmxToolkitOptions> options) :
[HtmlAttributeName(RequestInheritedAttributeName)]
public bool Inherited { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the generated attribute is appended to inherited configuration.
/// </summary>
/// <remarks>
/// HTMX 4.x emits the <c>append</c> modifier when this property is <see langword="true" />.
/// The property does not change the generated attribute name for HTMX 1.x and 2.x.
/// </remarks>
[HtmlAttributeName(RequestAppendAttributeName)]
public bool Append { get; set; }

/// <summary>
/// Gets or sets the timeout for the request in milliseconds.
/// </summary>
Expand Down Expand Up @@ -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"
};
Expand Down
24 changes: 20 additions & 4 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// HTMX 4.x requires either the explicit inheritance modifier or the global
/// <see cref="HtmxV4Config.ImplicitInheritance" /> option for inheritance.
/// </item>
/// <item>A child declaration of a value overrides a parent declaration.</item>
/// <item>The <c>append</c> modifier merges a child declaration into inherited values.</item>
/// </list>
/// </remarks>
[HtmlTargetElement(Attributes = InheritedAttributeName)]
[HtmlTargetElement(Attributes = AppendAttributeName)]
[HtmlTargetElement(Attributes = ValuesDictionaryName)]
[HtmlTargetElement(Attributes = ValuesPrefix + "*")]
public sealed class HtmxValsTagHelper(IOptions<HtmxToolkitOptions> 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";

Expand All @@ -42,6 +44,16 @@ public sealed class HtmxValsTagHelper(IOptions<HtmxToolkitOptions> options) : Ta
[HtmlAttributeName(InheritedAttributeName)]
public bool Inherited { get; set; }

/// <summary>
/// Gets or sets a value indicating whether <c>hx-vals</c> is appended to inherited values.
/// </summary>
/// <remarks>
/// HTMX 4.x emits the <c>append</c> modifier when this property is <see langword="true" />.
/// The property does not change the generated attribute name for HTMX 1.x and 2.x.
/// </remarks>
[HtmlAttributeName(AppendAttributeName)]
public bool Append { get; set; }

/// <summary>
/// Gets or sets the <c>hx-vals</c> attribute values.
/// </summary>
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -108,19 +143,22 @@ 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);
var attribute = output.Attributes["hx-headers"];

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -237,19 +272,22 @@ 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);
var attribute = output.Attributes["hx-request"];

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

Expand Down
Loading