b530bb8c97
Co-authored-by: Copilot <copilot@github.com>
181 lines
5.9 KiB
Markdown
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")]
|
|
```
|