ee8797c142
Co-authored-by: Copilot <copilot@github.com>
229 lines
7.5 KiB
Markdown
229 lines
7.5 KiB
Markdown
# 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
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
new CalendarRange(id: "vacation", name: "vacation")
|
|
```
|
|
|
|
### Pre-selected range
|
|
|
|
```csharp
|
|
new CalendarRange(
|
|
id: "vacation",
|
|
name: "vacation",
|
|
selectedStart: new DateOnly(2026, 7, 1),
|
|
selectedEnd: new DateOnly(2026, 7, 14))
|
|
```
|
|
|
|
### Inside a form
|
|
|
|
```html
|
|
<!-- 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:**
|
|
|
|
```csharp
|
|
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
|
|
|
|
```js
|
|
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`**
|
|
```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")]
|
|
```
|