ee8797c142
Co-authored-by: Copilot <copilot@github.com>
238 lines
8.1 KiB
Markdown
238 lines
8.1 KiB
Markdown
# 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 ← Sun–Sat 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 Jan–Dec 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")]
|
||
```
|