diff --git a/.editorconfig b/.editorconfig index 94ad698..8c749ed 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,15 +8,12 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[*.{yml,props,targets,csproj}] +[*.{cshtml,css,yml,props,targets,csproj}] indent_size = 2 [*.min.js] insert_final_newline = false -[*.{tt,ttinclude}] -end_of_line = crlf - [*.{cs,cshtml}] resharper_csharp_max_line_length = 160 diff --git a/Ramstack.HtmxToolkit.slnx b/Ramstack.HtmxToolkit.slnx index ecb0b2f..9f1b330 100644 --- a/Ramstack.HtmxToolkit.slnx +++ b/Ramstack.HtmxToolkit.slnx @@ -9,4 +9,5 @@ + diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml new file mode 100644 index 0000000..ef45a74 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml @@ -0,0 +1,47 @@ +@page +@model AntiforgeryModel +@{ + ViewData["Title"] = "Antiforgery"; +} + +
+
+

Example 10

+

Antiforgery forms

+

Submit HTMX forms while the toolkit automatically supplies the verification token

+
+ +
+
+

hx-post with antiforgery

+

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

+
+ +
+ + + + + + + +
+ +
+ Submit the form to see the posted values. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml.cs new file mode 100644 index 0000000..47f0b9a --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Antiforgery.cshtml.cs @@ -0,0 +1,23 @@ +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 string? Name { get; set; } + public string? Email { get; set; } + public string? Message { get; set; } + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml new file mode 100644 index 0000000..271c04b --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml @@ -0,0 +1,34 @@ +@page +@model BoostedModel +@{ + ViewData["Title"] = "Boosted requests"; +} + +
+
+

Example 08

+

Detect boosted navigation

+

Identify a request initiated by an hx-boost link

+
+ +
+
+

IsHtmxBoosted()

+

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

