From 7778a94cf5c0f429895b476ed09fdcb844fd1d6d Mon Sep 17 00:00:00 2001 From: Enciphered Date: Sun, 3 May 2026 23:35:42 +0500 Subject: [PATCH 01/18] Init --- .gitignore | 2 + Htmx.ApiDemo/AppJsonSerializerContext.cs | 9 + Htmx.ApiDemo/Htmx.ApiDemo.csproj | 33 +++ Htmx.ApiDemo/Htmx.ApiDemo.http | 11 + Htmx.ApiDemo/Htmx.ApiDemo.sln | 24 ++ Htmx.ApiDemo/Program.cs | 28 +++ Htmx.ApiDemo/Properties/launchSettings.json | 15 ++ Htmx.ApiDemo/Templates/Greeting.htmx | 4 + Htmx.ApiDemo/Templates/Greeting.htmx.cs | 37 ++++ Htmx.ApiDemo/appsettings.Development.json | 8 + Htmx.ApiDemo/appsettings.json | 9 + .../Htmx.SourceGenerator.csproj | 21 ++ Htmx.SourceGenerator/HtmxGenerator.cs | 205 ++++++++++++++++++ Htmx.slnx | 4 + 14 files changed, 410 insertions(+) create mode 100644 .gitignore create mode 100644 Htmx.ApiDemo/AppJsonSerializerContext.cs create mode 100644 Htmx.ApiDemo/Htmx.ApiDemo.csproj create mode 100644 Htmx.ApiDemo/Htmx.ApiDemo.http create mode 100644 Htmx.ApiDemo/Htmx.ApiDemo.sln create mode 100644 Htmx.ApiDemo/Program.cs create mode 100644 Htmx.ApiDemo/Properties/launchSettings.json create mode 100644 Htmx.ApiDemo/Templates/Greeting.htmx create mode 100644 Htmx.ApiDemo/Templates/Greeting.htmx.cs create mode 100644 Htmx.ApiDemo/appsettings.Development.json create mode 100644 Htmx.ApiDemo/appsettings.json create mode 100644 Htmx.SourceGenerator/Htmx.SourceGenerator.csproj create mode 100644 Htmx.SourceGenerator/HtmxGenerator.cs create mode 100644 Htmx.slnx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d4a6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin +obj \ No newline at end of file diff --git a/Htmx.ApiDemo/AppJsonSerializerContext.cs b/Htmx.ApiDemo/AppJsonSerializerContext.cs new file mode 100644 index 0000000..bdb87bb --- /dev/null +++ b/Htmx.ApiDemo/AppJsonSerializerContext.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Htmx.ApiDemo; + +[JsonSerializable(typeof(string))] +internal partial class AppJsonSerializerContext : JsonSerializerContext +{ + +} \ No newline at end of file diff --git a/Htmx.ApiDemo/Htmx.ApiDemo.csproj b/Htmx.ApiDemo/Htmx.ApiDemo.csproj new file mode 100644 index 0000000..fda4646 --- /dev/null +++ b/Htmx.ApiDemo/Htmx.ApiDemo.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + true + true + true + obj/Generated + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Htmx.ApiDemo/Htmx.ApiDemo.http b/Htmx.ApiDemo/Htmx.ApiDemo.http new file mode 100644 index 0000000..f64fdf5 --- /dev/null +++ b/Htmx.ApiDemo/Htmx.ApiDemo.http @@ -0,0 +1,11 @@ +@Htmx.ApiDemo_HostAddress = http://localhost:5120 + +GET {{Htmx.ApiDemo_HostAddress}}/todos/ +Accept: application/json + +### + +GET {{Htmx.ApiDemo_HostAddress}}/todos/1 +Accept: application/json + +### diff --git a/Htmx.ApiDemo/Htmx.ApiDemo.sln b/Htmx.ApiDemo/Htmx.ApiDemo.sln new file mode 100644 index 0000000..9f398a0 --- /dev/null +++ b/Htmx.ApiDemo/Htmx.ApiDemo.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Htmx.ApiDemo", "Htmx.ApiDemo.csproj", "{38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B66FEAA2-59A2-4489-9AEB-ED875EEE5D3E} + EndGlobalSection +EndGlobal diff --git a/Htmx.ApiDemo/Program.cs b/Htmx.ApiDemo/Program.cs new file mode 100644 index 0000000..a8ea6f5 --- /dev/null +++ b/Htmx.ApiDemo/Program.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using Htmx.ApiDemo.Templates; +using Microsoft.AspNetCore.Http.HttpResults; +using Immediate.Apis; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Htmx.ApiDemo; + +var builder = WebApplication.CreateSlimBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); +}); +builder.Services.AddHttpContextAccessor(); +builder.Services + .AddHtmxApiDemoBehaviors() + .AddHtmxApiDemoHandlers(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.MapOpenApi(); + +app.MapHtmxApiDemoEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/Htmx.ApiDemo/Properties/launchSettings.json b/Htmx.ApiDemo/Properties/launchSettings.json new file mode 100644 index 0000000..e74f83a --- /dev/null +++ b/Htmx.ApiDemo/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "todos", + "applicationUrl": "http://localhost:5120", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx b/Htmx.ApiDemo/Templates/Greeting.htmx new file mode 100644 index 0000000..bf76153 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Greeting.htmx @@ -0,0 +1,4 @@ +
+

Hello, $$User$$!

+

Welcome to high-performance htmx rendering.

