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

8.1 KiB
Raw Blame History

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

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 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

<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")]