diff --git a/README.md b/README.md index 2f65336..d92a5e5 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,8 @@ Antiforgery metadata is enabled by default. `` renders the curren the companion script attaches the token to non-GET HTMX requests and refreshes it after boosted navigation. > [!WARNING] -> The companion script sends the token but does not perform validation. The application must still enable server-side antiforgery validation for the relevant endpoints. +> The companion script sends the token but does not validate it. Razor Pages validates unsafe HTTP methods automatically. +> MVC applications must enable server-side antiforgery validation for the relevant actions. For example, MVC applications can validate all unsafe actions globally: @@ -485,7 +486,7 @@ by HTMX 2.x and 4.x; only HTMX 1.9.x needs the extension. ## Sample The [`samples/Ramstack.HtmxToolkit.Demo`](samples/Ramstack.HtmxToolkit.Demo) project demonstrates request detection, -response headers, Tag Helpers, polling, boosted navigation, and antiforgery integration. +response headers and events, MVC attributes, Tag Helpers, polling, boosted navigation, and antiforgery integration. Run it with: diff --git a/samples/Ramstack.HtmxToolkit.Demo/Controllers/InventoryController.cs b/samples/Ramstack.HtmxToolkit.Demo/Controllers/InventoryController.cs new file mode 100644 index 0000000..1f8724f --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Controllers/InventoryController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Ramstack.HtmxToolkit.Demo.Controllers; + +public sealed class InventoryController : Controller +{ + [HttpGet] + [HtmxRequest] + [HtmxResponse(Reswap = HtmxSwap.OuterHtml)] + public IActionResult Status(string sku) + { + var content = sku == "BOOK-42" + ? "BOOK-42: Hypermedia Systems — 3 copies in stock." + : "No inventory record was found for that SKU."; + + return Content($"
{content}
", "text/html"); + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml index ef45a74..f31d219 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml @@ -6,42 +6,32 @@
-

Example 10

-

Antiforgery forms

-

Submit HTMX forms while the toolkit automatically supplies the verification token

+

Example 12

+

Protect form submissions

+

Send the antiforgery token expected by Razor Pages with an HTMX form

hx-post with antiforgery

-

The layout configures token inclusion and the toolkit script attaches it to every HTMX request.

+

Razor Pages validates the POST automatically. <htmx-config /> renders the token and the toolkit script attaches it to the request.

- - - - - +
- Submit the form to see the posted values. + No settings have been saved.
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml.cs index 47f0b9a..158a047 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml.cs @@ -1,23 +1,17 @@ +using System.Text.Encodings.Web; + using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; -[ValidateAntiForgeryToken] public class AntiforgeryModel : PageModel { - public IActionResult OnPostFormSubmit(ContactForm form) => - Content($""" - Form submitted successfully!
- Name: {form.Name}
- Email: {form.Email}
- Message: {form.Message} - """); - - public class ContactForm + public IActionResult OnPostSave(string? displayName) { - public string? Name { get; set; } - public string? Email { get; set; } - public string? Message { get; set; } + if (string.IsNullOrWhiteSpace(displayName)) + return BadRequest("Display name is required."); + + return Content($"Settings saved for {HtmlEncoder.Default.Encode(displayName)}."); } } diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml index 271c04b..70498e1 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml @@ -6,7 +6,7 @@
-

Example 08

+

Example 07

Detect boosted navigation

Identify a request initiated by an hx-boost link

@@ -14,7 +14,7 @@

IsHtmxBoosted()

-

The link is progressively enhanced and its response is placed into the result panel.

+

HTMX enhances a regular link; without JavaScript, the handler redirects back to this page.

diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml.cs index e13ac5e..3aa8d55 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml.cs @@ -6,8 +6,7 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; public class BoostedModel : PageModel { public IActionResult OnGetBoostedCheck() => - Content( - Request.IsHtmxBoosted() - ? "Boosted HTMX request detected!" - : "Non-boosted HTMX request."); + Request.IsHtmxBoosted() + ? Content("Boosted HTMX request detected.") + : RedirectToPage(); } diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml index c23186a..524bca3 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml @@ -1,44 +1,46 @@ @page @model FluentResponseModel @{ - ViewData["Title"] = "Fluent response API"; + ViewData["Title"] = "Response control"; }
-