+
+ + + +
+ The handler has not been called. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml.cs new file mode 100644 index 0000000..e13ac5e --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Boosted.cshtml.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; + +public class BoostedModel : PageModel +{ + public IActionResult OnGetBoostedCheck() => + Content( + Request.IsHtmxBoosted() + ? "Boosted HTMX request detected!" + : "Non-boosted HTMX request."); +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml new file mode 100644 index 0000000..c23186a --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml @@ -0,0 +1,44 @@ +@page +@model FluentResponseModel +@{ + ViewData["Title"] = "Fluent response API"; +} + +
+
+

Example 04

+

Fluent response API

+

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

+
+ +
+
+

Retarget and reswap

+

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

+
+ +
+ + + + +
+ +
+ Try either response directive. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml.cs new file mode 100644 index 0000000..ee3982f --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/FluentResponse.cshtml.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; + +public class FluentResponseModel : PageModel +{ + public IActionResult OnGetReswap() + { + Response.Htmx(h => h.Reswap(HtmxSwap.AfterBegin)); + return Content("

Prepended to top with AfterBegin swap!

"); + } + + public IActionResult OnGetRetarget() + { + Response.Htmx(h => h.Retarget("#fluent-result")); + return Content("Retargeted to #fluent-result!"); + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml new file mode 100644 index 0000000..bf3e030 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml @@ -0,0 +1,33 @@ +@page +@model HeadersModel +@{ + ViewData["Title"] = "Request headers"; +} + +
+
+

Example 03

+

Request headers

+

Define custom HTMX headers declaratively in Razor

+
+ +
+
+

hx-header-*

+

The handler reads the header and triggers two client events.

+
+
+ +
+ +
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 new file mode 100644 index 0000000..813b0c8 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Headers.cshtml.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +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"]}" })); + + return Content("Custom headers sent!") + .Htmx(h => h + .TriggerEvent("customEvent", new { message = "#2 Fired from server!" }) + .TriggerEvent("customEvent", new { message = "#3 Fired from server!" })); + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml new file mode 100644 index 0000000..d29a421 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml @@ -0,0 +1,41 @@ +@page +@model HtmxRequestModel +@{ + ViewData["Title"] = "HTMX requests"; +} + +
+
+

Example 06

+

Detect HTMX requests

+

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

+
+ +
+
+

Request.IsHtmxRequest()

+

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

+
+ +
+ + + Open full request + +
+ +
+ Choose a request type. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs new file mode 100644 index 0000000..5af5f37 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +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."); +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml new file mode 100644 index 0000000..94efabc --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml @@ -0,0 +1,32 @@ +@page +@model PollingModel +@{ + ViewData["Title"] = "Polling"; +} + +
+
+

Example 05

+

Polling

+

Stop a repeating HTMX request from the server when a condition is reached

+
+ +
+
+

HtmxResponse.StopPolling()

+

The request is evaluated every second. The server randomly asks HTMX to stop polling.

+
+
+ Polling is active +
+ +
+ Waiting for the first response. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml.cs new file mode 100644 index 0000000..0d9adc9 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; + +public class PollingModel : PageModel +{ + public IActionResult OnGetStopPolling() + { + var stop = Random.Shared.Next(0, 20) == 10; + Response.Htmx((h, f) => h.StopPolling(f), stop); + + var content = $"Polling... {DateTime.Now:HH:mm:ss}"; + if (stop) + content += "Polling stopped!"; + + return Content(content); + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml new file mode 100644 index 0000000..7afc751 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml @@ -0,0 +1,34 @@ +@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 new file mode 100644 index 0000000..93fae0e --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Random.cshtml.cs @@ -0,0 +1,10 @@ +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/ResponseHeaders.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml new file mode 100644 index 0000000..472d7c1 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml @@ -0,0 +1,35 @@ +@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 new file mode 100644 index 0000000..d112176 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/ResponseHeaders.cshtml.cs @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..fd8c136 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml @@ -0,0 +1,44 @@ +@page +@model RouteDataModel +@{ + ViewData["Title"] = "Route parameters"; +} + +
+
+

Example 02

+

Route parameters

+

Pass route data with individual attributes or one Razor dictionary

+
+ +
+
+

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

+

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

+
+ +
+ + +
+ +
+ Choose a route-data strategy. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml.cs new file mode 100644 index 0000000..725af70 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RouteData.cshtml.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Ramstack.HtmxToolkit.Demo.Pages.Examples; + +public class RouteDataModel : PageModel +{ + public IActionResult OnGetGreet(string name) => + Content($"Hello, {name}!"); +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml new file mode 100644 index 0000000..1d2f1e9 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml @@ -0,0 +1,40 @@ +@page +@model UrlTagHelperModel +@{ + ViewData["Title"] = "URL Tag Helper"; +} + +
+
+

Example 01

+

URL Tag Helper

+

Generate HTMX URLs with familiar Razor Pages routing attributes

+
+ +
+
+

hx-page + hx-page-handler

+

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

+
+ +
+ + +
+ +
+ Run an action to see the response. +
+
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml.cs new file mode 100644 index 0000000..5b9599a --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/UrlTagHelper.cshtml.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +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!"); +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml new file mode 100644 index 0000000..54bff4d --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml @@ -0,0 +1,23 @@ +@page +@model IndexModel + +
+
+

Interactive documentation

+

HTMX integration for ASP.NET Core

+

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

+
+ + +
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml.cs new file mode 100644 index 0000000..c0cddd9 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Ramstack.HtmxToolkit.Demo.Pages; + +public class IndexModel : PageModel +{ + public void OnGet() + { + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..fb0b84e --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Shared/_Layout.cshtml @@ -0,0 +1,57 @@ + + + + + + @(ViewData["Title"] ?? "Home") — Ramstack.HtmxToolkit + + + + + + + + + + + + + + + + + + + diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/_ViewImports.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..76aa65a --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@namespace Ramstack.HtmxToolkit.Demo.Pages +@using Ramstack.HtmxToolkit +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Ramstack.HtmxToolkit diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/_ViewStart.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Program.cs b/samples/Ramstack.HtmxToolkit.Demo/Program.cs new file mode 100644 index 0000000..9c757de --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Program.cs @@ -0,0 +1,19 @@ +using Ramstack.HtmxToolkit.Builder; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorPages(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error"); +} + +app.UseStaticFiles(); +app.UseRouting(); +app.MapHtmxAntiforgeryScript(); +app.MapRazorPages(); + +app.Run(); diff --git a/samples/Ramstack.HtmxToolkit.Demo/Properties/launchSettings.json b/samples/Ramstack.HtmxToolkit.Demo/Properties/launchSettings.json new file mode 100644 index 0000000..a50afa1 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Ramstack.HtmxToolkit.Demo": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/Ramstack.HtmxToolkit.Demo.csproj b/samples/Ramstack.HtmxToolkit.Demo/Ramstack.HtmxToolkit.Demo.csproj new file mode 100644 index 0000000..7c12a08 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/Ramstack.HtmxToolkit.Demo.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + preview + enable + Ramstack.HtmxToolkit.Demo + + + + + + diff --git a/samples/Ramstack.HtmxToolkit.Demo/appsettings.json b/samples/Ramstack.HtmxToolkit.Demo/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/Icon.png b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/Icon.png new file mode 100644 index 0000000..a623b05 Binary files /dev/null and b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/Icon.png differ diff --git a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css new file mode 100644 index 0000000..ee545b5 --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css @@ -0,0 +1,364 @@ +:root { + color-scheme: dark; + --background: #090d16; + --surface: #101625; + --surface-raised: #171f31; + --border: #28334a; + --text: #edf2fb; + --muted: #9aa8c1; + --accent: #7c9cff; + --accent-strong: #9eb4ff; + --success: #77dbad; + --shadow: 0 24px 56px rgb(0 0 0 / 0.3); +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--background); +} + +body { + min-height: 100vh; + margin: 0; + background: radial-gradient(circle at top right, #182a4b 0, transparent 34rem), var(--background); + color: var(--text); + font: 16px/1.6 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +a { + color: inherit; +} + +button, +input, +textarea { + font: inherit; +} + +button, +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: 0.5rem; + background: var(--accent); + color: #0d1425; + cursor: pointer; + font-weight: 700; + text-decoration: none; + transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease; +} + +button:hover, +.button:hover { + background: var(--accent-strong); +} + +button:focus-visible, +.button:focus-visible, +input:focus-visible, +textarea:focus-visible, +.nav-link:focus-visible { + outline: 3px solid #d4deff; + outline-offset: 3px; +} + +.button-secondary { + border-color: var(--border); + background: transparent; + color: var(--text); +} + +.button-secondary:hover { + border-color: var(--accent); + background: rgb(124 156 255 / 0.1); +} + +code { + padding: 0.125rem 0.375rem; + border: 1px solid rgb(158 180 255 / 0.2); + border-radius: 0.25rem; + background: #0b1120; + color: #c5d2ff; + font: 0.875em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.app-shell { + display: grid; + grid-template-columns: 17rem minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + height: 100vh; + width: 285px; + padding: 1.5rem 1rem; + border-right: 1px solid var(--border); + background: rgb(10 15 27 / 0.9); + backdrop-filter: blur(1rem); +} + +.brand { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; + color: var(--text); + font-size: 1.2rem; + font-weight: 750; + text-decoration: none; +} + +.brand-mark { + display: block; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + object-fit: cover; +} + +.nav-label { + margin-bottom: 0.25rem; + padding-left: 1rem; + color: #6f7f9c; + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; +} + +.sidebar nav { + overflow-y: auto; +} + +.nav-link { + display: block; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + color: var(--muted); + font-size: 0.9rem; + text-decoration: none; +} + +.nav-link:hover { + background: rgb(124 156 255 / 0.09); + color: var(--text); +} + +.nav-link.active { + background: rgb(124 156 255 / 0.15); + color: #d8e0ff; + font-weight: 700; +} + +.main { + min-width: 0; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 76rem; + margin: 0 auto; + padding: 1.35rem clamp(1.25rem, 4vw, 4rem); + color: var(--muted); + font-size: 0.85rem; +} + +.menu-toggle { + display: none; +} + +.content { + width: min(100%, 76rem); + margin: 0 auto; + 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); + line-height: 1.2; +} + +.page-heading p:not(.eyebrow) { + margin: 0; + color: var(--muted); + font-size: 1.25rem; +} + +.eyebrow { + margin: 0; + color: var(--accent-strong); + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; +} + +.demo-card, +.overview-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); +} + +.demo-card__header h2 { + margin: 0 0 0.5rem; + font-size: 1.2rem; +} + +.demo-card__header p { + margin: 0; + color: var(--muted); +} + +.demo-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.5rem; +} + +.result { + margin-top: 1.5rem; + padding: 0.75rem 1rem; + border: 1px dashed #3a4a68; + border-radius: 0.5rem; + background: rgb(5 10 20 / 0.50); + color: #bdc9df; +} + +.event-log { + margin-top: 1rem; + color: var(--muted); + font-size: 0.9rem; +} + +.event-log p { + display: flex; + gap: 0.5rem; + margin: 0.5rem 0; +} + +.event-log b { + color: var(--success); +} + +.poll-status { + display: inline-block; + margin-top: 1.5rem; + padding: 0.25rem 1rem; + border-radius: 10rem; + background: rgb(120 220 175 / 0.15); + color: var(--success); + font-size: 0.85rem; +} + +.demo-form { + display: grid; + gap: 1rem; + max-width: 34rem; + margin-top: 1.5rem; +} + +.demo-form label { + display: grid; + gap: 0.25rem; + color: #cad5ea; + font-size: 0.9rem; + font-weight: 700; +} + +input, +textarea { + width: 100%; + padding: 0.5rem 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: #0b1120; + color: var(--text); +} + +textarea { + resize: vertical; +} + +.overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; +} + +.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-card:hover { + border-color: var(--accent); +} + +.overview-card p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.overview-card h2 { + margin: 0; + font-size: 1rem; +} + +@media (max-width: 760px) { + .app-shell { + display: block; + } + + .sidebar { + position: fixed; + z-index: 10; + width: 285px; + transform: translateX(-105%); + transition: transform 0.2s ease; + } + + .sidebar-open .sidebar { + transform: translateX(0); + box-shadow: var(--shadow); + } + + .menu-toggle { + display: inline-flex; + padding: 0.5rem 1rem; + border-color: var(--border); + background: var(--surface); + color: var(--text); + } + + .content { + padding-top: 1rem; + } +} diff --git a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/js/demo.js b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/js/demo.js new file mode 100644 index 0000000..7ffc7ac --- /dev/null +++ b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/js/demo.js @@ -0,0 +1,16 @@ +(() => { + const menu_toggle = document.querySelector("[data-menu-toggle]"); + menu_toggle.addEventListener("click", () => document.body.classList.toggle("sidebar-open")); + + 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}

`); + } +})(); diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js index f954ee8..2233f54 100644 --- a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js +++ b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js @@ -25,9 +25,9 @@ document._r_htmx ||= ((document, htmx) => { }); document.addEventListener("_r_proxy", e => { - for (let kvp of e.detail) + for (let kvp of e.detail.value) { htmx.trigger(e.target, kvp.key, kvp.value); + } }); - return true; })(document, htmx); diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js index 8703d23..671f04a 100644 --- a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js +++ b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js @@ -1 +1 @@ -document._r_htmx||=((e,t)=>(e.addEventListener("htmx:afterOnLoad",e=>{if(e.detail.boosted){const r=(new DOMParser).parseFromString(e.detail.xhr.responseText,"text/html").querySelector("meta[name='htmx-config']");r&&(t.config.antiForgery=JSON.parse(r.content).antiForgery)}}),e.addEventListener("htmx:configRequest",e=>{if(!/^get$/i.test(e.detail.verb)){const{headerName:r,formFieldName:a,requestToken:n}=t.config.antiForgery??{};n&&!e.detail.parameters[a]&&(r?e.detail.headers[r]=n:e.detail.parameters[a]=n)}}),e.addEventListener("_r_proxy",e=>{for(let r of e.detail)t.trigger(e.target,r.key,r.value)}),!0))(document,htmx); \ No newline at end of file +document._r_htmx||=((e,t)=>(e.addEventListener("htmx:afterOnLoad",e=>{if(e.detail.boosted){const r=(new DOMParser).parseFromString(e.detail.xhr.responseText,"text/html").querySelector("meta[name='htmx-config']");r&&(t.config.antiForgery=JSON.parse(r.content).antiForgery)}}),e.addEventListener("htmx:configRequest",e=>{if(!/^get$/i.test(e.detail.verb)){const{headerName:r,formFieldName:a,requestToken:n}=t.config.antiForgery??{};n&&!e.detail.parameters[a]&&(r?e.detail.headers[r]=n:e.detail.parameters[a]=n)}}),e.addEventListener("_r_proxy",e=>{for(let r of e.detail.value)t.trigger(e.target,r.key,r.value)}),!0))(document,htmx); \ No newline at end of file diff --git a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj index f9b5bec..bfb969f 100644 --- a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj +++ b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj @@ -1,6 +1,6 @@ - net6.0;net8.0 + net6.0 Enables seamless integration of HTMX with ASP.NET Core (https://htmx.org). enable enable diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs index b1d7520..d02b71a 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigJsonSerializerContext.cs @@ -1,4 +1,3 @@ -#if NET8_0_OR_GREATER using System.Text.Json.Serialization; namespace Ramstack.HtmxToolkit.TagHelpers; @@ -8,8 +7,5 @@ namespace Ramstack.HtmxToolkit.TagHelpers; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, GenerationMode = JsonSourceGenerationMode.Serialization)] -[JsonSerializable(typeof(HtmxConfigTagHelper.HtmxConfiguration))] -[JsonSerializable(typeof(HtmxConfigTagHelper.AntiForgeryTokenData?))] -[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(HtmxConfigTagHelper.HtmxConfigData))] internal partial class HtmxConfigJsonSerializerContext : JsonSerializerContext; -#endif diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs index 5d56999..56bcfcf 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using Microsoft.AspNetCore.Antiforgery; @@ -19,13 +18,7 @@ namespace Ramstack.HtmxToolkit.TagHelpers; [HtmlTargetElement("htmx-config", TagStructure = TagStructure.NormalOrSelfClosing)] public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper { - private readonly IAntiforgery _antiforgery = antiforgery; - - /// - /// The key used to pass items between - /// and this tag helper via . - /// - internal const string ResponseHandlingEntriesKey = "Htmx_ResponseHandlingEntries"; + private readonly HtmxConfigData _config = new(); /// /// Gets or sets a value indicating whether history is enabled. @@ -35,14 +28,22 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// Supported in HTMX 1.x and 2.x. This is mainly useful for testing. /// [HtmlAttributeName("history-enabled")] - public bool? HistoryEnabled { get; set; } + public bool? HistoryEnabled + { + get => _config.HistoryEnabled; + set => _config.HistoryEnabled = value; + } /// /// Gets or sets the size of the history cache. Defaults to 10. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("history-cache-size")] - public int? HistoryCacheSize { get; set; } + public int? HistoryCacheSize + { + get => _config.HistoryCacheSize; + set => _config.HistoryCacheSize = value; + } /// /// Gets or sets a value indicating whether a full page refresh @@ -51,7 +52,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("refresh-on-history-miss")] - public bool? RefreshOnHistoryMiss { get; set; } + public bool? RefreshOnHistoryMiss + { + get => _config.RefreshOnHistoryMiss; + set => _config.RefreshOnHistoryMiss = value; + } /// /// Gets or sets the default swap style. @@ -59,21 +64,34 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("default-swap-style")] - public HtmxSwap? DefaultSwapStyle { get; set; } + public HtmxSwap? DefaultSwapStyle + { + // NOTE: The getter exists primarily for debugging; performance is not a concern here. + get => EnumHelper.ParseHtmxSwap(_config.DefaultSwapStyle); + set => _config.DefaultSwapStyle = value.GetSwapValue(); + } /// /// Gets or sets the default swap delay. Defaults to 0. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("default-swap-delay")] - public int? DefaultSwapDelay { get; set; } + public int? DefaultSwapDelay + { + get => _config.DefaultSwapDelay; + set => _config.DefaultSwapDelay = value; + } /// /// Gets or sets the default settle delay. Defaults to 20. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("default-settle-delay")] - public int? DefaultSettleDelay { get; set; } + public int? DefaultSettleDelay + { + get => _config.DefaultSettleDelay; + set => _config.DefaultSettleDelay = value; + } /// /// Gets or sets a value indicating whether the indicator styles are loaded. @@ -81,42 +99,66 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("include-indicator-styles")] - public bool? IncludeIndicatorStyles { get; set; } + public bool? IncludeIndicatorStyles + { + get => _config.IncludeIndicatorStyles; + set => _config.IncludeIndicatorStyles = value; + } /// /// Gets or sets the indicator class. Defaults to htmx-indicator. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("indicator-class")] - public string? IndicatorClass { get; set; } + public string? IndicatorClass + { + get => _config.IndicatorClass; + set => _config.IndicatorClass = value; + } /// /// Gets or sets the request class. Defaults to htmx-request. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("request-class")] - public string? RequestClass { get; set; } + public string? RequestClass + { + get => _config.RequestClass; + set => _config.RequestClass = value; + } /// /// Gets or sets the added class. Defaults to htmx-added. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("added-class")] - public string? AddedClass { get; set; } + public string? AddedClass + { + get => _config.AddedClass; + set => _config.AddedClass = value; + } /// /// Gets or sets the swapping class. Defaults to htmx-swapping. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("swapping-class")] - public string? SwappingClass { get; set; } + public string? SwappingClass + { + get => _config.SwappingClass; + set => _config.SwappingClass = value; + } /// /// Gets or sets the settling class. Defaults to htmx-settling. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("settling-class")] - public string? SettlingClass { get; set; } + public string? SettlingClass + { + get => _config.SettlingClass; + set => _config.SettlingClass = value; + } /// /// Gets or sets a value indicating whether eval is allowed. @@ -124,7 +166,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("allow-eval")] - public bool? AllowEval { get; set; } + public bool? AllowEval + { + get => _config.AllowEval; + set => _config.AllowEval = value; + } /// /// Gets or sets a value indicating whether script tags should be processed in new content. @@ -132,7 +178,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("allow-script-tags")] - public bool? AllowScriptTags { get; set; } + public bool? AllowScriptTags + { + get => _config.AllowScriptTags; + set => _config.AllowScriptTags = value; + } /// /// Gets or sets a value meaning that no nonce will be added to inline scripts. @@ -140,7 +190,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("inline-script-nonce")] - public string? InlineScriptNonce { get; set; } + public string? InlineScriptNonce + { + get => _config.InlineScriptNonce; + set => _config.InlineScriptNonce = value; + } /// /// Gets or sets a value meaning that no nonce will be added to inline styles. @@ -148,7 +202,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported only in HTMX 2.x. [HtmlAttributeName("inline-style-nonce")] - public string? InlineStyleNonce { get; set; } + public string? InlineStyleNonce + { + get => _config.InlineStyleNonce; + set => _config.InlineStyleNonce = value; + } /// /// Gets or sets the attributes to settle during the settling phase. @@ -156,7 +214,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("attributes-to-settle")] - public string[]? AttributesToSettle { get; set; } + public string[]? AttributesToSettle + { + get => _config.AttributesToSettle; + set => _config.AttributesToSettle = value; + } /// /// Gets or sets a value indicating whether HTML template tags should be used for parsing content. @@ -164,14 +226,22 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported only in HTMX 1.x. Removed in HTMX 2.x. [HtmlAttributeName("use-template-fragments")] - public bool? UseTemplateFragments { get; set; } + public bool? UseTemplateFragments + { + get => _config.UseTemplateFragments; + set => _config.UseTemplateFragments = value; + } /// /// Gets or sets the WebSocket reconnect delay. Defaults to full-jitter. /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("ws-reconnect-delay")] - public string? WsReconnectDelay { get; set; } + public string? WsReconnectDelay + { + get => _config.WsReconnectDelay; + set => _config.WsReconnectDelay = value; + } /// /// Gets or sets the type of binary data @@ -179,7 +249,12 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("ws-binary-type")] - public HtmxBinaryType? WsBinaryType { get; set; } + public HtmxBinaryType? WsBinaryType + { + // NOTE: The getter exists primarily for debugging; performance is not a concern here. + get => Enum.TryParse(_config.WsBinaryType ?? "", true, out var v) ? v : null; + set => _config.WsBinaryType = value?.GetWsBinaryTypeValue(); + } /// /// Gets or sets the "disable" selector. @@ -188,7 +263,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("disable-selector")] - public string? DisableSelector { get; set; } + public string? DisableSelector + { + get => _config.DisableSelector; + set => _config.DisableSelector = value; + } /// /// Gets or sets the value that allows cross-site Access-Control requests @@ -197,7 +276,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("with-credentials")] - public bool? WithCredentials { get; set; } + public bool? WithCredentials + { + get => _config.WithCredentials; + set => _config.WithCredentials = value; + } /// /// Gets or sets a value indicating whether htmx attribute inheritance is disabled. @@ -207,7 +290,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported only in HTMX 2.x. [HtmlAttributeName("disable-inheritance")] - public bool? DisableInheritance { get; set; } + public bool? DisableInheritance + { + get => _config.DisableInheritance; + set => _config.DisableInheritance = value; + } /// /// Gets or sets the number of milliseconds a request can take before automatically being terminated. @@ -215,7 +302,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("timeout")] - public int? Timeout { get; set; } + public int? Timeout + { + get => _config.Timeout; + set => _config.Timeout = value; + } /// /// Gets or sets a value indicating the behavior for a boosted link on page transitions. @@ -224,7 +315,12 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("scroll-behavior")] - public HtmxScrollBehavior? ScrollBehavior { get; set; } + public HtmxScrollBehavior? ScrollBehavior + { + // NOTE: The getter exists primarily for debugging; performance is not a concern here. + get => Enum.TryParse(_config.ScrollBehavior ?? "", true, out var v) ? v : null; + set => _config.ScrollBehavior = value?.GetScrollBehaviorValue(); + } /// /// Gets or sets a value indicating whether the focused element should be scrolled into view. @@ -232,7 +328,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("default-focus-scroll")] - public bool? DefaultFocusScroll { get; set; } + public bool? DefaultFocusScroll + { + get => _config.DefaultFocusScroll; + set => _config.DefaultFocusScroll = value; + } /// /// Gets or sets a value indicating whether a cache‑busting parameter @@ -245,7 +345,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// where the target element is appended to the GET request. /// [HtmlAttributeName("get-cache-buster-param")] - public bool? GetCacheBusterParam { get; set; } + public bool? GetCacheBusterParam + { + get => _config.GetCacheBusterParam; + set => _config.GetCacheBusterParam = value; + } /// /// Gets or sets a value indicating whether the @@ -255,7 +359,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("global-view-transitions")] - public bool? GlobalViewTransitions { get; set; } + public bool? GlobalViewTransitions + { + get => _config.GlobalViewTransitions; + set => _config.GlobalViewTransitions = value; + } /// /// Gets or sets a list of HTTP methods that use URL parameters. @@ -269,7 +377,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// [HtmlAttributeName("methods-that-use-url-params")] - public string[]? MethodsThatUseUrlParams { get; set; } + public string[]? MethodsThatUseUrlParams + { + get => _config.MethodsThatUseUrlParams; + set => _config.MethodsThatUseUrlParams = value; + } /// /// Gets or sets a value indicating whether AJAX requests should be allowed only @@ -278,7 +390,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("self-requests-only")] - public bool? SelfRequestsOnly { get; set; } + public bool? SelfRequestsOnly + { + get => _config.SelfRequestsOnly; + set => _config.SelfRequestsOnly = value; + } /// /// Gets or sets a value indicating whether htmx should not update the title of the document @@ -286,7 +402,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("ignore-title")] - public bool? IgnoreTitle { get; set; } + public bool? IgnoreTitle + { + get => _config.IgnoreTitle; + set => _config.IgnoreTitle = value; + } /// /// Gets or sets a value indicating whether the target of a boosted element @@ -296,7 +416,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("scroll-into-view-on-boost")] - public bool? ScrollIntoViewOnBoost { get; set; } + public bool? ScrollIntoViewOnBoost + { + get => _config.ScrollIntoViewOnBoost; + set => _config.ScrollIntoViewOnBoost = value; + } /// /// Gets or sets the cache to store evaluated trigger specifications into, @@ -306,7 +430,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported in HTMX 1.x and 2.x. [HtmlAttributeName("trigger-specs-cache")] - public string? TriggerSpecsCache { get; set; } + public string? TriggerSpecsCache + { + get => _config.TriggerSpecsCache; + set => _config.TriggerSpecsCache = value; + } /// /// Gets or sets the default response handling behavior for HTTP response status codes. @@ -315,7 +443,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported only in HTMX 2.x. [HtmlAttributeName("response-handling")] - public IList? ResponseHandling { get; set; } + public IList? ResponseHandling + { + get => _config.ResponseHandling; + set => _config.ResponseHandling = value; + } /// /// Gets or sets a value indicating whether to process OOB swaps @@ -324,7 +456,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported only in HTMX 2.x. [HtmlAttributeName("allow-nested-oob-swaps")] - public bool? AllowNestedOobSwaps { get; set; } + public bool? AllowNestedOobSwaps + { + get => _config.AllowNestedOobSwaps; + set => _config.AllowNestedOobSwaps = value; + } /// /// Gets or sets a value indicating whether to treat history cache miss @@ -335,7 +471,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported only in HTMX 2.x. [HtmlAttributeName("history-restore-as-hx-request")] - public bool? HistoryRestoreAsHxRequest { get; set; } + public bool? HistoryRestoreAsHxRequest + { + get => _config.HistoryRestoreAsHxRequest; + set => _config.HistoryRestoreAsHxRequest = value; + } /// /// Gets or sets a value indicating whether to report input validation errors @@ -345,7 +485,11 @@ public sealed class HtmxConfigTagHelper(IAntiforgery antiforgery) : TagHelper /// /// Supported only in HTMX 2.x. [HtmlAttributeName("report-validity-of-forms")] - public bool? ReportValidityOfForms { get; set; } + public bool? ReportValidityOfForms + { + get => _config.ReportValidityOfForms; + set => _config.ReportValidityOfForms = value; + } /// /// Gets or sets a value indicating whether an antiforgery token should be included. @@ -372,94 +516,66 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu output.TagMode = TagMode.SelfClosing; output.Attributes.SetAttribute("name", "htmx-config"); + context.Items[typeof(HtmxConfigTagHelper)] = this; await output.GetChildContentAsync(); - if (context.Items.TryGetValue(ResponseHandlingEntriesKey, out var value)) - if (value is List entries && entries.Count != 0) - ResponseHandling = entries; + if (IncludeAntiForgeryToken) + _config.AntiForgery = antiforgery.GetAndStoreTokens(ViewContext.HttpContext); - #if NET8_0_OR_GREATER - var config = new HtmlString( - JsonSerializer.Serialize( - new HtmxConfiguration(this), - HtmxConfigJsonSerializerContext.Default.HtmxConfiguration)); - #else var config = new HtmlString( JsonSerializer.Serialize( - new HtmxConfiguration(this), - JsonOptions.CamelCase)); - #endif + _config, + HtmxConfigJsonSerializerContext.Default.HtmxConfigData)); output.Attributes.SetAttribute( new TagHelperAttribute("content", config, HtmlAttributeValueStyle.SingleQuotes)); } - #region Inner type: HtmxConfiguration - - /// - /// Represents a proxy structure for the class. - /// - [SuppressMessage("ReSharper", "UnusedMember.Local")] - internal readonly struct HtmxConfiguration(HtmxConfigTagHelper helper) - { - public bool? HistoryEnabled => helper.HistoryEnabled; - public int? HistoryCacheSize => helper.HistoryCacheSize; - public bool? RefreshOnHistoryMiss => helper.RefreshOnHistoryMiss; - public string? DefaultSwapStyle => helper.DefaultSwapStyle.GetSwapValue(); - public int? DefaultSwapDelay => helper.DefaultSwapDelay; - public int? DefaultSettleDelay => helper.DefaultSettleDelay; - public bool? IncludeIndicatorStyles => helper.IncludeIndicatorStyles; - public string? IndicatorClass => helper.IndicatorClass; - public string? RequestClass => helper.RequestClass; - public string? AddedClass => helper.AddedClass; - public string? SettlingClass => helper.SettlingClass; - public string? SwappingClass => helper.SwappingClass; - public bool? AllowEval => helper.AllowEval; - public bool? AllowScriptTags => helper.AllowScriptTags; - public string? InlineScriptNonce => helper.InlineScriptNonce; - public string? InlineStyleNonce => helper.InlineStyleNonce; - public string[]? AttributesToSettle => helper.AttributesToSettle; - public bool? UseTemplateFragments => helper.UseTemplateFragments; - public string? WsReconnectDelay => helper.WsReconnectDelay; - public string? WsBinaryType => helper.WsBinaryType?.GetWsBinaryTypeValue(); - public string? DisableSelector => helper.DisableSelector; - public bool? WithCredentials => helper.WithCredentials; - public bool? DisableInheritance => helper.DisableInheritance; - public int? Timeout => helper.Timeout; - public string? ScrollBehavior => helper.ScrollBehavior?.GetScrollBehaviorValue(); - public bool? DefaultFocusScroll => helper.DefaultFocusScroll; - public bool? GetCacheBusterParam => helper.GetCacheBusterParam; - public bool? GlobalViewTransitions => helper.GlobalViewTransitions; - public string[]? MethodsThatUseUrlParams => helper.MethodsThatUseUrlParams; - public bool? SelfRequestsOnly => helper.SelfRequestsOnly; - public bool? IgnoreTitle => helper.IgnoreTitle; - public bool? ScrollIntoViewOnBoost => helper.ScrollIntoViewOnBoost; - public string? TriggerSpecsCache => helper.TriggerSpecsCache; - public IList? ResponseHandling => helper.ResponseHandling; - public bool? AllowNestedOobSwaps => helper.AllowNestedOobSwaps; - public bool? HistoryRestoreAsHxRequest => helper.HistoryRestoreAsHxRequest; - public bool? ReportValidityOfForms => helper.ReportValidityOfForms; - public AntiForgeryTokenData? AntiForgery => GetAntiForgeryToken(helper); - - private static AntiForgeryTokenData? GetAntiForgeryToken(HtmxConfigTagHelper h) => - h.IncludeAntiForgeryToken - ? new AntiForgeryTokenData(h._antiforgery.GetAndStoreTokens(h.ViewContext.HttpContext)) - : null; - } - - #endregion - - #region Inner type: AntiForgeryTokenData + #region Inner type: HtmxConfigData /// - /// Represents a proxy structure for the class. + /// Represents the serializable configuration data for the class. /// - [SuppressMessage("ReSharper", "UnusedMember.Local")] - internal readonly struct AntiForgeryTokenData(AntiforgeryTokenSet antiforgery) + internal sealed class HtmxConfigData { - public string? HeaderName => antiforgery.HeaderName; - public string FormFieldName => antiforgery.FormFieldName; - public string? RequestToken => antiforgery.RequestToken; + public bool? HistoryEnabled { get; set; } + public int? HistoryCacheSize { get; set; } + public bool? RefreshOnHistoryMiss { get; set; } + public string? DefaultSwapStyle { get; set; } + public int? DefaultSwapDelay { get; set; } + public int? DefaultSettleDelay { get; set; } + public bool? IncludeIndicatorStyles { get; set; } + public string? IndicatorClass { get; set; } + public string? RequestClass { get; set; } + public string? AddedClass { get; set; } + public string? SwappingClass { get; set; } + public string? SettlingClass { get; set; } + public bool? AllowEval { get; set; } + public bool? AllowScriptTags { get; set; } + public string? InlineScriptNonce { get; set; } + public string? InlineStyleNonce { get; set; } + public string[]? AttributesToSettle { get; set; } + public bool? UseTemplateFragments { get; set; } + public string? WsReconnectDelay { get; set; } + public string? WsBinaryType { get; set; } + public string? DisableSelector { get; set; } + public bool? WithCredentials { get; set; } + public bool? DisableInheritance { get; set; } + public int? Timeout { get; set; } + public string? ScrollBehavior { get; set; } + public bool? DefaultFocusScroll { get; set; } + public bool? GetCacheBusterParam { get; set; } + public bool? GlobalViewTransitions { get; set; } + public string[]? MethodsThatUseUrlParams { get; set; } + public bool? SelfRequestsOnly { get; set; } + public bool? IgnoreTitle { get; set; } + public bool? ScrollIntoViewOnBoost { get; set; } + public string? TriggerSpecsCache { get; set; } + public IList? ResponseHandling { get; set; } + public bool? AllowNestedOobSwaps { get; set; } + public bool? HistoryRestoreAsHxRequest { get; set; } + public bool? ReportValidityOfForms { get; set; } + public AntiforgeryTokenSet? AntiForgery { get; set; } } #endregion diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntryTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntryTagHelper.cs index f767981..29faa3d 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntryTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntryTagHelper.cs @@ -11,69 +11,88 @@ namespace Ramstack.HtmxToolkit.TagHelpers; [HtmlTargetElement("response-handling", ParentTag = "htmx-config", TagStructure = TagStructure.WithoutEndTag)] public sealed class ResponseHandlingEntryTagHelper : TagHelper { + private readonly ResponseHandlingEntry _entry = new(); + /// /// Gets or sets a regular expression that will be tested against response status codes. /// [HtmlAttributeName("code")] - public string? Code { get; set; } + public string? Code + { + get => _entry.Code; + set => _entry.Code = value; + } /// /// Gets or sets a value indicating whether the response should be swapped into the DOM. /// [HtmlAttributeName("swap")] - public bool? Swap { get; set; } + public bool? Swap + { + get => _entry.Swap; + set => _entry.Swap = value; + } /// /// Gets or sets a value indicating whether htmx should treat this response as an error. /// [HtmlAttributeName("error")] - public bool? Error { get; set; } + public bool? Error + { + get => _entry.Error; + set => _entry.Error = value; + } /// /// Gets or sets a value indicating whether htmx should ignore title tags in the response. /// [HtmlAttributeName("ignore-title")] - public bool? IgnoreTitle { get; set; } + public bool? IgnoreTitle + { + get => _entry.IgnoreTitle; + set => _entry.IgnoreTitle = value; + } /// /// Gets or sets a CSS selector to use to select content from the response. /// [HtmlAttributeName("select")] - public string? Select { get; set; } + public string? Select + { + get => _entry.Select; + set => _entry.Select = value; + } /// /// Gets or sets a CSS selector specifying an alternative target for the response. /// [HtmlAttributeName("target")] - public string? Target { get; set; } + public string? Target + { + get => _entry.Target; + set => _entry.Target = value; + } /// /// Gets or sets an alternative swap mechanism for the response. /// [HtmlAttributeName("swap-override")] - public string? SwapOverride { get; set; } + public string? SwapOverride + { + get => _entry.SwapOverride; + set => _entry.SwapOverride = value; + } /// public override void Process(TagHelperContext context, TagHelperOutput output) { - if (!context.Items.TryGetValue(HtmxConfigTagHelper.ResponseHandlingEntriesKey, out var value)) - { - value = new List(); - context.Items[HtmxConfigTagHelper.ResponseHandlingEntriesKey] = value; - } - - if (value is List entries) + if (context.Items.TryGetValue(typeof(HtmxConfigTagHelper), out var value)) { - entries.Add(new ResponseHandlingEntry + if (value is HtmxConfigTagHelper config) { - Code = Code, - Swap = Swap, - Error = Error, - IgnoreTitle = IgnoreTitle, - Select = Select, - Target = Target, - SwapOverride = SwapOverride - }); + config.ResponseHandling ??= new List(); + config.ResponseHandling.Add(_entry); + } } output.SuppressOutput();