ee8797c142
Co-authored-by: Copilot <copilot@github.com>
8.1 KiB
8.1 KiB
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:
- Reads
data-yearanddata-month(0-based, JS-style) - Calculates leading empty cells for the first weekday offset
- Renders numbered
<button>elements; addscal-day-selectedto the matching date - Each day button stores
yyyy-MM-ddindata-date - On click: updates
data-sel-*, highlights the new selection, writes value to.cal-hidden-input, firescalendarChangeCustomEvent
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
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
new Calendar(id: "dob", name: "dateOfBirth")
Pre-selected date
new Calendar(
id: "appointment",
name: "appointmentDate",
selected: new DateOnly(2026, 9, 15))
Inside a form
<!-- 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>
// 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:
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
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
nameparameter — use this as the form field name when reading the submitted POST. - Months are 0-based in the JS
data-*attributes (matchingDateobject convention) but the hidden input always storesyyyy-MM-ddwith 1-based months. - If you need to clear the selection client-side, set
document.querySelector('#cal-myid .cal-hidden-input').value = ''and removecal-day-selectedfrom any button. - To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the
calendarChangeevent or override the generated grid with a custominitCalendarextension. - To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the
calendarChangeevent or override the generated grid with a custominitCalendarextension.
Complete page example
Templates/AppointmentPage.htmx
<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
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
[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
[JsonSerializable(typeof(PostAppointmentHandler.Command), TypeInfoPropertyName = "AppointmentCommand")]