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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,31 @@ 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

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:

| Razor attribute | Generated HTMX 4 attribute |
|-------------------------------|----------------------------|
| `hx-request-inherited="true"` | `hx-config:inherited` |
| `hx-vals-inherited="true"` | `hx-vals:inherited` |
| `hx-headers-inherited="true"` | `hx-headers:inherited` |

For example, values declared on a parent element can be inherited by its children:

```html
<div hx-request-inherited="true"
hx-request-timeout="2000">
...
</div>
```

When `HtmxV4Config.MetaCharacter` is configured, that character is used instead of `:` in the generated attribute name.
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.

## Configuration

Configure HTMX once during service registration. Only explicitly configured values are emitted,
Expand Down
35 changes: 31 additions & 4 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Options;

using Ramstack.HtmxToolkit.Collections;
using Ramstack.HtmxToolkit.Configuration;
using Ramstack.HtmxToolkit.Serialization;

namespace Ramstack.HtmxToolkit.TagHelpers;
Expand All @@ -13,17 +15,33 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><c>hx-headers</c> is inherited and can be placed on a parent element.</item>
/// <item>HTMX 1.x and 2.x merge-inherit <c>hx-headers</c> from parent elements.</item>
/// <item>
/// 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>
/// </list>
/// </remarks>
[HtmlTargetElement(Attributes = InheritedAttributeName)]
[HtmlTargetElement(Attributes = HeadersDictionaryName)]
[HtmlTargetElement(Attributes = HeadersPrefix + "*")]
public sealed class HtmxHeaderTagHelper : TagHelper
public sealed class HtmxHeaderTagHelper(IOptions<HtmxToolkitOptions> options) : TagHelper
{
private const string InheritedAttributeName = "hx-headers-inherited";
private const string HeadersPrefix = "hx-header-";
private const string HeadersDictionaryName = "hx-all-headers";

/// <summary>
/// Gets or sets a value indicating whether <c>hx-headers</c> is explicitly inherited.
/// </summary>
/// <remarks>
/// <para>HTMX 1.x and 2.x merge-inherit headers without a modifier.</para>
/// <para>HTMX 4.x emits the <c>inherited</c> modifier when this property is <see langword="true" />.</para>
/// </remarks>
[HtmlAttributeName(InheritedAttributeName)]
public bool Inherited { get; set; }

/// <summary>
/// Gets or sets the <c>hx-headers</c> attribute values.
/// </summary>
Expand All @@ -39,11 +57,20 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp
{
if (Headers is { Count: > 0 })
{
var name = "hx-headers";
if (Inherited && options.Value.TargetVersion == HtmxTargetVersion.V4)
{
if (options.Value.HtmxConfig is HtmxV4Config config)
name = string.IsNullOrEmpty(config.MetaCharacter)
? "hx-headers:inherited"
: $"hx-headers{config.MetaCharacter}inherited";
}

var info = HtmxDictionaryJsonSerializerContext.Default.IDictionaryStringString;
var headers = new HtmlString(JsonSerializer.Serialize(Headers, info));
var json = JsonSerializer.Serialize(Headers, info);

output.Attributes.SetAttribute(
new TagHelperAttribute("hx-headers", headers, HtmlAttributeValueStyle.SingleQuotes));
new TagHelperAttribute(name, new HtmlString(json), HtmlAttributeValueStyle.SingleQuotes));
}

return Task.CompletedTask;
Expand Down
52 changes: 45 additions & 7 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </summary>
/// <remarks>
/// <para>HTMX 1.x and 2.x use the merge-inherited <c>hx-request</c> attribute.</para>
/// <para>HTMX 4.x uses the <c>hx-config</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.
/// </para>
/// </remarks>
[HtmlTargetElement(Attributes = RequestInheritedAttributeName)]
[HtmlTargetElement(Attributes = RequestTimeoutAttributeName)]
[HtmlTargetElement(Attributes = RequestCredentialsAttributeName)]
[HtmlTargetElement(Attributes = RequestNoHeadersAttributeName)]
Expand All @@ -26,6 +30,7 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
[HtmlTargetElement(Attributes = RequestValidateAttributeName)]
public sealed class HtmxRequestTagHelper(IOptions<HtmxToolkitOptions> options) : TagHelper
{
private const string RequestInheritedAttributeName = "hx-request-inherited";
private const string RequestTimeoutAttributeName = "hx-request-timeout";
private const string RequestCredentialsAttributeName = "hx-request-credentials";
private const string RequestNoHeadersAttributeName = "hx-request-no-headers";
Expand All @@ -37,6 +42,16 @@ public sealed class HtmxRequestTagHelper(IOptions<HtmxToolkitOptions> options) :

private readonly HtmxRequestData _request = new();

/// <summary>
/// Gets or sets a value indicating whether the generated attribute is explicitly inherited.
/// </summary>
/// <remarks>
/// <para>HTMX 1.x and 2.x merge-inherit request configuration without a modifier.</para>
/// <para>HTMX 4.x emits the <c>inherited</c> modifier when this property is <see langword="true" />.</para>
/// </remarks>
[HtmlAttributeName(RequestInheritedAttributeName)]
public bool Inherited { get; set; }

/// <summary>
/// Gets or sets the timeout for the request in milliseconds.
/// </summary>
Expand Down Expand Up @@ -143,22 +158,37 @@ public bool? Validate
/// <inheritdoc />
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var targetVersion = options.Value.TargetVersion;
var request = targetVersion == HtmxTargetVersion.V4
var json = options.Value.TargetVersion == HtmxTargetVersion.V4
? JsonSerializer.Serialize(new HtmxRequestDataV4(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataV4)
: JsonSerializer.Serialize(new HtmxRequestDataPrior(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataPrior);

if (request != "{}")
if (json != "{}")
{
var attributeName = targetVersion == HtmxTargetVersion.V4 ? "hx-config" : "hx-request";
var name = "hx-request";

if (options.Value.TargetVersion == HtmxTargetVersion.V4)
{
if (Inherited)
{
if (options.Value.HtmxConfig is HtmxV4Config config)
name = string.IsNullOrEmpty(config.MetaCharacter)
? "hx-config:inherited"
: $"hx-config{config.MetaCharacter}inherited";
}
else
{
name = "hx-config";
}
}

output.Attributes.SetAttribute(
new TagHelperAttribute(attributeName, new HtmlString(request), HtmlAttributeValueStyle.SingleQuotes));
new TagHelperAttribute(name, new HtmlString(json), HtmlAttributeValueStyle.SingleQuotes));
}

return Task.CompletedTask;
}

#region Inner types
#region Inner type: HtmxRequestData

/// <summary>
/// Stores the request configuration shared by all supported HTMX versions.
Expand Down Expand Up @@ -190,6 +220,10 @@ internal sealed class HtmxRequestData
public bool? Validate { get; set; }
}

#endregion

#region Inner type: HtmxRequestDataPrior

/// <summary>
/// Projects request configuration into the <c>hx-request</c> contract used by HTMX 1.x and 2.x.
/// </summary>
Expand All @@ -215,6 +249,10 @@ internal readonly struct HtmxRequestDataPrior(HtmxRequestData data)
public bool? NoHeaders => data.NoHeaders;
}

