Co-authored-by: Copilot <copilot@github.com>
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)
- Nothing or both selected → set
start = clicked, clearend - Only start selected:
- Click after start → set
end, firerangeChange - Click before start → move
startto clicked, clearend - Click on start → clear both (toggle off)
- Click after start → set
- Writes values to hidden inputs, fires
rangeChangeCustomEvent:{ start: "yyyy-MM-dd", end: "yyyy-MM-dd" } - Calls
renderRangeto rebuild grid andupdateLabelto update the text summary
Hover preview (updateHoverClasses)
- Runs on
grid.onmouseoverwithout rebuilding the grid — only toggles CSS classes - Shades the tentative range from
startto 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
rangeChangeevent to validate client-side and show an error message. - The calendar always shows a single month. For a two-month range view, render two
CalendarRangecomponents side by side and sync their state via therangeChangeevent. - The calendar always shows a single month. For a two-month range view, render two
CalendarRangecomponents side by side and sync their state via therangeChangeevent.
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")]