diff --git a/README.md b/README.md
index 4cd482c..3ab9555 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ Provides HTMX integration for ASP.NET Core applications.
* [TagHelpers](#taghelpers)
* [HtmxUrlTagHelper](#htmxurltaghelper)
* [HtmxHeaderTagHelper](#htmxheadertaghelper)
+ * [HtmxRequestTagHelper](#htmxrequesttaghelper)
* [HtmxConfigTagHelper](#htmxconfigtaghelper)
* [Response Handling Configuration](#response-handling-configuration)
* [Toolkit Script](#toolkit-script)
@@ -437,11 +438,12 @@ allowing you to flexibly configure the `swap` header you need.
## TagHelpers
-The library provides 3 tag helpers:
+The library provides 4 tag helpers:
* `HtmxUrlTagHelper`
* `HtmxHeaderTagHelper`
* `HtmxConfigTagHelper`
+* `HtmxRequestTagHelper`
To make them available in your project, add the `@addTagHelper` directive in the Razor view.
@@ -580,6 +582,29 @@ you can assign them to the `hx-all-headers` attribute:
The `HtmxHeaderTagHelper` will take care of all the remaining work regarding JSON serialization and escaping.
+### HtmxRequestTagHelper
+
+The `HtmxRequestTagHelper` configures the htmx request options supported by HTMX 1.x and 2.x.
+Use the typed `hx-request-*` attributes instead of writing JSON manually:
+
+```html
+
+```
+
+The following HTML will be generated:
+
+```html
+
+```
+
### HtmxConfigTagHelper
As with `hx-headers`, configuring `htmx` settings requires a JSON representation.
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml
index d29a421..fb412ae 100644
--- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml
+++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml
@@ -38,4 +38,35 @@
Choose a request type.
+
+
+
+
hx-request-timeout
+
The handler responds after 1200 ms. The first request times out after 1000 ms; the second completes normally.
+
+
+
+
+
+
+
+
+ Choose a request mode.
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs
index 5af5f37..8e09def 100644
--- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs
+++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs
@@ -10,4 +10,10 @@ public IActionResult OnGetPartialOrFull() =>
Request.IsHtmxRequest()
? "Partial response (HTMX request detected via IsHtmxRequest())"
: "Full page response. This wouldn't normally be a Content result, but demonstrates the check.");
+
+ public async Task OnGetDelayedAsync()
+ {
+ await Task.Delay(1200);
+ return Content("Response received after 1200 ms.");
+ }
}
diff --git a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css
index ee545b5..4d76be2 100644
--- a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css
+++ b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css
@@ -185,10 +185,6 @@ code {
padding: 2rem clamp(1.25rem, 4vw, 4rem) 5rem;
}
-.page-heading {
- margin-bottom: 2.5rem;
-}
-
.page-heading h1 {
margin: 0.25rem 0 0.75rem;
font-size: clamp(1.8rem, 5vw, 3rem);
@@ -201,6 +197,11 @@ code {
font-size: 1.25rem;
}
+.example-page {
+ display: grid;
+ row-gap: 2.5rem;
+}
+
.eyebrow {
margin: 0;
color: var(--accent-strong);
diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs
new file mode 100644
index 0000000..9db20e7
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace Ramstack.HtmxToolkit.TagHelpers;
+
+[JsonSourceGenerationOptions(
+ WriteIndented = false,
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ GenerationMode = JsonSourceGenerationMode.Serialization)]
+[JsonSerializable(typeof(HtmxRequestTagHelper.HtmxRequestData))]
+internal partial class HtmxRequestJsonSerializerContext : JsonSerializerContext;
diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
new file mode 100644
index 0000000..bb2b429
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
@@ -0,0 +1,86 @@
+using System.Text.Json;
+
+using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+
+namespace Ramstack.HtmxToolkit.TagHelpers;
+
+///
+/// Represents a implementation that applies the hx-request attribute to matching elements.
+///
+///
+/// hx-request is merge-inherited and can be placed on a parent element.
+///
+[HtmlTargetElement(Attributes = RequestTimeoutAttributeName)]
+[HtmlTargetElement(Attributes = RequestCredentialsAttributeName)]
+[HtmlTargetElement(Attributes = RequestNoHeadersAttributeName)]
+public sealed class HtmxRequestTagHelper : TagHelper
+{
+ private const string RequestTimeoutAttributeName = "hx-request-timeout";
+ private const string RequestCredentialsAttributeName = "hx-request-credentials";
+ private const string RequestNoHeadersAttributeName = "hx-request-no-headers";
+
+ private readonly HtmxRequestData _request = new();
+
+ ///
+ /// Gets or sets the timeout for the request in milliseconds.
+ ///
+ /// Supported in HTMX 1.x and 2.x.
+ [HtmlAttributeName(RequestTimeoutAttributeName)]
+ public int? Timeout
+ {
+ get => _request.Timeout;
+ set => _request.Timeout = value;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the request sends credentials.
+ ///
+ /// Supported in HTMX 1.x and 2.x.
+ [HtmlAttributeName(RequestCredentialsAttributeName)]
+ public bool? Credentials
+ {
+ get => _request.Credentials;
+ set => _request.Credentials = value;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether htmx strips all request headers.
+ ///
+ /// Supported in HTMX 1.x and 2.x.
+ [HtmlAttributeName(RequestNoHeadersAttributeName)]
+ public bool? NoHeaders
+ {
+ get => _request.NoHeaders;
+ set => _request.NoHeaders = value;
+ }
+
+ ///
+ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
+ {
+ if (Timeout is not null || Credentials is not null || NoHeaders is not null)
+ {
+ var request = new HtmlString(
+ JsonSerializer.Serialize(_request, HtmxRequestJsonSerializerContext.Default.HtmxRequestData));
+
+ output.Attributes.SetAttribute(
+ new TagHelperAttribute("hx-request", request, HtmlAttributeValueStyle.SingleQuotes));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ #region Inner type: HtmxRequestData
+
+ ///
+ /// Represents the serializable request configuration data.
+ ///
+ internal sealed class HtmxRequestData
+ {
+ public int? Timeout { get; set; }
+ public bool? Credentials { get; set; }
+ public bool? NoHeaders { get; set; }
+ }
+
+ #endregion
+}
diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs
new file mode 100644
index 0000000..c66726b
--- /dev/null
+++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs
@@ -0,0 +1,59 @@
+namespace Ramstack.HtmxToolkit.Tests;
+
+[TestFixture]
+public class HtmxRequestTagHelperTests
+{
+ [Test]
+ public async Task ProcessAsync_SerializesRequestConfiguration()
+ {
+ var output = TestHelper.CreateTagHelperOutput();
+ var helper = new HtmxRequestTagHelper
+ {
+ Timeout = 500,
+ Credentials = true,
+ NoHeaders = false
+ };
+
+ await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
+ var attribute = output.Attributes["hx-request"];
+
+ Assert.That(attribute, Is.Not.Null);
+
+ var json = JsonHelper.ParseJson(attribute!.Value.ToString()!);
+ Assert.That(json["timeout"].GetInt32(), Is.EqualTo(500));
+ Assert.That(json["credentials"].GetBoolean(), Is.True);
+ Assert.That(json["noHeaders"].GetBoolean(), Is.False);
+ }
+
+ [Test]
+ public async Task ProcessAsync_OmitsUnsetProperties()
+ {
+ var output = TestHelper.CreateTagHelperOutput();
+ var helper = new HtmxRequestTagHelper
+ {
+ Timeout = 500
+ };
+
+ await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
+ var attribute = output.Attributes["hx-request"];
+
+ Assert.That(attribute, Is.Not.Null);
+
+ var json = JsonHelper.ParseJson(attribute!.Value.ToString()!);
+
+ Assert.That(json["timeout"].GetInt32(), Is.EqualTo(500));
+ Assert.That(json.ContainsKey("credentials"), Is.False);
+ Assert.That(json.ContainsKey("noHeaders"), Is.False);
+ }
+
+ [Test]
+ public async Task ProcessAsync_OmitsUnsetConfiguration()
+ {
+ var output = TestHelper.CreateTagHelperOutput();
+ var helper = new HtmxRequestTagHelper();
+
+ await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);
+
+ Assert.That(output.Attributes["hx-request"], Is.Null);
+ }
+}