CalendarATM
A month-view calendar for date selection. Features navigation, today highlighting, and day selection. Built with vanilla JS rendering into a <table> grid.
On this page (10)
§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, andgetState().configreports 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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
currentDate | string | — | "" | 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. |
minDate | string | — | "" | Lower bound of the selectable range (YYYY/YYYY-MM/YYYY-MM-DD, "" clears); days outside it render data-disabled. Mirrored as data-min-date. |
maxDate | string | — | "" | 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 mathconst 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');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