Example 04

-

Fluent response API

-

Set HTMX response headers from the page handler with Response.Htmx()

+

Example 09

+

Control the response target

+

Let the server redirect a response to a more appropriate element

-

Retarget and reswap

-

The response changes the target or swap mode chosen by the triggering element.

+

Response.Htmx(h => h.Retarget(...))

+

A successful lookup updates the product panel; a missing product is retargeted to the notice.

- - +
- +
+ No lookup errors.
-
- Try either response directive. +
+ Choose a product.
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml.cs index ee3982f..db37e3b 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml.cs @@ -5,15 +5,12 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; public class FluentResponseModel : PageModel { - public IActionResult OnGetReswap() + public IActionResult OnGetProduct(string sku) { - Response.Htmx(h => h.Reswap(HtmxSwap.AfterBegin)); - return Content("

Prepended to top with AfterBegin swap!

"); - } + if (sku == "BOOK-42") + return Content("BOOK-42: Hypermedia Systems — in stock."); - public IActionResult OnGetRetarget() - { - Response.Htmx(h => h.Retarget("#fluent-result")); - return Content("Retargeted to #fluent-result!"); + Response.Htmx(htmx => htmx.Retarget("#product-notice")); + return Content("No product found for that SKU."); } } diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml index bf3e030..3174200 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml @@ -6,7 +6,7 @@
-

Example 03

+

Example 04

Request headers

Define custom HTMX headers declaratively in Razor

@@ -14,20 +14,19 @@

hx-header-*

-

The handler reads the header and triggers two client events.

+

The toolkit serializes the attribute as hx-headers; the handler reads it normally.

No request has been sent.
-
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml.cs index 146eb8a..6339eb9 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml.cs @@ -1,3 +1,5 @@ +using System.Text.Encodings.Web; + using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -5,14 +7,6 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; public class HeadersModel : PageModel { - public IActionResult OnGetCustomHeader() - { - Response.Htmx(h => h - .TriggerEvent("customEvent", new { message = "#1 Fired from server!" }) - .TriggerEvent("logEvent", new { message = $"Custom-Header = {Request.Headers["Custom-Header"]}" }) - .TriggerEvent("customEvent", new { message = "#2 Fired from server!" }) - .TriggerEvent("customEvent", new { message = "#3 Fired from server!" })); - - return Content("Custom headers sent!"); - } + public IActionResult OnGetShow() => + Content($"X-Report-Format: {HtmlEncoder.Default.Encode(Request.Headers["X-Report-Format"].ToString())}"); } diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml index 507cea8..578ca8a 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml @@ -8,65 +8,35 @@

Example 06

Detect HTMX requests

-

Return an appropriate representation when the handler is called asynchronously or directly

+

Return a fragment to HTMX and inspect its strongly typed request metadata

-

Request.IsHtmxRequest()

-

Compare a normal browser navigation with an HTMX request to the same handler.

+

Request.IsHtmxRequest(out var headers)

+

Both controls request the same URL; the HTMX response also reports its source, target, and request type.

- Open full request + Open the same URL normally
-
- 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. +
+ @(Model.OrderStatus ?? "Order status has not been requested.")
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs index 8e09def..bff55a1 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs @@ -1,3 +1,5 @@ +using System.Text.Encodings.Web; + using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -5,15 +7,22 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; public class HtmxRequestModel : PageModel { - public IActionResult OnGetPartialOrFull() => - Content( - 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 string? OrderStatus { get; private set; } - public async Task OnGetDelayedAsync() + public IActionResult OnGet(string? id) { - await Task.Delay(1200); - return Content("Response received after 1200 ms."); + if (id is null) + return Page(); + + var status = $"Order #{id} is ready for pickup."; + + if (Request.IsHtmxRequest(out var headers)) + { + var metadata = $"{status} Source: {headers.Source}; target: {headers.Target}; type: {headers.RequestType}."; + return Content(HtmlEncoder.Default.Encode(metadata)); + } + + OrderStatus = $"{status} This was a normal browser request."; + return Page(); } } diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxVals.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxVals.cshtml index 3eb8838..6b889ff 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxVals.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxVals.cshtml @@ -6,15 +6,15 @@
-

Example 11

-

Send values with HTMX

-

Add request parameters declaratively without assembling JSON by hand

+

Example 03

+

Send additional values

+

Add request parameters without writing an hx-vals JSON object

hx-val-* and hx-all-vals

-

Both forms send values with the request and create an hx-vals attribute.

+

The toolkit combines individual values or a dictionary into hx-vals.

@@ -25,7 +25,7 @@ hx-val-category="science" hx-val-format="summary" hx-target="#values-result"> - Send individual values + Preview science summary
- Choose a value declaration. + Choose a report preview.
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxVals.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxVals.cshtml.cs index 4a2a6bc..c28e401 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxVals.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxVals.cshtml.cs @@ -11,7 +11,7 @@ public IActionResult OnGetShow(string? category, string? format) { return Content($"""

Category: {Encode(category)}

-

Format: {Encode(format)}

+

Format: {Encode(format)}

"""); static string Encode(string? text) => diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/MvcAttributes.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/MvcAttributes.cshtml new file mode 100644 index 0000000..97071e5 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/MvcAttributes.cshtml @@ -0,0 +1,33 @@ +@page +@{ + ViewData["Title"] = "MVC attributes"; +} + +
+
+

Example 08

+

Route HTMX requests to MVC actions

+

Generate a controller URL and apply request and response behavior declaratively

+
+ +
+
+

[HtmxRequest] and [HtmxResponse]

+

The action accepts only HTMX requests and replaces the complete inventory status element.

+
+ +
+ +
+ +
+ Inventory has not been checked. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml index 8ec7251..80108e3 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml @@ -1,22 +1,31 @@ @page @model PollingModel @{ - ViewData["Title"] = "Polling"; + ViewData["Title"] = "Progress bar"; }
-

Example 05

-

Polling

-

Let the server decide whether to schedule the next HTMX request

+

Example 11

+

Progress bar

+

Track a background job with polling controlled by server-rendered HTML

-

Server-controlled polling

-

The server returns the next polling element every second until it randomly stops.

+

Background job progress

+

The server updates the bar every 400 ms and stops polling when the job reaches 100%.

- +
+

Start the simulated job to see server-controlled polling in action.

+ +
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml.cs index 1d2b6bd..9d0f406 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -5,17 +6,21 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; public class PollingModel : PageModel { - public PollingState State { get; } = new(false, "Polling is active"); + public IActionResult OnPostStart() => + Partial("_PollingStatus", new ProgressState(0)); - public IActionResult OnGetPoll() + public IActionResult OnGetProgress(int progress) { - var stopped = Random.Shared.Next(0, 20) == 10; - var message = stopped - ? "Polling stopped!" - : $"Polling... {DateTime.Now:HH:mm:ss}"; + progress = Math.Clamp(progress + 10, 0, 100); - return Partial("_PollingStatus", new PollingState(stopped, message)); + return Partial("_PollingStatus", new ProgressState(progress)); } - public sealed record PollingState(bool Stopped, string Message); + public sealed record ProgressState(int Percent) + { + public bool Completed => Percent == 100; + + public string Scale => + (Percent / 100d).ToString("0.##", CultureInfo.InvariantCulture); + } } diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml deleted file mode 100644 index 7afc751..0000000 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml +++ /dev/null @@ -1,34 +0,0 @@ -@page -@model RandomModel -@{ - ViewData["Title"] = "On-demand content"; -} - -
-
-

Example 09

-

Load content on demand

-

A compact baseline HTMX interaction using a Razor Page handler

-
- -
-
-

Random number

-

Click the button to request a fresh server-generated value.

-
- -
- -
- -
- No value loaded yet. -
-
-
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml.cs deleted file mode 100644 index 93fae0e..0000000 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; - -namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; - -public class RandomModel : PageModel -{ - public IActionResult OnGetRandom() => - Content($"Random number: {Random.Shared.Next(1, 100)}"); -} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml new file mode 100644 index 0000000..8cbd6a4 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml @@ -0,0 +1,34 @@ +@page +@model RequestOptionsModel +@{ + ViewData["Title"] = "Request options"; +} + +
+
+

Example 05

+

Configure one request

+

Set typed HTMX request options without writing an hx-config value

+
+ +
+
+

hx-request-timeout

+

The toolkit emits an HTMX 4 hx-config timeout for this report preview.

+
+ +
+ +
+ +
+ No preview has been generated. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml.cs new file mode 100644 index 0000000..218cbbe --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; + +public class RequestOptionsModel : PageModel +{ + public async Task OnGetPreviewAsync() + { + await Task.Delay(750, HttpContext.RequestAborted); + return Content($"Report preview generated at {DateTime.UtcNow:HH:mm:ss} UTC."); + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml deleted file mode 100644 index 472d7c1..0000000 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml +++ /dev/null @@ -1,35 +0,0 @@ -@page -@model ResponseHeadersModel -@{ - ViewData["Title"] = "Response headers"; -} - -
-
-

Example 07

-

Response swap headers

-

Override an element's HTMX swap strategy in the response handler

-
- -
-
-

Imperative Reswap

-

The trigger asks for outerHTML, but the server responds with innerHTML.

-
- -
- -
- -
- The response will remain in this panel. -
-
-
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml.cs deleted file mode 100644 index d112176..0000000 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; - -namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; - -public class ResponseHeadersModel : PageModel -{ - public IActionResult OnGetDeclarativeReswap() - { - Response.Htmx(h => h.Reswap(HtmxSwap.InnerHtml)); - return Content("Reswapped with InnerHtml via Response.Htmx(h => h.Reswap(HtmxSwap.InnerHtml))!"); - } -} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml index fd8c136..4686e50 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml @@ -7,38 +7,38 @@

Example 02

-

Route parameters

-

Pass route data with individual attributes or one Razor dictionary

+

Pass route values

+

Add route values individually or from a dictionary

hx-route-* and hx-all-route-data

-

Both forms produce a typed Razor Pages URL without composing a query string manually.

+

Both buttons generate a handler URL without assembling a query string.

-
- Choose a route-data strategy. +
+ Choose an order.
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml.cs index 725af70..d3bb3ff 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml.cs @@ -5,6 +5,6 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; public class RouteDataModel : PageModel { - public IActionResult OnGetGreet(string name) => - Content($"Hello, {name}!"); + public IActionResult OnGetOrder(int id) => + Content($"Order #{id} is packed and ready for pickup."); } diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ServerEvents.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ServerEvents.cshtml new file mode 100644 index 0000000..b36417e --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ServerEvents.cshtml @@ -0,0 +1,47 @@ +@page +@model ServerEventsModel +@{ + ViewData["Title"] = "Server events"; +} + +
+
+

Example 10

+

Trigger UI updates from the server

+

Notify another element after a successful request without custom JavaScript

+
+ +
+
+

Response.Htmx(h => h.TriggerEvent(...))

+

Saving the form triggers profileSaved; the profile summary listens for that event and reloads itself.

+
+ +
+ + + +
+ +
+ No changes have been saved. +
+ +
+ The profile summary has not been refreshed. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ServerEvents.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ServerEvents.cshtml.cs new file mode 100644 index 0000000..fee8c33 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ServerEvents.cshtml.cs @@ -0,0 +1,26 @@ +using System.Text.Encodings.Web; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; + +public class ServerEventsModel : PageModel +{ + public IActionResult OnPostSave(string? displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + return BadRequest("Display name is required."); + + Response.Htmx(htmx => htmx.TriggerEvent("profileSaved")); + return Content("Profile saved."); + } + + public IActionResult OnGetSummary(string? displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + return BadRequest("Display name is required."); + + return Content($"Current profile: {HtmlEncoder.Default.Encode(displayName)}."); + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml index 1d2f1e9..f30f205 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml @@ -1,40 +1,33 @@ @page @model UrlTagHelperModel @{ - ViewData["Title"] = "URL Tag Helper"; + ViewData["Title"] = "Razor Pages URLs"; }

Example 01

-

URL Tag Helper

-

Generate HTMX URLs with familiar Razor Pages routing attributes

+

Generate Razor Pages URLs

+

Build an HTMX request URL from a Razor Page and handler name

hx-page + hx-page-handler

-

The toolkit generates the request URL and uses hx-get by default.

+

The toolkit generates hx-get, so the URL stays in sync with ASP.NET Core routing.

-
- Run an action to see the response. + The service has not been checked yet.
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml.cs index 5b9599a..2dca39b 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml.cs @@ -5,9 +5,6 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; public class UrlTagHelperModel : PageModel { - public IActionResult OnGetServerTime() => - Content($"Server time: {DateTime.Now:HH:mm:ss}"); - - public IActionResult OnGetHello() => - Content("Hello from the server!"); + public IActionResult OnGetStatus() => + Content($"Service is healthy. Checked at {DateTime.UtcNow:HH:mm:ss} UTC."); } diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/_PollingStatus.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/_PollingStatus.cshtml index bed0f74..6c4f832 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/_PollingStatus.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/_PollingStatus.cshtml @@ -1,18 +1,53 @@ -@model PollingModel.PollingState +@model PollingModel.ProgressState -@if (Model.Stopped) +@if (Model.Completed) { -
- @Model.Message +
+
+ Job complete + @Model.Percent% +
+
+
+
+
} else { -
- @Model.Message + hx-page-handler="Progress" + hx-route-progress="@Model.Percent" + hx-trigger="every 400ms" + hx-swap="outerMorph"> +
+ Processing background job + @Model.Percent% +
+
+
+
} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml index 7157d0a..24ae7f4 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml @@ -8,17 +8,44 @@

Explore the Ramstack.HtmxToolkit API through focused, runnable Razor Pages examples.

-
-

01. URL Tag Helper

Generate handler URLs declaratively.

-

02. Route data

Pass route values without query-string assembly.

-

03. Request headers

Attach custom HTMX headers in Razor.

-

04. Fluent responses

Configure response directives on the server.

-

05. Polling

Let the server control repeated requests.

-

06. HTMX requests

Detect asynchronous requests.

-

07. Response headers

Override the requested swap strategy.

-

08. Boosted navigation

Detect links enhanced by HTMX.

-

09. On-demand content

Request and swap a small response.

-

10. Antiforgery

Post a protected form via HTMX.

-

11. HTMX values

Send additional request values declaratively.

+ diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml index ae12d16..9c240ad 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml @@ -18,32 +18,39 @@
- Razor Pages — HTMX 2 + ASP.NET Core — HTMX 4
@RenderBody()
- + diff --git a/samples/Ramstack.HtmxToolkit.Demo/Program.cs b/samples/Ramstack.HtmxToolkit.Demo/Program.cs index 28b1fea..be91569 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Program.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Program.cs @@ -1,38 +1,26 @@ -using Ramstack.HtmxToolkit; -using Ramstack.HtmxToolkit.Configuration; using Ramstack.HtmxToolkit.Hosting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorPages(); +builder.Services.AddControllers(); builder.Services.AddHtmxToolkit(options => { - options.IncludeAntiforgeryToken = true; - options.UseHtmxV2(config => + options.UseHtmxV4(config => { - config.DefaultSwapStyle = HtmxSwap.InnerHtml; - config.MethodsThatUseUrlParams = [HttpVerb.Get, HttpVerb.Delete]; - config.ResponseHandling = - [ - new() { Code = "204", Swap = false }, - new() { Code = "422", Swap = true }, - new() { Code = "[23]..", Swap = true }, - new() { Code = "[45]..", Swap = false, Error = true }, - new() { Code = "...", Swap = true } - ]; + config.NoSwap = ["204", "304", "4xx", "5xx"]; }); }); var app = builder.Build(); if (!app.Environment.IsDevelopment()) -{ app.UseExceptionHandler("/Error"); -} app.UseStaticFiles(); app.UseRouting(); app.MapHtmxToolkitScript(); +app.MapDefaultControllerRoute(); app.MapRazorPages(); app.Run(); diff --git a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css index 0313687..ac56412 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css +++ b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css @@ -34,8 +34,7 @@ a { } button, -input, -textarea { +input { font: inherit; } @@ -63,7 +62,6 @@ button:hover, button:focus-visible, .button:focus-visible, input:focus-visible, -textarea:focus-visible, .nav-link:focus-visible { outline: 3px solid #d4deff; outline-offset: 3px; @@ -128,7 +126,7 @@ code { } .nav-label { - margin-bottom: 0.25rem; + margin: 1rem 0 0.25rem; padding-left: 1rem; color: #6f7f9c; font-size: 0.75rem; @@ -136,6 +134,10 @@ code { text-transform: uppercase; } +.nav-label:first-child { + margin-top: 0; +} + .sidebar nav { overflow-y: auto; } @@ -210,15 +212,11 @@ code { text-transform: uppercase; } -.demo-card, -.overview-card { +.demo-card { border: 1px solid var(--border); border-radius: 0.5rem; background: linear-gradient(145deg, rgb(25 30 50 / 0.9), rgb(15 20 35 / 0.9)); box-shadow: var(--shadow); -} - -.demo-card { padding: clamp(1.25rem, 3vw, 2rem); } @@ -248,35 +246,67 @@ code { color: #bdc9df; } -.event-log { - margin-top: 1rem; +.job-progress { + display: grid; + gap: 1rem; + max-width: 38rem; + margin-top: 1.5rem; + padding: 1.25rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: rgb(5 10 20 / 0.5); +} + +.job-progress--idle { + justify-items: start; +} + +.job-progress--idle p { + margin: 0; color: var(--muted); - font-size: 0.9rem; } -.event-log p { +.job-progress__meta { display: flex; - gap: 0.5rem; - margin: 0.5rem 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + color: var(--muted); } -.event-log b { - color: var(--success); +.job-progress__meta strong { + color: var(--accent-strong); + font-variant-numeric: tabular-nums; } -.poll-status { - display: inline-block; - margin-top: 1.5rem; - padding: 0.25rem 1rem; +.job-progress__track { + overflow: hidden; + width: 100%; + height: 0.75rem; border-radius: 10rem; - background: rgb(120 220 175 / 0.15); + background: var(--surface-raised); +} + +.job-progress__fill { + width: 100%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), #b29cff); + transform: scaleX(0); + transform-origin: left; + transition: transform 400ms ease-in-out; +} + +.job-progress--complete .job-progress__meta strong { color: var(--success); - font-size: 0.85rem; } -.poll-status--stopped { - background: rgb(255 165 0 / 0.15); - color: orange; +.job-progress--complete .job-progress__fill { + background: var(--success); +} + +.job-progress--complete .button-secondary { + justify-self: start; } .demo-form { @@ -294,8 +324,7 @@ code { font-weight: 700; } -input, -textarea { +input { width: 100%; padding: 0.5rem 1rem; border: 1px solid var(--border); @@ -304,40 +333,66 @@ textarea { color: var(--text); } -textarea { - resize: vertical; -} - -.overview-grid { +.overview-sections { display: grid; - grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); - gap: 1rem; + gap: 2rem; } -.overview-card { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 1.25rem; - text-decoration: none; - transition: border-color 0.16s ease, transform 0.16s ease; +.overview-section > header { + margin-bottom: 1rem; } -.overview-card:hover { - border-color: var(--accent); +.overview-section > header h2 { + margin: 0 0 0.25rem; + font-size: 1.2rem; } -.overview-card p { +.overview-section > header p { margin: 0; color: var(--muted); - font-size: 0.9rem; } -.overview-card h2 { +.overview-list { + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--border); +} + +.overview-list li { + border-bottom: 1px solid var(--border); +} + +.overview-link { + display: grid; + grid-template-columns: 2.5rem minmax(0, 1fr); + padding: 0.9rem 1rem; + text-decoration: none; + transition: background 0.16s ease; +} + +.overview-link:hover { + background: rgb(124 156 255 / 0.09); +} + +.overview-number { + grid-row: 1 / span 2; + color: var(--accent-strong); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.overview-link h3 { margin: 0; font-size: 1rem; } +.overview-link p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + @media (max-width: 760px) { .app-shell { display: block; @@ -368,3 +423,9 @@ textarea { padding-top: 1rem; } } + +@media (prefers-reduced-motion: reduce) { + .job-progress__fill { + transition: none; + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/js/demo.js b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/js/demo.js index 7ffc7ac..09a2861 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/js/demo.js +++ b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/js/demo.js @@ -5,12 +5,4 @@ document.querySelectorAll(".nav-link").forEach(link => { link.pathname === window.location.pathname && link.classList.add("active"); }); - - document.addEventListener("customEvent", e => append_event("Custom event", e.detail.message)); - document.addEventListener("logEvent", e => append_event("Log event", e.detail.message)); - - function append_event(name, message) { - const el = document.querySelector("#event-log"); - el.insertAdjacentHTML("beforeend", `

${name}: ${message}

`); - } })();