+
\ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx.cs b/Htmx.ApiDemo/Templates/Greeting.htmx.cs new file mode 100644 index 0000000..62b79ce --- /dev/null +++ b/Htmx.ApiDemo/Templates/Greeting.htmx.cs @@ -0,0 +1,37 @@ +using Htmx.ApiDemo; +using Immediate.Apis; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + +namespace Htmx.ApiDemo.Templates; + +public class Greeting : GreetingBase +{ + private byte[] _userData = []; + public required string Username { init => _userData = value.ToUtf8Bytes(); } + protected override void RenderUser(HtmxRenderContext context) => context.Writer.WriteUtf8(_userData); +} + +[Handler] +[MapGet("/greet/{username}")] +public static partial class GetGreetingHandler +{ + public record Query(string Username); + + private static ValueTask HandleAsync( + Query query, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + var context = httpContextAccessor.HttpContext; + if(context is null) + throw new InvalidOperationException("HttpContext is not available."); + + var template = new Greeting { Username = query.Username }; + + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.BodyWriter.WriteHtmx(template); + + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/Htmx.ApiDemo/appsettings.Development.json b/Htmx.ApiDemo/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/Htmx.ApiDemo/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Htmx.ApiDemo/appsettings.json b/Htmx.ApiDemo/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/Htmx.ApiDemo/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Htmx.SourceGenerator/Htmx.SourceGenerator.csproj b/Htmx.SourceGenerator/Htmx.SourceGenerator.csproj new file mode 100644 index 0000000..6b43140 --- /dev/null +++ b/Htmx.SourceGenerator/Htmx.SourceGenerator.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + latest + + + + true + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/Htmx.SourceGenerator/HtmxGenerator.cs b/Htmx.SourceGenerator/HtmxGenerator.cs new file mode 100644 index 0000000..5d9a781 --- /dev/null +++ b/Htmx.SourceGenerator/HtmxGenerator.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Htmx.SourceGenerator +{ + [Generator] + public class HtmxGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var globalOptions = context.AnalyzerConfigOptionsProvider.Select((opt, _) => + (RootNamespace: GetOption(opt.GlobalOptions, "build_property.RootNamespace", "HtmxTemplates"), + ProjectDir: GetOption(opt.GlobalOptions, "build_property.MSBuildProjectDirectory", ""))); + + context.RegisterSourceOutput(globalOptions, (spc, opt) => + { + var infrastructureSource = $@"// +using System; +using System.Buffers; +using System.Runtime.CompilerServices; + +namespace {opt.RootNamespace}; + +public readonly struct HtmxRenderContext +{{ + public readonly IBufferWriter Writer; + public readonly int Depth; + private const int MaxDepth = 512; + + public HtmxRenderContext(IBufferWriter writer, int depth = 0) + {{ + if (depth > MaxDepth) throw new InvalidOperationException(""Template recursion limit exceeded.""); + Writer = writer; + Depth = depth; + }} + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public HtmxRenderContext Next() => new(Writer, Depth + 1); +}} + +public interface IHtmxComponent +{{ + void Render(HtmxRenderContext context); +}} + +public static class HtmxGeneratedExtensions +{{ + public static byte[] ToUtf8Bytes(this string str) => System.Text.Encoding.UTF8.GetBytes(str); + + public static void WriteHtmx(this IBufferWriter writer, IHtmxComponent component) + {{ + component.Render(new HtmxRenderContext(writer)); + }} + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteUtf8(this IBufferWriter writer, ReadOnlySpan data) + {{ + var buffer = writer.GetSpan(data.Length); + data.CopyTo(buffer); + writer.Advance(data.Length); + }} + + public static void WriteHtmxBody(this HttpContext context, IHtmxComponent component) + {{ + context.Response.ContentType = ""text/html; charset=utf-8""; + var writerContext = new HtmxRenderContext(context.Response.BodyWriter); + component.Render(writerContext); + }} +}}"; + spc.AddSource("HtmxInfrastructure.g.cs", SourceText.From(infrastructureSource, Encoding.UTF8)); + }); + + var htmxFiles = context.AdditionalTextsProvider + .Where(file => file.Path.EndsWith(".htmx")) + .Combine(globalOptions); + + var templateData = htmxFiles.Select((combined, ct) => + { + var (file, opt) = combined; + var content = file.GetText(ct)?.ToString() ?? string.Empty; + + string relativePath = Path.GetDirectoryName(file.Path) ?? ""; + string subNamespace = ""; + + if (!string.IsNullOrEmpty(opt.ProjectDir) && relativePath.StartsWith(opt.ProjectDir)) + { + subNamespace = relativePath.Substring(opt.ProjectDir.Length) + .Trim(Path.DirectorySeparatorChar) + .Replace(Path.DirectorySeparatorChar, '.'); + } + + string fullNamespace = string.IsNullOrEmpty(subNamespace) + ? opt.RootNamespace + : $"{opt.RootNamespace}.{subNamespace}"; + + return new TemplateInfo( + Path.GetFileNameWithoutExtension(file.Path), + fullNamespace, + opt.RootNamespace, + ParseTemplate(content) + ); + }); + + context.RegisterSourceOutput(templateData, (spc, data) => + { + spc.AddSource($"{data.ClassName}.g.cs", GenerateSource(data)); + }); + } + + private static string GetOption(AnalyzerConfigOptions options, string key, string fallback) + => options.TryGetValue(key, out var value) ? value : fallback; + + private static string GenerateSource(TemplateInfo data) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Buffers;"); + sb.AppendLine("using System.Runtime.CompilerServices;"); + + if (data.Namespace != data.RootNamespace) + { + sb.AppendLine($"using {data.RootNamespace};"); + } + + sb.AppendLine($"namespace {data.Namespace};"); + sb.AppendLine(); + sb.AppendLine($"public abstract partial class {data.ClassName}Base : IHtmxComponent"); + sb.AppendLine("{"); + + var variables = data.Segments.Where(s => !s.IsStatic).Select(s => s.Value).Distinct(); + foreach (var v in variables) + { + // Passing context allows user to write direct spans OR nested components + sb.AppendLine($" protected abstract void Render{v}(HtmxRenderContext context);"); + } + + int staticCount = 0; + foreach (var s in data.Segments.Where(s => s.IsStatic)) + { + var bytes = Encoding.UTF8.GetBytes(s.Value); + var byteString = string.Join(", ", bytes.Select(b => $"0x{b:X2}")); + sb.AppendLine($" private static ReadOnlySpan _part{staticCount++} => new byte[] {{ {byteString} }};"); + } + + sb.AppendLine(); + sb.AppendLine(" public void Render(HtmxRenderContext context)"); + sb.AppendLine(" {"); + + int sIdx = 0; + foreach (var segment in data.Segments) + { + if (segment.IsStatic) + sb.AppendLine($" context.Writer.WriteUtf8(_part{sIdx++});"); + else + // context.Next() enforces the depth limit before calling the user implementation + sb.AppendLine($" Render{segment.Value}(context.Next());"); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static ImmutableArray ParseTemplate(string content) + { + var builder = ImmutableArray.CreateBuilder(); + var variableRegex = new Regex(@"\$\$([a-zA-Z0-9_]+)\$\$", RegexOptions.Compiled); + + var matches = variableRegex.Matches(content); + int lastPos = 0; + + foreach (Match match in matches) + { + if (match.Index > lastPos) + builder.Add(new(content.Substring(lastPos, match.Index - lastPos), true)); + + builder.Add(new(match.Groups[1].Value, false)); + lastPos = match.Index + match.Length; + } + + if (lastPos < content.Length) + builder.Add(new(content.Substring(lastPos), true)); + + return builder.ToImmutable(); + } + + private record TemplateSegment(string Value, bool IsStatic); + private record TemplateInfo(string ClassName, string Namespace, string RootNamespace, ImmutableArray Segments); + } +} + +namespace System.Runtime.CompilerServices +{ + // Required for records/init properties in netstandard2.0 + internal static class IsExternalInit { } +} \ No newline at end of file diff --git a/Htmx.slnx b/Htmx.slnx new file mode 100644 index 0000000..0b8728d --- /dev/null +++ b/Htmx.slnx @@ -0,0 +1,4 @@ + + + + From 277a65c6c73e361432184a4818f57a7a451a2ce9 Mon Sep 17 00:00:00 2001 From: Enciphered Date: Sun, 3 May 2026 23:46:03 +0500 Subject: [PATCH 02/18] Removed Unnecessary usings and comments --- Htmx.ApiDemo/AppJsonSerializerContext.cs | 2 +- Htmx.ApiDemo/Program.cs | 6 ------ Htmx.ApiDemo/Templates/Greeting.htmx.cs | 5 ----- Htmx.SourceGenerator/HtmxGenerator.cs | 15 +-------------- 4 files changed, 2 insertions(+), 26 deletions(-) diff --git a/Htmx.ApiDemo/AppJsonSerializerContext.cs b/Htmx.ApiDemo/AppJsonSerializerContext.cs index bdb87bb..d3c188b 100644 --- a/Htmx.ApiDemo/AppJsonSerializerContext.cs +++ b/Htmx.ApiDemo/AppJsonSerializerContext.cs @@ -5,5 +5,5 @@ namespace Htmx.ApiDemo; [JsonSerializable(typeof(string))] internal partial class AppJsonSerializerContext : JsonSerializerContext { - + } \ No newline at end of file diff --git a/Htmx.ApiDemo/Program.cs b/Htmx.ApiDemo/Program.cs index a8ea6f5..33b7644 100644 --- a/Htmx.ApiDemo/Program.cs +++ b/Htmx.ApiDemo/Program.cs @@ -1,9 +1,3 @@ -using System.Text.Json.Serialization; -using Htmx.ApiDemo.Templates; -using Microsoft.AspNetCore.Http.HttpResults; -using Immediate.Apis; -using Immediate.Apis.Shared; -using Immediate.Handlers.Shared; using Htmx.ApiDemo; var builder = WebApplication.CreateSlimBuilder(args); diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx.cs b/Htmx.ApiDemo/Templates/Greeting.htmx.cs index 62b79ce..0bafed9 100644 --- a/Htmx.ApiDemo/Templates/Greeting.htmx.cs +++ b/Htmx.ApiDemo/Templates/Greeting.htmx.cs @@ -1,8 +1,3 @@ -using Htmx.ApiDemo; -using Immediate.Apis; -using Immediate.Apis.Shared; -using Immediate.Handlers.Shared; - namespace Htmx.ApiDemo.Templates; public class Greeting : GreetingBase diff --git a/Htmx.SourceGenerator/HtmxGenerator.cs b/Htmx.SourceGenerator/HtmxGenerator.cs index 5d9a781..fe829c2 100644 --- a/Htmx.SourceGenerator/HtmxGenerator.cs +++ b/Htmx.SourceGenerator/HtmxGenerator.cs @@ -1,14 +1,4 @@ -using System; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Text; - -namespace Htmx.SourceGenerator +namespace Htmx.SourceGenerator { [Generator] public class HtmxGenerator : IIncrementalGenerator @@ -138,7 +128,6 @@ public static class HtmxGeneratedExtensions var variables = data.Segments.Where(s => !s.IsStatic).Select(s => s.Value).Distinct(); foreach (var v in variables) { - // Passing context allows user to write direct spans OR nested components sb.AppendLine($" protected abstract void Render{v}(HtmxRenderContext context);"); } @@ -160,7 +149,6 @@ public static class HtmxGeneratedExtensions if (segment.IsStatic) sb.AppendLine($" context.Writer.WriteUtf8(_part{sIdx++});"); else - // context.Next() enforces the depth limit before calling the user implementation sb.AppendLine($" Render{segment.Value}(context.Next());"); } @@ -200,6 +188,5 @@ public static class HtmxGeneratedExtensions namespace System.Runtime.CompilerServices { - // Required for records/init properties in netstandard2.0 internal static class IsExternalInit { } } \ No newline at end of file From 493cd71d17e0a0ce0cf2c17c3167911336ec822a Mon Sep 17 00:00:00 2001 From: Enciphered Date: Sun, 3 May 2026 23:53:33 +0500 Subject: [PATCH 03/18] Made the Greeting class sealed as it's not suppose to be inherited from --- Htmx.ApiDemo/Templates/Greeting.htmx.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx.cs b/Htmx.ApiDemo/Templates/Greeting.htmx.cs index 0bafed9..30ce110 100644 --- a/Htmx.ApiDemo/Templates/Greeting.htmx.cs +++ b/Htmx.ApiDemo/Templates/Greeting.htmx.cs @@ -1,6 +1,6 @@ namespace Htmx.ApiDemo.Templates; -public class Greeting : GreetingBase +public sealed class Greeting : GreetingBase { private byte[] _userData = []; public required string Username { init => _userData = value.ToUtf8Bytes(); } From fb1cb8e83421e24b6f9fc723bbf90529e9d2557e Mon Sep 17 00:00:00 2001 From: shaamilahmed Date: Mon, 4 May 2026 16:53:19 +0500 Subject: [PATCH 04/18] Added components, authentication and authorization --- .gitignore | 4 +- Htmx.ApiDemo/AppJsonSerializerContext.cs | 6 +- Htmx.ApiDemo/Data/ApplicationUser.cs | 31 + Htmx.ApiDemo/Data/AuthService.cs | 83 + Htmx.ApiDemo/Data/MongoDbService.cs | 47 + Htmx.ApiDemo/Htmx.ApiDemo.csproj | 25 +- Htmx.ApiDemo/HtmxPageExtensions.cs | 44 + Htmx.ApiDemo/Program.cs | 78 + Htmx.ApiDemo/Templates/Components/Button.htmx | 1 + .../Templates/Components/Button.htmx.cs | 59 + .../Templates/Components/Calendar.htmx | 38 + .../Templates/Components/Calendar.htmx.cs | 43 + .../Templates/Components/CalendarRange.htmx | 41 + .../Components/CalendarRange.htmx.cs | 49 + Htmx.ApiDemo/Templates/Components/Input.htmx | 15 + .../Templates/Components/Input.htmx.cs | 52 + Htmx.ApiDemo/Templates/Components/Select.htmx | 14 + .../Templates/Components/Select.htmx.cs | 56 + .../Templates/Components/TimePicker.htmx | 28 + .../Templates/Components/TimePicker.htmx.cs | 89 ++ Htmx.ApiDemo/Templates/Greeting.htmx | 4 +- Htmx.ApiDemo/Templates/Greeting.htmx.cs | 28 +- Htmx.ApiDemo/Templates/Login.htmx | 45 + Htmx.ApiDemo/Templates/Login.htmx.cs | 88 ++ Htmx.ApiDemo/Templates/Logout.cs | 28 + Htmx.ApiDemo/Templates/MainLayout.htmx | 116 ++ Htmx.ApiDemo/Templates/MainLayout.htmx.cs | 91 ++ Htmx.ApiDemo/Templates/Register.htmx | 63 + Htmx.ApiDemo/Templates/Register.htmx.cs | 99 ++ Htmx.ApiDemo/Templates/UiDemo.htmx | 68 + Htmx.ApiDemo/Templates/UiDemo.htmx.cs | 101 ++ Htmx.ApiDemo/appsettings.json | 4 + Htmx.ApiDemo/package.json | 6 + Htmx.ApiDemo/wwwroot/css/input.css | 187 +++ Htmx.ApiDemo/wwwroot/css/output.css | 1365 +++++++++++++++++ Htmx.ApiDemo/wwwroot/js/components.js | 458 ++++++ Htmx.SourceGenerator/HtmxGenerator.cs | 12 +- 37 files changed, 3545 insertions(+), 21 deletions(-) create mode 100644 Htmx.ApiDemo/Data/ApplicationUser.cs create mode 100644 Htmx.ApiDemo/Data/AuthService.cs create mode 100644 Htmx.ApiDemo/Data/MongoDbService.cs create mode 100644 Htmx.ApiDemo/HtmxPageExtensions.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Button.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Button.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Calendar.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Calendar.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/CalendarRange.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/CalendarRange.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Input.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Input.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Select.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Select.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/TimePicker.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/TimePicker.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Login.htmx create mode 100644 Htmx.ApiDemo/Templates/Login.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Logout.cs create mode 100644 Htmx.ApiDemo/Templates/MainLayout.htmx create mode 100644 Htmx.ApiDemo/Templates/MainLayout.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Register.htmx create mode 100644 Htmx.ApiDemo/Templates/Register.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/UiDemo.htmx create mode 100644 Htmx.ApiDemo/Templates/UiDemo.htmx.cs create mode 100644 Htmx.ApiDemo/package.json create mode 100644 Htmx.ApiDemo/wwwroot/css/input.css create mode 100644 Htmx.ApiDemo/wwwroot/css/output.css create mode 100644 Htmx.ApiDemo/wwwroot/js/components.js diff --git a/.gitignore b/.gitignore index 8d4a6c0..b759220 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ bin -obj \ No newline at end of file +obj +node_modules +package-lock.json \ No newline at end of file diff --git a/Htmx.ApiDemo/AppJsonSerializerContext.cs b/Htmx.ApiDemo/AppJsonSerializerContext.cs index d3c188b..840b621 100644 --- a/Htmx.ApiDemo/AppJsonSerializerContext.cs +++ b/Htmx.ApiDemo/AppJsonSerializerContext.cs @@ -1,9 +1,13 @@ using System.Text.Json.Serialization; +using Htmx.ApiDemo.Templates; namespace Htmx.ApiDemo; [JsonSerializable(typeof(string))] +[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")] +[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")] +[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")] internal partial class AppJsonSerializerContext : JsonSerializerContext { - + } \ No newline at end of file diff --git a/Htmx.ApiDemo/Data/ApplicationUser.cs b/Htmx.ApiDemo/Data/ApplicationUser.cs new file mode 100644 index 0000000..1b0fe91 --- /dev/null +++ b/Htmx.ApiDemo/Data/ApplicationUser.cs @@ -0,0 +1,31 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Htmx.ApiDemo.Data; + +/// +/// Simple user document stored in MongoDB. +/// All property→field name mappings are declared explicitly via [BsonElement] +/// and registered in Program.cs via BsonClassMap — no AutoMap() reflection. +/// +public sealed class AppUser +{ + [BsonId] + public ObjectId Id { get; set; } = ObjectId.GenerateNewId(); + + [BsonElement("email")] + public string Email { get; set; } = ""; + + /// Email.ToUpperInvariant() — used for case-insensitive lookups. + [BsonElement("normalizedEmail")] + public string NormalizedEmail { get; set; } = ""; + + [BsonElement("passwordHash")] + public string PasswordHash { get; set; } = ""; + + [BsonElement("displayName")] + public string? DisplayName { get; set; } + + [BsonElement("createdAtUtc")] + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/Htmx.ApiDemo/Data/AuthService.cs b/Htmx.ApiDemo/Data/AuthService.cs new file mode 100644 index 0000000..1a1aeaa --- /dev/null +++ b/Htmx.ApiDemo/Data/AuthService.cs @@ -0,0 +1,83 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; + +namespace Htmx.ApiDemo.Data; + +/// +/// AOT-safe authentication service backed by MongoDB. +/// No EF Core, no LINQ-to-SQL, no RelationalModel fully NativeAOT safe. +/// IPasswordHasher is pure PBKDF2 crypto with no dynamic IL. +/// +public sealed class AuthService( + MongoDbService mongo, + IPasswordHasher passwordHasher, + IHttpContextAccessor httpContextAccessor) +{ + public async Task<(bool Success, string? Error)> RegisterAsync( + string email, string password, string? displayName, CancellationToken ct = default) + { + var normalized = email.ToUpperInvariant(); + + if (await mongo.EmailExistsAsync(normalized, ct)) + return (false, "That email address is already registered."); + + var user = new AppUser + { + Email = email, + NormalizedEmail = normalized, + DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), + CreatedAtUtc = DateTime.UtcNow, + }; + user.PasswordHash = passwordHasher.HashPassword(user, password); + + await mongo.InsertAsync(user, ct); + await SignInUserAsync(user); + return (true, null); + } + + public async Task<(bool Success, string? Error)> LoginAsync( + string email, string password, CancellationToken ct = default) + { + var normalized = email.ToUpperInvariant(); + var user = await mongo.FindByNormalizedEmailAsync(normalized, ct); + + if (user is null) + return (false, "Invalid email or password."); + + var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (result == PasswordVerificationResult.Failed) + return (false, "Invalid email or password."); + + await SignInUserAsync(user); + return (true, null); + } + + public async Task SignOutAsync() + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } + + private async Task SignInUserAsync(AppUser user) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + List claims = + [ + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Email), + new Claim(ClaimTypes.Email, user.Email), + ]; + + if (!string.IsNullOrEmpty(user.DisplayName)) + claims.Add(new Claim("DisplayName", user.DisplayName)); + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + } +} diff --git a/Htmx.ApiDemo/Data/MongoDbService.cs b/Htmx.ApiDemo/Data/MongoDbService.cs new file mode 100644 index 0000000..0cf49fa --- /dev/null +++ b/Htmx.ApiDemo/Data/MongoDbService.cs @@ -0,0 +1,47 @@ +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Htmx.ApiDemo.Data; + +/// +/// Scoped service wrapping the AppUser MongoDB collection. +/// All operations use MongoDB's native async API — no EF, no LINQ-to-SQL +/// translation, no RelationalModel, fully NativeAOT safe. +/// +public sealed class MongoDbService +{ + private readonly IMongoCollection _users; + + public MongoDbService(IMongoClient client, IConfiguration configuration) + { + var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb"); + _users = db.GetCollection("users"); + } + + /// Ensures the unique index on NormalizedEmail exists (idempotent). + public async Task EnsureIndexesAsync(CancellationToken ct = default) + { + var indexKeys = Builders.IndexKeys.Ascending(u => u.NormalizedEmail); + var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" }; + var model = new CreateIndexModel(indexKeys, indexOptions); + await _users.Indexes.CreateOneAsync(model, cancellationToken: ct); + } + + /// Returns true if a user with the given normalised email already exists. + public async Task EmailExistsAsync(string normalizedEmail, CancellationToken ct = default) + { + var filter = Builders.Filter.Eq(u => u.NormalizedEmail, normalizedEmail); + return await _users.Find(filter).AnyAsync(ct); + } + + /// Returns the user matching the normalised email, or null. + public async Task FindByNormalizedEmailAsync(string normalizedEmail, CancellationToken ct = default) + { + var filter = Builders.Filter.Eq(u => u.NormalizedEmail, normalizedEmail); + return await _users.Find(filter).FirstOrDefaultAsync(ct); + } + + /// Inserts a new user document. + public Task InsertAsync(AppUser user, CancellationToken ct = default) => + _users.InsertOneAsync(user, cancellationToken: ct); +} diff --git a/Htmx.ApiDemo/Htmx.ApiDemo.csproj b/Htmx.ApiDemo/Htmx.ApiDemo.csproj index fda4646..b80b84b 100644 --- a/Htmx.ApiDemo/Htmx.ApiDemo.csproj +++ b/Htmx.ApiDemo/Htmx.ApiDemo.csproj @@ -4,30 +4,43 @@ net10.0 enable enable - true true true obj/Generated + + true + true + $(InterceptorsPreviewNamespaces);Immediate.Apis.Generators + + + + + - + + - - - - + + + + --minify + + + + diff --git a/Htmx.ApiDemo/HtmxPageExtensions.cs b/Htmx.ApiDemo/HtmxPageExtensions.cs new file mode 100644 index 0000000..eddb8e0 --- /dev/null +++ b/Htmx.ApiDemo/HtmxPageExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Antiforgery; + +namespace Htmx.ApiDemo; + +/// +/// Renders a full page or just the body component depending on whether +/// the request was made by HTMX (HX-Request header present). +/// +/// Full request → wraps body in MainLayout (complete HTML page) +/// HTMX request → renders body only + sets HX-Title so the browser +/// tab title still updates +/// +public static class HtmxPageExtensions +{ + public static void WriteHtmxPage( + this HttpContext ctx, + IHtmxComponent body, + string title = "App", + string appName = "HtmxApp", + string pageTitle = "") + { + if (ctx.Request.Headers.ContainsKey("HX-Request")) + { + // Partial swap: tell HTMX to update the browser tag + ctx.Response.Headers["HX-Title"] = title; + ctx.WriteHtmxBody(body); + } + else + { + // Resolve display name: prefer DisplayName claim, fall back to email/name + string? userName = ctx.User.Identity?.IsAuthenticated == true + ? (ctx.User.FindFirst("DisplayName")?.Value + ?? ctx.User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value) + : null; + + // Resolve antiforgery token for the logout form in the layout + var antiforgery = ctx.RequestServices.GetRequiredService<IAntiforgery>(); + var afTokens = antiforgery.GetAndStoreTokens(ctx); + + // Full page load: wrap in the shell layout + ctx.WriteHtmxBody(new Templates.MainLayout(body, title, appName, pageTitle, userName, afTokens.RequestToken)); + } + } +} diff --git a/Htmx.ApiDemo/Program.cs b/Htmx.ApiDemo/Program.cs index 33b7644..124377c 100644 --- a/Htmx.ApiDemo/Program.cs +++ b/Htmx.ApiDemo/Program.cs @@ -1,22 +1,100 @@ using Htmx.ApiDemo; +using Htmx.ApiDemo.Data; +using Immediate.Apis; +using Immediate.Apis.Shared; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Driver; + +// ── Explicit BsonClassMap — no AutoMap() reflection, fully AOT-safe ─────── +BsonClassMap.RegisterClassMap<AppUser>(cm => +{ + cm.MapIdProperty(u => u.Id).SetSerializer(new ObjectIdSerializer()); + cm.MapProperty(u => u.Email).SetElementName("email"); + cm.MapProperty(u => u.NormalizedEmail).SetElementName("normalizedEmail"); + cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash"); + cm.MapProperty(u => u.DisplayName).SetElementName("displayName"); + cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc"); + cm.SetIgnoreExtraElements(true); +}); var builder = WebApplication.CreateSlimBuilder(args); + +// ── Antiforgery ─────────────────────────────────────────────────────────── + +builder.Services.AddAntiforgery(); + +// ── JSON ────────────────────────────────────────────────────────────────── builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); }); + +// ── MongoDB ─────────────────────────────────────────────────────────────── +builder.Services.AddSingleton<IMongoClient>( + new MongoClient(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped<MongoDbService>(); + +// ── Cookie Authentication ───────────────────────────────────────────────── +builder.Services + .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/logout"; + options.AccessDeniedPath = "/login"; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + }); + +builder.Services.AddScoped<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>(); +builder.Services.AddScoped<AuthService>(); + +// ── App services ────────────────────────────────────────────────────────── builder.Services.AddHttpContextAccessor(); builder.Services .AddHtmxApiDemoBehaviors() .AddHtmxApiDemoHandlers(); builder.Services.AddOpenApi(); +builder.Services.AddAuthorization(); var app = builder.Build(); +// Ensure the unique index on NormalizedEmail exists (runs once on startup, idempotent). +using (var scope = app.Services.CreateScope()) + await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync(); + if (app.Environment.IsDevelopment()) app.MapOpenApi(); +app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +// ── Guard: redirect unauthenticated users to /login ─────────────────────── +app.Use(async (context, next) => +{ + var path = context.Request.Path.Value ?? ""; + bool isPublic = path.StartsWith("/login", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/register", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/logout", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/css/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/js/", StringComparison.OrdinalIgnoreCase); + + if (!isPublic && context.User.Identity?.IsAuthenticated != true) + { + context.Response.Redirect("/login"); + return; + } + + await next(); +}); + app.MapHtmxApiDemoEndpoints(); app.Run(); \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Components/Button.htmx b/Htmx.ApiDemo/Templates/Components/Button.htmx new file mode 100644 index 0000000..f324aec --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Button.htmx @@ -0,0 +1 @@ +<button type="$$Type$$" class="$$Classes$$" $$HxAttrs$$>$$Label$$</button> diff --git a/Htmx.ApiDemo/Templates/Components/Button.htmx.cs b/Htmx.ApiDemo/Templates/Components/Button.htmx.cs new file mode 100644 index 0000000..9b96d4b --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Button.htmx.cs @@ -0,0 +1,59 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style Button component. +/// Variant: default | destructive | outline | secondary | ghost | link +/// Size: default | sm | lg | icon +/// </summary> +public sealed class Button : ButtonBase +{ + private static readonly Dictionary<string, string> VariantClasses = new() + { + ["default"] = "bg-primary text-primary-foreground hover:bg-primary/90", + ["destructive"] = "bg-destructive text-destructive-foreground hover:bg-destructive/90", + ["outline"] = "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + ["secondary"] = "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ["ghost"] = "hover:bg-accent hover:text-accent-foreground", + ["link"] = "text-primary underline-offset-4 hover:underline", + }; + + private static readonly Dictionary<string, string> SizeClasses = new() + { + ["default"] = "h-10 px-4 py-2 text-sm", + ["sm"] = "h-9 rounded-md px-3 text-xs", + ["lg"] = "h-11 rounded-md px-8 text-base", + ["icon"] = "h-10 w-10", + }; + + private const string BaseClasses = + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium " + + "ring-offset-background transition-colors " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 " + + "disabled:pointer-events-none disabled:opacity-50"; + + private readonly byte[] _labelData; + private readonly byte[] _classesData; + private readonly byte[] _typeData; + private readonly byte[] _hxAttrsData; + + public Button( + string label, + string variant = "default", + string size = "default", + string type = "button", + string hxAttrs = "") + { + _labelData = label.ToUtf8Bytes(); + _typeData = type.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + + var v = VariantClasses.GetValueOrDefault(variant, VariantClasses["default"]); + var s = SizeClasses.GetValueOrDefault(size, SizeClasses["default"]); + _classesData = $"{BaseClasses} {s} {v}".ToUtf8Bytes(); + } + + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData); + protected override void RenderType(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_typeData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Calendar.htmx b/Htmx.ApiDemo/Templates/Components/Calendar.htmx new file mode 100644 index 0000000..7607683 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Calendar.htmx @@ -0,0 +1,38 @@ +<div id="cal-$$Id$$" + class="calendar-root inline-block min-w-72 rounded-md border border-border bg-card p-4 shadow-sm" + data-year="$$Year$$" + data-month="$$Month$$" + data-sel-day="$$SelectedDay$$" + data-sel-month="$$SelectedMonth$$" + data-sel-year="$$SelectedYear$$" + data-view="days"> + + <!-- Header row --> + <div class="mb-3 flex items-center justify-between"> + <button type="button" class="cal-prev cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input + bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base" + aria-label="Previous month">‹</button> + <button type="button" class="cal-month-label text-sm font-semibold px-2 py-0.5 rounded-md + hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"></button> + <button type="button" class="cal-next cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input + bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base" + aria-label="Next month">›</button> + </div> + + <!-- Day-of-week headers --> + <div class="cal-dow-row mb-1 grid grid-cols-7 text-center"> + <span class="cal-dow">Su</span> + <span class="cal-dow">Mo</span> + <span class="cal-dow">Tu</span> + <span class="cal-dow">We</span> + <span class="cal-dow">Th</span> + <span class="cal-dow">Fr</span> + <span class="cal-dow">Sa</span> + </div> + + <!-- Day grid (populated by JS below) --> + <div class="cal-grid grid grid-cols-7 gap-0.5 text-center"></div> + + <!-- Hidden input --> + <input type="hidden" name="$$Name$$" class="cal-hidden-input" value="$$DefaultValue$$" /> +</div> diff --git a/Htmx.ApiDemo/Templates/Components/Calendar.htmx.cs b/Htmx.ApiDemo/Templates/Components/Calendar.htmx.cs new file mode 100644 index 0000000..53f3d77 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Calendar.htmx.cs @@ -0,0 +1,43 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style Calendar (date-picker) component driven entirely by HyperScript. +/// Pass a selected date to pre-highlight a day; defaults to today. +/// </summary> +public sealed class Calendar : CalendarBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _yearData; + private readonly byte[] _monthData; // 0-based JS month + private readonly byte[] _selectedDayData; + private readonly byte[] _selectedMonthData; // 0-based + private readonly byte[] _selectedYearData; + private readonly byte[] _defaultValueData; + + public Calendar( + string id, + string name = "date", + DateOnly? selected = null) + { + var date = selected ?? DateOnly.FromDateTime(DateTime.Today); + + _idData = id.ToUtf8Bytes(); + _nameData = name.ToUtf8Bytes(); + _yearData = date.Year.ToString().ToUtf8Bytes(); + _monthData = (date.Month - 1).ToString().ToUtf8Bytes(); // JS months are 0-based + _selectedDayData = date.Day.ToString().ToUtf8Bytes(); + _selectedMonthData= (date.Month - 1).ToString().ToUtf8Bytes(); + _selectedYearData = date.Year.ToString().ToUtf8Bytes(); + _defaultValueData = date.ToString("yyyy-MM-dd").ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_yearData); + protected override void RenderMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_monthData); + protected override void RenderSelectedDay(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedDayData); + protected override void RenderSelectedMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedMonthData); + protected override void RenderSelectedYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedYearData); + protected override void RenderDefaultValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultValueData); +} diff --git a/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx b/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx new file mode 100644 index 0000000..657cd8c --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx @@ -0,0 +1,41 @@ +<div id="calr-$$Id$$" + class="calr-root inline-block min-w-72 rounded-md border border-border bg-card p-4 shadow-sm" + data-year="$$Year$$" + data-month="$$Month$$" + data-start="$$DefaultStart$$" + data-end="$$DefaultEnd$$" + data-view="days"> + + <!-- Header row --> + <div class="mb-3 flex items-center justify-between"> + <button type="button" class="calr-prev cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input + bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base" + aria-label="Previous month">‹</button> + <button type="button" class="calr-month-label text-sm font-semibold px-2 py-0.5 rounded-md + hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"></button> + <button type="button" class="calr-next cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input + bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base" + aria-label="Next month">›</button> + </div> + + <!-- Day-of-week headers --> + <div class="cal-dow-row mb-1 grid grid-cols-7 text-center"> + <span class="cal-dow">Su</span> + <span class="cal-dow">Mo</span> + <span class="cal-dow">Tu</span> + <span class="cal-dow">We</span> + <span class="cal-dow">Th</span> + <span class="cal-dow">Fr</span> + <span class="cal-dow">Sa</span> + </div> + + <!-- Day grid (populated by JS) --> + <div class="calr-grid grid grid-cols-7 text-center"></div> + + <!-- Range label --> + <div class="calr-label mt-3 text-xs text-muted-foreground min-h-4"></div> + + <!-- Hidden inputs --> + <input type="hidden" name="$$NameStart$$" class="calr-hidden-start" value="$$DefaultStart$$" /> + <input type="hidden" name="$$NameEnd$$" class="calr-hidden-end" value="$$DefaultEnd$$" /> +</div> diff --git a/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx.cs b/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx.cs new file mode 100644 index 0000000..4a844c6 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx.cs @@ -0,0 +1,49 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style range Calendar. Lets the user pick a start and end date. +/// State and rendering are handled by components.js (initCalendarRange). +/// Fires a <c>rangeChange</c> CustomEvent with <c>{ start, end }</c> detail. +/// </summary> +public sealed class CalendarRange : CalendarRangeBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameStartData; + private readonly byte[] _nameEndData; + private readonly byte[] _yearData; + private readonly byte[] _monthData; + private readonly byte[] _defaultStartData; + private readonly byte[] _defaultEndData; + + public CalendarRange( + string id, + string name = "date", + DateOnly? selectedStart = null, + DateOnly? selectedEnd = null) + { + // Show the start month if provided, otherwise today + var viewDate = selectedStart ?? DateOnly.FromDateTime(DateTime.Today); + + _idData = id.ToUtf8Bytes(); + _nameStartData = (name + "-start").ToUtf8Bytes(); + _nameEndData = (name + "-end").ToUtf8Bytes(); + _yearData = viewDate.Year.ToString().ToUtf8Bytes(); + _monthData = (viewDate.Month - 1).ToString().ToUtf8Bytes(); // 0-based + + _defaultStartData = selectedStart.HasValue + ? selectedStart.Value.ToString("yyyy-MM-dd").ToUtf8Bytes() + : [] ; + + _defaultEndData = selectedEnd.HasValue + ? selectedEnd.Value.ToString("yyyy-MM-dd").ToUtf8Bytes() + : []; + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderNameStart(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameStartData); + protected override void RenderNameEnd(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameEndData); + protected override void RenderYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_yearData); + protected override void RenderMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_monthData); + protected override void RenderDefaultStart(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultStartData); + protected override void RenderDefaultEnd(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultEndData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Input.htmx b/Htmx.ApiDemo/Templates/Components/Input.htmx new file mode 100644 index 0000000..e885de6 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Input.htmx @@ -0,0 +1,15 @@ +<div class="flex flex-col gap-1.5"> + $$Label$$ + <input + id="$$Id$$" + name="$$Name$$" + type="$$InputType$$" + placeholder="$$Placeholder$$" + class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm + ring-offset-background placeholder:text-muted-foreground + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 + disabled:cursor-not-allowed disabled:opacity-50 $$ExtraClasses$$" + $$HxAttrs$$ + /> + $$Description$$ +</div> diff --git a/Htmx.ApiDemo/Templates/Components/Input.htmx.cs b/Htmx.ApiDemo/Templates/Components/Input.htmx.cs new file mode 100644 index 0000000..b94bec0 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Input.htmx.cs @@ -0,0 +1,52 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style Input component with optional label and description. +/// InputType: text | email | password | number | search | tel | url | date | time +/// </summary> +public sealed class Input : InputBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _inputTypeData; + private readonly byte[] _placeholderData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _extraClassesData; + private readonly byte[] _hxAttrsData; + + public Input( + string id, + string name = "", + string inputType = "text", + string placeholder = "", + string label = "", + string description = "", + string extraClasses = "", + string hxAttrs = "") + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _inputTypeData = inputType.ToUtf8Bytes(); + _placeholderData = placeholder.ToUtf8Bytes(); + _extraClassesData = extraClasses.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""<label for="{id}" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{label}</label>""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderInputType(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_inputTypeData); + protected override void RenderPlaceholder(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_placeholderData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Select.htmx b/Htmx.ApiDemo/Templates/Components/Select.htmx new file mode 100644 index 0000000..d15e4c6 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Select.htmx @@ -0,0 +1,14 @@ +<div class="flex flex-col gap-1.5"> + $$Label$$ + <select + id="$$Id$$" + name="$$Name$$" + class="flex h-10 w-full items-center justify-between rounded-md border border-input + bg-background px-3 py-2 text-sm ring-offset-background + focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 + disabled:cursor-not-allowed disabled:opacity-50 appearance-none $$ExtraClasses$$" + $$HxAttrs$$> + $$Options$$ + </select> + $$Description$$ +</div> diff --git a/Htmx.ApiDemo/Templates/Components/Select.htmx.cs b/Htmx.ApiDemo/Templates/Components/Select.htmx.cs new file mode 100644 index 0000000..503ed92 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Select.htmx.cs @@ -0,0 +1,56 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style Select (native HTML select) component. +/// </summary> +public sealed class Select : SelectBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _optionsData; + private readonly byte[] _extraClassesData; + private readonly byte[] _hxAttrsData; + + /// <param name="options">Collection of (value, display) tuples. Mark selected with selectedValue.</param> + public Select( + string id, + IEnumerable<(string Value, string Display)> options, + string selectedValue = "", + string name = "", + string label = "", + string description = "", + string extraClasses = "", + string hxAttrs = "") + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _extraClassesData = extraClasses.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""<label for="{id}" class="text-sm font-medium leading-none">{label}</label>""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes(); + + var sb = new System.Text.StringBuilder(); + foreach (var (value, display) in options) + { + var selected = value == selectedValue ? " selected" : ""; + sb.Append($"""<option value="{value}"{selected}>{display}</option>"""); + } + _optionsData = sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderOptions(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_optionsData); + protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/TimePicker.htmx b/Htmx.ApiDemo/Templates/Components/TimePicker.htmx new file mode 100644 index 0000000..9f35a90 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/TimePicker.htmx @@ -0,0 +1,28 @@ +<div class="timepicker-root flex flex-col gap-1.5" data-use12h="$$Use12h$$" id="tp-$$UniqueId$$"> + $$Label$$ + <div class="flex items-center gap-1"> + + <!-- Hour --> + <input type="number" min="$$HourMin$$" max="$$HourMax$$" step="1" + class="timepicker-hour h-10 w-16 rounded-md border border-input bg-background px-2 text-center text-sm + ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + focus-visible:ring-offset-2" + value="$$DefaultHour$$" /> + + <span class="text-sm font-bold text-foreground">:</span> + + <!-- Minute --> + <input type="number" min="0" max="59" step="1" + class="timepicker-minute h-10 w-16 rounded-md border border-input bg-background px-2 text-center text-sm + ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + focus-visible:ring-offset-2" + value="$$DefaultMinute$$" /> + + <!-- AM/PM toggle (only rendered when use12h=true) --> + $$AmPmToggle$$ + + <!-- Hidden input that stores HH:MM value --> + <input type="hidden" name="$$Name$$" class="timepicker-hidden" value="$$DefaultValue$$" /> + </div> + $$Description$$ +</div> diff --git a/Htmx.ApiDemo/Templates/Components/TimePicker.htmx.cs b/Htmx.ApiDemo/Templates/Components/TimePicker.htmx.cs new file mode 100644 index 0000000..8f8671a --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/TimePicker.htmx.cs @@ -0,0 +1,89 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style TimePicker. Syncs hour+minute inputs to a hidden HH:MM field via inline JS. +/// </summary> +public sealed class TimePicker : TimePickerBase +{ + private readonly byte[] _uniqueIdData; + private readonly byte[] _nameData; + private readonly byte[] _use12hData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _defaultHourData; + private readonly byte[] _defaultMinuteData; + private readonly byte[] _defaultValueData; + private readonly byte[] _hourMinData; + private readonly byte[] _hourMaxData; + private readonly byte[] _amPmToggleData; + + public TimePicker( + string name = "time", + TimeOnly? selected = null, + string label = "", + string description = "", + bool use12h = false) + { + var time = selected ?? TimeOnly.FromDateTime(DateTime.Now); + var uid = Guid.NewGuid().ToString("N")[..8]; // short unique suffix + + _uniqueIdData = uid.ToUtf8Bytes(); + _nameData = name.ToUtf8Bytes(); + _use12hData = (use12h ? "true" : "false").ToUtf8Bytes(); + _defaultValueData = time.ToString("HH:mm").ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""<span class="text-sm font-medium leading-none">{label}</span>""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes(); + + if (use12h) + { + int hour12 = time.Hour % 12; + if (hour12 == 0) hour12 = 12; + bool isPm = time.Hour >= 12; + + _defaultHourData = hour12.ToString().ToUtf8Bytes(); + _defaultMinuteData = time.Minute.ToString("D2").ToUtf8Bytes(); + _hourMinData = "1".ToUtf8Bytes(); + _hourMaxData = "12".ToUtf8Bytes(); + _amPmToggleData = BuildAmPmToggle(isPm); + } + else + { + _defaultHourData = time.Hour.ToString("D2").ToUtf8Bytes(); + _defaultMinuteData = time.Minute.ToString("D2").ToUtf8Bytes(); + _hourMinData = "0".ToUtf8Bytes(); + _hourMaxData = "23".ToUtf8Bytes(); + _amPmToggleData = []; + } + } + + private static byte[] BuildAmPmToggle(bool isPm) + { + var amSel = isPm ? "" : " selected"; + var pmSel = isPm ? " selected" : ""; + return $""" + <select class="timepicker-ampm h-10 rounded-md border border-input bg-background px-2 text-sm + focus:outline-none focus:ring-2 focus:ring-ring"> + <option value="AM"{amSel}>AM</option> + <option value="PM"{pmSel}>PM</option> + </select> + """.ToUtf8Bytes(); + } + + protected override void RenderUniqueId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_uniqueIdData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderUse12h(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_use12hData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderDefaultHour(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultHourData); + protected override void RenderDefaultMinute(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultMinuteData); + protected override void RenderDefaultValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultValueData); + protected override void RenderHourMin(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hourMinData); + protected override void RenderHourMax(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hourMaxData); + protected override void RenderAmPmToggle(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_amPmToggleData); +} diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx b/Htmx.ApiDemo/Templates/Greeting.htmx index bf76153..1ad6d0f 100644 --- a/Htmx.ApiDemo/Templates/Greeting.htmx +++ b/Htmx.ApiDemo/Templates/Greeting.htmx @@ -1,4 +1,6 @@ -<div class="greeting"> +<div id="Greeting-$$GreetingId$$" class="greeting"> <h1>Hello, $$User$$!</h1> <p>Welcome to high-performance htmx rendering.</p> + + <button hx-get="/greet/$$User$$/$$Count$$/$$GreetingId$$" hx-target="#Greeting-$$GreetingId$$" hx-swap="outerHTML">Click to increase count $$Count$$</button> </div> \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx.cs b/Htmx.ApiDemo/Templates/Greeting.htmx.cs index 30ce110..2091c56 100644 --- a/Htmx.ApiDemo/Templates/Greeting.htmx.cs +++ b/Htmx.ApiDemo/Templates/Greeting.htmx.cs @@ -1,32 +1,38 @@ +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + namespace Htmx.ApiDemo.Templates; public sealed class Greeting : GreetingBase { private byte[] _userData = []; + private byte[] _countData = []; + private byte[] _greetingIdData = []; public required string Username { init => _userData = value.ToUtf8Bytes(); } + public required int Count { init => _countData = $"{value}".ToUtf8Bytes(); } + public required Guid GreetingId { init => _greetingIdData = $"{value}".ToUtf8Bytes(); } + + protected override void RenderCount(HtmxRenderContext context) => context.Writer.WriteUtf8(_countData); + protected override void RenderGreetingId(HtmxRenderContext context) => context.Writer.WriteUtf8(_greetingIdData); protected override void RenderUser(HtmxRenderContext context) => context.Writer.WriteUtf8(_userData); } [Handler] -[MapGet("/greet/{username}")] +[MapGet("/greet/{username}/{count?}/{id?}")] public static partial class GetGreetingHandler { - public record Query(string Username); + public record Query(string Username, int? Count, Guid? Id); private static ValueTask HandleAsync( Query query, - IHttpContextAccessor httpContextAccessor, + IHttpContextAccessor httpContextAccessor, CancellationToken token) { - var context = httpContextAccessor.HttpContext; - if(context is null) - throw new InvalidOperationException("HttpContext is not available."); - - var template = new Greeting { Username = query.Username }; - - context.Response.ContentType = "text/html; charset=utf-8"; - context.Response.BodyWriter.WriteHtmx(template); + var context = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + var template = new Greeting { Username = query.Username, Count = query.Count + 1 ?? 0, GreetingId = query.Id ?? Guid.NewGuid() }; + context.WriteHtmxBody(template); return ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Login.htmx b/Htmx.ApiDemo/Templates/Login.htmx new file mode 100644 index 0000000..53c2016 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Login.htmx @@ -0,0 +1,45 @@ +<div class="flex min-h-full items-center justify-center py-12"> + <div class="w-full max-w-sm space-y-6"> + + <div class="text-center"> + <h1 class="text-2xl font-bold tracking-tight text-foreground">Sign in</h1> + <p class="mt-1 text-sm text-muted-foreground">Enter your credentials to access your account</p> + </div> + + $$ErrorMessage$$ + + <form method="post" action="/login" class="space-y-4"> + $$AntiforgeryToken$$ + <div class="space-y-2"> + <label class="text-sm font-medium leading-none text-foreground" for="login-email">Email</label> + <input id="login-email" name="email" type="email" required autocomplete="email" + class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm + placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 + focus-visible:ring-ring" + placeholder="you@example.com" /> + </div> + + <div class="space-y-2"> + <label class="text-sm font-medium leading-none text-foreground" for="login-password">Password</label> + <input id="login-password" name="password" type="password" required autocomplete="current-password" + class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm + placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 + focus-visible:ring-ring" + placeholder="••••••••" /> + </div> + + <button type="submit" + class="inline-flex h-9 w-full items-center justify-center rounded-md bg-primary px-4 py-2 + text-sm font-medium text-primary-foreground shadow transition-colors + hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"> + Sign in + </button> + </form> + + <p class="text-center text-sm text-muted-foreground"> + Don't have an account? + <a href="/register" class="font-medium text-primary hover:underline">Sign up</a> + </p> + + </div> +</div> diff --git a/Htmx.ApiDemo/Templates/Login.htmx.cs b/Htmx.ApiDemo/Templates/Login.htmx.cs new file mode 100644 index 0000000..77b81f4 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Login.htmx.cs @@ -0,0 +1,88 @@ +using Htmx.ApiDemo.Data; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Mvc; + +namespace Htmx.ApiDemo.Templates; + +public sealed class Login : LoginBase +{ + private readonly byte[] _errorData; + private readonly byte[] _afTokenData; + + public Login(string? errorMessage = null, string? afToken = null) + { + _errorData = string.IsNullOrEmpty(errorMessage) + ? [] + : $"""<div class="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive border border-destructive/30">{System.Web.HttpUtility.HtmlEncode(errorMessage)}</div>""".ToUtf8Bytes(); + + _afTokenData = string.IsNullOrEmpty(afToken) + ? [] + : $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afToken)}" />""".ToUtf8Bytes(); + } + + protected override void RenderErrorMessage(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_errorData); + protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afTokenData); +} + + +[Handler] +[MapGet("/login")] +public static partial class GetLoginHandler +{ + public record Query; + + private static ValueTask HandleAsync( + Query _, + IHttpContextAccessor httpContextAccessor, + IAntiforgery antiforgery, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + if (ctx.User.Identity?.IsAuthenticated == true) + { + ctx.Response.Redirect("/"); + return ValueTask.CompletedTask; + } + + var afTokens = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Login(afToken: afTokens.RequestToken), title: "Sign in", appName: "HtmxApp", pageTitle: "Sign in"); + return ValueTask.CompletedTask; + } +} + + +[Handler] +[MapPost("/login")] +public static partial class PostLoginHandler +{ + public record Command( + [property: FromForm] string Email, + [property: FromForm] string Password + ); + + private static async ValueTask HandleAsync( + [AsParameters] Command command, + IHttpContextAccessor httpContextAccessor, + IAntiforgery antiforgery, + AuthService authService, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + var (success, error) = await authService.LoginAsync(command.Email, command.Password); + + if (success) + { + ctx.Response.Redirect("/"); + return; + } + + var afTokens = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Login(error, afToken: afTokens.RequestToken), title: "Sign in", appName: "HtmxApp", pageTitle: "Sign in"); + } +} diff --git a/Htmx.ApiDemo/Templates/Logout.cs b/Htmx.ApiDemo/Templates/Logout.cs new file mode 100644 index 0000000..8b67af7 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Logout.cs @@ -0,0 +1,28 @@ +using Htmx.ApiDemo.Data; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Mvc; + +namespace Htmx.ApiDemo.Templates; + +[Handler] +[MapPost("/logout")] +public static partial class PostLogoutHandler +{ + // Empty command — [AsParameters] ensures form content-type is accepted + // and antiforgery token in the form is validated by the middleware. + public record Command; + + private static async ValueTask HandleAsync( + [AsParameters] Command _, + AuthService authService, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + await authService.SignOutAsync(); + + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + ctx.Response.Redirect("/login"); + } +} diff --git a/Htmx.ApiDemo/Templates/MainLayout.htmx b/Htmx.ApiDemo/Templates/MainLayout.htmx new file mode 100644 index 0000000..aec463a --- /dev/null +++ b/Htmx.ApiDemo/Templates/MainLayout.htmx @@ -0,0 +1,116 @@ +<!DOCTYPE html> +<html lang="en" class="dark"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>$$Title$$ + + + + + + + +
+ + + + + + +
+ + +
+ + + + + +
$$PageTitle$$
+ + +
+ + + + $$UserSection$$ +
+
+ + +
+ $$Body$$ +
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/MainLayout.htmx.cs b/Htmx.ApiDemo/Templates/MainLayout.htmx.cs new file mode 100644 index 0000000..c88be67 --- /dev/null +++ b/Htmx.ApiDemo/Templates/MainLayout.htmx.cs @@ -0,0 +1,91 @@ +using Htmx.ApiDemo; +using Htmx.ApiDemo.Templates.Components; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + +namespace Htmx.ApiDemo.Templates; + +public sealed class MainLayout : MainLayoutBase +{ + private byte[] _titleData = []; + private byte[] _appNameData = []; + private byte[] _pageTitleData = []; + private byte[] _userSectionData = []; + + public IHtmxComponent Body { get; } + + public MainLayout(IHtmxComponent body, string title = "App", string appName = "MyApp", + string pageTitle = "Dashboard", string? userName = null, string? afToken = null) + { + Body = body; + _titleData = title.ToUtf8Bytes(); + _appNameData = appName.ToUtf8Bytes(); + _pageTitleData = pageTitle.ToUtf8Bytes(); + + var afInput = string.IsNullOrEmpty(afToken) + ? "" + : $""""""; + + _userSectionData = userName is not null + ? $""" +
+
+ {System.Web.HttpUtility.HtmlEncode(GetInitials(userName))} +
+
+ {afInput} + +
+
+ """.ToUtf8Bytes() + : """ + + Sign in + + """.ToUtf8Bytes(); + } + + private static string GetInitials(string name) + { + var parts = name.Trim().Split(' ', System.StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 + ? $"{parts[0][0]}{parts[^1][0]}".ToUpperInvariant() + : name.Length > 0 ? name[0].ToString().ToUpperInvariant() : "?"; + } + + protected override void RenderBody(HtmxRenderContext context) => Body.Render(context); + protected override void RenderTitle(HtmxRenderContext context) => context.Writer.WriteUtf8(_titleData); + protected override void RenderAppName(HtmxRenderContext context) => context.Writer.WriteUtf8(_appNameData); + protected override void RenderPageTitle(HtmxRenderContext context) => context.Writer.WriteUtf8(_pageTitleData); + protected override void RenderUserSection(HtmxRenderContext context) => context.Writer.WriteUtf8(_userSectionData); +} + + +[Handler] +[MapGet("/")] +public static partial class GetIndexHandler +{ + public record Command; + + private static ValueTask HandleAsync( + Command command, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + var context = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + var greet = new Greeting { Username = "Enciphered", Count = 0, GreetingId = Guid.NewGuid() }; + context.WriteHtmxPage(greet, title: "Home", appName: "HtmxApp", pageTitle: "Home"); + + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Register.htmx b/Htmx.ApiDemo/Templates/Register.htmx new file mode 100644 index 0000000..c08d497 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Register.htmx @@ -0,0 +1,63 @@ +
+
+ +
+

Create an account

+

Fill in the details below to get started

+
+ + $$ErrorMessage$$ + +
+ $$AntiforgeryToken$$ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account? + Sign in +

+ +
+
diff --git a/Htmx.ApiDemo/Templates/Register.htmx.cs b/Htmx.ApiDemo/Templates/Register.htmx.cs new file mode 100644 index 0000000..3ff1d86 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Register.htmx.cs @@ -0,0 +1,99 @@ +using Htmx.ApiDemo.Data; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Mvc; + +namespace Htmx.ApiDemo.Templates; + +public sealed class Register : RegisterBase +{ + private readonly byte[] _errorData; + private readonly byte[] _afTokenData; + + public Register(string? errorMessage = null, string? afToken = null) + { + _errorData = string.IsNullOrEmpty(errorMessage) + ? [] + : $"""
{System.Web.HttpUtility.HtmlEncode(errorMessage)}
""".ToUtf8Bytes(); + + _afTokenData = string.IsNullOrEmpty(afToken) + ? [] + : $"""""".ToUtf8Bytes(); + } + + protected override void RenderErrorMessage(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_errorData); + protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afTokenData); +} + + +[Handler] +[MapGet("/register")] +public static partial class GetRegisterHandler +{ + public record Query; + + private static ValueTask HandleAsync( + Query _, + IHttpContextAccessor httpContextAccessor, + IAntiforgery antiforgery, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + if (ctx.User.Identity?.IsAuthenticated == true) + { + ctx.Response.Redirect("/"); + return ValueTask.CompletedTask; + } + + var afTokens = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Register(afToken: afTokens.RequestToken), title: "Register", appName: "HtmxApp", pageTitle: "Create account"); + return ValueTask.CompletedTask; + } +} + + +[Handler] +[MapPost("/register")] +public static partial class PostRegisterHandler +{ + public record Command( + [property: FromForm] string Email, + [property: FromForm] string Password, + [property: FromForm] string ConfirmPassword, + [property: FromForm] string? DisplayName + ); + + private static async ValueTask HandleAsync( + [AsParameters] Command command, + IHttpContextAccessor httpContextAccessor, + IAntiforgery antiforgery, + AuthService authService, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + if (command.Password != command.ConfirmPassword) + { + var afTokens1 = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Register("Passwords do not match.", afToken: afTokens1.RequestToken), + title: "Register", appName: "HtmxApp", pageTitle: "Create account"); + return; + } + + var (success, error) = await authService.RegisterAsync(command.Email, command.Password, command.DisplayName); + + if (success) + { + ctx.Response.Redirect("/"); + return; + } + + var afTokens2 = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Register(error, afToken: afTokens2.RequestToken), + title: "Register", appName: "HtmxApp", pageTitle: "Create account"); + } +} diff --git a/Htmx.ApiDemo/Templates/UiDemo.htmx b/Htmx.ApiDemo/Templates/UiDemo.htmx new file mode 100644 index 0000000..9ac6605 --- /dev/null +++ b/Htmx.ApiDemo/Templates/UiDemo.htmx @@ -0,0 +1,68 @@ +
+ + +
+