#endregion

#region Inner type: HtmxRequestDataV4

/// <summary>
/// Projects request configuration into the <c>hx-config</c> contract used by HTMX 4.x.
/// </summary>
Expand Down
31 changes: 28 additions & 3 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Options;

using Ramstack.HtmxToolkit.Collections;
using Ramstack.HtmxToolkit.Configuration;
using Ramstack.HtmxToolkit.Serialization;

namespace Ramstack.HtmxToolkit.TagHelpers;
Expand All @@ -13,17 +15,33 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><c>hx-vals</c> is inherited and can be placed on a parent element.</item>
/// <item>HTMX 1.x and 2.x merge-inherit <c>hx-vals</c> from parent elements.</item>
/// <item>
/// 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>
/// </list>
/// </remarks>
[HtmlTargetElement(Attributes = InheritedAttributeName)]
[HtmlTargetElement(Attributes = ValuesDictionaryName)]
[HtmlTargetElement(Attributes = ValuesPrefix + "*")]
public sealed class HtmxValsTagHelper : TagHelper
public sealed class HtmxValsTagHelper(IOptions<HtmxToolkitOptions> options) : TagHelper
{
private const string InheritedAttributeName = "hx-vals-inherited";
private const string ValuesPrefix = "hx-val-";
private const string ValuesDictionaryName = "hx-all-vals";

/// <summary>
/// Gets or sets a value indicating whether <c>hx-vals</c> is explicitly inherited.
/// </summary>
/// <remarks>
/// HTMX 1.x and 2.x merge-inherit values without a modifier.
/// HTMX 4.x emits the <c>inherited</c> modifier when this property is <see langword="true" />.
/// </remarks>
[HtmlAttributeName(InheritedAttributeName)]
public bool Inherited { get; set; }

/// <summary>
/// Gets or sets the <c>hx-vals</c> attribute values.
/// </summary>
Expand All @@ -39,9 +57,16 @@ 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)
if (options.Value.HtmxConfig is HtmxV4Config config)
name = string.IsNullOrEmpty(config.MetaCharacter)
? "hx-vals:inherited"
: $"hx-vals{config.MetaCharacter}inherited";

var info = HtmxDictionaryJsonSerializerContext.Default.IDictionaryStringString;
var json = JsonSerializer.Serialize(values, info);
var attribute = new TagHelperAttribute("hx-vals", new HtmlString(json), HtmlAttributeValueStyle.SingleQuotes);
var attribute = new TagHelperAttribute(name, new HtmlString(json), HtmlAttributeValueStyle.SingleQuotes);

output.Attributes.SetAttribute(attribute);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public void AddEvents_TracksTimingsIndependently()

[TestCase(HtmxTargetVersion.V1)]
[TestCase(HtmxTargetVersion.V2)]
public void AddEvents_LegacyVersions_WriteEachTimingToItsOwnHeader(HtmxTargetVersion targetVersion)
public void AddEvents_PriorVersions_WriteEachTimingToItsOwnHeader(HtmxTargetVersion targetVersion)
{
var context = TestHelper.CreateHtmxRequestContext(targetVersion);
var pending = PendingEvents.GetOrCreate(context.Response);
Expand Down
Loading