# CalendarRange A date-range picker. The user clicks once to set a start date and clicks again to set an end date. While hovering, the range between start and the cursor is shaded as a preview. Great for booking forms, report filters, or anything that needs a "from / to" date pair. --- ## Quick example ```csharp new CalendarRange(id: "vacation", name: "vacation") ``` This renders an empty picker. The user clicks two dates to form a range. Both dates are submitted with the form as `vacation-start` and `vacation-end`. --- ## All the options ```csharp public CalendarRange( string id, string name = "date", DateOnly? selectedStart = null, DateOnly? selectedEnd = null) ``` | Parameter | What it does | |---|---| | `id` | A unique identifier. The root element gets `id="calr-{id}"`. | | `name` | Base form field name. The two hidden inputs become `{name}-start` and `{name}-end`. | | `selectedStart` | Pre-selected start date. | | `selectedEnd` | Pre-selected end date. | --- ## Real-world examples ### Vacation request form ```html
$$Token$$
$$RangePicker$$
``` **Reading the submitted values on the server:** ```csharp public record Command( [property: FromForm] string VacationStart, // "yyyy-MM-dd" [property: FromForm] string VacationEnd // "yyyy-MM-dd" ); // Validate they are not empty before parsing if (string.IsNullOrEmpty(command.VacationStart) || string.IsNullOrEmpty(command.VacationEnd)) return; // user did not complete the selection var start = DateOnly.ParseExact(command.VacationStart, "yyyy-MM-dd"); var end = DateOnly.ParseExact(command.VacationEnd, "yyyy-MM-dd"); ``` ### Pre-selected range (e.g. editing an existing request) ```csharp new CalendarRange( id: "vacation", name: "vacation", selectedStart: existingRequest.StartDate, selectedEnd: existingRequest.EndDate) ``` ### Reacting to selection changes in JavaScript ```js document.getElementById('calr-vacation').addEventListener('rangeChange', e => { console.log(e.detail.start, e.detail.end); // Update a price estimate, nights count, etc. }); ``` The `.calr-label` element inside the calendar automatically updates to show `start → end` (or `start → pick end date` while mid-selection). You do not need custom JS for the label. --- ## How it works The click logic follows three states: 1. **Nothing selected** — first click sets the start date, clears the end 2. **Start selected, no end** — next click after start sets the end and fires `rangeChange`; clicking before start moves the start; clicking on start again clears both 3. **Both selected** — any click resets and starts again from step 1 The hover preview does not rebuild the grid. It only toggles CSS classes on the day buttons, so it is fast even for long ranges. All state is stored in `data-start` and `data-end` attributes on the root element, not in closures, so HTMX-swapped calendars re-initialise correctly. ## Complete page example **`Templates/ReportRangePage.htmx`** ```html

Generate report

$$AntiforgeryToken$$
$$RangePicker$$
$$SubmitBtn$$
$$Error$$
``` **`Templates/ReportRangePage.htmx.cs`** ```csharp namespace Htmx.ApiDemo.Templates; public sealed class ReportRangePage : ReportRangePageBase { private readonly IHtmxComponent _rangePicker; private readonly IHtmxComponent _submitBtn; private readonly IHtmxComponent _error; private readonly byte[] _afToken; public ReportRangePage(IAntiforgery af, HttpContext ctx, string? errorMessage = null) { var tokens = af.GetAndStoreTokens(ctx); _afToken = $"""""".ToUtf8Bytes(); _rangePicker = new Components.CalendarRange(id: "report", name: "reportDate"); _submitBtn = new Components.Button("Generate", type: "submit"); _error = errorMessage is not null ? new Components.Alert(title: "Error", description: errorMessage, variant: "destructive") : HtmxEmpty.Instance; } protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken); protected override void RenderRangePicker(HtmxRenderContext ctx) => _rangePicker.Render(ctx.Next()); protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submitBtn.Render(ctx.Next()); protected override void RenderError(HtmxRenderContext ctx) => _error.Render(ctx.Next()); } ``` **POST handler** ```csharp [Handler] [MapPost("/reports")] public static partial class PostReportHandler { public record Command( [property: FromForm] string ReportDateStart, [property: FromForm] string ReportDateEnd); private static Task HandleAsync( [AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct) { if (string.IsNullOrEmpty(cmd.ReportDateStart) || string.IsNullOrEmpty(cmd.ReportDateEnd)) { var errorPage = new ReportRangePage(af, ctx, "Please select both a start and end date."); return ctx.WriteHtmxPage(errorPage, title: "Generate report"); } var start = DateOnly.ParseExact(cmd.ReportDateStart, "yyyy-MM-dd"); var end = DateOnly.ParseExact(cmd.ReportDateEnd, "yyyy-MM-dd"); return Task.FromResult(Results.Redirect($"/reports/result?from={start}&to={end}")); } } ``` **`AppJsonSerializerContext.cs` — add the new command** ```csharp [JsonSerializable(typeof(PostReportHandler.Command), TypeInfoPropertyName = "ReportCommand")] ```