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

181 lines
5.9 KiB
Markdown

# 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
<!-- 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:**
```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
<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`**
```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 = $"""<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**
```csharp
[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**
```csharp
[JsonSerializable(typeof(PostReportHandler.Command), TypeInfoPropertyName = "ReportCommand")]
```