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

238 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Calendar
A single-date picker rendered server-side with full client-side interaction. The selected date is stored in a hidden input and submitted as part of a form. Supports three drill-down views: days → months → years.
---
## HTML structure
```
div.calendar-root[id=cal-{id}, data-year, data-month, data-sel-day,
data-sel-month, data-sel-year, data-view="days"]
div.mb-3.flex.items-center.justify-between ← navigation row
button.cal-prev ← previous month/year/decade
button.cal-month-label ← shows "Month YYYY" / "YYYY" / decade range
button.cal-next ← next
div.cal-dow-row.grid.grid-cols-7 ← SunSat headings (hidden in month/year views)
div.cal-grid.grid.grid-cols-7 ← day/month/year cells, built by JS
input.cal-hidden-input[type=hidden, name] ← holds selected date as yyyy-MM-dd
```
---
## CSS mechanics
| Class | Effect |
|---|---|
| `cal-day` | Base day button style (text-center, rounded, hover highlight) |
| `cal-day-selected` | Filled primary circle on the selected day |
| `cal-view-btn` | Base style for month/year selection buttons |
| `cal-view-btn-selected` | Highlighted active month or year |
| Grid is 7-column for days, 3-column for months/years | Switched via `gridTemplateColumns` inline style |
---
## JavaScript (`initCalendar` in `components.js`)
State is stored entirely in `data-*` attributes on the root element. JS reads and writes these attributes — no hidden state in closures.
### `renderCalendar(root)` — three view modes
**Days view:**
1. Reads `data-year` and `data-month` (0-based, JS-style)
2. Calculates leading empty cells for the first weekday offset
3. Renders numbered `<button>` elements; adds `cal-day-selected` to the matching date
4. Each day button stores `yyyy-MM-dd` in `data-date`
5. On click: updates `data-sel-*`, highlights the new selection, writes value to `.cal-hidden-input`, fires `calendarChange` CustomEvent
**Months view:**
- Renders JanDec abbreviated buttons in a 3-column grid
- Click drills back to days view for that month
**Years view:**
- Renders 12 consecutive years (decade rounded to nearest 12)
- Click drills back to months view for that year
### Navigation buttons
- Prev/Next adjust month ± 1 (wrapping year), year ± 1, or decade ± 12 depending on `data-view`
- Month-label click drills down: days → months → years (no further drill from years)
### Re-initialization
`initAll` re-queries `.calendar-root` after `htmx:afterSwap`, so HTMX-swapped calendars work correctly.
---
## Constructor signature
```csharp
public Calendar(
string id,
string name = "date",
DateOnly? selected = null)
```
| Parameter | Description |
|---|---|
| `id` | Logical id; element gets `id="cal-{id}"` |
| `name` | Form field name for the hidden input |
| `selected` | Pre-selected date; defaults to today |
---
## Usage examples
### Basic date picker
```csharp
new Calendar(id: "dob", name: "dateOfBirth")
```
### Pre-selected date
```csharp
new Calendar(
id: "appointment",
name: "appointmentDate",
selected: new DateOnly(2026, 9, 15))
```
### Inside a form
```html
<!-- Templates/BookingForm.htmx -->
<form method="post" action="/book">
$$AntiforgeryToken$$
<label class="text-sm font-medium">Pick a date</label>
$$DatePicker$$
<button type="submit">Book</button>
</form>
```
```csharp
// Templates/BookingForm.htmx.cs
public IHtmxComponent DatePicker { get; }
public BookingForm(string? afToken = null)
{
DatePicker = new Calendar(id: "booking", name: "bookingDate");
_afTokenData = /* antiforgery hidden input */;
}
protected override void RenderDatePicker(HtmxRenderContext ctx)
=> DatePicker.Render(ctx.Next());
```
**Reading the submitted value on the server:**
```csharp
public record Command(
[property: FromForm] string BookingDate // "yyyy-MM-dd"
);
// Parse:
var date = DateOnly.ParseExact(command.BookingDate, "yyyy-MM-dd");
```
### Listening for selection changes client-side
```js
document.getElementById('cal-appointment').addEventListener('calendarChange', e => {
console.log(e.detail.date); // "2026-09-15"
// update other UI elements based on selection
});
```
---
## Tips and tricks
- The hidden input is always named with the `name` parameter — use this as the form field name when reading the submitted POST.
- Months are 0-based in the JS `data-*` attributes (matching `Date` object convention) but the hidden input always stores `yyyy-MM-dd` with 1-based months.
- If you need to clear the selection client-side, set `document.querySelector('#cal-myid .cal-hidden-input').value = ''` and remove `cal-day-selected` from any button.
- To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the `calendarChange` event or override the generated grid with a custom `initCalendar` extension.
- To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the `calendarChange` event or override the generated grid with a custom `initCalendar` extension.
---
## Complete page example
**`Templates/AppointmentPage.htmx`**
```html
<div class="max-w-sm mx-auto py-10">
<h1 class="text-2xl font-bold mb-6">Book an appointment</h1>
<form method="post" action="/appointments">
$$AntiforgeryToken$$
<div class="mb-4">
<label class="text-sm font-medium block mb-2">Select a date</label>
$$DatePicker$$
</div>
$$SubmitBtn$$
</form>
$$Confirmation$$
</div>
```
**`Templates/AppointmentPage.htmx.cs`**
```csharp
namespace Htmx.ApiDemo.Templates;
public sealed class AppointmentPage : AppointmentPageBase
{
private readonly IHtmxComponent _datePicker;
private readonly IHtmxComponent _submitBtn;
private readonly IHtmxComponent _confirmation;
private readonly byte[] _afToken;
public AppointmentPage(IAntiforgery af, HttpContext ctx, string? confirmedDate = null)
{
var tokens = af.GetAndStoreTokens(ctx);
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
_datePicker = new Components.Calendar(id: "appt", name: "appointmentDate");
_submitBtn = new Components.Button("Book", type: "submit");
_confirmation = confirmedDate is not null
? new Components.Alert(title: "Booked!", description: $"Your appointment is on {confirmedDate}.")
: HtmxEmpty.Instance;
}
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
protected override void RenderDatePicker(HtmxRenderContext ctx) => _datePicker.Render(ctx.Next());
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submitBtn.Render(ctx.Next());
protected override void RenderConfirmation(HtmxRenderContext ctx) => _confirmation.Render(ctx.Next());
}
```
**GET + POST handlers**
```csharp
[Handler]
[MapGet("/appointments/new")]
public static partial class GetAppointmentHandler
{
public record Query();
private static Task<IResult> HandleAsync(
Query _, HttpContext ctx, IAntiforgery af, CancellationToken ct)
=> ctx.WriteHtmxPage(new AppointmentPage(af, ctx), title: "Book appointment");
}
[Handler]
[MapPost("/appointments")]
public static partial class PostAppointmentHandler
{
public record Command([property: FromForm] string AppointmentDate);
private static Task<IResult> HandleAsync(
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
{
var date = DateOnly.ParseExact(cmd.AppointmentDate, "yyyy-MM-dd");
var page = new AppointmentPage(af, ctx, confirmedDate: date.ToLongDateString());
return ctx.WriteHtmxPage(page, title: "Book appointment");
}
}
```
**`AppJsonSerializerContext.cs` — add the new command**
```csharp
[JsonSerializable(typeof(PostAppointmentHandler.Command), TypeInfoPropertyName = "AppointmentCommand")]
```