Files
Htmx/docs/Components/CalendarRange.md
T
2026-05-05 23:55:26 +05:00

5.9 KiB

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

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

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

<!-- Templates/VacationForm.htmx -->
<form method="post" action="/vacation" class="space-y-6">
  $$Token$$
  <div>
    <label class="text-sm font-medium">Select vacation dates</label>
    $$RangePicker$$
  </div>
  <button type="submit">Submit request</button>
</form>

Reading the submitted values on the server:

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)

new CalendarRange(
    id:            "vacation",
    name:          "vacation",
    selectedStart: existingRequest.StartDate,
    selectedEnd:   existingRequest.EndDate)

Reacting to selection changes in JavaScript

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

<div class="max-w-md mx-auto py-10">
  <h1 class="text-2xl font-bold mb-6">Generate report</h1>
  <form method="post" action="/reports">
  $$AntiforgeryToken$$
  <div class="mb-6">
    <label class="text-sm font-medium block mb-2">Date range</label>
    $$RangePicker$$
  </div>
  $$SubmitBtn$$
  </form>
  $$Error$$
</div>

Templates/ReportRangePage.htmx.cs

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     = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".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

[Handler]
[MapPost("/reports")]
public static partial class PostReportHandler
{
  public record Command(
    [property: FromForm] string ReportDateStart,
    [property: FromForm] string ReportDateEnd);

  private static Task<IResult> 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

[JsonSerializable(typeof(PostReportHandler.Command), TypeInfoPropertyName = "ReportCommand")]