Theme
On this page (10)
Component Skill — components/calendar/component-skill.md

Native basis

<table> element rendered as a month grid with navigation controls. Uses role="grid" for accessible day cell navigation.

Web Platform APIs

<table>role="grid"<button>

Classes

.calendar.calendar-header.calendar-heading.calendar-nav.calendar-grid.calendar-day-label.calendar-day[data-today][data-selected][data-outside][data-disabled]

Date range

[data-mode="range"].calendar-range.calendar-range-footer[data-range-start][data-range-end][data-in-range][data-range-input]calendar:range

Accessibility

• Month heading uses aria-live="polite" for navigation announcements

• Day cells are focusable buttons within the grid

• Arrow keys navigate the grid, Enter/Space selects a day - in range mode across every month shown

• Selected days carry aria-selected="true" (both endpoints and the span between)

• Previous/next navigation buttons have aria-label

§Basic Calendar

Arrows step a month. The month and year in the middle are a button: it opens a month grid (the arrows then step a year), and the year there opens a grid of 12 years (the arrows step 12) - pick a year, then a month, and you are there. Escape goes back to the days. A plain <span class="calendar-heading"> is upgraded to this button automatically.

§Date of birth (dropdowns)

data-caption="dropdown" puts native month and year selects between the arrows - the quickest way to a date decades away: open the year and type 1987. Years run from data-year-from to data-year-to (defaults: data-min-date / data-max-date, else 100 years back to 10 ahead); months outside min / max are disabled.

§Marked days

Your own day types: a JSON <script class="calendar-days"> inside the calendar maps ISO dates to { mark, label, note, disabled }. mark becomes data-mark on the cell - holiday (red), event (a dot) and booked (struck through) have a look built in, any other name gets a neutral dot for your own CSS. label is the tooltip and part of the day's accessible name ("Friday, 25 December 2026, Christmas Day"); disabled: true blocks the day (a blackout date).

§Prices per night

A note is a second line under the number - here each night's price; the cells grow to fit. The data is loaded per month: calendar:view fires whenever the visible month changes (arrows, month / year picker, setState) and the page answers with api.setDays() for exactly that month (fetch it from your API; this one computes it). Booked nights are mark: booked + disabled, so a stay can't start on them.

§Date range

data-mode='range' turns a calendar into a range picker: the first click sets the start, the second the end, and the days between form one highlighted span. A click before the start begins again there, so an end can never precede its start. data-range-start / data-range-end preset (and always mirror) the choice; every change fires calendar:range.

§Two-month range

Wrap calendars in .calendar-range and they become ONE picker: consecutive months that page together (one pair of arrows), one shared range drawn across both grids, and a live preview of the span while you choose the end. Form fields marked data-range-input='start' / 'end' inside the wrapper receive the ISO dates, so the stay submits as one answer.

§Density

Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only the frame padding and the day hit-areas scale (typography stays identical). comfortable matches the unsized default.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - reset to today's month with no selection; setState('default', { year, month, day }) (0-based month) navigates and optionally selects, and getState().config reports the live view

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/calendar-{state}.png.

Machine contract - verified against calendar.schema.json by bun run verify:

StateTypeValuesDefaultDescription
currentDatestring—""Selected day as ISO YYYY-MM-DD (first of the month when nothing is selected); setState('default', { date }) navigates and selects. Mirrored as data-current-date.
minDatestring—""Lower bound of the selectable range (YYYY/YYYY-MM/YYYY-MM-DD, "" clears); days outside it render data-disabled. Mirrored as data-min-date.
maxDatestring—""Upper bound of the selectable range (same formats, "" clears). Mirrored as data-max-date.

§CSS view file

Styles for the calendar component. Uses design tokens for colors, spacing, and radius.

