Files
Htmx/docs/Components/CalendarRange.md
T
2026-05-04 19:57:48 +05:00

7.5 KiB

CalendarRange

A date-range picker. The user selects a start date and then an end date. Hover preview shades the range before the second click commits it. Fires a rangeChange CustomEvent on every selection change.


HTML structure

div.calr-root[id=calr-{id}, data-year, data-month, data-start, data-end, data-view="days"]
  div.mb-3.flex.items-center.justify-between   ← navigation row
    button.calr-prev
    button.calr-month-label
    button.calr-next
  div.cal-dow-row.grid.grid-cols-7             ← day-of-week headings
  div.calr-grid.grid.grid-cols-7              ← day cells, rebuilt by JS on each interaction
  span.calr-label                             ← "start → end" or "start → pick end date"
  input.calr-hidden-start[type=hidden, name={name}-start]
  input.calr-hidden-end[type=hidden,   name={name}-end]

CSS mechanics

Class Effect
calr-day-start Filled primary circle on start date
calr-day-end Filled primary circle on end date
calr-day-mid Lighter primary tint for dates between start and end
calr-day-plain Default un-selected day style

Hover preview is applied by updateHoverClasses by toggling the same CSS classes without rebuilding the DOM.


JavaScript (initCalendarRange in components.js)

State

Stored in data-start and data-end attributes on the root (empty string = not selected).

Click logic (grid.onclick)

  1. Nothing or both selected → set start = clicked, clear end
  2. Only start selected:
    • Click after start → set end, fire rangeChange
    • Click before start → move start to clicked, clear end
    • Click on start → clear both (toggle off)
  3. Writes values to hidden inputs, fires rangeChange CustomEvent: { start: "yyyy-MM-dd", end: "yyyy-MM-dd" }
  4. Calls renderRange to rebuild grid and updateLabel to update the text summary

Hover preview (updateHoverClasses)

  • Runs on grid.onmouseover without rebuilding the grid — only toggles CSS classes
  • Shades the tentative range from start to the hovered date before a click commits it
  • Cleared on grid.onmouseleave

View navigation

Same as Calendar: Prev/Next, month-label click drills days → months → years. renderRange rebuilds the grid on each navigation.


Constructor signature

public CalendarRange(
    string id,
    string name             = "date",
    DateOnly? selectedStart = null,
    DateOnly? selectedEnd   = null)
Parameter Description
id Logical id; element gets id="calr-{id}"
name Base form field name; hidden inputs are {name}-start and {name}-end
selectedStart Pre-selected start date
selectedEnd Pre-selected end date

Usage examples

Empty picker

new CalendarRange(id: "vacation", name: "vacation")

Pre-selected range

new CalendarRange(
    id: "vacation",
    name: "vacation",
    selectedStart: new DateOnly(2026, 7, 1),
    selectedEnd:   new DateOnly(2026, 7, 14))

Inside a form

<!-- Templates/VacationForm.htmx -->
<form method="post" action="/vacation">
  $$AntiforgeryToken$$
  <label class="text-sm font-medium">Select vacation dates</label>
  $$RangePicker$$
  <button type="submit">Request</button>
</form>

Reading the submitted values:

public record Command(
    [property: FromForm] string VacationStart,  // "yyyy-MM-dd"
    [property: FromForm] string VacationEnd     // "yyyy-MM-dd"
);

var start = DateOnly.ParseExact(command.VacationStart, "yyyy-MM-dd");
var end   = DateOnly.ParseExact(command.VacationEnd,   "yyyy-MM-dd");

Listening for range changes client-side

document.getElementById('calr-vacation').addEventListener('rangeChange', e => {
    console.log(e.detail.start, e.detail.end);
    // e.g. "2026-07-01", "2026-07-14"
});

Showing a summary label elsewhere on the page

The .calr-label span inside the component automatically updates to show start → end or start → pick end date. You don't need custom JS for this.


Tips and tricks

  • Both hidden inputs are always submitted with the form. An empty string means the date was not selected — validate server-side before parsing.
  • The user can clear the selection by clicking the start date again after both are set.
  • To enforce a minimum range length (e.g. at least 2 nights), use the rangeChange event to validate client-side and show an error message.
  • The calendar always shows a single month. For a two-month range view, render two CalendarRange components side by side and sync their state via the rangeChange event.
  • The calendar always shows a single month. For a two-month range view, render two CalendarRange components side by side and sync their state via the rangeChange event.

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")]