Buttons

+
+ $$BtnDefault$$ + $$BtnDestructive$$ + $$BtnOutline$$ + $$BtnSecondary$$ + $$BtnGhost$$ + $$BtnLink$$ + $$BtnSm$$ + $$BtnLg$$ +
+
+ +
+ + +
+

Inputs

+
+ $$InputText$$ + $$InputEmail$$ + $$InputPassword$$ + $$InputSearch$$ +
+
+ +
+ + +
+

Select

+
+ $$SelectDemo$$ +
+
+ +
+ + +
+

Calendar

+ $$CalendarDemo$$ +
+ +
+ + +
+

Calendar Range

+ $$CalendarRangeDemo$$ +
+ +
+ + +
+

Time Picker

+
+ $$TimePickerDemo$$ + $$TimePicker12hDemo$$ +
+
+ +
diff --git a/Htmx.ApiDemo/Templates/UiDemo.htmx.cs b/Htmx.ApiDemo/Templates/UiDemo.htmx.cs new file mode 100644 index 0000000..fc6bff9 --- /dev/null +++ b/Htmx.ApiDemo/Templates/UiDemo.htmx.cs @@ -0,0 +1,101 @@ +using Htmx.ApiDemo.Templates.Components; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + +namespace Htmx.ApiDemo.Templates; + +public sealed class UiDemo : UiDemoBase +{ + public IHtmxComponent BtnDefault { get; } + public IHtmxComponent BtnDestructive { get; } + public IHtmxComponent BtnOutline { get; } + public IHtmxComponent BtnSecondary { get; } + public IHtmxComponent BtnGhost { get; } + public IHtmxComponent BtnLink { get; } + public IHtmxComponent BtnSm { get; } + public IHtmxComponent BtnLg { get; } + + public IHtmxComponent InputText { get; } + public IHtmxComponent InputEmail { get; } + public IHtmxComponent InputPassword { get; } + public IHtmxComponent InputSearch { get; } + + public IHtmxComponent SelectDemo { get; } + public IHtmxComponent CalendarDemo { get; } + public IHtmxComponent CalendarRangeDemo{ get; } + public IHtmxComponent TimePickerDemo { get; } + public IHtmxComponent TimePicker12hDemo { get; } + + public UiDemo() + { + BtnDefault = new Button("Default"); + BtnDestructive = new Button("Destructive", variant: "destructive"); + BtnOutline = new Button("Outline", variant: "outline"); + BtnSecondary = new Button("Secondary", variant: "secondary"); + BtnGhost = new Button("Ghost", variant: "ghost"); + BtnLink = new Button("Link", variant: "link"); + BtnSm = new Button("Small", size: "sm"); + BtnLg = new Button("Large", size: "lg"); + + InputText = new Input("username", label: "Username", placeholder: "Enter username"); + InputEmail = new Input("email", inputType: "email", label: "Email", placeholder: "you@example.com"); + InputPassword = new Input("password", inputType: "password", label: "Password", placeholder: "••••••••"); + InputSearch = new Input("search", inputType: "search", label: "Search", placeholder: "Search…", + hxAttrs: "hx-get=\"/search\" hx-trigger=\"keyup changed delay:300ms\" hx-target=\"#search-results\""); + + SelectDemo = new Select( + id: "framework", + label: "Framework", + options: [("htmx", "HTMX"), ("react", "React"), ("vue", "Vue"), ("svelte", "Svelte")], + selectedValue: "htmx", + description: "Choose your preferred framework"); + + CalendarDemo = new Calendar(id: "demo-cal", name: "demo-date"); + CalendarRangeDemo = new CalendarRange(id: "demo-calr", name: "demo-range"); + + TimePickerDemo = new TimePicker(name: "time-24h", label: "Time (24h)"); + TimePicker12hDemo = new TimePicker(name: "time-12h", label: "Time (12h)", use12h: true); + } + + protected override void RenderBtnDefault(HtmxRenderContext ctx) => BtnDefault.Render(ctx); + protected override void RenderBtnDestructive(HtmxRenderContext ctx) => BtnDestructive.Render(ctx); + protected override void RenderBtnOutline(HtmxRenderContext ctx) => BtnOutline.Render(ctx); + protected override void RenderBtnSecondary(HtmxRenderContext ctx) => BtnSecondary.Render(ctx); + protected override void RenderBtnGhost(HtmxRenderContext ctx) => BtnGhost.Render(ctx); + protected override void RenderBtnLink(HtmxRenderContext ctx) => BtnLink.Render(ctx); + protected override void RenderBtnSm(HtmxRenderContext ctx) => BtnSm.Render(ctx); + protected override void RenderBtnLg(HtmxRenderContext ctx) => BtnLg.Render(ctx); + + protected override void RenderInputText(HtmxRenderContext ctx) => InputText.Render(ctx); + protected override void RenderInputEmail(HtmxRenderContext ctx) => InputEmail.Render(ctx); + protected override void RenderInputPassword(HtmxRenderContext ctx) => InputPassword.Render(ctx); + protected override void RenderInputSearch(HtmxRenderContext ctx) => InputSearch.Render(ctx); + + protected override void RenderSelectDemo(HtmxRenderContext ctx) => SelectDemo.Render(ctx); + protected override void RenderCalendarDemo(HtmxRenderContext ctx) => CalendarDemo.Render(ctx); + protected override void RenderCalendarRangeDemo(HtmxRenderContext ctx) => CalendarRangeDemo.Render(ctx); + protected override void RenderTimePickerDemo(HtmxRenderContext ctx) => TimePickerDemo.Render(ctx); + protected override void RenderTimePicker12hDemo(HtmxRenderContext ctx) => TimePicker12hDemo.Render(ctx); +} + + +[Handler] +[MapGet("/ui-demo")] +public static partial class GetUiDemoHandler +{ + public record Query; + + private static ValueTask HandleAsync( + Query query, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + var context = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + var page = new UiDemo(); + context.WriteHtmxPage(page, title: "UI Demo", appName: "HtmxApp", pageTitle: "UI Components"); + + return ValueTask.CompletedTask; + } +} diff --git a/Htmx.ApiDemo/appsettings.json b/Htmx.ApiDemo/appsettings.json index 4d56694..1dd6e4c 100644 --- a/Htmx.ApiDemo/appsettings.json +++ b/Htmx.ApiDemo/appsettings.json @@ -1,4 +1,8 @@ { + "ConnectionStrings": { + "DefaultConnection": "mongodb://localhost:27017" + }, + "MongoDbName": "HtmxAppDb", "Logging": { "LogLevel": { "Default": "Information", diff --git a/Htmx.ApiDemo/package.json b/Htmx.ApiDemo/package.json new file mode 100644 index 0000000..13622c0 --- /dev/null +++ b/Htmx.ApiDemo/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@tailwindcss/cli": "^4.2.4", + "tailwindcss": "^4.2.4" + } +} diff --git a/Htmx.ApiDemo/wwwroot/css/input.css b/Htmx.ApiDemo/wwwroot/css/input.css new file mode 100644 index 0000000..3d520cd --- /dev/null +++ b/Htmx.ApiDemo/wwwroot/css/input.css @@ -0,0 +1,187 @@ +@import "tailwindcss"; + +@theme { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +/* ── Calendar component ───────────────────────────────────────────────── */ +@layer components { + .cal-dow { + @apply text-xs font-medium text-muted-foreground py-1; + } + + .cal-day { + @apply h-9 w-full rounded-md text-sm text-center + text-foreground bg-transparent + hover:bg-accent hover:text-accent-foreground + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + transition-colors cursor-pointer; + } + + .cal-day-selected { + @apply bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground; + } + + .cal-day-today { + @apply font-semibold underline underline-offset-2; + } + + .cal-nav { + @apply text-lg leading-none; + } + + /* ── Month / year quick-pick grid ── */ + .cal-view-btn { + @apply h-9 w-full rounded-md text-sm text-center + text-foreground bg-transparent + hover:bg-accent hover:text-accent-foreground + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + transition-colors cursor-pointer; + } + + .cal-view-btn-selected { + @apply bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground; + } + + /* ── CalendarRange day states ── */ + .calr-day { + @apply h-9 w-full text-sm text-center text-foreground + transition-colors cursor-pointer focus-visible:outline-none + focus-visible:ring-2 focus-visible:ring-ring; + } + + /* Plain days (no range involvement) */ + .calr-day-plain { + @apply rounded-md hover:bg-accent hover:text-accent-foreground; + } + + /* Start cap — primary, rounded left only */ + .calr-day-start { + @apply rounded-l-md bg-primary text-primary-foreground + hover:bg-primary; + } + + /* End cap — primary, rounded right only */ + .calr-day-end { + @apply rounded-r-md bg-primary text-primary-foreground + hover:bg-primary; + } + + /* Days strictly between start and end */ + .calr-day-mid { + @apply rounded-none bg-accent text-accent-foreground + hover:bg-accent; + } +} + +/* ── Select – custom caret via background SVG ─────────────────────────── */ +@layer components { + select.appearance-none { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.25rem; + } +} + +/* ── TimePicker – hide number spinner arrows ──────────────────────────── */ +@layer utilities { + input[type=number].timepicker-hour, + input[type=number].timepicker-minute { + -moz-appearance: textfield; + } + input[type=number].timepicker-hour::-webkit-outer-spin-button, + input[type=number].timepicker-hour::-webkit-inner-spin-button, + input[type=number].timepicker-minute::-webkit-outer-spin-button, + input[type=number].timepicker-minute::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +} diff --git a/Htmx.ApiDemo/wwwroot/css/output.css b/Htmx.ApiDemo/wwwroot/css/output.css new file mode 100644 index 0000000..c5a2acc --- /dev/null +++ b/Htmx.ApiDemo/wwwroot/css/output.css @@ -0,0 +1,1365 @@ +/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --color-black: #000; + --spacing: 0.25rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-xl: 36rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --tracking-tight: -0.025em; + --radius-md: calc(var(--radius) - 2px); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .pointer-events-none { + pointer-events: none; + } + .fixed { + position: fixed; + } + .static { + position: static; + } + .inset-0 { + inset: calc(var(--spacing) * 0); + } + .inset-y-0 { + inset-block: calc(var(--spacing) * 0); + } + .-start { + inset-inline-start: calc(var(--spacing) * -1); + } + .start { + inset-inline-start: var(--spacing); + } + .\!end { + inset-inline-end: var(--spacing) !important; + } + .-end { + inset-inline-end: calc(var(--spacing) * -1); + } + .end { + inset-inline-end: var(--spacing); + } + .left-0 { + left: calc(var(--spacing) * 0); + } + .z-20 { + z-index: 20; + } + .z-30 { + z-index: 30; + } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-block { + display: inline-block; + } + .inline-flex { + display: inline-flex; + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .h-5 { + height: calc(var(--spacing) * 5); + } + .h-8 { + height: calc(var(--spacing) * 8); + } + .h-9 { + height: calc(var(--spacing) * 9); + } + .h-10 { + height: calc(var(--spacing) * 10); + } + .h-11 { + height: calc(var(--spacing) * 11); + } + .h-16 { + height: calc(var(--spacing) * 16); + } + .min-h-4 { + min-height: calc(var(--spacing) * 4); + } + .min-h-dvh { + min-height: 100dvh; + } + .min-h-full { + min-height: 100%; + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .w-5 { + width: calc(var(--spacing) * 5); + } + .w-8 { + width: calc(var(--spacing) * 8); + } + .w-9 { + width: calc(var(--spacing) * 9); + } + .w-10 { + width: calc(var(--spacing) * 10); + } + .w-16 { + width: calc(var(--spacing) * 16); + } + .w-64 { + width: calc(var(--spacing) * 64); + } + .w-full { + width: 100%; + } + .max-w-sm { + max-width: var(--container-sm); + } + .max-w-xl { + max-width: var(--container-xl); + } + .max-w-xs { + max-width: var(--container-xs); + } + .min-w-72 { + min-width: calc(var(--spacing) * 72); + } + .flex-1 { + flex: 1; + } + .shrink-0 { + flex-shrink: 0; + } + .-translate-x-full { + --tw-translate-x: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .cursor-pointer { + cursor: pointer; + } + .appearance-none { + appearance: none; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .grid-cols-7 { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .gap-0\.5 { + gap: calc(var(--spacing) * 0.5); + } + .gap-1 { + gap: calc(var(--spacing) * 1); + } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .gap-8 { + gap: calc(var(--spacing) * 8); + } + .space-y-1 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-6 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-10 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse))); + } + } + .overflow-hidden { + overflow: hidden; + } + .overflow-y-auto { + overflow-y: auto; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-md { + border-radius: var(--radius-md); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-border { + border-color: var(--color-border); + } + .border-destructive\/30 { + border-color: color-mix(in srgb, hsl(var(--destructive)) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-destructive) 30%, transparent); + } + } + .border-input { + border-color: var(--color-input); + } + .bg-background { + background-color: var(--color-background); + } + .bg-black\/50 { + background-color: color-mix(in srgb, #000 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 50%, transparent); + } + } + .bg-card { + background-color: var(--color-card); + } + .bg-card\/80 { + background-color: color-mix(in srgb, hsl(var(--card)) 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-card) 80%, transparent); + } + } + .bg-destructive { + background-color: var(--color-destructive); + } + .bg-destructive\/15 { + background-color: color-mix(in srgb, hsl(var(--destructive)) 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-destructive) 15%, transparent); + } + } + .bg-primary { + background-color: var(--color-primary); + } + .bg-secondary { + background-color: var(--color-secondary); + } + .bg-transparent { + background-color: transparent; + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-12 { + padding-block: calc(var(--spacing) * 12); + } + .text-center { + text-align: center; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .leading-none { + --tw-leading: 1; + line-height: 1; + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .whitespace-nowrap { + white-space: nowrap; + } + .text-destructive { + color: var(--color-destructive); + } + .text-destructive-foreground { + color: var(--color-destructive-foreground); + } + .text-foreground { + color: var(--color-foreground); + } + .text-muted-foreground { + color: var(--color-muted-foreground); + } + .text-primary { + color: var(--color-primary); + } + .text-primary-foreground { + color: var(--color-primary-foreground); + } + .text-secondary-foreground { + color: var(--color-secondary-foreground); + } + .underline-offset-4 { + text-underline-offset: 4px; + } + .antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + .opacity-0 { + opacity: 0%; + } + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-offset-background { + --tw-ring-offset-color: var(--color-background); + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .backdrop-blur { + --tw-backdrop-blur: blur(8px); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-opacity { + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-300 { + --tw-duration: 300ms; + transition-duration: 300ms; + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .select-none { + -webkit-user-select: none; + user-select: none; + } + .peer-disabled\:cursor-not-allowed { + &:is(:where(.peer):disabled ~ *) { + cursor: not-allowed; + } + } + .peer-disabled\:opacity-70 { + &:is(:where(.peer):disabled ~ *) { + opacity: 70%; + } + } + .placeholder\:text-muted-foreground { + &::placeholder { + color: var(--color-muted-foreground); + } + } + .hover\:bg-accent { + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + } + .hover\:bg-destructive\/90 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--destructive)) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-destructive) 90%, transparent); + } + } + } + } + .hover\:bg-primary\/90 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--primary)) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-primary) 90%, transparent); + } + } + } + } + .hover\:bg-secondary\/80 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--secondary)) 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-secondary) 80%, transparent); + } + } + } + } + .hover\:text-accent-foreground { + &:hover { + @media (hover: hover) { + color: var(--color-accent-foreground); + } + } + } + .hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-ring { + &:focus { + --tw-ring-color: var(--color-ring); + } + } + .focus\:ring-offset-2 { + &:focus { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } + .focus-visible\:ring-1 { + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus-visible\:ring-2 { + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus-visible\:ring-ring { + &:focus-visible { + --tw-ring-color: var(--color-ring); + } + } + .focus-visible\:ring-offset-2 { + &:focus-visible { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + } + .focus-visible\:outline-none { + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + } + .disabled\:pointer-events-none { + &:disabled { + pointer-events: none; + } + } + .disabled\:cursor-not-allowed { + &:disabled { + cursor: not-allowed; + } + } + .disabled\:opacity-50 { + &:disabled { + opacity: 50%; + } + } + .sm\:grid-cols-2 { + @media (width >= 40rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .md\:relative { + @media (width >= 48rem) { + position: relative; + } + } + .md\:hidden { + @media (width >= 48rem) { + display: none; + } + } + .md\:translate-x-0 { + @media (width >= 48rem) { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .md\:px-6 { + @media (width >= 48rem) { + padding-inline: calc(var(--spacing) * 6); + } + } + .md\:shadow-none { + @media (width >= 48rem) { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .\[\&\.open\]\:translate-x-0 { + &.open { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } +} +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} +@layer base { + * { + border-color: var(--color-border); + } + body { + background-color: var(--color-background); + color: var(--color-foreground); + } +} +@layer components { + .cal-dow { + padding-block: calc(var(--spacing) * 1); + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + color: var(--color-muted-foreground); + } + .cal-day { + height: calc(var(--spacing) * 9); + width: 100%; + cursor: pointer; + border-radius: var(--radius-md); + background-color: transparent; + text-align: center; + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + color: var(--color-foreground); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-accent-foreground); + } + } + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + &:focus-visible { + --tw-ring-color: var(--color-ring); + } + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + } + .cal-day-selected { + background-color: var(--color-primary); + color: var(--color-primary-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-primary); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-primary-foreground); + } + } + } + .cal-day-today { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + text-decoration-line: underline; + text-underline-offset: 2px; + } + .cal-nav { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + --tw-leading: 1; + line-height: 1; + } + .cal-view-btn { + height: calc(var(--spacing) * 9); + width: 100%; + cursor: pointer; + border-radius: var(--radius-md); + background-color: transparent; + text-align: center; + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + color: var(--color-foreground); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-accent-foreground); + } + } + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + &:focus-visible { + --tw-ring-color: var(--color-ring); + } + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + } + .cal-view-btn-selected { + background-color: var(--color-primary); + color: var(--color-primary-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-primary); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-primary-foreground); + } + } + } + .calr-day { + height: calc(var(--spacing) * 9); + width: 100%; + cursor: pointer; + text-align: center; + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + color: var(--color-foreground); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + &:focus-visible { + --tw-ring-color: var(--color-ring); + } + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + } + .calr-day-plain { + border-radius: var(--radius-md); + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-accent-foreground); + } + } + } + .calr-day-start { + border-top-left-radius: var(--radius-md); + border-bottom-left-radius: var(--radius-md); + background-color: var(--color-primary); + color: var(--color-primary-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-primary); + } + } + } + .calr-day-end { + border-top-right-radius: var(--radius-md); + border-bottom-right-radius: var(--radius-md); + background-color: var(--color-primary); + color: var(--color-primary-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-primary); + } + } + } + .calr-day-mid { + border-radius: 0; + background-color: var(--color-accent); + color: var(--color-accent-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + } +} +@layer components { + select.appearance-none { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.25rem; + } +} +@layer utilities { + input[type=number].timepicker-hour, input[type=number].timepicker-minute { + -moz-appearance: textfield; + } + input[type=number].timepicker-hour::-webkit-outer-spin-button, input[type=number].timepicker-hour::-webkit-inner-spin-button, input[type=number].timepicker-minute::-webkit-outer-spin-button, input[type=number].timepicker-minute::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-space-y-reverse: 0; + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + --tw-duration: initial; + --tw-ease: initial; + } + } +} diff --git a/Htmx.ApiDemo/wwwroot/js/components.js b/Htmx.ApiDemo/wwwroot/js/components.js new file mode 100644 index 0000000..2b360e9 --- /dev/null +++ b/Htmx.ApiDemo/wwwroot/js/components.js @@ -0,0 +1,458 @@ +/* ───────────────────────────────────────────────────────────────────────── + * components.js – client-side logic for htmx server-rendered components + * ───────────────────────────────────────────────────────────────────────── */ + +// ── Calendar ────────────────────────────────────────────────────────────── + +(function () { + var MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + function renderCalendar(root) { + var view = root.dataset.view || 'days'; + var year = parseInt(root.dataset.year, 10); + var month = parseInt(root.dataset.month, 10); + var selD = parseInt(root.dataset.selDay, 10); + var selM = parseInt(root.dataset.selMonth, 10); + var selY = parseInt(root.dataset.selYear, 10); + + var labelBtn = root.querySelector('.cal-month-label'); + var grid = root.querySelector('.cal-grid'); + var dowRow = root.querySelector('.cal-dow-row'); + + // ── Update header label based on view ── + if (view === 'days') { + labelBtn.textContent = MONTHS[month] + ' ' + year; + } else if (view === 'months') { + labelBtn.textContent = year; + } else { // years + var ds = Math.floor(year / 12) * 12; + labelBtn.textContent = ds + ' – ' + (ds + 11); + } + + // Show DOW row only in day view + if (dowRow) dowRow.style.display = view === 'days' ? '' : 'none'; + + grid.innerHTML = ''; + + if (view === 'days') { + grid.style.gridTemplateColumns = ''; // let CSS class (grid-cols-7) take over + + var firstDay = new Date(year, month, 1).getDay(); + var daysInMonth = new Date(year, month + 1, 0).getDate(); + + for (var i = 0; i < firstDay; i++) { + grid.appendChild(document.createElement('div')); + } + + for (var d = 1; d <= daysInMonth; d++) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = d; + btn.className = 'cal-day'; + + if (d === selD && month === selM && year === selY) { + btn.classList.add('cal-day-selected'); + } + + btn.dataset.date = year + '-' + + String(month + 1).padStart(2, '0') + '-' + + String(d).padStart(2, '0'); + + btn.addEventListener('click', (function (b, r) { + return function () { + var parts = b.dataset.date.split('-'); + r.dataset.selYear = parts[0]; + r.dataset.selMonth = parseInt(parts[1], 10) - 1; + r.dataset.selDay = parseInt(parts[2], 10); + r.querySelectorAll('.cal-day').forEach(function (el) { + el.classList.remove('cal-day-selected'); + }); + b.classList.add('cal-day-selected'); + r.querySelector('.cal-hidden-input').value = b.dataset.date; + r.dispatchEvent(new CustomEvent('calendarChange', { + detail: { date: b.dataset.date }, + bubbles: true + })); + }; + })(btn, root)); + + grid.appendChild(btn); + } + + } else if (view === 'months') { + grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; + + MONTHS.forEach(function (name, i) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = name.slice(0, 3); + btn.className = 'cal-view-btn' + (i === month ? ' cal-view-btn-selected' : ''); + btn.addEventListener('click', function () { + root.dataset.month = i; + root.dataset.view = 'days'; + renderCalendar(root); + }); + grid.appendChild(btn); + }); + + } else { // years + var decadeStart = Math.floor(year / 12) * 12; + grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; + + for (var yi = 0; yi < 12; yi++) { + (function (y) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = y; + btn.className = 'cal-view-btn' + (y === year ? ' cal-view-btn-selected' : ''); + btn.addEventListener('click', function () { + root.dataset.year = y; + root.dataset.view = 'months'; + renderCalendar(root); + }); + grid.appendChild(btn); + })(decadeStart + yi); + } + } + } + + function initCalendar(root) { + root.querySelector('.cal-prev').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + var m = parseInt(root.dataset.month, 10); + var y = parseInt(root.dataset.year, 10); + if (view === 'days') { + if (m === 0) { m = 11; y--; } else { m--; } + root.dataset.month = m; + root.dataset.year = y; + } else if (view === 'months') { + root.dataset.year = y - 1; + } else { // years + root.dataset.year = Math.floor(y / 12) * 12 - 12; + } + renderCalendar(root); + }); + + root.querySelector('.cal-next').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + var m = parseInt(root.dataset.month, 10); + var y = parseInt(root.dataset.year, 10); + if (view === 'days') { + if (m === 11) { m = 0; y++; } else { m++; } + root.dataset.month = m; + root.dataset.year = y; + } else if (view === 'months') { + root.dataset.year = y + 1; + } else { // years + root.dataset.year = Math.floor(y / 12) * 12 + 12; + } + renderCalendar(root); + }); + + root.querySelector('.cal-month-label').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + if (view === 'days') root.dataset.view = 'months'; + else if (view === 'months') root.dataset.view = 'years'; + // already at years — nothing deeper + renderCalendar(root); + }); + + renderCalendar(root); + } + + // Initialise all calendars on page load, and again after any htmx swap + function initAll() { + document.querySelectorAll('.calendar-root').forEach(initCalendar); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); + + +// ── CalendarRange ───────────────────────────────────────────────────────── + +(function () { + var MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + function cmpDate(a, b) { return a < b ? -1 : a > b ? 1 : 0; } + + function isBetween(d, start, end) { + return cmpDate(d, start) > 0 && cmpDate(d, end) < 0; + } + + function toDateStr(year, month, day) { + return year + '-' + + String(month + 1).padStart(2, '0') + '-' + + String(day).padStart(2, '0'); + } + + function updateLabel(root) { + var lbl = root.querySelector('.calr-label'); + if (!lbl) return; + var start = root.dataset.start; + var end = root.dataset.end; + if (start && end) { lbl.textContent = start + ' → ' + end; return; } + if (start) { lbl.textContent = start + ' → pick end date'; return; } + lbl.textContent = ''; + } + + // Only updates CSS classes on already-rendered buttons — no DOM destruction. + function updateHoverClasses(root, hoverDate) { + var start = root.dataset.start; + var end = root.dataset.end; + var rangeEnd = (start && !end && hoverDate && cmpDate(hoverDate, start) >= 0) + ? hoverDate : end; + + root.querySelectorAll('.calr-day').forEach(function (btn) { + var ds = btn.dataset.date; + var isStart = !!(start && ds === start); + var isEnd = !!(end && ds === end); + var isHoverEnd = !!(!end && start && hoverDate && ds === hoverDate + && cmpDate(hoverDate, start) > 0); + var isMid = !!(start && rangeEnd && isBetween(ds, start, rangeEnd)); + + btn.classList.remove('calr-day-start', 'calr-day-end', 'calr-day-mid', 'calr-day-plain'); + if (isStart) btn.classList.add('calr-day-start'); + if (isEnd || isHoverEnd) btn.classList.add('calr-day-end'); + if (isMid) btn.classList.add('calr-day-mid'); + if (!isStart && !isEnd && !isHoverEnd && !isMid) btn.classList.add('calr-day-plain'); + }); + } + + // Full re-render of the grid. Called on mount, click, and view changes. + function renderRange(root) { + var view = root.dataset.view || 'days'; + var year = parseInt(root.dataset.year, 10); + var month = parseInt(root.dataset.month, 10); + var start = root.dataset.start || ''; + var end = root.dataset.end || ''; + + var labelBtn = root.querySelector('.calr-month-label'); + var grid = root.querySelector('.calr-grid'); + var dowRow = root.querySelector('.cal-dow-row'); + + // ── Update header label ── + if (view === 'days') { + labelBtn.textContent = MONTHS[month] + ' ' + year; + } else if (view === 'months') { + labelBtn.textContent = year; + } else { // years + var ds = Math.floor(year / 12) * 12; + labelBtn.textContent = ds + ' – ' + (ds + 11); + } + + if (dowRow) dowRow.style.display = view === 'days' ? '' : 'none'; + + grid.innerHTML = ''; + + // ── Clear event handlers (will be reassigned per view below) ── + grid.onmouseover = null; + grid.onmouseleave = null; + + if (view === 'days') { + grid.style.gridTemplateColumns = ''; + + var firstDay = new Date(year, month, 1).getDay(); + var daysInMonth = new Date(year, month + 1, 0).getDate(); + + for (var i = 0; i < firstDay; i++) { + grid.appendChild(document.createElement('div')); + } + + for (var d = 1; d <= daysInMonth; d++) { + var dateStr = toDateStr(year, month, d); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = d; + btn.dataset.date = dateStr; + + var isStart = start && dateStr === start; + var isEnd = end && dateStr === end; + var isMid = start && end && isBetween(dateStr, start, end); + var cls = 'calr-day'; + if (isStart) cls += ' calr-day-start'; + else if (isEnd) cls += ' calr-day-end'; + else if (isMid) cls += ' calr-day-mid'; + else cls += ' calr-day-plain'; + btn.className = cls; + + grid.appendChild(btn); + } + + // Click: update state → full re-render + grid.onclick = function (e) { + var btn = e.target.closest('.calr-day'); + if (!btn) return; + var ds = btn.dataset.date; + var s = root.dataset.start; + var en = root.dataset.end; + + if (!s || (s && en)) { + root.dataset.start = ds; + root.dataset.end = ''; + } else { + if (cmpDate(ds, s) > 0) { + root.dataset.end = ds; + } else if (cmpDate(ds, s) < 0) { + root.dataset.start = ds; + root.dataset.end = ''; + } else { + root.dataset.start = ''; + root.dataset.end = ''; + } + } + + root.querySelector('.calr-hidden-start').value = root.dataset.start; + root.querySelector('.calr-hidden-end').value = root.dataset.end; + root.dispatchEvent(new CustomEvent('rangeChange', { + detail: { start: root.dataset.start, end: root.dataset.end }, + bubbles: true + })); + + renderRange(root); + updateLabel(root); + }; + + grid.onmouseover = function (e) { + var btn = e.target.closest('.calr-day'); + if (!btn) return; + updateHoverClasses(root, btn.dataset.date); + }; + + grid.onmouseleave = function () { + updateHoverClasses(root, null); + }; + + } else if (view === 'months') { + grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; + + MONTHS.forEach(function (name, i) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = name.slice(0, 3); + btn.className = 'cal-view-btn' + (i === month ? ' cal-view-btn-selected' : ''); + btn.addEventListener('click', function () { + root.dataset.month = i; + root.dataset.view = 'days'; + renderRange(root); + }); + grid.appendChild(btn); + }); + + } else { // years + var decadeStart = Math.floor(year / 12) * 12; + grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; + + for (var yi = 0; yi < 12; yi++) { + (function (y) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = y; + btn.className = 'cal-view-btn' + (y === year ? ' cal-view-btn-selected' : ''); + btn.addEventListener('click', function () { + root.dataset.year = y; + root.dataset.view = 'months'; + renderRange(root); + }); + grid.appendChild(btn); + })(decadeStart + yi); + } + } + } + + function initCalendarRange(root) { + root.querySelector('.calr-prev').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + var m = parseInt(root.dataset.month, 10); + var y = parseInt(root.dataset.year, 10); + if (view === 'days') { + if (m === 0) { m = 11; y--; } else { m--; } + root.dataset.month = m; + root.dataset.year = y; + } else if (view === 'months') { + root.dataset.year = y - 1; + } else { // years + root.dataset.year = Math.floor(y / 12) * 12 - 12; + } + renderRange(root); + }); + + root.querySelector('.calr-next').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + var m = parseInt(root.dataset.month, 10); + var y = parseInt(root.dataset.year, 10); + if (view === 'days') { + if (m === 11) { m = 0; y++; } else { m++; } + root.dataset.month = m; + root.dataset.year = y; + } else if (view === 'months') { + root.dataset.year = y + 1; + } else { // years + root.dataset.year = Math.floor(y / 12) * 12 + 12; + } + renderRange(root); + }); + + root.querySelector('.calr-month-label').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + if (view === 'days') root.dataset.view = 'months'; + else if (view === 'months') root.dataset.view = 'years'; + renderRange(root); + }); + + renderRange(root); + updateLabel(root); + } + + function initAll() { + document.querySelectorAll('.calr-root').forEach(initCalendarRange); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); + + +// ── TimePicker ──────────────────────────────────────────────────────────── + +(function () { + function syncTime(root) { + var h = parseInt(root.querySelector('.timepicker-hour').value, 10) || 0; + var m = parseInt(root.querySelector('.timepicker-minute').value, 10) || 0; + var use12h = root.dataset.use12h === 'true'; + var h24 = h; + + if (use12h) { + var ampmEl = root.querySelector('.timepicker-ampm'); + var ampm = ampmEl ? ampmEl.value : 'AM'; + if (ampm === 'PM') { h24 = h === 12 ? 12 : h + 12; } + else { h24 = h === 12 ? 0 : h; } + } + + root.querySelector('.timepicker-hidden').value = + String(h24).padStart(2, '0') + ':' + String(m).padStart(2, '0'); + } + + function initTimePicker(root) { + var sync = syncTime.bind(null, root); + root.querySelector('.timepicker-hour').addEventListener('input', sync); + root.querySelector('.timepicker-minute').addEventListener('input', sync); + var ampmEl = root.querySelector('.timepicker-ampm'); + if (ampmEl) ampmEl.addEventListener('change', sync); + sync(); + } + + function initAll() { + document.querySelectorAll('.timepicker-root').forEach(initTimePicker); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); diff --git a/Htmx.SourceGenerator/HtmxGenerator.cs b/Htmx.SourceGenerator/HtmxGenerator.cs index fe829c2..3fbf095 100644 --- a/Htmx.SourceGenerator/HtmxGenerator.cs +++ b/Htmx.SourceGenerator/HtmxGenerator.cs @@ -1,4 +1,14 @@ -namespace Htmx.SourceGenerator +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Htmx.SourceGenerator { [Generator] public class HtmxGenerator : IIncrementalGenerator From 40a7d9018c82361b108e127491ddfece378f8255 Mon Sep 17 00:00:00 2001 From: Enciphered Date: Mon, 4 May 2026 18:58:48 +0500 Subject: [PATCH 05/18] Created more components --- Htmx.ApiDemo/Properties/launchSettings.json | 4 +- .../Templates/Components/Accordion.htmx | 3 + .../Templates/Components/Accordion.htmx.cs | 55 ++ Htmx.ApiDemo/Templates/Components/Alert.htmx | 7 + .../Templates/Components/Alert.htmx.cs | 40 ++ Htmx.ApiDemo/Templates/Components/Avatar.htmx | 3 + .../Templates/Components/Avatar.htmx.cs | 31 + Htmx.ApiDemo/Templates/Components/Badge.htmx | 1 + .../Templates/Components/Badge.htmx.cs | 33 + .../Templates/Components/Breadcrumb.htmx | 5 + .../Templates/Components/Breadcrumb.htmx.cs | 42 ++ Htmx.ApiDemo/Templates/Components/Card.htmx | 5 + .../Templates/Components/Card.htmx.cs | 48 ++ .../Templates/Components/Checkbox.htmx | 13 + .../Templates/Components/Checkbox.htmx.cs | 35 ++ Htmx.ApiDemo/Templates/Components/Dialog.htmx | 11 + .../Templates/Components/Dialog.htmx.cs | 55 ++ .../Templates/Components/DropdownMenu.htmx | 9 + .../Templates/Components/DropdownMenu.htmx.cs | 55 ++ .../Templates/Components/FileInput.htmx | 18 + .../Templates/Components/FileInput.htmx.cs | 51 ++ .../Templates/Components/Pagination.htmx | 5 + .../Templates/Components/Pagination.htmx.cs | 49 ++ .../Templates/Components/Progress.htmx | 4 + .../Templates/Components/Progress.htmx.cs | 26 + .../Templates/Components/RadioGroup.htmx | 6 + .../Templates/Components/RadioGroup.htmx.cs | 46 ++ .../Templates/Components/Separator.htmx | 1 + .../Templates/Components/Separator.htmx.cs | 24 + .../Templates/Components/Skeleton.htmx | 1 + .../Templates/Components/Skeleton.htmx.cs | 17 + Htmx.ApiDemo/Templates/Components/Slider.htmx | 17 + .../Templates/Components/Slider.htmx.cs | 59 ++ Htmx.ApiDemo/Templates/Components/Switch.htmx | 10 + .../Templates/Components/Switch.htmx.cs | 32 + Htmx.ApiDemo/Templates/Components/Table.htmx | 14 + .../Templates/Components/Table.htmx.cs | 50 ++ Htmx.ApiDemo/Templates/Components/Tabs.htmx | 7 + .../Templates/Components/Tabs.htmx.cs | 54 ++ .../Templates/Components/Textarea.htmx | 15 + .../Templates/Components/Textarea.htmx.cs | 55 ++ Htmx.ApiDemo/Templates/Components/Toast.htmx | 18 + .../Templates/Components/Toast.htmx.cs | 34 + .../Templates/Components/ToastViewport.htmx | 4 + .../Components/ToastViewport.htmx.cs | 20 + .../Templates/Components/Tooltip.htmx | 9 + .../Templates/Components/Tooltip.htmx.cs | 36 ++ Htmx.ApiDemo/Templates/UiDemo.htmx | 252 ++++++++ Htmx.ApiDemo/Templates/UiDemo.htmx.cs | 305 ++++++++- Htmx.ApiDemo/wwwroot/css/input.css | 3 + Htmx.ApiDemo/wwwroot/css/output.css | 581 ++++++++++++++++++ Htmx.ApiDemo/wwwroot/js/components.js | 259 ++++++++ 52 files changed, 2526 insertions(+), 11 deletions(-) create mode 100644 Htmx.ApiDemo/Templates/Components/Accordion.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Accordion.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Alert.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Alert.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Avatar.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Avatar.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Badge.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Badge.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Breadcrumb.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Breadcrumb.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Card.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Card.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Checkbox.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Checkbox.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Dialog.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Dialog.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/DropdownMenu.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/DropdownMenu.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/FileInput.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/FileInput.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Pagination.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Pagination.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Progress.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Progress.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/RadioGroup.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/RadioGroup.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Separator.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Separator.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Skeleton.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Skeleton.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Slider.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Slider.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Switch.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Switch.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Table.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Table.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Tabs.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Tabs.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Textarea.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Textarea.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Toast.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Toast.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/ToastViewport.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/ToastViewport.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Tooltip.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Tooltip.htmx.cs diff --git a/Htmx.ApiDemo/Properties/launchSettings.json b/Htmx.ApiDemo/Properties/launchSettings.json index e74f83a..12cf25f 100644 --- a/Htmx.ApiDemo/Properties/launchSettings.json +++ b/Htmx.ApiDemo/Properties/launchSettings.json @@ -4,8 +4,8 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "todos", + "launchBrowser": false, + "launchUrl": "/", "applicationUrl": "http://localhost:5120", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/Htmx.ApiDemo/Templates/Components/Accordion.htmx b/Htmx.ApiDemo/Templates/Components/Accordion.htmx new file mode 100644 index 0000000..79733d7 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Accordion.htmx @@ -0,0 +1,3 @@ +
+ $$Items$$ +
diff --git a/Htmx.ApiDemo/Templates/Components/Accordion.htmx.cs b/Htmx.ApiDemo/Templates/Components/Accordion.htmx.cs new file mode 100644 index 0000000..89f62b4 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Accordion.htmx.cs @@ -0,0 +1,55 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Accordion. Items collapse/expand client-side via components.js. +/// Pass a list of (Title, Content) tuples; set openIndex to expand one by default (-1 = all closed). +/// +public sealed class Accordion : AccordionBase +{ + private const string ChevronSvg = + """"""; + + private readonly byte[] _idData; + private readonly byte[] _itemsData; + + public Accordion(string id, IEnumerable<(string Title, string Content)> items, int openIndex = -1) + { + _idData = id.ToUtf8Bytes(); + + var list = items.ToList(); + var sb = new System.Text.StringBuilder(); + + for (int i = 0; i < list.Count; i++) + { + var (title, content) = list[i]; + var expanded = i == openIndex; + var height = expanded ? "auto" : "0"; + var opacity = expanded ? "1" : "0"; + + sb.Append($""" +
+

+ +

+
+
{content}
+
+
+ """); + } + + _itemsData = sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderItems(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_itemsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Alert.htmx b/Htmx.ApiDemo/Templates/Components/Alert.htmx new file mode 100644 index 0000000..4be6b4a --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Alert.htmx @@ -0,0 +1,7 @@ + diff --git a/Htmx.ApiDemo/Templates/Components/Alert.htmx.cs b/Htmx.ApiDemo/Templates/Components/Alert.htmx.cs new file mode 100644 index 0000000..e5bec89 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Alert.htmx.cs @@ -0,0 +1,40 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Alert component. +/// Variant: default | destructive +/// +public sealed class Alert : AlertBase +{ + private static readonly Dictionary VariantClasses = new() + { + ["default"] = "relative w-full rounded-lg border border-border bg-background p-4 " + + "[&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + ["destructive"] = "relative w-full rounded-lg border border-destructive/50 p-4 text-destructive " + + "[&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive", + }; + + private readonly byte[] _classesData; + private readonly byte[] _iconData; + private readonly byte[] _titleData; + private readonly byte[] _descriptionData; + + public Alert( + string title, + string description = "", + string variant = "default", + string icon = "") + { + _classesData = VariantClasses.GetValueOrDefault(variant, VariantClasses["default"]).ToUtf8Bytes(); + _iconData = icon.ToUtf8Bytes(); + _titleData = $"""
{title}
""".ToUtf8Bytes(); + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""
{description}
""".ToUtf8Bytes(); + } + + protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData); + protected override void RenderIcon(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_iconData); + protected override void RenderTitle(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_titleData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Avatar.htmx b/Htmx.ApiDemo/Templates/Components/Avatar.htmx new file mode 100644 index 0000000..05d1e8e --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Avatar.htmx @@ -0,0 +1,3 @@ + + $$Content$$ + diff --git a/Htmx.ApiDemo/Templates/Components/Avatar.htmx.cs b/Htmx.ApiDemo/Templates/Components/Avatar.htmx.cs new file mode 100644 index 0000000..465553c --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Avatar.htmx.cs @@ -0,0 +1,31 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Avatar component. Shows an image or falls back to initials. +/// Size: sm (h-8 w-8) | default (h-10 w-10) | lg (h-14 w-14) | xl (h-20 w-20) +/// +public sealed class Avatar : AvatarBase +{ + private static readonly Dictionary Sizes = new() + { + ["sm"] = "h-8 w-8", + ["default"] = "h-10 w-10", + ["lg"] = "h-14 w-14", + ["xl"] = "h-20 w-20", + }; + + private readonly byte[] _sizeClassesData; + private readonly byte[] _contentData; + + public Avatar(string fallback, string? src = null, string size = "default") + { + _sizeClassesData = Sizes.GetValueOrDefault(size, Sizes["default"]).ToUtf8Bytes(); + + _contentData = !string.IsNullOrEmpty(src) + ? $"""{fallback}""".ToUtf8Bytes() + : $"""{fallback}""".ToUtf8Bytes(); + } + + protected override void RenderSizeClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_sizeClassesData); + protected override void RenderContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_contentData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Badge.htmx b/Htmx.ApiDemo/Templates/Components/Badge.htmx new file mode 100644 index 0000000..bb65b2e --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Badge.htmx @@ -0,0 +1 @@ +$$Text$$ diff --git a/Htmx.ApiDemo/Templates/Components/Badge.htmx.cs b/Htmx.ApiDemo/Templates/Components/Badge.htmx.cs new file mode 100644 index 0000000..b1a8371 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Badge.htmx.cs @@ -0,0 +1,33 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Badge component. +/// Variant: default | secondary | destructive | outline +/// +public sealed class Badge : BadgeBase +{ + private static readonly Dictionary VariantClasses = new() + { + ["default"] = "bg-primary text-primary-foreground hover:bg-primary/80", + ["secondary"] = "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ["destructive"] = "bg-destructive text-destructive-foreground hover:bg-destructive/80", + ["outline"] = "text-foreground border border-input hover:bg-accent", + }; + + private const string BaseClasses = + "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors " + + "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"; + + private readonly byte[] _textData; + private readonly byte[] _classesData; + + public Badge(string text, string variant = "default") + { + _textData = text.ToUtf8Bytes(); + var v = VariantClasses.GetValueOrDefault(variant, VariantClasses["default"]); + _classesData = $"{BaseClasses} {v}".ToUtf8Bytes(); + } + + protected override void RenderText(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_textData); + protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Breadcrumb.htmx b/Htmx.ApiDemo/Templates/Components/Breadcrumb.htmx new file mode 100644 index 0000000..8ccca42 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Breadcrumb.htmx @@ -0,0 +1,5 @@ + diff --git a/Htmx.ApiDemo/Templates/Components/Breadcrumb.htmx.cs b/Htmx.ApiDemo/Templates/Components/Breadcrumb.htmx.cs new file mode 100644 index 0000000..9d982fc --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Breadcrumb.htmx.cs @@ -0,0 +1,42 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Breadcrumb navigation. +/// Pass items as (Label, Href) tuples — empty Href renders a non-linked span. +/// The last item is always rendered as the current page (non-linked, foreground colour). +/// +public sealed class Breadcrumb : BreadcrumbBase +{ + private const string ChevronSvg = + """"""; + + private readonly byte[] _itemsData; + + public Breadcrumb(IEnumerable<(string Label, string Href)> items) + { + var list = items.ToList(); + var sb = new System.Text.StringBuilder(); + + for (int i = 0; i < list.Count; i++) + { + var (label, href) = list[i]; + bool isLast = i == list.Count - 1; + + sb.Append("""
  • """); + + if (isLast || string.IsNullOrEmpty(href)) + sb.Append($"""{label}"""); + else + sb.Append($"""{label}"""); + + if (!isLast) + sb.Append($""""""); + + sb.Append("
  • "); + } + + _itemsData = sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderItems(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_itemsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Card.htmx b/Htmx.ApiDemo/Templates/Components/Card.htmx new file mode 100644 index 0000000..6600df7 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Card.htmx @@ -0,0 +1,5 @@ +
    + $$Header$$ +
    $$Content$$
    + $$Footer$$ +
    diff --git a/Htmx.ApiDemo/Templates/Components/Card.htmx.cs b/Htmx.ApiDemo/Templates/Components/Card.htmx.cs new file mode 100644 index 0000000..e0254ba --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Card.htmx.cs @@ -0,0 +1,48 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Card component with optional header (title + description) and footer. +/// +public sealed class Card : CardBase +{ + private readonly byte[] _extraClassesData; + private readonly byte[] _headerData; + private readonly byte[] _contentData; + private readonly byte[] _footerData; + + public Card( + string content, + string title = "", + string description = "", + string footer = "", + string extraClasses = "") + { + _extraClassesData = extraClasses.ToUtf8Bytes(); + _contentData = content.ToUtf8Bytes(); + + _headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description)) + ? [] + : BuildHeader(title, description); + + _footerData = string.IsNullOrEmpty(footer) + ? [] + : $"""
    {footer}
    """.ToUtf8Bytes(); + } + + private static byte[] BuildHeader(string title, string description) + { + var sb = new System.Text.StringBuilder(); + sb.Append("""
    """); + if (!string.IsNullOrEmpty(title)) + sb.Append($"""

    {title}

    """); + if (!string.IsNullOrEmpty(description)) + sb.Append($"""

    {description}

    """); + sb.Append("
    "); + return sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData); + protected override void RenderHeader(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_headerData); + protected override void RenderContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_contentData); + protected override void RenderFooter(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_footerData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Checkbox.htmx b/Htmx.ApiDemo/Templates/Components/Checkbox.htmx new file mode 100644 index 0000000..06f6846 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Checkbox.htmx @@ -0,0 +1,13 @@ +
    + + $$Label$$ +
    diff --git a/Htmx.ApiDemo/Templates/Components/Checkbox.htmx.cs b/Htmx.ApiDemo/Templates/Components/Checkbox.htmx.cs new file mode 100644 index 0000000..f44980e --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Checkbox.htmx.cs @@ -0,0 +1,35 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Checkbox with an optional label. +/// +public sealed class Checkbox : CheckboxBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _valueData; + private readonly byte[] _checkedData; + private readonly byte[] _labelData; + + public Checkbox( + string id, + string label = "", + string name = "", + string value = "true", + bool @checked = false) + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _valueData = value.ToUtf8Bytes(); + _checkedData = (@checked ? "checked" : "").ToUtf8Bytes(); + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""""".ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_valueData); + protected override void RenderChecked(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_checkedData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Dialog.htmx b/Htmx.ApiDemo/Templates/Components/Dialog.htmx new file mode 100644 index 0000000..eccc42c --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Dialog.htmx @@ -0,0 +1,11 @@ + +
    + $$Header$$ +
    $$Content$$
    + $$Footer$$ +
    +
    diff --git a/Htmx.ApiDemo/Templates/Components/Dialog.htmx.cs b/Htmx.ApiDemo/Templates/Components/Dialog.htmx.cs new file mode 100644 index 0000000..f6f9630 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Dialog.htmx.cs @@ -0,0 +1,55 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Dialog using the native HTML <dialog> element. +/// Open with data-dialog-open="id" on any button; close with data-dialog-close or .dialog-close. +/// JS wiring is in components.js. +/// +public sealed class Dialog : DialogBase +{ + private readonly byte[] _idData; + private readonly byte[] _headerData; + private readonly byte[] _contentData; + private readonly byte[] _footerData; + + public Dialog( + string id, + string content, + string title = "", + string description = "", + string footer = "") + { + _idData = id.ToUtf8Bytes(); + _contentData = content.ToUtf8Bytes(); + + _headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description)) + ? [] + : BuildHeader(id, title, description); + + _footerData = string.IsNullOrEmpty(footer) + ? [] + : $"""
    {footer}
    """.ToUtf8Bytes(); + } + + private static byte[] BuildHeader(string id, string title, string description) + { + var sb = new System.Text.StringBuilder(); + sb.Append("""
    """); + sb.Append("""
    """); + if (!string.IsNullOrEmpty(title)) + sb.Append($"""

    {title}

    """); + if (!string.IsNullOrEmpty(description)) + sb.Append($"""

    {description}

    """); + sb.Append("
    "); + sb.Append(""""); + sb.Append("
    "); + return sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderHeader(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_headerData); + protected override void RenderContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_contentData); + protected override void RenderFooter(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_footerData); +} diff --git a/Htmx.ApiDemo/Templates/Components/DropdownMenu.htmx b/Htmx.ApiDemo/Templates/Components/DropdownMenu.htmx new file mode 100644 index 0000000..c151650 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/DropdownMenu.htmx @@ -0,0 +1,9 @@ + diff --git a/Htmx.ApiDemo/Templates/Components/DropdownMenu.htmx.cs b/Htmx.ApiDemo/Templates/Components/DropdownMenu.htmx.cs new file mode 100644 index 0000000..e8f466a --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/DropdownMenu.htmx.cs @@ -0,0 +1,55 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// CSS-native DropdownMenu using <details>/<summary>. +/// Position: "left-0 top-full mt-1" (default) | "right-0 top-full mt-1" | etc. +/// Items: pre-built HTML — use BuildItem() helper for consistent styling. +/// +public sealed class DropdownMenu : DropdownMenuBase +{ + private const string ItemClasses = + "relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm " + + "outline-none transition-colors hover:bg-accent hover:text-accent-foreground " + + "focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"; + + private readonly byte[] _triggerClassesData; + private readonly byte[] _triggerData; + private readonly byte[] _positionData; + private readonly byte[] _itemsData; + + public DropdownMenu( + IHtmxComponent trigger, + IEnumerable<(string Label, string Href, bool IsSeparator)> items, + string position = "left-0 top-full mt-1") + { + // Render trigger to bytes + var writer = new System.Buffers.ArrayBufferWriter(); + trigger.Render(new HtmxRenderContext(writer)); + _triggerData = writer.WrittenSpan.ToArray(); + _triggerClassesData = []; // trigger already supplies its own classes + _positionData = position.ToUtf8Bytes(); + + var sb = new System.Text.StringBuilder(); + foreach (var (label, href, isSeparator) in items) + { + if (isSeparator) + { + sb.Append("""
    """); + } + else if (string.IsNullOrEmpty(href)) + { + sb.Append($""""""); + } + else + { + sb.Append($"""{label}"""); + } + } + _itemsData = sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderTriggerClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_triggerClassesData); + protected override void RenderTrigger(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_triggerData); + protected override void RenderPosition(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_positionData); + protected override void RenderItems(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_itemsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/FileInput.htmx b/Htmx.ApiDemo/Templates/Components/FileInput.htmx new file mode 100644 index 0000000..8baca5f --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/FileInput.htmx @@ -0,0 +1,18 @@ +
    + $$Label$$ + + $$Description$$ +
    diff --git a/Htmx.ApiDemo/Templates/Components/FileInput.htmx.cs b/Htmx.ApiDemo/Templates/Components/FileInput.htmx.cs new file mode 100644 index 0000000..2b4caa0 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/FileInput.htmx.cs @@ -0,0 +1,51 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style FileInput component with optional label and description. +/// +public sealed class FileInput : FileInputBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _acceptData; + private readonly byte[] _multipleData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _extraClassesData; + private readonly byte[] _hxAttrsData; + + public FileInput( + string id, + string name = "", + string accept = "", + bool multiple = false, + string label = "", + string description = "", + string extraClasses = "", + string hxAttrs = "") + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _acceptData = string.IsNullOrEmpty(accept) ? [] : $"""accept="{accept}" """.ToUtf8Bytes(); + _multipleData = multiple ? "multiple".ToUtf8Bytes() : []; + _extraClassesData = extraClasses.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""

    {description}

    """.ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderAccept(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_acceptData); + protected override void RenderMultiple(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_multipleData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Pagination.htmx b/Htmx.ApiDemo/Templates/Components/Pagination.htmx new file mode 100644 index 0000000..490ce26 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Pagination.htmx @@ -0,0 +1,5 @@ + diff --git a/Htmx.ApiDemo/Templates/Components/Pagination.htmx.cs b/Htmx.ApiDemo/Templates/Components/Pagination.htmx.cs new file mode 100644 index 0000000..71d73a2 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Pagination.htmx.cs @@ -0,0 +1,49 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Pagination. Generates prev/next and page-number buttons. +/// urlPattern: format string where {0} is replaced by the page number, e.g. "/items?page={0}" +/// +public sealed class Pagination : PaginationBase +{ + private const string BtnBase = + "inline-flex items-center justify-center rounded-md border border-input bg-background " + + "px-3 h-9 text-sm font-medium ring-offset-background transition-colors " + + "hover:bg-accent hover:text-accent-foreground " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 " + + "disabled:pointer-events-none disabled:opacity-50"; + + private const string ActiveBtn = + "inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground " + + "px-3 h-9 text-sm font-medium ring-offset-background " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"; + + private readonly byte[] _prevData; + private readonly byte[] _pagesData; + private readonly byte[] _nextData; + + public Pagination(int current, int total, string urlPattern = "?page={0}") + { + _prevData = current <= 1 + ? $"""""".ToUtf8Bytes() + : $"""""".ToUtf8Bytes(); + + _nextData = current >= total + ? $"""""".ToUtf8Bytes() + : $"""""".ToUtf8Bytes(); + + var sb = new System.Text.StringBuilder(); + for (int p = 1; p <= total; p++) + { + if (p == current) + sb.Append($"""{p}"""); + else + sb.Append($"""{p}"""); + } + _pagesData = sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderPrev(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_prevData); + protected override void RenderPages(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_pagesData); + protected override void RenderNext(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nextData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Progress.htmx b/Htmx.ApiDemo/Templates/Components/Progress.htmx new file mode 100644 index 0000000..3c2460f --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Progress.htmx @@ -0,0 +1,4 @@ +
    +
    +
    diff --git a/Htmx.ApiDemo/Templates/Components/Progress.htmx.cs b/Htmx.ApiDemo/Templates/Components/Progress.htmx.cs new file mode 100644 index 0000000..9e05862 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Progress.htmx.cs @@ -0,0 +1,26 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Progress bar. Value is clamped to 0–100. +/// Size: sm (h-2) | default (h-4) | lg (h-6) +/// +public sealed class Progress : ProgressBase +{ + private readonly byte[] _valueNowData; + private readonly byte[] _heightClassData; + + public Progress(int value = 0, string size = "default") + { + var clamped = Math.Clamp(value, 0, 100); + _valueNowData = clamped.ToString().ToUtf8Bytes(); + _heightClassData = size switch + { + "sm" => "h-2".ToUtf8Bytes(), + "lg" => "h-6".ToUtf8Bytes(), + _ => "h-4".ToUtf8Bytes(), + }; + } + + protected override void RenderValueNow(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_valueNowData); + protected override void RenderHeightClass(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_heightClassData); +} diff --git a/Htmx.ApiDemo/Templates/Components/RadioGroup.htmx b/Htmx.ApiDemo/Templates/Components/RadioGroup.htmx new file mode 100644 index 0000000..3f9ba10 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/RadioGroup.htmx @@ -0,0 +1,6 @@ +
    + $$GroupLabel$$ +
    + $$Items$$ +
    +
    diff --git a/Htmx.ApiDemo/Templates/Components/RadioGroup.htmx.cs b/Htmx.ApiDemo/Templates/Components/RadioGroup.htmx.cs new file mode 100644 index 0000000..2148728 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/RadioGroup.htmx.cs @@ -0,0 +1,46 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style RadioGroup. +/// Direction: flex-col | flex-row +/// +public sealed class RadioGroup : RadioGroupBase +{ + private readonly byte[] _groupLabelData; + private readonly byte[] _directionData; + private readonly byte[] _itemsData; + + public RadioGroup( + string name, + IEnumerable<(string Value, string Label, bool Selected)> options, + string label = "", + string direction = "flex-col") + { + _groupLabelData = string.IsNullOrEmpty(label) + ? [] + : $"""{label}""".ToUtf8Bytes(); + + _directionData = direction.ToUtf8Bytes(); + + var sb = new System.Text.StringBuilder(); + foreach (var (value, optLabel, selected) in options) + { + var optId = $"{name}-{value}"; + var sel = selected ? " checked" : ""; + sb.Append($""" + + """); + } + _itemsData = sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderGroupLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_groupLabelData); + protected override void RenderDirection(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_directionData); + protected override void RenderItems(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_itemsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Separator.htmx b/Htmx.ApiDemo/Templates/Components/Separator.htmx new file mode 100644 index 0000000..305e090 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Separator.htmx @@ -0,0 +1 @@ + diff --git a/Htmx.ApiDemo/Templates/Components/Separator.htmx.cs b/Htmx.ApiDemo/Templates/Components/Separator.htmx.cs new file mode 100644 index 0000000..1a8599b --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Separator.htmx.cs @@ -0,0 +1,24 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Separator component. +/// Orientation: horizontal | vertical +/// +public sealed class Separator : SeparatorBase +{ + private readonly byte[] _classesData; + private readonly byte[] _orientationData; + + public Separator(string orientation = "horizontal", string extraClasses = "") + { + var cls = orientation == "vertical" + ? $"inline-block h-full w-px bg-border {extraClasses}" + : $"block h-px w-full bg-border {extraClasses}"; + + _classesData = cls.Trim().ToUtf8Bytes(); + _orientationData = orientation.ToUtf8Bytes(); + } + + protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData); + protected override void RenderOrientation(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_orientationData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Skeleton.htmx b/Htmx.ApiDemo/Templates/Components/Skeleton.htmx new file mode 100644 index 0000000..3cacee9 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Skeleton.htmx @@ -0,0 +1 @@ +
    diff --git a/Htmx.ApiDemo/Templates/Components/Skeleton.htmx.cs b/Htmx.ApiDemo/Templates/Components/Skeleton.htmx.cs new file mode 100644 index 0000000..a9dd567 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Skeleton.htmx.cs @@ -0,0 +1,17 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Skeleton loading placeholder. +/// Pass size classes via the classes parameter, e.g. "h-4 w-48" or "h-10 w-full". +/// +public sealed class Skeleton : SkeletonBase +{ + private readonly byte[] _classesData; + + public Skeleton(string classes = "h-4 w-full") + { + _classesData = classes.ToUtf8Bytes(); + } + + protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Slider.htmx b/Htmx.ApiDemo/Templates/Components/Slider.htmx new file mode 100644 index 0000000..35a6ab4 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Slider.htmx @@ -0,0 +1,17 @@ +
    + $$Label$$ + + $$Description$$ +
    diff --git a/Htmx.ApiDemo/Templates/Components/Slider.htmx.cs b/Htmx.ApiDemo/Templates/Components/Slider.htmx.cs new file mode 100644 index 0000000..5392d17 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Slider.htmx.cs @@ -0,0 +1,59 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Slider (range input) with optional label and description. +/// +public sealed class Slider : SliderBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _minData; + private readonly byte[] _maxData; + private readonly byte[] _stepData; + private readonly byte[] _valueData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _extraClassesData; + private readonly byte[] _hxAttrsData; + + public Slider( + string id, + string name = "", + int min = 0, + int max = 100, + int step = 1, + int value = 50, + string label = "", + string description = "", + string extraClasses = "", + string hxAttrs = "") + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _minData = min.ToString().ToUtf8Bytes(); + _maxData = max.ToString().ToUtf8Bytes(); + _stepData = step.ToString().ToUtf8Bytes(); + _valueData = value.ToString().ToUtf8Bytes(); + _extraClassesData = extraClasses.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""

    {description}

    """.ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderMin(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_minData); + protected override void RenderMax(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_maxData); + protected override void RenderStep(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_stepData); + protected override void RenderValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_valueData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Switch.htmx b/Htmx.ApiDemo/Templates/Components/Switch.htmx new file mode 100644 index 0000000..f1ad1cc --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Switch.htmx @@ -0,0 +1,10 @@ + diff --git a/Htmx.ApiDemo/Templates/Components/Switch.htmx.cs b/Htmx.ApiDemo/Templates/Components/Switch.htmx.cs new file mode 100644 index 0000000..57963c0 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Switch.htmx.cs @@ -0,0 +1,32 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Switch (toggle). Rendered as a styled checkbox. +/// JS in components.js handles the visual on/off state. +/// +public sealed class Switch : SwitchBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _checkedData; + private readonly byte[] _labelData; + + public Switch( + string id, + string label = "", + string name = "", + bool isChecked = false) + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _checkedData = (isChecked ? "checked" : "").ToUtf8Bytes(); + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""{label}""".ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderChecked(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_checkedData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Table.htmx b/Htmx.ApiDemo/Templates/Components/Table.htmx new file mode 100644 index 0000000..5d57f82 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Table.htmx @@ -0,0 +1,14 @@ +
    + + $$Caption$$ + + + $$Headers$$ + + + + $$Rows$$ + + $$Footer$$ +
    +
    diff --git a/Htmx.ApiDemo/Templates/Components/Table.htmx.cs b/Htmx.ApiDemo/Templates/Components/Table.htmx.cs new file mode 100644 index 0000000..9063766 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Table.htmx.cs @@ -0,0 +1,50 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Table component. +/// Headers: column header strings. +/// Rows: each row is an IEnumerable of cell strings. +/// Caption and Footer are optional. +/// +public sealed class Table : TableBase +{ + private readonly byte[] _captionData; + private readonly byte[] _headersData; + private readonly byte[] _rowsData; + private readonly byte[] _footerData; + + public Table( + IEnumerable headers, + IEnumerable> rows, + string caption = "", + string footer = "") + { + _captionData = string.IsNullOrEmpty(caption) + ? [] + : $"""{caption}""".ToUtf8Bytes(); + + var hSb = new System.Text.StringBuilder(); + foreach (var h in headers) + hSb.Append($"""{h}"""); + _headersData = hSb.ToString().ToUtf8Bytes(); + + var rSb = new System.Text.StringBuilder(); + foreach (var row in rows) + { + rSb.Append(""""""); + foreach (var cell in row) + rSb.Append($"""{cell}"""); + rSb.Append(""); + } + _rowsData = rSb.ToString().ToUtf8Bytes(); + + _footerData = string.IsNullOrEmpty(footer) + ? [] + : $"""{footer}""".ToUtf8Bytes(); + } + + protected override void RenderCaption(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_captionData); + protected override void RenderHeaders(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_headersData); + protected override void RenderRows(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_rowsData); + protected override void RenderFooter(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_footerData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Tabs.htmx b/Htmx.ApiDemo/Templates/Components/Tabs.htmx new file mode 100644 index 0000000..d33ef96 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Tabs.htmx @@ -0,0 +1,7 @@ +
    +
    + $$TabsList$$ +
    + $$TabsPanels$$ +
    diff --git a/Htmx.ApiDemo/Templates/Components/Tabs.htmx.cs b/Htmx.ApiDemo/Templates/Components/Tabs.htmx.cs new file mode 100644 index 0000000..2957d7e --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Tabs.htmx.cs @@ -0,0 +1,54 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Tabs component. Tabs are activated client-side via components.js. +/// Pass a list of (Id, Label, Content) tuples. +/// +public sealed class Tabs : TabsBase +{ + private readonly byte[] _idData; + private readonly byte[] _tabsListData; + private readonly byte[] _tabsPanelsData; + + private const string TriggerBase = + "tabs-trigger inline-flex items-center justify-center whitespace-nowrap rounded-sm " + + "px-3 py-1.5 text-sm font-medium ring-offset-background transition-all " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring " + + "focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"; + + private const string PanelBase = + "tabs-panel mt-2 ring-offset-background " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"; + + public Tabs(string id, IEnumerable<(string Id, string Label, string Content)> tabs) + { + _idData = id.ToUtf8Bytes(); + + var tabList = tabs.ToList(); + var triggerSb = new System.Text.StringBuilder(); + var panelSb = new System.Text.StringBuilder(); + + foreach (var (tabId, label, content) in tabList) + { + triggerSb.Append($""" + + """); + + panelSb.Append($""" +
    + {content} +
    + """); + } + + _tabsListData = triggerSb.ToString().ToUtf8Bytes(); + _tabsPanelsData = panelSb.ToString().ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderTabsList(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tabsListData); + protected override void RenderTabsPanels(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tabsPanelsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Textarea.htmx b/Htmx.ApiDemo/Templates/Components/Textarea.htmx new file mode 100644 index 0000000..4960545 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Textarea.htmx @@ -0,0 +1,15 @@ +
    + $$Label$$ + + $$Description$$ +
    diff --git a/Htmx.ApiDemo/Templates/Components/Textarea.htmx.cs b/Htmx.ApiDemo/Templates/Components/Textarea.htmx.cs new file mode 100644 index 0000000..dff0c7b --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Textarea.htmx.cs @@ -0,0 +1,55 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Textarea component with optional label and description. +/// +public sealed class Textarea : TextareaBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _rowsData; + private readonly byte[] _placeholderData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _defaultValueData; + private readonly byte[] _extraClassesData; + private readonly byte[] _hxAttrsData; + + public Textarea( + string id, + string name = "", + string placeholder = "", + string label = "", + string description = "", + string defaultValue = "", + string extraClasses = "", + string hxAttrs = "", + int rows = 4) + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _rowsData = rows.ToString().ToUtf8Bytes(); + _placeholderData = placeholder.ToUtf8Bytes(); + _extraClassesData = extraClasses.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + _defaultValueData = defaultValue.ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""

    {description}

    """.ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderRows(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_rowsData); + protected override void RenderPlaceholder(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_placeholderData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderDefaultValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultValueData); + protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Toast.htmx b/Htmx.ApiDemo/Templates/Components/Toast.htmx new file mode 100644 index 0000000..d6d9665 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Toast.htmx @@ -0,0 +1,18 @@ + diff --git a/Htmx.ApiDemo/Templates/Components/Toast.htmx.cs b/Htmx.ApiDemo/Templates/Components/Toast.htmx.cs new file mode 100644 index 0000000..83d009d --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Toast.htmx.cs @@ -0,0 +1,34 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// shadcn-style Toast notification. Typically created dynamically via window.showToast(), +/// but can also be server-rendered and injected via htmx. +/// +public sealed class Toast : ToastBase +{ + private static readonly Dictionary VariantClasses = new() + { + ["default"] = "", + ["destructive"] = "border-destructive text-destructive", + }; + + private readonly byte[] _titleData; + private readonly byte[] _descriptionData; + private readonly byte[] _extraClassesData; + + public Toast( + string title, + string description = "", + string variant = "default") + { + _titleData = $"""
    {title}
    """.ToUtf8Bytes(); + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""
    {description}
    """.ToUtf8Bytes(); + _extraClassesData = VariantClasses.GetValueOrDefault(variant, "").ToUtf8Bytes(); + } + + protected override void RenderTitle(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_titleData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderExtraClasses(HtmxRenderContext ctx)=> ctx.Writer.WriteUtf8(_extraClassesData); +} diff --git a/Htmx.ApiDemo/Templates/Components/ToastViewport.htmx b/Htmx.ApiDemo/Templates/Components/ToastViewport.htmx new file mode 100644 index 0000000..5c0a346 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/ToastViewport.htmx @@ -0,0 +1,4 @@ +
    + $$Toasts$$ +
    diff --git a/Htmx.ApiDemo/Templates/Components/ToastViewport.htmx.cs b/Htmx.ApiDemo/Templates/Components/ToastViewport.htmx.cs new file mode 100644 index 0000000..5cdd15c --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/ToastViewport.htmx.cs @@ -0,0 +1,20 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// Fixed viewport container for toast notifications. +/// Place once in the page (or layout). Toasts appear via window.showToast() from components.js. +/// +public sealed class ToastViewport : ToastViewportBase +{ + private readonly byte[] _idData; + private readonly byte[] _toastsData; + + public ToastViewport(string id = "toast-viewport") + { + _idData = id.ToUtf8Bytes(); + _toastsData = []; + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderToasts(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_toastsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Tooltip.htmx b/Htmx.ApiDemo/Templates/Components/Tooltip.htmx new file mode 100644 index 0000000..161064d --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Tooltip.htmx @@ -0,0 +1,9 @@ + + $$Trigger$$ + + $$Text$$ + + diff --git a/Htmx.ApiDemo/Templates/Components/Tooltip.htmx.cs b/Htmx.ApiDemo/Templates/Components/Tooltip.htmx.cs new file mode 100644 index 0000000..c2c6f7f --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Tooltip.htmx.cs @@ -0,0 +1,36 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// +/// CSS-only Tooltip using group-hover. Wraps a trigger element. +/// Position: "top" | "bottom" | "left" | "right" (default: top) +/// +public sealed class Tooltip : TooltipBase +{ + private static readonly Dictionary PositionClasses = new() + { + ["top"] = "bottom-full left-1/2 -translate-x-1/2 mb-2", + ["bottom"] = "top-full left-1/2 -translate-x-1/2 mt-2", + ["left"] = "right-full top-1/2 -translate-y-1/2 mr-2", + ["right"] = "left-full top-1/2 -translate-y-1/2 ml-2", + }; + + private readonly byte[] _triggerData; + private readonly byte[] _textData; + private readonly byte[] _positionData; + + public Tooltip(string text, IHtmxComponent trigger, string position = "top") + { + _textData = text.ToUtf8Bytes(); + _positionData = PositionClasses.GetValueOrDefault(position, PositionClasses["top"]).ToUtf8Bytes(); + + var bufferWriter = new System.IO.Pipelines.Pipe().Writer; + // Render trigger to bytes via a simple ArrayBufferWriter + var writer = new System.Buffers.ArrayBufferWriter(); + trigger.Render(new HtmxRenderContext(writer)); + _triggerData = writer.WrittenSpan.ToArray(); + } + + protected override void RenderTrigger(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_triggerData); + protected override void RenderText(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_textData); + protected override void RenderPosition(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_positionData); +} diff --git a/Htmx.ApiDemo/Templates/UiDemo.htmx b/Htmx.ApiDemo/Templates/UiDemo.htmx index 9ac6605..00b9688 100644 --- a/Htmx.ApiDemo/Templates/UiDemo.htmx +++ b/Htmx.ApiDemo/Templates/UiDemo.htmx @@ -65,4 +65,256 @@ +
    + + +
    +

    Badge

    +
    + $$BadgeDefault$$ + $$BadgeSecondary$$ + $$BadgeDestructive$$ + $$BadgeOutline$$ +
    +
    + +
    + + +
    +

    Card

    +
    + $$CardDemo$$ +
    +
    + +
    + + +
    +

    Separator

    +
    +

    Horizontal

    + $$SeparatorH$$ +

    Vertical (inline)

    +
    + Section + $$SeparatorV$$ + Another + $$SeparatorV$$ + More +
    +
    +
    + +
    + + +
    +

    Skeleton

    +
    + $$SkeletonTitle$$ + $$SkeletonLine1$$ + $$SkeletonLine2$$ + $$SkeletonAvatar$$ +
    +
    + +
    + + +
    +

    Avatar

    +
    + $$AvatarSm$$ + $$AvatarDefault$$ + $$AvatarLg$$ + $$AvatarImg$$ +
    +
    + +
    + + +
    +

    Progress

    +
    + $$Progress25$$ + $$Progress60$$ + $$Progress100$$ +
    +
    + +
    + + +
    +

    Alert

    +
    + $$AlertDefault$$ + $$AlertDestructive$$ +
    +
    + +
    + + +
    +

    Breadcrumb

    + $$BreadcrumbDemo$$ +
    + +
    + + +
    +

    Checkbox

    +
    + $$CheckboxAccept$$ + $$CheckboxChecked$$ +
    +
    + +
    + + +
    +

    Radio Group

    +
    + $$RadioGroupCol$$ + $$RadioGroupRow$$ +
    +
    + +
    + + +
    +

    Switch

    +
    + $$SwitchOff$$ + $$SwitchOn$$ +
    +
    + +
    + + +
    +

    Textarea

    +
    + $$TextareaDemo$$ +
    +
    + +
    + + +
    +

    Slider

    +
    + $$SliderDemo$$ +
    +
    + +
    + + +
    +

    File Input

    +
    + $$FileInputDemo$$ +
    +
    + +
    + + +
    +

    Table

    + $$TableDemo$$ +
    + +
    + + +
    +

    Pagination

    + $$PaginationDemo$$ +
    + +
    + + +
    +

    Tabs

    +
    + $$TabsDemo$$ +
    +
    + +
    + + +
    +

    Accordion

    +
    + $$AccordionDemo$$ +
    +
    + +
    + + +
    +

    Tooltip

    +
    + $$TooltipTop$$ + $$TooltipBottom$$ + $$TooltipRight$$ +
    +
    + +
    + + +
    +

    Toast

    +
    + + +
    +
    + +
    + + +
    +

    Dialog

    + + $$DialogDemo$$ +
    + +
    + + +
    +

    Dropdown Menu

    + $$DropdownDemo$$ +
    + + + $$ToastViewportDemo$$ + diff --git a/Htmx.ApiDemo/Templates/UiDemo.htmx.cs b/Htmx.ApiDemo/Templates/UiDemo.htmx.cs index fc6bff9..ac778f0 100644 --- a/Htmx.ApiDemo/Templates/UiDemo.htmx.cs +++ b/Htmx.ApiDemo/Templates/UiDemo.htmx.cs @@ -6,6 +6,7 @@ namespace Htmx.ApiDemo.Templates; public sealed class UiDemo : UiDemoBase { + // ── Buttons ────────────────────────────────────────────────────────── public IHtmxComponent BtnDefault { get; } public IHtmxComponent BtnDestructive { get; } public IHtmxComponent BtnOutline { get; } @@ -15,19 +16,106 @@ public sealed class UiDemo : UiDemoBase public IHtmxComponent BtnSm { get; } public IHtmxComponent BtnLg { get; } + // ── Inputs ─────────────────────────────────────────────────────────── public IHtmxComponent InputText { get; } public IHtmxComponent InputEmail { get; } public IHtmxComponent InputPassword { get; } public IHtmxComponent InputSearch { get; } - public IHtmxComponent SelectDemo { get; } - public IHtmxComponent CalendarDemo { get; } - public IHtmxComponent CalendarRangeDemo{ get; } + // ── Select / Calendar / TimePicker ─────────────────────────────────── + public IHtmxComponent SelectDemo { get; } + public IHtmxComponent CalendarDemo { get; } + public IHtmxComponent CalendarRangeDemo { get; } public IHtmxComponent TimePickerDemo { get; } public IHtmxComponent TimePicker12hDemo { get; } + // ── Badge ───────────────────────────────────────────────────────────── + public IHtmxComponent BadgeDefault { get; } + public IHtmxComponent BadgeSecondary { get; } + public IHtmxComponent BadgeDestructive { get; } + public IHtmxComponent BadgeOutline { get; } + + // ── Card ────────────────────────────────────────────────────────────── + public IHtmxComponent CardDemo { get; } + + // ── Separator ───────────────────────────────────────────────────────── + public IHtmxComponent SeparatorH { get; } + public IHtmxComponent SeparatorV { get; } + + // ── Skeleton ────────────────────────────────────────────────────────── + public IHtmxComponent SkeletonTitle { get; } + public IHtmxComponent SkeletonLine1 { get; } + public IHtmxComponent SkeletonLine2 { get; } + public IHtmxComponent SkeletonAvatar { get; } + + // ── Avatar ──────────────────────────────────────────────────────────── + public IHtmxComponent AvatarSm { get; } + public IHtmxComponent AvatarDefault { get; } + public IHtmxComponent AvatarLg { get; } + public IHtmxComponent AvatarImg { get; } + + // ── Progress ────────────────────────────────────────────────────────── + public IHtmxComponent Progress25 { get; } + public IHtmxComponent Progress60 { get; } + public IHtmxComponent Progress100 { get; } + + // ── Alert ───────────────────────────────────────────────────────────── + public IHtmxComponent AlertDefault { get; } + public IHtmxComponent AlertDestructive { get; } + + // ── Breadcrumb ──────────────────────────────────────────────────────── + public IHtmxComponent BreadcrumbDemo { get; } + + // ── Checkbox ────────────────────────────────────────────────────────── + public IHtmxComponent CheckboxAccept { get; } + public IHtmxComponent CheckboxChecked { get; } + + // ── RadioGroup ──────────────────────────────────────────────────────── + public IHtmxComponent RadioGroupCol { get; } + public IHtmxComponent RadioGroupRow { get; } + + // ── Switch ──────────────────────────────────────────────────────────── + public IHtmxComponent SwitchOff { get; } + public IHtmxComponent SwitchOn { get; } + + // ── Textarea ────────────────────────────────────────────────────────── + public IHtmxComponent TextareaDemo { get; } + + // ── Slider ──────────────────────────────────────────────────────────── + public IHtmxComponent SliderDemo { get; } + + // ── FileInput ───────────────────────────────────────────────────────── + public IHtmxComponent FileInputDemo { get; } + + // ── Table ───────────────────────────────────────────────────────────── + public IHtmxComponent TableDemo { get; } + + // ── Pagination ──────────────────────────────────────────────────────── + public IHtmxComponent PaginationDemo { get; } + + // ── Tabs ────────────────────────────────────────────────────────────── + public IHtmxComponent TabsDemo { get; } + + // ── Accordion ───────────────────────────────────────────────────────── + public IHtmxComponent AccordionDemo { get; } + + // ── Tooltip ─────────────────────────────────────────────────────────── + public IHtmxComponent TooltipTop { get; } + public IHtmxComponent TooltipBottom { get; } + public IHtmxComponent TooltipRight { get; } + + // ── Dialog ──────────────────────────────────────────────────────────── + public IHtmxComponent DialogDemo { get; } + + // ── Dropdown ────────────────────────────────────────────────────────── + public IHtmxComponent DropdownDemo { get; } + + // ── Toast Viewport ──────────────────────────────────────────────────── + public IHtmxComponent ToastViewportDemo { get; } + public UiDemo() { + // Buttons BtnDefault = new Button("Default"); BtnDestructive = new Button("Destructive", variant: "destructive"); BtnOutline = new Button("Outline", variant: "outline"); @@ -37,12 +125,14 @@ public sealed class UiDemo : UiDemoBase BtnSm = new Button("Small", size: "sm"); BtnLg = new Button("Large", size: "lg"); + // Inputs InputText = new Input("username", label: "Username", placeholder: "Enter username"); InputEmail = new Input("email", inputType: "email", label: "Email", placeholder: "you@example.com"); InputPassword = new Input("password", inputType: "password", label: "Password", placeholder: "••••••••"); InputSearch = new Input("search", inputType: "search", label: "Search", placeholder: "Search…", hxAttrs: "hx-get=\"/search\" hx-trigger=\"keyup changed delay:300ms\" hx-target=\"#search-results\""); + // Select / Calendar / TimePicker SelectDemo = new Select( id: "framework", label: "Framework", @@ -52,9 +142,151 @@ public sealed class UiDemo : UiDemoBase CalendarDemo = new Calendar(id: "demo-cal", name: "demo-date"); CalendarRangeDemo = new CalendarRange(id: "demo-calr", name: "demo-range"); - TimePickerDemo = new TimePicker(name: "time-24h", label: "Time (24h)"); TimePicker12hDemo = new TimePicker(name: "time-12h", label: "Time (12h)", use12h: true); + + // Badge + BadgeDefault = new Badge("Default"); + BadgeSecondary = new Badge("Secondary", variant: "secondary"); + BadgeDestructive = new Badge("Destructive", variant: "destructive"); + BadgeOutline = new Badge("Outline", variant: "outline"); + + // Card + CardDemo = new Card( + title: "Component Card", + description: "A reusable card surface with header and footer.", + content: "

    Cards group related content and provide a contained, elevated surface for information.

    ", + footer: ""); + + // Separator + SeparatorH = new Separator(); + SeparatorV = new Separator(orientation: "vertical"); + + // Skeleton + SkeletonTitle = new Skeleton("h-5 w-48"); + SkeletonLine1 = new Skeleton("h-4 w-full"); + SkeletonLine2 = new Skeleton("h-4 w-3/4"); + SkeletonAvatar = new Skeleton("h-10 w-10 rounded-full"); + + // Avatar + AvatarSm = new Avatar("SM", size: "sm"); + AvatarDefault = new Avatar("JD"); + AvatarLg = new Avatar("AB", size: "lg"); + AvatarImg = new Avatar("GitHub", src: "https://github.com/github.png", size: "default"); + + // Progress + Progress25 = new Progress(25); + Progress60 = new Progress(60); + Progress100 = new Progress(100); + + // Alert + AlertDefault = new Alert("Information", description: "This is an informational alert with a default style."); + AlertDestructive = new Alert("Error", description: "Something went wrong. Please check your input.", variant: "destructive"); + + // Breadcrumb + BreadcrumbDemo = new Breadcrumb([ + ("Home", "/"), + ("Components", "/components"), + ("UI Demo", ""), + ]); + + // Checkbox + CheckboxAccept = new Checkbox("accept-terms", label: "Accept terms and conditions"); + CheckboxChecked = new Checkbox("newsletter", label: "Subscribe to newsletter", @checked: true); + + // RadioGroup + RadioGroupCol = new RadioGroup( + name: "plan-v", + label: "Plan (vertical)", + options: [ + ("starter", "Starter", true), + ("pro", "Pro", false), + ("enterprise", "Enterprise", false), + ]); + RadioGroupRow = new RadioGroup( + name: "size-h", + label: "Size (horizontal)", + direction: "flex-row", + options: [ + ("sm", "SM", true), + ("md", "MD", false), + ("lg", "LG", false), + ]); + + // Switch + SwitchOff = new Switch("notif-off", label: "Notifications"); + SwitchOn = new Switch("darkmode", label: "Dark mode", isChecked: true); + + // Textarea + TextareaDemo = new Textarea( + id: "bio", + label: "Bio", + placeholder: "Tell us about yourself…", + description: "Max 200 characters."); + + // Slider + SliderDemo = new Slider(id: "volume", label: "Volume", value: 40, description: "Drag to adjust"); + + // FileInput + FileInputDemo = new FileInput( + id: "avatar-upload", + label: "Profile picture", + accept: "image/*", + description: "PNG, JPG or GIF up to 2 MB."); + + // Table + TableDemo = new Table( + headers: ["Name", "Role", "Status"], + rows: [ + ["Alice", "Admin", "Active"], + ["Bob", "Editor", "Active"], + ["Charlie", "Viewer", "Inactive"], + ], + caption: "Team members"); + + // Pagination + PaginationDemo = new Pagination(current: 3, total: 7, urlPattern: "/ui-demo?page={0}"); + + // Tabs + TabsDemo = new Tabs("demo", [ + ("overview", "Overview", "

    This is the overview tab content.

    "), + ("settings", "Settings", "

    Manage your settings here.

    "), + ("billing", "Billing", "

    View billing information.

    "), + ]); + + // Accordion + AccordionDemo = new Accordion("demo-acc", [ + ("What is htmx?", "htmx allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML."), + ("Is it production ready?", "Yes — htmx is used by thousands of projects in production worldwide."), + ("Does it replace React?", "htmx is a different tool suited to server-driven UIs. Use whatever fits your team best."), + ], openIndex: 0); + + // Tooltip + TooltipTop = new Tooltip("Top tooltip", new Button("Hover me"), position: "top"); + TooltipBottom = new Tooltip("Bottom tooltip", new Button("Bottom", variant: "secondary"), position: "bottom"); + TooltipRight = new Tooltip("Right tooltip", new Button("Right", variant: "outline"), position: "right"); + + // Dialog + DialogDemo = new Dialog( + id: "demo-dialog", + title: "Are you sure?", + description: "This action cannot be undone.", + content: "Please confirm that you want to proceed with this operation.", + footer: "" + + ""); + + // Dropdown + DropdownDemo = new DropdownMenu( + trigger: new Button("Options ▾", variant: "outline"), + items: [ + ("Edit", "/edit", false), + ("Duplicate", "/dup", false), + ("", "", true), // separator + ("Delete", "/delete", false), + ]); + + // Toast Viewport + ToastViewportDemo = new ToastViewport(); } protected override void RenderBtnDefault(HtmxRenderContext ctx) => BtnDefault.Render(ctx); @@ -71,11 +303,66 @@ public sealed class UiDemo : UiDemoBase protected override void RenderInputPassword(HtmxRenderContext ctx) => InputPassword.Render(ctx); protected override void RenderInputSearch(HtmxRenderContext ctx) => InputSearch.Render(ctx); - protected override void RenderSelectDemo(HtmxRenderContext ctx) => SelectDemo.Render(ctx); - protected override void RenderCalendarDemo(HtmxRenderContext ctx) => CalendarDemo.Render(ctx); - protected override void RenderCalendarRangeDemo(HtmxRenderContext ctx) => CalendarRangeDemo.Render(ctx); - protected override void RenderTimePickerDemo(HtmxRenderContext ctx) => TimePickerDemo.Render(ctx); - protected override void RenderTimePicker12hDemo(HtmxRenderContext ctx) => TimePicker12hDemo.Render(ctx); + protected override void RenderSelectDemo(HtmxRenderContext ctx) => SelectDemo.Render(ctx); + protected override void RenderCalendarDemo(HtmxRenderContext ctx) => CalendarDemo.Render(ctx); + protected override void RenderCalendarRangeDemo(HtmxRenderContext ctx) => CalendarRangeDemo.Render(ctx); + protected override void RenderTimePickerDemo(HtmxRenderContext ctx) => TimePickerDemo.Render(ctx); + protected override void RenderTimePicker12hDemo(HtmxRenderContext ctx) => TimePicker12hDemo.Render(ctx); + + protected override void RenderBadgeDefault(HtmxRenderContext ctx) => BadgeDefault.Render(ctx); + protected override void RenderBadgeSecondary(HtmxRenderContext ctx) => BadgeSecondary.Render(ctx); + protected override void RenderBadgeDestructive(HtmxRenderContext ctx) => BadgeDestructive.Render(ctx); + protected override void RenderBadgeOutline(HtmxRenderContext ctx) => BadgeOutline.Render(ctx); + + protected override void RenderCardDemo(HtmxRenderContext ctx) => CardDemo.Render(ctx); + + protected override void RenderSeparatorH(HtmxRenderContext ctx) => SeparatorH.Render(ctx); + protected override void RenderSeparatorV(HtmxRenderContext ctx) => SeparatorV.Render(ctx); + + protected override void RenderSkeletonTitle(HtmxRenderContext ctx) => SkeletonTitle.Render(ctx); + protected override void RenderSkeletonLine1(HtmxRenderContext ctx) => SkeletonLine1.Render(ctx); + protected override void RenderSkeletonLine2(HtmxRenderContext ctx) => SkeletonLine2.Render(ctx); + protected override void RenderSkeletonAvatar(HtmxRenderContext ctx) => SkeletonAvatar.Render(ctx); + + protected override void RenderAvatarSm(HtmxRenderContext ctx) => AvatarSm.Render(ctx); + protected override void RenderAvatarDefault(HtmxRenderContext ctx) => AvatarDefault.Render(ctx); + protected override void RenderAvatarLg(HtmxRenderContext ctx) => AvatarLg.Render(ctx); + protected override void RenderAvatarImg(HtmxRenderContext ctx) => AvatarImg.Render(ctx); + + protected override void RenderProgress25(HtmxRenderContext ctx) => Progress25.Render(ctx); + protected override void RenderProgress60(HtmxRenderContext ctx) => Progress60.Render(ctx); + protected override void RenderProgress100(HtmxRenderContext ctx) => Progress100.Render(ctx); + + protected override void RenderAlertDefault(HtmxRenderContext ctx) => AlertDefault.Render(ctx); + protected override void RenderAlertDestructive(HtmxRenderContext ctx) => AlertDestructive.Render(ctx); + + protected override void RenderBreadcrumbDemo(HtmxRenderContext ctx) => BreadcrumbDemo.Render(ctx); + + protected override void RenderCheckboxAccept(HtmxRenderContext ctx) => CheckboxAccept.Render(ctx); + protected override void RenderCheckboxChecked(HtmxRenderContext ctx) => CheckboxChecked.Render(ctx); + + protected override void RenderRadioGroupCol(HtmxRenderContext ctx) => RadioGroupCol.Render(ctx); + protected override void RenderRadioGroupRow(HtmxRenderContext ctx) => RadioGroupRow.Render(ctx); + + protected override void RenderSwitchOff(HtmxRenderContext ctx) => SwitchOff.Render(ctx); + protected override void RenderSwitchOn(HtmxRenderContext ctx) => SwitchOn.Render(ctx); + + protected override void RenderTextareaDemo(HtmxRenderContext ctx) => TextareaDemo.Render(ctx); + protected override void RenderSliderDemo(HtmxRenderContext ctx) => SliderDemo.Render(ctx); + protected override void RenderFileInputDemo(HtmxRenderContext ctx)=> FileInputDemo.Render(ctx); + protected override void RenderTableDemo(HtmxRenderContext ctx) => TableDemo.Render(ctx); + protected override void RenderPaginationDemo(HtmxRenderContext ctx)=> PaginationDemo.Render(ctx); + protected override void RenderTabsDemo(HtmxRenderContext ctx) => TabsDemo.Render(ctx); + protected override void RenderAccordionDemo(HtmxRenderContext ctx)=> AccordionDemo.Render(ctx); + + protected override void RenderTooltipTop(HtmxRenderContext ctx) => TooltipTop.Render(ctx); + protected override void RenderTooltipBottom(HtmxRenderContext ctx) => TooltipBottom.Render(ctx); + protected override void RenderTooltipRight(HtmxRenderContext ctx) => TooltipRight.Render(ctx); + + protected override void RenderDialogDemo(HtmxRenderContext ctx) => DialogDemo.Render(ctx); + protected override void RenderDropdownDemo(HtmxRenderContext ctx) => DropdownDemo.Render(ctx); + + protected override void RenderToastViewportDemo(HtmxRenderContext ctx) => ToastViewportDemo.Render(ctx); } diff --git a/Htmx.ApiDemo/wwwroot/css/input.css b/Htmx.ApiDemo/wwwroot/css/input.css index 3d520cd..518e0aa 100644 --- a/Htmx.ApiDemo/wwwroot/css/input.css +++ b/Htmx.ApiDemo/wwwroot/css/input.css @@ -1,5 +1,8 @@ @import "tailwindcss"; +@source "../../**/*.{html,htmx,cs}"; +@source "../../src/**/!(*.g).cs"; + @theme { --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); diff --git a/Htmx.ApiDemo/wwwroot/css/output.css b/Htmx.ApiDemo/wwwroot/css/output.css index c5a2acc..e9351c3 100644 --- a/Htmx.ApiDemo/wwwroot/css/output.css +++ b/Htmx.ApiDemo/wwwroot/css/output.css @@ -8,9 +8,12 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --color-black: #000; + --color-white: #fff; --spacing: 0.25rem; --container-xs: 20rem; --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; --container-xl: 36rem; --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.75); @@ -22,12 +25,18 @@ --text-lg--line-height: calc(1.75 / 1.125); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); + --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --tracking-tight: -0.025em; + --leading-relaxed: 1.625; + --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --blur-sm: 8px; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -35,10 +44,14 @@ --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); --color-primary: hsl(var(--primary)); --color-primary-foreground: hsl(var(--primary-foreground)); --color-secondary: hsl(var(--secondary)); --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); --color-muted-foreground: hsl(var(--muted-foreground)); --color-accent: hsl(var(--accent)); --color-accent-foreground: hsl(var(--accent-foreground)); @@ -198,12 +211,32 @@ } } @layer utilities { + .pointer-events-auto { + pointer-events: auto; + } .pointer-events-none { pointer-events: none; } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + .absolute { + position: absolute; + } .fixed { position: fixed; } + .relative { + position: relative; + } .static { position: static; } @@ -228,30 +261,108 @@ .end { inset-inline-end: var(--spacing); } + .top-1\/2 { + top: calc(1 / 2 * 100%); + } + .top-full { + top: 100%; + } + .right-0 { + right: calc(var(--spacing) * 0); + } + .right-4 { + right: calc(var(--spacing) * 4); + } + .right-full { + right: 100%; + } + .bottom-4 { + bottom: calc(var(--spacing) * 4); + } + .bottom-full { + bottom: 100%; + } .left-0 { left: calc(var(--spacing) * 0); } + .left-1\/2 { + left: calc(1 / 2 * 100%); + } + .left-full { + left: 100%; + } .z-20 { z-index: 20; } .z-30 { z-index: 30; } + .z-50 { + z-index: 50; + } + .z-\[100\] { + z-index: 100; + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .m-0 { + margin: calc(var(--spacing) * 0); + } + .-mx-1 { + margin-inline: calc(var(--spacing) * -1); + } + .my-1 { + margin-block: calc(var(--spacing) * 1); + } .mt-1 { margin-top: calc(var(--spacing) * 1); } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } .mt-3 { margin-top: calc(var(--spacing) * 3); } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mr-2 { + margin-right: calc(var(--spacing) * 2); + } .mb-1 { margin-bottom: calc(var(--spacing) * 1); } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } + .block { + display: block; + } .flex { display: flex; } @@ -270,12 +381,24 @@ .inline-flex { display: inline-flex; } + .aspect-square { + aspect-ratio: 1 / 1; + } + .h-2 { + height: calc(var(--spacing) * 2); + } + .h-3\.5 { + height: calc(var(--spacing) * 3.5); + } .h-4 { height: calc(var(--spacing) * 4); } .h-5 { height: calc(var(--spacing) * 5); } + .h-6 { + height: calc(var(--spacing) * 6); + } .h-8 { height: calc(var(--spacing) * 8); } @@ -288,18 +411,45 @@ .h-11 { height: calc(var(--spacing) * 11); } + .h-12 { + height: calc(var(--spacing) * 12); + } + .h-14 { + height: calc(var(--spacing) * 14); + } .h-16 { height: calc(var(--spacing) * 16); } + .h-20 { + height: calc(var(--spacing) * 20); + } + .h-full { + height: 100%; + } + .h-px { + height: 1px; + } + .max-h-screen { + max-height: 100vh; + } .min-h-4 { min-height: calc(var(--spacing) * 4); } + .min-h-20 { + min-height: calc(var(--spacing) * 20); + } .min-h-dvh { min-height: 100dvh; } .min-h-full { min-height: 100%; } + .w-3\.5 { + width: calc(var(--spacing) * 3.5); + } + .w-3\/4 { + width: calc(3 / 4 * 100%); + } .w-4 { width: calc(var(--spacing) * 4); } @@ -315,15 +465,39 @@ .w-10 { width: calc(var(--spacing) * 10); } + .w-11 { + width: calc(var(--spacing) * 11); + } + .w-14 { + width: calc(var(--spacing) * 14); + } .w-16 { width: calc(var(--spacing) * 16); } + .w-20 { + width: calc(var(--spacing) * 20); + } + .w-48 { + width: calc(var(--spacing) * 48); + } .w-64 { width: calc(var(--spacing) * 64); } .w-full { width: 100%; } + .w-max { + width: max-content; + } + .w-px { + width: 1px; + } + .max-w-lg { + max-width: var(--container-lg); + } + .max-w-md { + max-width: var(--container-md); + } .max-w-sm { max-width: var(--container-sm); } @@ -333,6 +507,9 @@ .max-w-xs { max-width: var(--container-xs); } + .min-w-40 { + min-width: calc(var(--spacing) * 40); + } .min-w-72 { min-width: calc(var(--spacing) * 72); } @@ -342,13 +519,41 @@ .shrink-0 { flex-shrink: 0; } + .caption-bottom { + caption-side: bottom; + } + .-translate-x-1\/2 { + --tw-translate-x: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-x-full { --tw-translate-x: -100%; translate: var(--tw-translate-x) var(--tw-translate-y); } + .translate-x-0\.5 { + --tw-translate-x: calc(var(--spacing) * 0.5); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .-translate-y-1\/2 { + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .translate-y-2 { + --tw-translate-y: calc(var(--spacing) * 2); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .animate-pulse { + animation: var(--animate-pulse); + } .cursor-pointer { cursor: pointer; } + .resize-y { + resize: vertical; + } .appearance-none { appearance: none; } @@ -361,18 +566,33 @@ .flex-col { flex-direction: column; } + .flex-col-reverse { + flex-direction: column-reverse; + } + .flex-row { + flex-direction: row; + } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } + .items-start { + align-items: flex-start; + } .justify-between { justify-content: space-between; } .justify-center { justify-content: center; } + .justify-end { + justify-content: flex-end; + } + .justify-start { + justify-content: flex-start; + } .gap-0\.5 { gap: calc(var(--spacing) * 0.5); } @@ -391,6 +611,9 @@ .gap-4 { gap: calc(var(--spacing) * 4); } + .gap-6 { + gap: calc(var(--spacing) * 6); + } .gap-8 { gap: calc(var(--spacing) * 8); } @@ -401,6 +624,13 @@ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-1\.5 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-2 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -429,6 +659,30 @@ margin-block-end: calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse))); } } + .space-x-4 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-border { + :where(& > :not(:last-child)) { + border-color: var(--color-border); + } + } + .overflow-auto { + overflow: auto; + } .overflow-hidden { overflow: hidden; } @@ -438,9 +692,15 @@ .rounded-full { border-radius: calc(infinity * 1px); } + .rounded-lg { + border-radius: var(--radius-lg); + } .rounded-md { border-radius: var(--radius-md); } + .rounded-sm { + border-radius: var(--radius-sm); + } .border { border-style: var(--tw-border-style); border-width: 1px; @@ -460,12 +720,21 @@ .border-border { border-color: var(--color-border); } + .border-destructive { + border-color: var(--color-destructive); + } .border-destructive\/30 { border-color: color-mix(in srgb, hsl(var(--destructive)) 30%, transparent); @supports (color: color-mix(in lab, red, red)) { border-color: color-mix(in oklab, var(--color-destructive) 30%, transparent); } } + .border-destructive\/50 { + border-color: color-mix(in srgb, hsl(var(--destructive)) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-destructive) 50%, transparent); + } + } .border-input { border-color: var(--color-input); } @@ -478,6 +747,9 @@ background-color: color-mix(in oklab, var(--color-black) 50%, transparent); } } + .bg-border { + background-color: var(--color-border); + } .bg-card { background-color: var(--color-card); } @@ -496,6 +768,21 @@ background-color: color-mix(in oklab, var(--color-destructive) 15%, transparent); } } + .bg-input { + background-color: var(--color-input); + } + .bg-muted { + background-color: var(--color-muted); + } + .bg-muted\/50 { + background-color: color-mix(in srgb, hsl(var(--muted)) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-muted) 50%, transparent); + } + } + .bg-popover { + background-color: var(--color-popover); + } .bg-primary { background-color: var(--color-primary); } @@ -505,6 +792,18 @@ .bg-transparent { background-color: transparent; } + .bg-white { + background-color: var(--color-white); + } + .object-cover { + object-fit: cover; + } + .p-0 { + padding: calc(var(--spacing) * 0); + } + .p-1 { + padding: calc(var(--spacing) * 1); + } .p-4 { padding: calc(var(--spacing) * 4); } @@ -514,6 +813,9 @@ .px-2 { padding-inline: calc(var(--spacing) * 2); } + .px-2\.5 { + padding-inline: calc(var(--spacing) * 2.5); + } .px-3 { padding-inline: calc(var(--spacing) * 3); } @@ -532,6 +834,9 @@ .py-1 { padding-block: calc(var(--spacing) * 1); } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } .py-2 { padding-block: calc(var(--spacing) * 2); } @@ -544,9 +849,24 @@ .py-12 { padding-block: calc(var(--spacing) * 12); } + .pt-0 { + padding-top: calc(var(--spacing) * 0); + } + .pt-4 { + padding-top: calc(var(--spacing) * 4); + } + .pb-4 { + padding-bottom: calc(var(--spacing) * 4); + } .text-center { text-align: center; } + .text-left { + text-align: left; + } + .align-middle { + vertical-align: middle; + } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); @@ -579,6 +899,10 @@ --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); } + .font-normal { + --tw-font-weight: var(--font-weight-normal); + font-weight: var(--font-weight-normal); + } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); @@ -587,9 +911,15 @@ --tw-tracking: var(--tracking-tight); letter-spacing: var(--tracking-tight); } + .break-words { + overflow-wrap: break-word; + } .whitespace-nowrap { white-space: nowrap; } + .text-card-foreground { + color: var(--color-card-foreground); + } .text-destructive { color: var(--color-destructive); } @@ -602,6 +932,9 @@ .text-muted-foreground { color: var(--color-muted-foreground); } + .text-popover-foreground { + color: var(--color-popover-foreground); + } .text-primary { color: var(--color-primary); } @@ -618,9 +951,18 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + .accent-primary { + accent-color: var(--color-primary); + } .opacity-0 { opacity: 0%; } + .opacity-70 { + opacity: 70%; + } + .opacity-90 { + opacity: 90%; + } .shadow { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -629,10 +971,18 @@ --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-md { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .shadow-sm { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .ring-0 { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .ring-offset-background { --tw-ring-offset-color: var(--color-background); } @@ -648,6 +998,11 @@ -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } + .transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); @@ -663,6 +1018,14 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .duration-150 { + --tw-duration: 150ms; + transition-duration: 150ms; + } + .duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } .duration-300 { --tw-duration: 300ms; transition-duration: 300ms; @@ -671,10 +1034,21 @@ --tw-ease: var(--ease-in-out); transition-timing-function: var(--ease-in-out); } + .outline-none { + --tw-outline-style: none; + outline-style: none; + } .select-none { -webkit-user-select: none; user-select: none; } + .group-hover\:opacity-100 { + &:is(:where(.group):hover *) { + @media (hover: hover) { + opacity: 100%; + } + } + } .peer-disabled\:cursor-not-allowed { &:is(:where(.peer):disabled ~ *) { cursor: not-allowed; @@ -685,11 +1059,59 @@ opacity: 70%; } } + .file\:border-0 { + &::file-selector-button { + border-style: var(--tw-border-style); + border-width: 0px; + } + } + .file\:bg-transparent { + &::file-selector-button { + background-color: transparent; + } + } + .file\:text-sm { + &::file-selector-button { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + } + .file\:font-medium { + &::file-selector-button { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + } + .file\:text-foreground { + &::file-selector-button { + color: var(--color-foreground); + } + } .placeholder\:text-muted-foreground { &::placeholder { color: var(--color-muted-foreground); } } + .backdrop\:bg-black\/50 { + &::backdrop { + background-color: color-mix(in srgb, #000 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 50%, transparent); + } + } + } + .backdrop\:backdrop-blur-sm { + &::backdrop { + --tw-backdrop-blur: blur(var(--blur-sm)); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + } + .open\:block { + &:is([open], :popover-open, :open) { + display: block; + } + } .hover\:bg-accent { &:hover { @media (hover: hover) { @@ -697,6 +1119,16 @@ } } } + .hover\:bg-destructive\/80 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--destructive)) 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-destructive) 80%, transparent); + } + } + } + } .hover\:bg-destructive\/90 { &:hover { @media (hover: hover) { @@ -707,6 +1139,26 @@ } } } + .hover\:bg-muted\/50 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--muted)) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-muted) 50%, transparent); + } + } + } + } + .hover\:bg-primary\/80 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--primary)) 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-primary) 80%, transparent); + } + } + } + } .hover\:bg-primary\/90 { &:hover { @media (hover: hover) { @@ -734,6 +1186,13 @@ } } } + .hover\:text-foreground { + &:hover { + @media (hover: hover) { + color: var(--color-foreground); + } + } + } .hover\:underline { &:hover { @media (hover: hover) { @@ -741,6 +1200,29 @@ } } } + .hover\:opacity-100 { + &:hover { + @media (hover: hover) { + opacity: 100%; + } + } + } + .focus\:bg-accent { + &:focus { + background-color: var(--color-accent); + } + } + .focus\:text-accent-foreground { + &:focus { + color: var(--color-accent-foreground); + } + } + .focus\:ring-1 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } .focus\:ring-2 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); @@ -813,6 +1295,11 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + .sm\:flex-col { + @media (width >= 40rem) { + flex-direction: column; + } + } .md\:relative { @media (width >= 48rem) { position: relative; @@ -823,6 +1310,11 @@ display: none; } } + .md\:max-w-\[420px\] { + @media (width >= 48rem) { + max-width: 420px; + } + } .md\:translate-x-0 { @media (width >= 48rem) { --tw-translate-x: calc(var(--spacing) * 0); @@ -840,12 +1332,59 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } } + .\[\&_p\]\:leading-relaxed { + & p { + --tw-leading: var(--leading-relaxed); + line-height: var(--leading-relaxed); + } + } .\[\&\.open\]\:translate-x-0 { &.open { --tw-translate-x: calc(var(--spacing) * 0); translate: var(--tw-translate-x) var(--tw-translate-y); } } + .\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0 { + &:has([role=checkbox]) { + padding-right: calc(var(--spacing) * 0); + } + } + .\[\&\>svg\]\:absolute { + &>svg { + position: absolute; + } + } + .\[\&\>svg\]\:top-4 { + &>svg { + top: calc(var(--spacing) * 4); + } + } + .\[\&\>svg\]\:left-4 { + &>svg { + left: calc(var(--spacing) * 4); + } + } + .\[\&\>svg\]\:text-destructive { + &>svg { + color: var(--color-destructive); + } + } + .\[\&\>svg\]\:text-foreground { + &>svg { + color: var(--color-foreground); + } + } + .\[\&\>svg\+div\]\:translate-y-\[-3px\] { + &>svg+div { + --tw-translate-y: -3px; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .\[\&\>svg\~\*\]\:pl-7 { + &>svg~* { + padding-left: calc(var(--spacing) * 7); + } + } } @layer base { :root { @@ -1121,11 +1660,41 @@ inherits: false; initial-value: 0; } +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} @property --tw-space-y-reverse { syntax: "*"; inherits: false; initial-value: 0; } +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-border-style { syntax: "*"; inherits: false; @@ -1310,13 +1879,25 @@ syntax: "*"; inherits: false; } +@keyframes pulse { + 50% { + opacity: 0.5; + } +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-translate-x: 0; --tw-translate-y: 0; --tw-translate-z: 0; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; --tw-space-y-reverse: 0; + --tw-space-x-reverse: 0; + --tw-divide-y-reverse: 0; --tw-border-style: solid; --tw-leading: initial; --tw-font-weight: initial; diff --git a/Htmx.ApiDemo/wwwroot/js/components.js b/Htmx.ApiDemo/wwwroot/js/components.js index 2b360e9..e8db1f0 100644 --- a/Htmx.ApiDemo/wwwroot/js/components.js +++ b/Htmx.ApiDemo/wwwroot/js/components.js @@ -456,3 +456,262 @@ document.addEventListener('DOMContentLoaded', initAll); document.addEventListener('htmx:afterSwap', initAll); })(); + + +// ── Switch ──────────────────────────────────────────────────────────────── + +(function () { + function updateSwitch(input) { + var track = input.parentElement && input.parentElement.querySelector('.switch-track'); + if (!track) return; + var thumb = track.querySelector('.switch-thumb'); + if (input.checked) { + track.classList.add('bg-primary'); + track.classList.remove('bg-input'); + if (thumb) thumb.style.transform = 'translateX(1.375rem)'; + } else { + track.classList.remove('bg-primary'); + track.classList.add('bg-input'); + if (thumb) thumb.style.transform = ''; + } + } + + function initAll() { + document.querySelectorAll('.switch-checkbox').forEach(function (input) { + updateSwitch(input); + if (!input._switchBound) { + input._switchBound = true; + input.addEventListener('change', function () { updateSwitch(input); }); + } + }); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); + + +// ── Tabs ────────────────────────────────────────────────────────────────── + +(function () { + var ACTIVE = 'bg-background text-foreground shadow-sm'; + var INACTIVE = 'text-muted-foreground'; + + function initTabs(root) { + if (root._tabsInitialised) return; + root._tabsInitialised = true; + + var triggers = Array.from(root.querySelectorAll('.tabs-trigger')); + var panels = Array.from(root.querySelectorAll('.tabs-panel')); + + function activate(idx) { + triggers.forEach(function (t, i) { + var active = i === idx; + t.setAttribute('aria-selected', String(active)); + ACTIVE.split(' ').forEach(function (c) { t.classList.toggle(c, active); }); + INACTIVE.split(' ').forEach(function (c) { t.classList.toggle(c, !active); }); + }); + panels.forEach(function (p, i) { p.hidden = i !== idx; }); + } + + triggers.forEach(function (trigger, idx) { + trigger.addEventListener('click', function () { activate(idx); }); + }); + activate(0); + } + + function initAll() { + document.querySelectorAll('.tabs-root').forEach(initTabs); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); + + +// ── Accordion ───────────────────────────────────────────────────────────── + +(function () { + function initAccordion(root) { + if (root._accInitialised) return; + root._accInitialised = true; + + root.querySelectorAll('.accordion-trigger').forEach(function (trigger) { + trigger.addEventListener('click', function () { + var expanded = trigger.getAttribute('aria-expanded') === 'true'; + var panel = trigger.closest('.accordion-item').querySelector('.accordion-panel'); + if (expanded) { + trigger.setAttribute('aria-expanded', 'false'); + panel.style.height = '0'; + panel.style.opacity = '0'; + } else { + trigger.setAttribute('aria-expanded', 'true'); + panel.style.height = panel.scrollHeight + 'px'; + panel.style.opacity = '1'; + } + var chevron = trigger.querySelector('.accordion-chevron'); + if (chevron) chevron.style.transform = expanded ? '' : 'rotate(180deg)'; + }); + }); + } + + function initAll() { + document.querySelectorAll('.accordion-root').forEach(initAccordion); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); + + +// ── Toast ───────────────────────────────────────────────────────────────── + +(function () { + function dismissToast(toast) { + toast.style.opacity = '0'; + toast.style.transform = 'translateY(0.5rem)'; + setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); + } + + window.showToast = function (options) { + var viewport = document.querySelector('.toast-viewport'); + if (!viewport) return; + + var title = options.title || ''; + var description = options.description || ''; + var variant = options.variant || 'default'; + var duration = typeof options.duration === 'number' ? options.duration : 5000; + + var variantCls = variant === 'destructive' ? ' border-destructive text-destructive' : ''; + + var toast = document.createElement('div'); + toast.setAttribute('role', 'alert'); + toast.className = 'toast-item pointer-events-auto relative flex w-full items-center justify-between' + + ' space-x-4 overflow-hidden rounded-md border border-border bg-background p-4' + + ' shadow-lg transition-all duration-300 opacity-0 translate-y-2' + variantCls; + + toast.innerHTML = + '
    ' + + (title ? '
    ' + title + '
    ' : '') + + (description ? '
    ' + description + '
    ' : '') + + '
    ' + + ''; + + viewport.appendChild(toast); + + requestAnimationFrame(function () { + toast.classList.remove('opacity-0', 'translate-y-2'); + }); + + var timer = duration > 0 ? setTimeout(function () { dismissToast(toast); }, duration) : null; + + toast.querySelector('.toast-close').addEventListener('click', function () { + if (timer) clearTimeout(timer); + dismissToast(toast); + }); + }; + + // Delegate clicks on server-rendered toast close buttons + document.addEventListener('click', function (e) { + var btn = e.target.closest('.toast-close'); + if (btn) { + var item = btn.closest('.toast-item'); + if (item) dismissToast(item); + } + }); + + document.addEventListener('keydown', function (e) { + var trigger = e.target.closest('.dropdown-trigger'); + if (!trigger) return; + if (e.key !== 'Enter' && e.key !== ' ') return; + + e.preventDefault(); + var root = trigger.closest('.dropdown-root'); + var content = root && root.querySelector('.dropdown-content'); + var isOpen = content && !content.classList.contains('hidden'); + + document.querySelectorAll('.dropdown-root').forEach(closeDropdown); + if (!isOpen && root) openDropdown(root); + }); +})(); + + +// ── Dialog ──────────────────────────────────────────────────────────────── + +(function () { + document.addEventListener('click', function (e) { + // Open + var openBtn = e.target.closest('[data-dialog-open]'); + if (openBtn) { + var dlg = document.getElementById('dlg-' + openBtn.dataset.dialogOpen); + if (dlg && dlg.showModal) dlg.showModal(); + } + // Close via button + var closeBtn = e.target.closest('[data-dialog-close], .dialog-close'); + if (closeBtn) { + var dlg = closeBtn.closest('dialog'); + if (dlg) dlg.close(); + } + }); + + // Close on backdrop click + document.addEventListener('click', function (e) { + if (e.target && e.target.tagName === 'DIALOG') { + e.target.close(); + } + }); +})(); + + +// ── DropdownMenu ────────────────────────────────────────────────────────── + +(function () { + function closeDropdown(root) { + var trigger = root.querySelector('.dropdown-trigger'); + var content = root.querySelector('.dropdown-content'); + if (!trigger || !content) return; + content.classList.add('hidden'); + trigger.setAttribute('aria-expanded', 'false'); + } + + function openDropdown(root) { + var trigger = root.querySelector('.dropdown-trigger'); + var content = root.querySelector('.dropdown-content'); + if (!trigger || !content) return; + content.classList.remove('hidden'); + trigger.setAttribute('aria-expanded', 'true'); + } + + document.addEventListener('click', function (e) { + var trigger = e.target.closest('.dropdown-trigger'); + if (trigger) { + var root = trigger.closest('.dropdown-root'); + var content = root && root.querySelector('.dropdown-content'); + var isOpen = content && !content.classList.contains('hidden'); + + document.querySelectorAll('.dropdown-root').forEach(closeDropdown); + if (!isOpen && root) openDropdown(root); + return; + } + + var insideMenu = e.target.closest('.dropdown-content'); + if (insideMenu) { + var rootInMenu = insideMenu.closest('.dropdown-root'); + if (e.target.closest('a, button') && rootInMenu) { + closeDropdown(rootInMenu); + } + return; + } + + document.querySelectorAll('.dropdown-root').forEach(function (root) { + if (!root.contains(e.target)) closeDropdown(root); + }); + }); +})(); + From ee8797c142931343ec3a579d6c1fe679b60b93f9 Mon Sep 17 00:00:00 2001 From: Enciphered Date: Mon, 4 May 2026 19:57:48 +0500 Subject: [PATCH 06/18] Documentations added Co-authored-by: Copilot --- docs/01-getting-started.md | 92 +++++++++++ docs/02-creating-a-page.md | 181 ++++++++++++++++++++++ docs/03-creating-a-component.md | 215 ++++++++++++++++++++++++++ docs/04-data-models-and-aot.md | 163 ++++++++++++++++++++ docs/05-form-submission.md | 200 ++++++++++++++++++++++++ docs/06-component-reference.md | 35 +++++ docs/Components/Accordion.md | 175 +++++++++++++++++++++ docs/Components/Alert.md | 177 ++++++++++++++++++++++ docs/Components/Avatar.md | 169 +++++++++++++++++++++ docs/Components/Badge.md | 182 ++++++++++++++++++++++ docs/Components/Breadcrumb.md | 175 +++++++++++++++++++++ docs/Components/Button.md | 193 +++++++++++++++++++++++ docs/Components/Calendar.md | 237 +++++++++++++++++++++++++++++ docs/Components/CalendarRange.md | 228 ++++++++++++++++++++++++++++ docs/Components/Card.md | 184 ++++++++++++++++++++++ docs/Components/Checkbox.md | 207 +++++++++++++++++++++++++ docs/Components/Dialog.md | 252 +++++++++++++++++++++++++++++++ docs/Components/DropdownMenu.md | 198 ++++++++++++++++++++++++ docs/Components/FileInput.md | 212 ++++++++++++++++++++++++++ docs/Components/Input.md | 221 +++++++++++++++++++++++++++ docs/Components/Pagination.md | 172 +++++++++++++++++++++ docs/Components/Progress.md | 176 +++++++++++++++++++++ docs/Components/RadioGroup.md | 211 ++++++++++++++++++++++++++ docs/Components/Select.md | 234 ++++++++++++++++++++++++++++ docs/Components/Separator.md | 148 ++++++++++++++++++ docs/Components/Skeleton.md | 185 +++++++++++++++++++++++ docs/Components/Slider.md | 203 +++++++++++++++++++++++++ docs/Components/Switch.md | 210 ++++++++++++++++++++++++++ docs/Components/Table.md | 200 ++++++++++++++++++++++++ docs/Components/Tabs.md | 200 ++++++++++++++++++++++++ docs/Components/Textarea.md | 229 ++++++++++++++++++++++++++++ docs/Components/TimePicker.md | 236 +++++++++++++++++++++++++++++ docs/Components/Toast.md | 213 ++++++++++++++++++++++++++ docs/Components/ToastViewport.md | 154 +++++++++++++++++++ docs/Components/Tooltip.md | 188 +++++++++++++++++++++++ 35 files changed, 6655 insertions(+) create mode 100644 docs/01-getting-started.md create mode 100644 docs/02-creating-a-page.md create mode 100644 docs/03-creating-a-component.md create mode 100644 docs/04-data-models-and-aot.md create mode 100644 docs/05-form-submission.md create mode 100644 docs/06-component-reference.md create mode 100644 docs/Components/Accordion.md create mode 100644 docs/Components/Alert.md create mode 100644 docs/Components/Avatar.md create mode 100644 docs/Components/Badge.md create mode 100644 docs/Components/Breadcrumb.md create mode 100644 docs/Components/Button.md create mode 100644 docs/Components/Calendar.md create mode 100644 docs/Components/CalendarRange.md create mode 100644 docs/Components/Card.md create mode 100644 docs/Components/Checkbox.md create mode 100644 docs/Components/Dialog.md create mode 100644 docs/Components/DropdownMenu.md create mode 100644 docs/Components/FileInput.md create mode 100644 docs/Components/Input.md create mode 100644 docs/Components/Pagination.md create mode 100644 docs/Components/Progress.md create mode 100644 docs/Components/RadioGroup.md create mode 100644 docs/Components/Select.md create mode 100644 docs/Components/Separator.md create mode 100644 docs/Components/Skeleton.md create mode 100644 docs/Components/Slider.md create mode 100644 docs/Components/Switch.md create mode 100644 docs/Components/Table.md create mode 100644 docs/Components/Tabs.md create mode 100644 docs/Components/Textarea.md create mode 100644 docs/Components/TimePicker.md create mode 100644 docs/Components/Toast.md create mode 100644 docs/Components/ToastViewport.md create mode 100644 docs/Components/Tooltip.md diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md new file mode 100644 index 0000000..48550ed --- /dev/null +++ b/docs/01-getting-started.md @@ -0,0 +1,92 @@ +# Getting Started + +This guide gets the solution running locally and explains what happens during startup. + +## What is in this solution + +- `Htmx.ApiDemo`: ASP.NET Core app (Minimal API + generated HTMX endpoints) +- `Htmx.SourceGenerator`: Roslyn source generator that discovers `.htmx` files and generates endpoint mapping code +- `Htmx.slnx`: solution file at the repository root + +## Prerequisites + +- .NET SDK 10.x (target framework is `net10.0`) +- Node.js + npm (used for Tailwind CSS compilation during build) +- MongoDB running locally on `mongodb://localhost:27017` + +## First-time setup + +From the repository root: + +```bash +cd Htmx.ApiDemo +npm install +``` + +Why this is required: + +- The app build runs Tailwind via `npx @tailwindcss/cli ...` in a custom MSBuild target. +- Without `npm install`, build fails because the Tailwind CLI package is missing. + +## Run the app + +From the repository root: + +```bash +dotnet run --project Htmx.ApiDemo/Htmx.ApiDemo.csproj +``` + +Default local URL: + +- `http://localhost:5120` + +This comes from the launch profile in `Htmx.ApiDemo/Properties/launchSettings.json`. + +## Verify it works + +1. Open `http://localhost:5120` +2. If you are not authenticated, middleware redirects to `/login` +3. Create an account at `/register` +4. Sign in and navigate the app + +## What startup config does + +`Htmx.ApiDemo/Program.cs` configures: + +- MongoDB DI and index initialization (`EnsureIndexesAsync`) +- Cookie authentication + authorization +- Antiforgery middleware +- AOT-friendly JSON resolver chain using `AppJsonSerializerContext` +- Endpoint registration via generated mapping call: + - `app.MapHtmxApiDemoEndpoints();` + +## Build behavior worth knowing + +- Tailwind CSS is compiled before build into `Htmx.ApiDemo/wwwroot/css/output.css` +- `.htmx` files are treated as generator inputs (``) +- AOT is enabled (`true`), so reflection-heavy patterns can break publish/runtime + +## Optional: publish as AOT + +```bash +dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release +``` + +Use this early to catch AOT issues while developing features. + +## Troubleshooting + +### Build fails on Tailwind command + +- Run `npm install` inside `Htmx.ApiDemo` +- Confirm `node -v` and `npm -v` are available + +### Mongo connection errors + +- Confirm MongoDB is running on `localhost:27017` +- Confirm `ConnectionStrings:DefaultConnection` in `Htmx.ApiDemo/appsettings.json` + +### App keeps redirecting to login + +- This is expected for unauthenticated routes +- Register at `/register` or sign in at `/login` diff --git a/docs/02-creating-a-page.md b/docs/02-creating-a-page.md new file mode 100644 index 0000000..29f1ea4 --- /dev/null +++ b/docs/02-creating-a-page.md @@ -0,0 +1,181 @@ +# Creating a New Page + +This guide explains the full lifecycle of adding a new page to the app: the template file, the code-behind class, the handler, and the sidebar link. + +## How pages work + +Every page is a pair of files: + +| File | Purpose | +|---|---| +| `Templates/MyPage.htmx` | HTML markup with `$$SlotName$$` slots | +| `Templates/MyPage.htmx.cs` | C# class that fills slots + declares the Minimal API handler | + +The Roslyn source generator (`Htmx.SourceGenerator`) reads every `.htmx` file at build time and generates an abstract base class for it. You then write a concrete class in the companion `.htmx.cs` file that inherits from that base. + +## How `$$SlotName$$` becomes code + +Take this simple template: + +```html + +
    +

    $$Title$$

    +

    $$Body$$

    +
    +``` + +The generator splits the file on `$$...$$` patterns and produces: + +```csharp +// auto-generated — do NOT edit +public abstract partial class MyPageBase : IHtmxComponent +{ + protected abstract void RenderTitle(HtmxRenderContext context); + protected abstract void RenderBody(HtmxRenderContext context); + + // static HTML segments stored as ReadOnlySpan for zero-allocation output + private static ReadOnlySpan _part0 => new byte[] { ... }; + private static ReadOnlySpan _part1 => new byte[] { ... }; + private static ReadOnlySpan _part2 => new byte[] { ... }; + + public void Render(HtmxRenderContext context) + { + context.Writer.WriteUtf8(_part0); //

    + RenderTitle(context.Next()); + context.Writer.WriteUtf8(_part1); //

    + RenderBody(context.Next()); + context.Writer.WriteUtf8(_part2); //

    + } +} +``` + +Your job is to write the concrete class that implements each `RenderXxx` method. + +## Step 1 — Create the `.htmx` template + +Create `Htmx.ApiDemo/Templates/MyPage.htmx`: + +```html +
    +

    $$Heading$$

    +

    $$Description$$

    +
    +``` + +Rules: +- Slot names are **PascalCase** and surrounded by `$$` — e.g. `$$MySlot$$` +- A slot can hold plain text, HTML, or another rendered component +- The file must be saved in `Templates/` (or a subfolder) so the `.csproj` `AdditionalFiles` glob picks it up + +## Step 2 — Create the `.htmx.cs` code-behind + +Create `Htmx.ApiDemo/Templates/MyPage.htmx.cs`: + +```csharp +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + +namespace Htmx.ApiDemo.Templates; + +// Concrete template — inherits from the generated base +public sealed class MyPage : MyPageBase +{ + private byte[] _headingData = []; + private byte[] _descriptionData = []; + + // Use `init`-only setters to pre-encode strings to UTF-8 bytes once + public required string Heading { init => _headingData = value.ToUtf8Bytes(); } + public required string Description { init => _descriptionData = value.ToUtf8Bytes(); } + + protected override void RenderHeading(HtmxRenderContext context) + => context.Writer.WriteUtf8(_headingData); + + protected override void RenderDescription(HtmxRenderContext context) + => context.Writer.WriteUtf8(_descriptionData); +} + +// Minimal API handler — discovered and registered by the source generator +[Handler] +[MapGet("/my-page")] +public static partial class GetMyPageHandler +{ + public record Query; // add route/query parameters here if needed + + private static ValueTask HandleAsync( + Query query, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + var page = new MyPage + { + Heading = "My New Page", + Description = "This is a minimal example page." + }; + + // WriteHtmxPage: full HTML shell for direct browser loads, + // bare fragment for HTMX partial swaps (HX-Request header present) + ctx.WriteHtmxPage(page, title: "My Page", appName: "HtmxApp", pageTitle: "My Page"); + return ValueTask.CompletedTask; + } +} +``` + +## Step 3 — Add a sidebar link (optional but typical) + +Open `Templates/MainLayout.htmx` and add a nav entry inside the `