@layer components {
  .calendar {
    display: inline-flex;
    flex-direction: column;
    padding: 0.75rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    background-color: var(--card);
    user-select: none;
  }
  .calendar-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 0.25rem 0.5rem;
  }
  .calendar-heading {
    font-size: 0.875rem;
    font-weight: 500;
    color: var(--foreground);
  }
  /* The heading as a button (the JS upgrades a plain heading): the month +
     year are the obvious thing to click - it opens the month grid, and in
     the month grid the year opens the year grid. The chevron says "menu". */
  button.calendar-heading {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.25rem 0.5rem;
    border: none;
    border-radius: var(--radius-md);
    background: transparent;
    font-family: inherit;
    cursor: pointer;
    transition: background-color 150ms ease;
    &::after {
      content: '';
      width: 0.375rem;
      height: 0.375rem;
      margin-top: -0.1875rem;
      border: solid currentColor;
      border-width: 0 1.5px 1.5px 0;
      rotate: 45deg;
      transition: rotate 150ms ease;
    }
    &[aria-expanded="true"]::after { rotate: -135deg; margin-top: 0.1875rem; }
    &:hover { background-color: var(--accent); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
  }
  /* data-caption="dropdown": native month + year selects between the arrows
     (the fastest way to a date decades away - type the year) */
  .calendar-caption {
    display: inline-flex;
    gap: 0.25rem;
  }
  .calendar-select {
    padding: 0.25rem 0.375rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background-color: transparent;
    color: var(--foreground);
    font: inherit;
    font-size: 0.875rem;
    font-weight: 500;
    cursor: pointer;
    &:hover { background-color: var(--accent); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
  }
  /* -- Month / year picker (data-view="months" | "years") -------
     Swaps in for the day grid at the same size: 12 months or 12 years in a
     4-column grid. The current one is filled, this month / year is tinted. */
  .calendar-picker { display: none; }
  .calendar[data-view] {
    & .calendar-grid { display: none; }
    & .calendar-picker {
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      align-content: center;
      gap: 0.375rem;
      box-sizing: border-box;
      padding: 0.25rem;
    }
  }
  .calendar-pick {
    height: 2.25rem;
    padding: 0;
    border: none;
    border-radius: var(--radius-md);
    background-color: transparent;
    color: var(--foreground);
    font: inherit;
    font-size: 0.8125rem;
    cursor: pointer;
    transition: background-color 150ms ease, color 150ms ease;
    &:hover { background-color: var(--accent); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
    &[data-today] { background-color: var(--accent); font-weight: 600; }
    &[aria-current="true"] {
      background-color: var(--primary);
      color: var(--primary-foreground);
    }
    /* legible but inert (outside min / max), like every disabled control */
    &:disabled {
      background-color: transparent;
      color: var(--muted-foreground);
      text-decoration: line-through;
      cursor: not-allowed;
    }
  }
  .calendar-nav {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background-color: transparent;
    color: var(--foreground);
    cursor: pointer;
    transition: background-color 150ms ease;
    &:hover {
      background-color: var(--accent);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    & svg {
      width: 0.875rem;
      height: 0.875rem;
    }
  }
  .calendar-grid {
    border-collapse: collapse;
    border-spacing: 0;
  }
  .calendar-day-label {
    width: 2.25rem;
    height: 2.25rem;
    font-size: 0.75rem;
    font-weight: 400;
    color: var(--muted-foreground);
    text-align: center;
    vertical-align: middle;
  }
  /* -- Density --------------------------------------------------
     data-density on the .calendar root scales the whitespace between the
     frame and the grid plus the day hit-areas (0.75 / 1 / 1.25-ish scale of
     the 2rem/2.25rem cells); comfortable matches the unsized default.
     Typography stays put - a spacing policy, not a zoom. */
  .calendar:where([data-density="compact"]) {
    padding: 0.5rem;
    & .calendar-day-label,
    & .calendar-day { width: 1.75rem; height: 1.75rem; }
    & .calendar-day button { width: 1.5rem; height: 1.5rem; }
  }
  .calendar:where([data-density="comfortable"]) {
    padding: 0.75rem;
  }
  .calendar:where([data-density="spacious"]) {
    padding: 1rem;
    & .calendar-day-label,
    & .calendar-day { width: 2.75rem; height: 2.75rem; }
    & .calendar-day button { width: 2.5rem; height: 2.5rem; }
  }
  .calendar-day {
    width: 2.25rem;
    height: 2.25rem;
    text-align: center;
    vertical-align: middle;
    padding: 0;
    & button {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 2rem;
      height: 2rem;
      font-size: 0.8125rem;
      border: none;
      border-radius: var(--radius-md);
      background-color: transparent;
      color: var(--foreground);
      cursor: pointer;
      transition: background-color 150ms ease, color 150ms ease;
      &:hover {
        background-color: var(--accent);
      }
      &:focus-visible {
        outline: 2px solid var(--ring);
        outline-offset: 2px;
      }
    }
    &[data-today] button {
      background-color: var(--accent);
      font-weight: 600;
    }
    &[data-selected] button {
      background-color: var(--primary);
      color: var(--primary-foreground);
      &:hover {
        opacity: 0.9;
      }
    }
    &[data-outside] button {
      color: var(--muted-foreground);
      opacity: 0.5;
    }
    &[data-disabled] button {
      color: var(--muted-foreground);
      opacity: 0.35;
      cursor: not-allowed;
    }
  }
  /* -- Day data: marks + notes -----------------------------------
     data-mark="…" is the page's own classification of a day (a holiday, a
     booked night, a scheduled event): a dot in the cell's corner plus, for
     the built-in names, a look of its own. Any other name gets the neutral
     dot - style it with .calendar-day[data-mark="name"]. A note is a second
     line under the number (a price, a count); a calendar with notes gets
     taller cells (data-notes, set by the JS). */
  .calendar-day button { position: relative; }
  .calendar-day[data-mark] button::after {
    content: '';
    position: absolute;
    top: 0.1875rem;
    inset-inline-end: 0.1875rem;
    width: 0.3125rem;
    height: 0.3125rem;
    border-radius: 50%;
    background-color: var(--muted-foreground);
  }
  .calendar-day[data-mark="holiday"] button {
    color: var(--destructive);
    font-weight: 600;
    &::after { background-color: var(--destructive); }
  }
  .calendar-day[data-mark="event"] button::after { background-color: var(--primary); }
  .calendar-day[data-mark="booked"] button {
    color: var(--muted-foreground);
    text-decoration: line-through;
    background-image: repeating-linear-gradient(135deg, transparent 0 4px, color-mix(in oklch, var(--muted-foreground) 18%, transparent) 4px 5px);
    &::after { display: none; }
  }
  /* a marked day that is also blocked (booked, a blackout date) keeps its
     look at full strength - the mark IS the information, the 0.35 fade of
     a plain disabled day would erase it */
  .calendar-day[data-mark][data-disabled]:not([data-outside]) button { opacity: 1; }
  /* on a filled (selected / endpoint) day the marks follow its foreground */
  .calendar-day:is([data-selected], [data-range-start], [data-range-end])[data-mark] button {
    color: var(--primary-foreground);
    &::after { background-color: currentColor; }
  }
  .calendar-day-note {
    display: block;
    font-size: 0.625rem;
    line-height: 1;
    font-weight: 400;
    color: var(--muted-foreground);
    text-decoration: none;
  }
  .calendar-day:is([data-selected], [data-range-start], [data-range-end]) .calendar-day-note {
    color: inherit;
    opacity: 0.85;
  }
  .calendar[data-notes] {
    & .calendar-day-label { width: 2.75rem; }
    & .calendar-day {
      width: 2.75rem;
      height: 3rem;
      & button {
        flex-direction: column;
        gap: 0.1875rem;
        width: 2.5rem;
        height: 2.75rem;
      }
    }
  }
  /* -- Range mode ------------------------------------------------
     A range picker is a .calendar[data-mode="range"] or a .calendar-range
     wrapper around several calendars (consecutive months, one shared range).
     Look: endpoints (data-range-start / -end) are filled with --primary,
     the days between (data-in-range) form a continuous --accent band that
     runs half into each endpoint cell (data-range-span). While only the start
     is chosen, the hovered/focused day previews the span (data-range-preview:
     lighter band, outlined end). Outside days are hidden - each month grid
     shows only its own days, so the band never jumps between months. */
  .calendar-range {
    display: inline-flex;
    flex-wrap: wrap;
    gap: 0.75rem 1.5rem;
    padding: 0.75rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    background-color: var(--card);
    /* the months sit on the wrapper's card */
    & > .calendar {
      padding: 0;
      border: none;
      border-radius: 0;
      background-color: transparent;
    }
    /* one pair of arrows for the whole picker: back on the first month,
       forward on the last (visibility keeps the headings centred) */
    & > .calendar ~ .calendar [data-action="prev-month"],
    & > .calendar:has(~ .calendar) [data-action="next-month"] {
      visibility: hidden;
    }
  }
  /* summary row under the months: the chosen range as one answer.
     contain: inline-size - the row takes the months' width instead of
     widening the picker to its own (a 100%-basis row would stretch the
     inline-flex card across the page) */
  .calendar-range-footer {
    flex-basis: 100%;
    contain: inline-size;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem 1rem;
    padding-top: 0.75rem;
    border-top: 1px solid var(--border);
    font-size: 0.875rem;
    color: var(--foreground);
  }
  :is(.calendar[data-mode="range"], .calendar-range .calendar) .calendar-day[data-outside] {
    visibility: hidden;
    pointer-events: none;
  }
  .calendar-day {
    &[data-in-range] {
      background-color: var(--accent);
      & button {
        color: var(--accent-foreground);
      }
    }
    &[data-range-start][data-range-span] {
      background: linear-gradient(to right, transparent 50%, var(--accent) 50%);
    }
    &[data-range-end][data-range-span] {
      background: linear-gradient(to left, transparent 50%, var(--accent) 50%);
    }
    &:is([data-range-start], [data-range-end]) button {
      background-color: var(--primary);
      color: var(--primary-foreground);
      font-weight: 500;
    }
    /* preview (start chosen, end not yet): lighter band, outlined end */
    &[data-in-range][data-range-preview] {
      background-color: color-mix(in oklch, var(--accent) 60%, transparent);
    }
    &[data-range-end][data-range-preview][data-range-span] {
      background: linear-gradient(to left, transparent 50%, color-mix(in oklch, var(--accent) 60%, transparent) 50%);
    }
    &[data-range-end][data-range-preview] button {
      background-color: var(--background);
      color: var(--foreground);
      box-shadow: inset 0 0 0 1.5px var(--primary);
    }
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .calendar,
    .calendar *,
    .calendar::before,
    .calendar::after,
    .calendar *::before,
    .calendar *::after,
    .calendar::backdrop,
    .calendar-header,
    .calendar-header *,
    .calendar-header::before,
    .calendar-header::after,
    .calendar-header *::before,
    .calendar-header *::after,
    .calendar-header::backdrop,
    .calendar-heading,
    .calendar-heading *,
    .calendar-heading::before,
    .calendar-heading::after,
    .calendar-heading *::before,
    .calendar-heading *::after,
    .calendar-heading::backdrop,
    .calendar-nav,
    .calendar-nav *,
    .calendar-nav::before,
    .calendar-nav::after,
    .calendar-nav *::before,
    .calendar-nav *::after,
    .calendar-nav::backdrop,
    .calendar-grid,
    .calendar-grid *,
    .calendar-grid::before,
    .calendar-grid::after,
    .calendar-grid *::before,
    .calendar-grid *::after,
    .calendar-grid::backdrop,
    .calendar-day-label,
    .calendar-day-label *,
    .calendar-day-label::before,
    .calendar-day-label::after,
    .calendar-day-label *::before,
    .calendar-day-label *::after,
    .calendar-day-label::backdrop,
    .calendar-day,
    .calendar-day *,
    .calendar-day::before,
    .calendar-day::after,
    .calendar-day *::before,
    .calendar-day *::after,
    .calendar-day::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}
/* Forced colors (Windows High Contrast) drop the accent band and the primary
   fills: selected days (the single selection, both endpoints and the days
   between) take the system Highlight pair instead. */
@media (forced-colors: active) {
  @layer components {
    .calendar-day[aria-selected="true"] button {
      forced-color-adjust: none;
      background-color: Highlight;
      color: HighlightText;
    }
    .calendar-pick[aria-current="true"] {
      forced-color-adjust: none;
      background-color: Highlight;
      color: HighlightText;
    }
    .calendar-day[data-mark] button::after {
      forced-color-adjust: none;
      background-color: CanvasText;
    }
    .calendar-pick:disabled,
    .calendar-day[data-mark="booked"] button { color: GrayText; }
  }
}

§JavaScript view file

Interaction logic for the calendar component. Uses data attributes for wiring.

// -- Calendar -------------------------------------------------
// Interactive calendar grid with month navigation and day selection, plus
// the named-state API (AGENTS.md "State API"). The calendar's observable
// state is its view (visible month + selected day), so 'default' resets to
// today (or navigates/selects via { year, month, day }) and getState()
// reports the live view.
// Jumping: the heading is a button - days -> a month grid -> a year grid
// (12 years a page), so a date decades away is a few clicks, not hundreds;
// data-caption="dropdown" swaps it for native month + year <select>s.
// Day data: marks, a second line (price, count) and blocked days come from a
// JSON <script class="calendar-days"> inside the calendar (or its
// .calendar-range) or api.setDays(map); every rendered cell reads it, and
// calendar:view fires whenever the visible month changes so a page can load
// the data for exactly that month.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
// defussQuery: the callable runtime - the grid renders through df$(grid).morph()
// (plans/defuss-query-morph-integration.md §3 Tier-1: keyed DOM diffing replaces
// innerHTML so day-node identity + focus survive re-renders).
import { defussGlobals, defussQuery } from '../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// id prefix source for calendars without their own #id (unique per element,
// so morph day-cell ids never collide between calendars on one page)
let calSeq = 0;
const calendarStates = ['default'];
/**
 * UI side of setState: 'default' (re)renders the view. Without config it
 * resets to today with no selection; { year, month, day } navigates to that
 * month (month is 0-based, like Date) and optionally selects a day;
 * { date: 'YYYY[-MM[-DD]]' } navigates by ISO string (day selects too);
 * { minDate/maxDate } set the selectable range ('' clears). All ranges are
 * read live from state in renderGrid, so attribute-style control via the
 * State API takes effect on the very next render.
 */
function triggerStateChange(cal, stateName, config) {
  const state = cal._calState;
  if (!state || stateName !== 'default') return;
  // range mode: { start, end } (ISO 'YYYY-MM-DD', '' / null clears) sets the
  // shared range of the calendar's owner and moves the view to the start
  const owner = rangeOwnerOf(cal);
  if (owner && ('start' in (config ?? {}) || 'end' in (config ?? {}))) {
    const r = rangeState(owner);
    const iso = (v) => (typeof v === 'string' && ISO_DAY.test(v) ? v : null);
    r.start = iso(config.start);
    r.end = r.start ? iso(config.end) : null;
    if (r.end && r.end < r.start) r.end = null; // never an end before the start
    r.hover = null;
    if (r.start) {
      const [y, m] = r.start.split('-').map(Number);
      r.year = y;
      r.month = m - 1;
    }
    syncRange(owner);
    return;
  }
  const now = new Date();
  if (typeof config?.minDate === 'string') state.minDate = config.minDate || null;
  if (typeof config?.maxDate === 'string') state.maxDate = config.maxDate || null;
  if (typeof config?.date === 'string' && /^\d{4}(-\d{2}(-\d{2})?)?$/.test(config.date)) {
    const [y, m, d] = config.date.split('-').map(Number);
    state.year = y;
    state.month = (m ?? now.getMonth() + 1) - 1;
    state.selected = d ?? null;
  } else {
    state.year = config?.year ?? now.getFullYear();
    state.month = config?.month ?? now.getMonth();
    state.selected = config?.day ?? null;
  }
  renderCalendar(cal, state.year, state.month, state.selected);
}
// ISO yyyy-mm-dd of a cell's REAL date (outside cells resolve to their own
// month) - the morph key basis: identity, not filtered position (§3/guide)
const isoDate = (d: Date) =>
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
/** Registry-level API; pass the calendar element explicitly. Unknown names throw. */
export const calendarApi = {
  setState(cal, stateName, config = {}) {
    if (!calendarStates.includes(stateName)) {
      throw new Error(`calendar: unknown state "${stateName}" (supported: ${calendarStates.join(', ')})`);
    }
    triggerStateChange(cal, stateName, config);
    // state lives on the ELEMENT, not the module (many calendars per page)
    cal.dataset.stateName = stateName;
    cal._stateConfig = config;
  },
  /**
   * Day data for this calendar (a range picker: for its whole .calendar-range):
   * { 'YYYY-MM-DD': { mark?, note?, label?, disabled? } }. Replaces the map
   * unless { merge: true }; re-renders without moving the view.
   */
  setDays(cal, days, options: { merge?: boolean } = {}) {
    const holder = dayHolderOf(cal);
    holder._calDays = options.merge ? { ...holder._calDays, ...days } : { ...days };
    rerender(cal);
  },
  getState(cal) {
    const state = cal._calState ?? {};
    return {
      name: cal.dataset.stateName || 'default',
      // live view - reflects nav clicks and day selection, not just setState
      config: {
        ...cal._stateConfig,
        year: state.year,
        month: state.month,
        selected: state.selected,
        minDate: state.minDate ?? null,
        maxDate: state.maxDate ?? null,
        view: cal.dataset.view || 'days',
        // range mode: the shared range of the calendar's owner (ISO or null)
        ...(rangeOwnerOf(cal) ? { rangeStart: rangeState(rangeOwnerOf(cal)).start, rangeEnd: rangeState(rangeOwnerOf(cal)).end } : {}),
      },
    };
  },
};
df$.calendarApi = calendarApi;
df$.calendarStates = calendarStates;
const DAYS = Array.from({ length: 7 }, (_, i) =>
  new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(new Date(2024, 0, i))
);
const MONTHS = Array.from({ length: 12 }, (_, i) =>
  new Intl.DateTimeFormat(undefined, { month: 'long' }).format(new Date(2024, i, 1))
);
const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = (year, month) => new Date(year, month, 1).getDay();
const isToday = (year, month, day) => {
  const now = new Date();
  return now.getFullYear() === year && now.getMonth() === month && now.getDate() === day;
};
/**
 * Build one month's thead+tbody markup. Every day cell carries a stable
 * `id` (`<calId>-<ISO date>`) plus `data-cal-date` - morph matches cells by
 * id, so a re-render moves/reuses nodes instead of replacing them. The id
 * also carries the cell's full date, letting consumers (tests, custom
 * state APIs) read the selection as an ISO date via the grid.
 */
// ISO strings compare lexicographically - the range check needs no Date math
const isoInRange = (iso: string, min?: string | null, max?: string | null) =>
  (!min || iso >= min) && (!max || iso <= max);
// -- Range mode -------------------------------------------------------------
// A range belongs to its OWNER: a .calendar-range wrapper (several calendars
// showing consecutive months, one shared range) or a lone
// .calendar[data-mode="range"]. The owner's data-range-start/-end (ISO) are
// the single source of truth; every calendar of the owner renders from them.
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
/** The range owner of a calendar, or null in single-date mode. */
function rangeOwnerOf(cal) {
  return cal.closest('.calendar-range') ?? (cal.dataset.mode === 'range' ? cal : null);
}
/** The calendars an owner drives, in document (= month) order. */
function calendarsOf(owner) {
  if (!owner.classList.contains('calendar-range')) return [owner];
  return Array.from(owner.querySelectorAll('.calendar')).filter((c) => c.closest('.calendar-range') === owner);
}
/** Lazily created range state on the owner (authored attributes seed it). */
function rangeState(owner) {
  if (owner._range) return owner._range;
  const start = ISO_DAY.test(owner.dataset.rangeStart ?? '') ? owner.dataset.rangeStart : null;
  let end = ISO_DAY.test(owner.dataset.rangeEnd ?? '') ? owner.dataset.rangeEnd : null;
  if (end && (!start || end < start)) end = null;
  // first visible month: the range start, else an authored data-current-date
  // ('YYYY-MM[-DD]'), else today
  const anchor = start ?? (/^\d{4}-\d{2}/.test(owner.dataset.currentDate ?? '') ? owner.dataset.currentDate : isoDate(new Date()));
  const [y, m] = anchor.split('-').map(Number);
  owner._range = { start, end, hover: null, year: y, month: m - 1 };
  return owner._range;
}
/** The month shown by the calendar at `index` of an owner whose first month is (year, month). */
const monthAt = (year, month, index) => {
  const d = new Date(year, month + index, 1);
  return { year: d.getFullYear(), month: d.getMonth() };
};
const isoToDate = (iso) => {
  const [y, m, d] = iso.split('-').map(Number);
  return new Date(y, m - 1, d);
};
/** Re-render every calendar of an owner from the shared range + first month. */
function renderRange(owner) {
  const r = rangeState(owner);
  calendarsOf(owner).forEach((cal, i) => {
    const state = cal._calState;
    if (!state) return;
    const { year, month } = monthAt(r.year, r.month, i);
    state.year = year;
    state.month = month;
    state.selected = null;
    renderCalendar(cal, year, month, null);
  });
}
/**
 * Commit the range: mirror it onto the owner (data-range-start/-end), into
 * any form fields inside the owner (input[data-range-input="start|end"],
 * change events fire) and re-render every calendar.
 */
function syncRange(owner) {
  const r = rangeState(owner);
  for (const [key, value] of [['rangeStart', r.start], ['rangeEnd', r.end]]) {
    if (value) owner.dataset[key] = value;
    else delete owner.dataset[key];
  }
  owner.querySelectorAll('input[data-range-input]').forEach((input) => {
    const value = (input.dataset.rangeInput === 'end' ? r.end : r.start) ?? '';
    if (input.value === value) return;
    input.value = value;
    input.dispatchEvent(new Event('change', { bubbles: true }));
  });
  renderRange(owner);
}
// -- Day data ------------------------------------------------------------------
// Marks and notes belong to whoever renders the days: a .calendar-range owns
// them for all its calendars, otherwise the calendar itself.
const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const MARK = /^[a-z][a-z0-9-]*$/;
const FULL_DATE = new Intl.DateTimeFormat(undefined, { dateStyle: 'full' });
function dayHolderOf(cal) {
  const owner = rangeOwnerOf(cal);
  return owner && owner.classList.contains('calendar-range') ? owner : cal;
}
/** The holder's day map, seeded once from its own <script class="calendar-days"> JSON. */
function daysOf(cal) {
  const holder = dayHolderOf(cal);
  if (holder._calDays) return holder._calDays;
  const script = Array.from(holder.querySelectorAll('script.calendar-days')).find(
    (el) => (el as Element).parentElement === holder,
  ) as HTMLScriptElement | undefined;
  let days = {};
  if (script) {
    try {
      days = JSON.parse(script.textContent || '{}') ?? {};
    } catch {
      days = {};
    }
  }
  holder._calDays = days;
  return days;
}
/** Re-render whatever shows this calendar's days, keeping the view. */
function rerender(cal) {
  const owner = rangeOwnerOf(cal);
  if (owner) renderRange(owner);
  else {
    const st = cal._calState;
    if (st) renderCalendar(cal, st.year, st.month, st.selected);
  }
}
/** Attributes + button content for one day from the day map. */
function dayData(days, iso) {
  const d = days?.[iso];
  if (!d || typeof d !== 'object') return { attrs: '', note: '', aria: '', blocked: false };
  let attrs = '';
  if (typeof d.mark === 'string' && MARK.test(d.mark)) attrs += ` data-mark="${d.mark}"`;
  const note = d.note != null && d.note !== '' ? String(d.note) : '';
  if (note) attrs += ' data-note';
  if (d.label) attrs += ` title="${esc(d.label)}"`;
  // the button names the full date plus the extras - a screen reader hears
  // "Friday, 25 December 2026, Christmas Day, €129", not just "25"
  const aria = ` aria-label="${esc([FULL_DATE.format(isoToDate(iso)), d.label, note].filter(Boolean).join(', '))}"`;
  return { attrs, note: note ? `<span class="calendar-day-note">${esc(note)}</span>` : '', aria, blocked: d.disabled === true };
}
// -- Month / year picker ------------------------------------------------------------
// The heading button switches the calendar's view: days -> months (the year
// in the heading, arrows step a year) -> years (12 a page, arrows step 12).
// Picking a year shows its months, picking a month shows its days.
const YEARS_PER_PAGE = 12;
const pageStart = (year) => year - (((year % YEARS_PER_PAGE) + YEARS_PER_PAGE) % YEARS_PER_PAGE);
const pad2 = (n) => String(n).padStart(2, '0');
const monthOff = (y, m, min, max) =>
  !isoInRange(`${y}-${pad2(m + 1)}-${pad2(daysInMonth(y, m))}`, min, null) || !isoInRange(`${y}-${pad2(m + 1)}-01`, null, max);
const yearOff = (y, min, max) => !isoInRange(`${y}-12-31`, min, null) || !isoInRange(`${y}-01-01`, null, max);
function renderPicker(el) {
  const st = el._calState;
  const panel = el.querySelector('.calendar-picker');
  if (!st || !panel) return;
  const now = new Date();
  let html = '';
  if (el.dataset.view === 'months') {
    const y = st.pickYear;
    html = MONTHS.map((name, m) => {
      const current = y === st.year && m === st.month ? ' aria-current="true"' : '';
      const today = y === now.getFullYear() && m === now.getMonth() ? ' data-today' : '';
      const off = monthOff(y, m, st.minDate, st.maxDate) ? ' disabled' : '';
      return `<button type="button" class="calendar-pick" data-month="${m}" id="${el.dataset.calId}-m${m}"${current}${today}${off}>${esc(name.slice(0, 3))}</button>`;
    }).join('');
  } else {
    const start = st.pickPage;
    for (let y = start; y < start + YEARS_PER_PAGE; y++) {
      const current = y === st.year ? ' aria-current="true"' : '';
      const today = y === now.getFullYear() ? ' data-today' : '';
      const off = yearOff(y, st.minDate, st.maxDate) ? ' disabled' : '';
      html += `<button type="button" class="calendar-pick" data-year="${y}" id="${el.dataset.calId}-y${y}"${current}${today}${off}>${y}</button>`;
    }
  }
  dfDollar(panel).morph(html);
}
/** Heading text + nav labels for the current view (and the dropdown caption's values). */
function renderHeader(el) {
  const st = el._calState;
  if (!st) return;
  const view = el.dataset.view || 'days';
  const heading = el.querySelector('.calendar-heading');
  if (heading) {
    const text =
      view === 'months' ? String(st.pickYear)
      : view === 'years' ? `${st.pickPage} – ${st.pickPage + YEARS_PER_PAGE - 1}`
      : `${MONTHS[st.month]} ${st.year}`;
    dfDollar(heading).text(text);
    if (heading.tagName === 'BUTTON') {
      dfDollar(heading).attr('aria-label',
        view === 'months' ? `${text}, choose a year`
        : view === 'years' ? `Years ${text}, back to the days`
        : `${text}, choose a month and year`);
      dfDollar(heading).attr('aria-expanded', String(view !== 'days'));
    }
  }
  const labels = view === 'months' ? ['Previous year', 'Next year'] : view === 'years' ? ['Previous years', 'Next years'] : ['Previous month', 'Next month'];
  dfDollar(el).find('.calendar-nav[data-action="prev-month"]').attr('aria-label', labels[0]);
  dfDollar(el).find('.calendar-nav[data-action="next-month"]').attr('aria-label', labels[1]);
  // dropdown caption: reflect the view into the two selects
  const monthSel = el.querySelector('.calendar-select[data-part="month"]') as HTMLSelectElement | null;
  const yearSel = el.querySelector('.calendar-select[data-part="year"]') as HTMLSelectElement | null;
  if (yearSel) {
    if (!Array.from(yearSel.options).some((o) => Number(o.value) === st.year)) fillYears(el, yearSel);
    yearSel.value = String(st.year);
  }
  if (monthSel) {
    Array.from(monthSel.options).forEach((o, m) => { o.disabled = monthOff(st.year, m, st.minDate, st.maxDate); });
    monthSel.value = String(st.month);
  }
}
/** Year options for the dropdown caption: data-year-from/-to, else min/max, else 100 back / 10 ahead. */
function fillYears(el, select) {
  const st = el._calState;
  const now = new Date().getFullYear();
  const bound = (attr, date, fallback) => {
    const v = Number(el.dataset[attr]);
    if (Number.isInteger(v) && v > 0) return v;
    const fromDate = date ? Number(String(date).slice(0, 4)) : NaN;
    return Number.isInteger(fromDate) ? fromDate : fallback;
  };
  let from = bound('yearFrom', st.minDate, now - 100);
  let to = bound('yearTo', st.maxDate, now + 10);
  from = Math.min(from, st.year);
  to = Math.max(to, st.year);
  let html = '';
  for (let y = to; y >= from; y--) html += `<option value="${y}">${y}</option>`;
  dfDollar(select).html(html);
}
/** Move the view to (year, month): the calendar itself, or its range owner's first month. */
function jumpTo(el, year, month) {
  const st = el._calState;
  const owner = rangeOwnerOf(el);
  if (owner) {
    const rs = rangeState(owner);
    const first = monthAt(year, month, -calendarsOf(owner).indexOf(el));
    rs.year = first.year;
    rs.month = first.month;
    renderRange(owner);
    return;
  }
  st.year = year;
  st.month = month;
  st.selected = null;
  renderCalendar(el, year, month, null);
}
/** Switch view; focus follows into the new view (current month/year, else the first live button). */
function setView(el, view) {
  const st = el._calState;
  const grid = el.querySelector('.calendar-grid');
  const panel = el.querySelector('.calendar-picker');
  if (!st || !panel) return;
  if (view !== 'days' && (el.dataset.view || 'days') === 'days' && grid) {
    // hold the calendar's size while the grid is swapped for the picker
    dfDollar(panel).css('minHeight', `${grid.offsetHeight}px`).css('width', `${grid.offsetWidth}px`);
  }
  if (view === 'months' && st.pickYear == null) st.pickYear = st.year;
  if (view === 'years') st.pickPage = pageStart(st.pickYear ?? st.year);
  if (view === 'days') {
    delete el.dataset.view;
    st.pickYear = null;
  } else el.dataset.view = view;
  if (view !== 'days') renderPicker(el);
  renderHeader(el);
  if (view === 'days') {
    const pick =
      el.querySelector('.calendar-day[data-selected] button') ??
      el.querySelector('.calendar-day[data-today]:not([data-outside]) button') ??
      el.querySelector('.calendar-day:not([data-outside]):not([data-disabled]) button');
    (pick as HTMLElement | null)?.focus();
  } else {
    const pick = panel.querySelector('.calendar-pick[aria-current]:not([disabled])') ?? panel.querySelector('.calendar-pick:not([disabled])');
    (pick as HTMLElement | null)?.focus();
  }
  el.dispatchEvent(new CustomEvent('calendar:view', { bubbles: true, detail: { view, year: st.year, month: st.month } }));
}
const renderGrid = (year, month, selectedDay, calId, minDate?, maxDate?, range?, days?) => {
  // range marks: endpoints + the span between them; while only the start is
  // chosen, the hovered/focused day previews the span (data-range-preview).
  // aria-selected marks the committed range for assistive tech.
  const rangeAttrs = (iso: string) => {
    if (!range || !range.start) return '';
    const end = range.end ?? range.preview;
    let a = '';
    // data-range-span: the endpoint joins a band (start !== end) - the band
    // runs half into the endpoint cell
    const span = end && end !== range.start ? ' data-range-span' : '';
    if (iso === range.start) a += ' data-range-start aria-selected="true"' + span;
    if (range.end && iso === range.end && iso !== range.start) a += ' data-range-end aria-selected="true"' + span;
    else if (range.end && iso === range.end) a += ' data-range-end';
    else if (!range.end && end && iso === end && iso !== range.start) a += ' data-range-end data-range-preview' + span;
    if (end && iso > range.start && iso < end) a += range.end ? ' data-in-range aria-selected="true"' : ' data-in-range data-range-preview';
    return a;
  };
  const total = daysInMonth(year, month);
  const startDay = firstDayOfMonth(year, month);
  const prevTotal = daysInMonth(year, month - 1);
  let html = '<thead><tr>';
  for (let d = 0; d < 7; d++) {
    html += `<th class="calendar-day-label" scope="col">${DAYS[d]}</th>`;
  }
  html += '</tr></thead><tbody>';
  let dayNum = 1;
  let nextDayNum = 1;
  const rows = Math.ceil((startDay + total) / 7);
  for (let r = 0; r < rows; r++) {
    html += '<tr>';
    for (let c = 0; c < 7; c++) {
      const cellIndex = r * 7 + c;
      if (cellIndex < startDay) {
        const prevDay = prevTotal - startDay + cellIndex + 1;
        const iso = isoDate(new Date(year, month - 1, prevDay));
        const dd = dayData(days, iso);
        // outside days honor the range too: clicking one selects there, so an
        // out-of-range preview day must be disabled exactly like an in-month one
        const off = !isoInRange(iso, minDate, maxDate) || dd.blocked ? ' data-disabled' : '';
        html += `<td class="calendar-day" data-outside${off}${dd.attrs} id="${calId}-${iso}" data-cal-date="${iso}"><button tabindex="-1" data-day="${prevDay}" data-outside="prev"${dd.aria}>${prevDay}${dd.note}</button></td>`;
      } else if (dayNum > total) {
        const iso = isoDate(new Date(year, month + 1, nextDayNum));
        const dd = dayData(days, iso);
        const off = !isoInRange(iso, minDate, maxDate) || dd.blocked ? ' data-disabled' : '';
        html += `<td class="calendar-day" data-outside${off}${dd.attrs} id="${calId}-${iso}" data-cal-date="${iso}"><button tabindex="-1" data-day="${nextDayNum}" data-outside="next"${dd.aria}>${nextDayNum}${dd.note}</button></td>`;
        nextDayNum++;
      } else {
        let attrs = '';
        if (isToday(year, month, dayNum)) attrs += ' data-today';
        if (dayNum === selectedDay) attrs += ' data-selected aria-selected="true"';
        const iso = isoDate(new Date(year, month, dayNum));
        const dd = dayData(days, iso);
        // a blocked day (disabled in the day data) is unselectable like one outside min/max
        if (!isoInRange(iso, minDate, maxDate) || dd.blocked) attrs += ' data-disabled';
        attrs += rangeAttrs(iso) + dd.attrs;
        html += `<td class="calendar-day"${attrs} id="${calId}-${iso}" data-cal-date="${iso}"><button data-day="${dayNum}"${dd.aria}>${dayNum}${dd.note}</button></td>`;
        dayNum++;
      }
    }
    html += '</tr>';
  }
  html += '</tbody>';
  return html;
};
/**
 * Render the calendar's view: heading text + keyed morph of the grid.
 * Focus policy (plans §3): keyboard focus that was on a day cell is
 * restored after the morph (the node usually survives - morph moves it);
 * an activation started elsewhere (nav buttons) keeps focus there. The
 * grid mirrors the selection as data-selected-date (ISO) - a single
 * stable place to read it.
 */
const renderCalendar = (el, year, month, selectedDay) => {
  const grid = el.querySelector('.calendar-grid');
  if (!grid) return;
  const st = el._calState ?? {};
  // a view change (month/year) always lands in the days view
  if (el.dataset.view && (st.year !== year || st.month !== month)) delete el.dataset.view;
  if (st.year !== undefined) {
    st.year = year;
    st.month = month;
  }
  renderHeader(el);
  // the view + range mirror onto the ROOT as stable attributes - schema
  // observations (currentDate/minDate/maxDate) read them from one place, and
  // they survive grid morphs
  el.dataset.currentDate = selectedDay
    ? isoDate(new Date(year, month, selectedDay))
    : `${year}-${String(month + 1).padStart(2, '0')}-01`;
  if (st.minDate) el.dataset.minDate = st.minDate;
  else el.removeAttribute('data-min-date');
  if (st.maxDate) el.dataset.maxDate = st.maxDate;
  else el.removeAttribute('data-max-date');
  // capture focus BEFORE the morph: the focused button's day cell carries
  // the ISO key on the <td> (data-cal-date), so walk up to the cell
  const active = el.ownerDocument.activeElement;
  const focusKey =
    active && el.contains(active)
      ? active.closest('.calendar-day')?.getAttribute('data-cal-date')
      : null;
  const owner = rangeOwnerOf(el);
  const r = owner ? rangeState(owner) : null;
  const range = r
    ? { start: r.start, end: r.end, preview: !r.end && r.start && r.hover && r.hover >= r.start ? r.hover : null }
    : null;
  const days = daysOf(el);
  // cells grow a second line when any day carries a note
  el.toggleAttribute('data-notes', Object.values(days).some((d: any) => d && d.note != null && d.note !== ''));
  dfDollar(grid).morph(renderGrid(year, month, selectedDay, el.dataset.calId || '', st.minDate, st.maxDate, range, days));
  // refocus the cell's button (the td itself isn't focusable) - morph usually
  // kept it, but after a month change the old cell is gone; stay put then
  if (focusKey) grid.querySelector(`[data-cal-date="${focusKey}"] button`)?.focus();
  // selection mirrors onto the grid so it survives node reuse/replacement
  const selDate = el.querySelector('.calendar-day[data-selected]')?.getAttribute('data-cal-date');
  if (selDate) grid.setAttribute('data-selected-date', selDate);
  else grid.removeAttribute('data-selected-date');
  // the visible month changed: tell the page (load prices / holidays for it)
  const viewKey = `${year}-${month}`;
  if (el._viewKey !== viewKey) {
    el._viewKey = viewKey;
    el.dispatchEvent(new CustomEvent('calendar:view', { bubbles: true, detail: { view: 'days', year, month } }));
  }
};
function init() {
document.querySelectorAll('.calendar:not([data-init])').forEach((cal) => {
  cal.dataset.init = '';
  // stable id prefix for the grid's day-cell morph keys (§3) - the
  // generated fallback uses a dfsc- prefix so it can't collide with any
  // calendar's real #id
  cal.dataset.calId = cal.id || `dfsc-${++calSeq}`;
  const now = new Date();
    // state lives on the ELEMENT, not module scope (AGENTS.md "State API")
    const state = (cal._calState = {
      year: now.getFullYear(),
      month: now.getMonth(),
      selected: null,
      // selectable range authored as attributes (ISO substrings - the markup
      // may carry 'YYYY', 'YYYY-MM' or 'YYYY-MM-DD' bounds, compared as given)
      minDate: cal.dataset.minDate || null,
      maxDate: cal.dataset.maxDate || null,
    });
    // authored view (not just range): data-current-date = 'YYYY-MM' / 'YYYY-MM-DD'
    if (/^\d{4}(-\d{2}(-\d{2})?)?$/.test(cal.dataset.currentDate ?? '')) {
      const [y, m, d] = cal.dataset.currentDate!.split('-').map(Number);
      state.year = y;
      state.month = (m ?? now.getMonth() + 1) - 1;
      state.selected = d ?? null;
    }
    // bind-scope the api per instance: `$('#my-calendar').api.setState('default', { year: 2024, month: 0, day: 15 })`
    cal.api = {
      setState: (stateName, config) => calendarApi.setState(cal, stateName, config),
      getState: () => calendarApi.getState(cal),
      setDays: (days, options) => calendarApi.setDays(cal, days, options),
    };
    // -- caption: a heading button (month / year picker) or native dropdowns --
    const header = cal.querySelector('.calendar-header');
    let heading = cal.querySelector('.calendar-heading');
    if (cal.dataset.caption === 'dropdown' && header) {
      // two native selects between the arrows; the heading stays for
      // assistive tech only (the selects already show the month and year)
      if (heading) dfDollar(heading).attr('hidden', '');
      const caption = document.createElement('span');
      caption.className = 'calendar-caption';
      dfDollar(caption).html(
        `<select class="calendar-select" data-part="month" aria-label="Month">${MONTHS.map((n, m) => `<option value="${m}">${esc(n)}</option>`).join('')}</select>` +
        `<select class="calendar-select" data-part="year" aria-label="Year"></select>`,
      );
      if (heading) dfDollar(heading).after(caption);
      else dfDollar(header).append(caption);
      fillYears(cal, caption.querySelector('[data-part="year"]'));
      caption.addEventListener('change', (e) => {
        const sel = e.target as HTMLSelectElement;
        const m = Number((caption.querySelector('[data-part="month"]') as HTMLSelectElement).value);
        const y = Number((caption.querySelector('[data-part="year"]') as HTMLSelectElement).value);
        jumpTo(cal, y, m);
        sel.focus();
      });
    } else if (heading && heading.tagName !== 'BUTTON') {
      // the month + year are the obvious thing to click: make the heading a
      // real button (authored <button class="calendar-heading"> skips this)
      const button = document.createElement('button');
      button.type = 'button';
      button.className = heading.className;
      button.setAttribute('aria-live', heading.getAttribute('aria-live') || 'polite');
      dfDollar(heading).replaceWith(button);
      heading = button;
    }
    if (heading && heading.tagName === 'BUTTON') {
      dfDollar(heading).attr('aria-haspopup', 'grid');
      if (!cal.querySelector('.calendar-picker')) {
        const panel = document.createElement('div');
        panel.className = 'calendar-picker';
        panel.setAttribute('role', 'group');
        const grid = cal.querySelector('.calendar-grid');
        if (grid) dfDollar(grid).after(panel);
        else dfDollar(cal).append(panel);
      }
      heading.addEventListener('click', () => {
        const view = cal.dataset.view || 'days';
        setView(cal, view === 'days' ? 'months' : view === 'months' ? 'years' : 'days');
      });
    }
    const owner = rangeOwnerOf(cal);
    if (owner) {
      const r = rangeState(owner);
      const { year, month } = monthAt(r.year, r.month, calendarsOf(owner).indexOf(cal));
      state.year = year;
      state.month = month;
      state.selected = null;
      syncRange(owner); // mirrors the authored range into form fields too
      if (!owner._rangeWired) {
        owner._rangeWired = true;
        // the preview ends when the pointer leaves the whole picker
        owner.addEventListener('mouseleave', () => {
          const rs = rangeState(owner);
          if (!rs.hover) return;
          rs.hover = null;
          renderRange(owner);
        });
      }
    } else {
      renderCalendar(cal, state.year, state.month, state.selected);
    }
    /* Range preview: hovering (or focusing) a day after the start is chosen
       draws the would-be span across every calendar of the owner */
    const preview = (e) => {
      const o = rangeOwnerOf(cal);
      if (!o) return;
      const rs = rangeState(o);
      if (!rs.start || rs.end) return;
      const cell = e.target.closest?.('.calendar-day:not([data-outside]):not([data-disabled])');
      const iso = cell?.getAttribute('data-cal-date') ?? null;
      if (!iso || iso === rs.hover) return;
      rs.hover = iso;
      renderRange(o);
    };
    cal.addEventListener('mouseover', preview);
    cal.addEventListener('focusin', preview);
    /* Navigation */
    cal.addEventListener('click', (e) => {
      const nav = e.target.closest('.calendar-nav');
      // month / year picker: arrows page the picker, a pick moves the view
      const view = cal.dataset.view;
      if (view && nav) {
        const dir = nav.dataset.action === 'prev-month' ? -1 : 1;
        if (view === 'months') state.pickYear += dir;
        else state.pickPage += dir * YEARS_PER_PAGE;
        renderPicker(cal);
        renderHeader(cal);
        return;
      }
      const pick = e.target.closest('.calendar-pick');
      if (pick && !pick.disabled) {
        if (pick.dataset.year !== undefined) {
          state.pickYear = Number(pick.dataset.year);
          setView(cal, 'months');
        } else {
          jumpTo(cal, state.pickYear, Number(pick.dataset.month));
          setView(cal, 'days');
        }
        return;
      }
      const rangeOwner = rangeOwnerOf(cal);
      if (nav && rangeOwner) {
        // every calendar of the owner moves together (consecutive months)
        const rs = rangeState(rangeOwner);
        const { year, month } = monthAt(rs.year, rs.month, nav.dataset.action === 'prev-month' ? -1 : 1);
        rs.year = year;
        rs.month = month;
        renderRange(rangeOwner);
        return;
      }
      const rangeBtn = rangeOwner && e.target.closest('.calendar-day button');
      if (rangeBtn) {
        const cell = rangeBtn.closest('.calendar-day');
        // outside days are hidden in range mode; disabled days never select
        if (cell.hasAttribute('data-outside') || cell.hasAttribute('data-disabled')) return;
        const iso = cell.getAttribute('data-cal-date');
        const rs = rangeState(rangeOwner);
        // first pick (or a fresh start after a complete range) sets the start;
        // a day BEFORE the start restarts there - an end can never precede
        // its start; anything else closes the range
        if (!rs.start || rs.end || iso < rs.start) {
          rs.start = iso;
          rs.end = null;
        } else {
          rs.end = iso;
        }
        rs.hover = null;
        syncRange(rangeOwner);
        rangeOwner.dispatchEvent(new CustomEvent('calendar:range', {
          detail: {
            start: rs.start ? isoToDate(rs.start) : null,
            end: rs.end ? isoToDate(rs.end) : null,
            startIso: rs.start,
            endIso: rs.end,
          },
          bubbles: true,
        }));
        return;
      }
      if (nav) {
        const action = nav.dataset.action;
        if (action === 'prev-month') {
          state.month--;
          if (state.month < 0) { state.month = 11; state.year--; }
          state.selected = null;
        } else if (action === 'next-month') {
          state.month++;
          if (state.month > 11) { state.month = 0; state.year++; }
          state.selected = null;
        }
        renderCalendar(cal, state.year, state.month, state.selected);
        return;
      }
      /* Day selection */
      const dayBtn = e.target.closest('.calendar-day button');
      if (dayBtn && !dayBtn.closest('[data-disabled]')) {
        const day = parseInt(dayBtn.dataset.day, 10);
        const outside = dayBtn.dataset.outside;
        if (outside === 'prev') {
          state.month--;
          if (state.month < 0) { state.month = 11; state.year--; }
          state.selected = day;
        } else if (outside === 'next') {
          state.month++;
          if (state.month > 11) { state.month = 0; state.year++; }
          state.selected = day;
        } else {
          state.selected = day;
        }
        renderCalendar(cal, state.year, state.month, state.selected);
        /* Dispatch custom event */
        cal.dispatchEvent(new CustomEvent('calendar:select', {
          detail: { date: new Date(state.year, state.month, state.selected) },
          bubbles: true
        }));
      }
    });
    /* Keyboard navigation in grid */
    cal.addEventListener('keydown', (e) => {
      // month / year picker: Escape goes back to the days, arrows walk the 4-column grid
      if (cal.dataset.view) {
        if (e.key === 'Escape') {
          e.preventDefault();
          setView(cal, 'days');
          return;
        }
        const pickBtn = e.target.closest('.calendar-pick');
        const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 4, ArrowUp: -4 }[e.key];
        if (pickBtn && step) {
          e.preventDefault();
          const picks = Array.from(cal.querySelectorAll('.calendar-pick'));
          (picks[picks.indexOf(pickBtn) + step] as HTMLElement | undefined)?.focus();
        }
        return;
      }
      const dayBtn = e.target.closest('.calendar-day button');
      if (!dayBtn) return;
      const keyOwner = rangeOwnerOf(cal);
      const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 7, ArrowUp: -7 }[e.key];
      if (keyOwner && step) {
        // range pickers hide outside days and span several grids, so move by
        // DATE: the target day is found in whichever calendar shows it
        e.preventDefault();
        const from = isoToDate(dayBtn.closest('.calendar-day').getAttribute('data-cal-date'));
        from.setDate(from.getDate() + step);
        const target = keyOwner.querySelector(`.calendar-day:not([data-outside])[data-cal-date="${isoDate(from)}"] button`);
        target?.focus();
        return;
      }
      const allBtns = Array.from(cal.querySelectorAll('.calendar-day button'));
      const idx = allBtns.indexOf(dayBtn);
      let next = null;
      switch (e.key) {
        case 'ArrowRight':
          e.preventDefault();
          next = allBtns[idx + 1];
          break;
        case 'ArrowLeft':
          e.preventDefault();
          next = allBtns[idx - 1];
          break;
        case 'ArrowDown':
          e.preventDefault();
          next = allBtns[idx + 7];
          break;
        case 'ArrowUp':
          e.preventDefault();
          next = allBtns[idx - 7];
          break;
      }
      if (next) next.focus();
    });
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub