Calendar
A calendar for selecting a single date, several dates, or a range of dates.
<twig:Calendar
mode="single"
today="2026-03-15"
selected="2026-03-15"
captionLayout="dropdown"
class="mx-auto rounded-lg border"
/>
Installation
Available since UX Toolkit 3.5.
php bin/console ux:install calendar --kit shadcn
Install the following Composer dependencies:
composer require twig/extra-bundle twig/html-extra:^3.24.0 twig/intl-extra symfony/ux-icons symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0
Copy the following file(s) into your app:
import { Controller } from '@hotwired/stimulus';
const DAY = 86400000;
/**
* Month navigation and date selection for the `Calendar` component.
*
* The grid is rendered server-side as a fixed six-by-seven table whose every day state is carried
* by a `data-*` attribute, so navigating never rewrites markup nor class names: it walks the
* existing cells and flips their attributes, leaving the Twig template the only source of classes.
*
* Dates are `Y-m-d` strings, converted to UTC timestamps only for arithmetic, so a daylight saving
* transition can never shift a day by one.
*
* @value mode The selection mode, one of `single`, `multiple` or `range`.
* @value locale The locale used to format the caption and day labels, falling back to the document language.
* @value name The name of the hidden inputs mirroring the selection.
* @value month The first displayed month, as a `Y-m-d` string.
* @value selected The selected dates, as `Y-m-d` strings.
* @value disabled The dates that cannot be selected, as `Y-m-d` strings.
* @value modifiers Extra flags keyed by name, each holding a list of `Y-m-d` strings.
* @value today The date considered as today, as a `Y-m-d` string.
* @value minDate The earliest selectable date, as a `Y-m-d` string.
* @value maxDate The latest selectable date, as a `Y-m-d` string.
* @value startMonth The earliest month reachable through navigation, as a `Y-m-d` string.
* @value endMonth The latest month reachable through navigation, as a `Y-m-d` string.
* @value weekStartsOn The first day of the week, from `0` for Sunday to `6` for Saturday.
* @value numberOfMonths The number of months displayed side by side.
* @value showOutsideDays Whether the days of the surrounding months are displayed.
* @value showWeekNumber Whether the leading week number column is displayed.
* @value fixedWeeks Whether every month always displays six weeks.
* @target month A displayed month, holding its caption and its grid.
* @target previous The button moving the calendar to the previous month.
* @target next The button moving the calendar to the next month.
* @target input A hidden input mirroring the selection.
* @action previousMonth Moves the calendar one month backwards.
* @action nextMonth Moves the calendar one month forwards.
* @action goToMonth Moves the calendar to the month and year picked in the dropdowns.
* @action selectDate Selects the date carried by the `date` param, or by the clicked day.
* @action handleKeydown Moves the focus between days with the arrow, home, end and page keys.
* @action trackFocus Reflects the focused day on its cell.
* @action previewRange Previews the range being drawn up to the hovered day.
* @action clearPreview Drops the range preview.
*/
export default class extends Controller {
static targets = ['month', 'previous', 'next', 'input'];
static values = {
mode: { type: String, default: 'single' },
locale: String,
name: String,
month: String,
selected: Array,
disabled: Array,
modifiers: Object,
today: String,
minDate: String,
maxDate: String,
startMonth: String,
endMonth: String,
weekStartsOn: Number,
numberOfMonths: { type: Number, default: 1 },
showOutsideDays: { type: Boolean, default: true },
showWeekNumber: Boolean,
fixedWeeks: Boolean,
};
#connected = false;
#focusDate = null;
#preview = null;
#rendered = null;
#formatters = {};
connect() {
this.#focusDate =
this.element.querySelector('[data-slot="calendar-day"] button[tabindex="0"]')?.dataset.day ??
this.selectedValue[0] ??
this.monthValue;
this.#connected = true;
}
previousMonth() {
if (!this.#canGoPrevious) {
return;
}
this.monthValue = this.#addMonths(this.monthValue, -1);
}
nextMonth() {
if (!this.#canGoNext) {
return;
}
this.monthValue = this.#addMonths(this.monthValue, 1);
}
goToMonth(event) {
const monthElement = event.target.closest('[data-slot="calendar-month"]');
const selects = monthElement?.querySelectorAll('select');
if (2 !== selects?.length) {
return;
}
const month = String(selects[0].value).padStart(2, '0');
this.monthValue = this.#addMonths(`${selects[1].value}-${month}-01`, -(event.params.index ?? 0));
}
selectDate(event) {
const date = event.params?.date ?? event.currentTarget.dataset.day;
if (!date || this.#isDisabled(date)) {
return;
}
this.#preview = null;
this.#focusDate = date;
if (!this.#isDisplayed(date)) {
this.monthValue = `${date.slice(0, 7)}-01`;
}
this.selectedValue = this.#nextSelection(date);
}
handleKeydown(event) {
const button = event.target.closest('[data-slot="calendar-day"] button');
if (!button) {
return;
}
const current = button.dataset.day;
const weekday = (new Date(this.#parse(current)).getUTCDay() - this.weekStartsOnValue + 7) % 7;
const rtl = 'rtl' === getComputedStyle(this.element).direction;
let target;
switch (event.key) {
case 'ArrowLeft':
target = this.#shift(current, rtl ? 1 : -1);
break;
case 'ArrowRight':
target = this.#shift(current, rtl ? -1 : 1);
break;
case 'ArrowUp':
target = this.#shift(current, -7);
break;
case 'ArrowDown':
target = this.#shift(current, 7);
break;
case 'Home':
target = this.#shift(current, -weekday);
break;
case 'End':
target = this.#shift(current, 6 - weekday);
break;
case 'PageUp':
target = this.#clampToMonth(current, -1);
break;
case 'PageDown':
target = this.#clampToMonth(current, 1);
break;
default:
return;
}
event.preventDefault();
this.#focusDate = target;
// Stimulus reports a value change through a mutation observer, so it lands a microtask
// too late for the focus below: render right away and let that later call deduplicate.
if (!this.#isDisplayed(target)) {
this.monthValue = `${target.slice(0, 7)}-01`;
}
this.#render();
this.#dayButton(target)?.focus();
}
trackFocus(event) {
const focused = 'focusin' === event.type ? event.target.closest('[data-slot="calendar-day"]') : null;
for (const cell of this.#cells()) {
cell.dataset.focused = String(cell === focused);
}
if (focused) {
this.#focusDate = focused.dataset.day;
this.#render();
}
}
previewRange(event) {
if ('range' !== this.modeValue || 1 !== this.selectedValue.length) {
return;
}
const date = event.target.closest('[data-slot="calendar-day"]')?.dataset.day;
if (!date || date === this.#preview || this.#isDisabled(date)) {
return;
}
this.#preview = date;
this.#render();
}
clearPreview() {
if (null === this.#preview) {
return;
}
this.#preview = null;
this.#render();
}
monthValueChanged() {
if (!this.#connected) {
return;
}
this.#render();
this.dispatch('month-change', { detail: { month: this.monthValue } });
}
selectedValueChanged() {
if (!this.#connected) {
return;
}
this.#render();
this.#syncInputs();
this.dispatch('select', { detail: { selected: this.selectedValue, mode: this.modeValue } });
}
get #canGoPrevious() {
return '' === this.startMonthValue || this.monthValue > this.startMonthValue;
}
get #canGoNext() {
const last = this.#addMonths(this.monthValue, this.numberOfMonthsValue - 1);
return '' === this.endMonthValue || last < this.endMonthValue;
}
get #range() {
if ('range' !== this.modeValue) {
return [null, null];
}
const [from, to] = this.selectedValue;
if (from && !to && this.#preview && this.#preview >= from) {
return [from, this.#preview];
}
return [from ?? null, to ?? null];
}
#render() {
const signature = [
this.monthValue,
this.selectedValue.join(','),
this.#preview ?? '',
this.#focusDate ?? '',
].join('|');
if (signature === this.#rendered) {
return;
}
this.#rendered = signature;
this.monthTargets.forEach((monthElement, index) => this.#renderMonth(monthElement, index));
for (const button of this.previousTargets) {
button.setAttribute('aria-disabled', String(!this.#canGoPrevious));
}
for (const button of this.nextTargets) {
button.setAttribute('aria-disabled', String(!this.#canGoNext));
}
}
#renderMonth(monthElement, index) {
const monthStart = this.#addMonths(this.monthValue, index);
const prefix = monthStart.slice(0, 7);
const caption = this.#format('caption', this.#parse(monthStart));
monthElement.dataset.month = monthStart;
monthElement.querySelector('[data-slot="calendar-grid"]')?.setAttribute('aria-label', caption);
const selects = monthElement.querySelectorAll('select');
const labels = monthElement.querySelectorAll('[data-slot="calendar-caption-label"]');
if (2 === selects.length) {
selects[0].value = String(Number(prefix.slice(5)));
selects[1].value = prefix.slice(0, 4);
labels[0].firstChild.textContent = this.#format('monthShort', this.#parse(monthStart));
labels[1].firstChild.textContent = prefix.slice(0, 4);
} else if (labels[0]) {
labels[0].textContent = caption;
}
const lead = (new Date(this.#parse(monthStart)).getUTCDay() - this.weekStartsOnValue + 7) % 7;
const gridStart = this.#parse(monthStart) - lead * DAY;
const [from, to] = this.#range;
const modifiers = Object.entries(this.modifiersValue);
this.#cells(monthElement).forEach((cell, offset) => {
const timestamp = gridStart + offset * DAY;
const date = this.#toIso(timestamp);
const outside = !date.startsWith(prefix);
const rangeStart = null !== from && date === from;
const rangeEnd = null !== to && date === to;
const rangeMiddle = null !== from && null !== to && date > from && date < to;
const selected =
'range' === this.modeValue ? rangeStart || rangeEnd || rangeMiddle : this.selectedValue.includes(date);
const disabled = this.#isDisabled(date);
cell.dataset.day = date;
cell.dataset.outside = String(outside);
cell.dataset.today = String(date === this.todayValue);
cell.dataset.selected = String(selected);
cell.dataset.disabled = String(disabled);
cell.dataset.hidden = String(outside && !this.showOutsideDaysValue);
cell.dataset.rangeStart = String(rangeStart);
cell.dataset.rangeMiddle = String(rangeMiddle);
cell.dataset.rangeEnd = String(rangeEnd);
cell.setAttribute('aria-selected', String(selected));
for (const [name, dates] of modifiers) {
cell.setAttribute(`data-${name}`, String(dates.includes(date)));
}
const button = cell.querySelector('button');
button.textContent = this.#format('day', timestamp);
button.dataset.day = date;
button.dataset.calendarDateParam = date;
button.dataset.selectedSingle = String('range' !== this.modeValue && selected);
button.dataset.rangeStart = String(rangeStart);
button.dataset.rangeMiddle = String(rangeMiddle);
button.dataset.rangeEnd = String(rangeEnd);
button.setAttribute('aria-label', this.#format('full', timestamp));
button.disabled = disabled;
button.tabIndex = date === this.#focusDate ? 0 : -1;
});
monthElement.querySelectorAll('[data-slot="calendar-week"]').forEach((row, week) => {
const start = this.#toIso(gridStart + week * 7 * DAY);
const end = this.#toIso(gridStart + (week * 7 + 6) * DAY);
row.dataset.hidden = String(!this.fixedWeeksValue && !start.startsWith(prefix) && !end.startsWith(prefix));
});
monthElement.querySelectorAll('[data-slot="calendar-week-number"]').forEach((cell, week) => {
const monday = gridStart + (week * 7 + ((1 - this.weekStartsOnValue + 7) % 7)) * DAY;
cell.firstElementChild.textContent = String(this.#isoWeek(monday)).padStart(2, '0');
});
}
#nextSelection(date) {
if ('multiple' === this.modeValue) {
return this.selectedValue.includes(date)
? this.selectedValue.filter((selected) => selected !== date)
: [...this.selectedValue, date].sort();
}
if ('range' === this.modeValue) {
const [from, to] = this.selectedValue;
return !from || to || date < from ? [date] : [from, date];
}
return this.selectedValue.includes(date) ? [] : [date];
}
#syncInputs() {
if ('' === this.nameValue) {
return;
}
if ('range' === this.modeValue) {
const [from, to] = this.selectedValue;
for (const input of this.inputTargets) {
input.value = ('from' === input.dataset.role ? from : to) ?? '';
}
return;
}
if ('multiple' !== this.modeValue) {
if (this.hasInputTarget) {
this.inputTarget.value = this.selectedValue[0] ?? '';
}
return;
}
for (const input of this.inputTargets) {
input.remove();
}
for (const date of this.selectedValue) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = `${this.nameValue}[]`;
input.value = date;
input.dataset.calendarTarget = 'input';
this.element.append(input);
}
}
#isDisabled(date) {
return (
this.disabledValue.includes(date) ||
('' !== this.minDateValue && date < this.minDateValue) ||
('' !== this.maxDateValue && date > this.maxDateValue)
);
}
#isDisplayed(date) {
const month = `${date.slice(0, 7)}-01`;
return month >= this.monthValue && month <= this.#addMonths(this.monthValue, this.numberOfMonthsValue - 1);
}
#cells(root = this.element) {
return Array.from(root.querySelectorAll('[data-slot="calendar-day"]'));
}
#dayButton(date) {
return this.element.querySelector(`[data-slot="calendar-day"][data-day="${date}"] button`);
}
#shift(date, days) {
return this.#toIso(this.#parse(date) + days * DAY);
}
/** Moves by whole months, keeping the day of the month when the target month is shorter. */
#clampToMonth(date, months) {
const target = this.#addMonths(`${date.slice(0, 7)}-01`, months);
const lastDay = new Date(this.#parse(this.#addMonths(target, 1)) - DAY).getUTCDate();
return `${target.slice(0, 8)}${String(Math.min(Number(date.slice(8)), lastDay)).padStart(2, '0')}`;
}
#addMonths(month, count) {
const total = Number(month.slice(0, 4)) * 12 + Number(month.slice(5, 7)) - 1 + count;
return `${String(Math.floor(total / 12)).padStart(4, '0')}-${String((total % 12) + 1).padStart(2, '0')}-01`;
}
#parse(date) {
return Date.UTC(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)));
}
#toIso(timestamp) {
return new Date(timestamp).toISOString().slice(0, 10);
}
#isoWeek(timestamp) {
const date = new Date(timestamp);
date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7) + 3);
const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4));
firstThursday.setUTCDate(firstThursday.getUTCDate() - ((firstThursday.getUTCDay() + 6) % 7) + 3);
return 1 + Math.round((date.getTime() - firstThursday.getTime()) / (7 * DAY));
}
#format(style, timestamp) {
if (!this.#formatters[style]) {
const options = {
caption: { month: 'long', year: 'numeric' },
monthShort: { month: 'short' },
day: { day: 'numeric' },
full: { dateStyle: 'full' },
}[style];
this.#formatters[style] = new Intl.DateTimeFormat(
this.localeValue || document.documentElement.lang || undefined,
{ ...options, timeZone: 'UTC' }
);
}
return this.#formatters[style].format(timestamp);
}
}
import { Controller } from '@hotwired/stimulus';
/**
* @target output The form control reflecting the calendar selection.
* @action update Writes the calendar selection into the output target.
*/
export default class extends Controller {
static targets = ['output'];
update(event) {
this.outputTarget.value = event.detail.selected.join(', ');
}
}
{%- props
## 'single'|'multiple'|'range' The selection mode.
mode = 'single',
## string|null The name of the hidden inputs holding the selection, so the calendar can be submitted with a form.
name = null,
## string|array<string>|null The initially selected dates, as `Y-m-d` strings. In `range` mode, the first entry is the start and the second one the end.
selected = null,
## string|null The month to display, as a `Y-m-d` string.
month = null,
## int The number of months displayed side by side.
numberOfMonths = 1,
## 'label'|'dropdown' Whether the caption shows a plain label or month and year dropdowns.
captionLayout = 'label',
## boolean Whether the days of the surrounding months are displayed.
showOutsideDays = true,
## boolean Whether a leading column shows the ISO week number.
showWeekNumber = false,
## boolean Whether every month always displays six weeks.
fixedWeeks = false,
## int The first day of the week, from `0` for Sunday to `6` for Saturday.
weekStartsOn = 0,
## string|null The locale used to format the month, weekday and day labels, falling back to the current request locale.
locale = null,
## string|null The date considered as today, as a `Y-m-d` string.
today = null,
## array<string> The dates that cannot be selected, as `Y-m-d` strings.
disabled = [],
## string|null The earliest selectable date, as a `Y-m-d` string.
minDate = null,
## string|null The latest selectable date, as a `Y-m-d` string.
maxDate = null,
## string|null The earliest month reachable through navigation, as a `Y-m-d` string.
startMonth = null,
## string|null The latest month reachable through navigation, as a `Y-m-d` string.
endMonth = null,
## array<string,array<string>> Extra flags keyed by name, each holding a list of `Y-m-d` strings, rendered as `data-<name>` on the matching days.
modifiers = {},
## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The style variant of the navigation buttons.
buttonVariant = 'ghost',
## string The accessible name of the calendar.
label = 'Calendar'
-%}
{%- set _locale = locale ?? app.request.locale|default(null) -%}
{%- set _today = (today ?? 'today')|date('Y-m-d') -%}
{%- set _selected = null == selected ? [] : (selected is iterable ? selected|map(date => date|date('Y-m-d')) : [selected|date('Y-m-d')]) -%}
{%- set _rangeStart = 'range' == mode and _selected|length > 0 ? _selected[0] : null -%}
{%- set _rangeEnd = 'range' == mode and _selected|length > 1 ? _selected[1] : null -%}
{%- set _month = (month ?? (_selected is empty ? _today : _selected|first))|date('Y-m-01') -%}
{%- set _lastMonth = _month|date_modify('+' ~ (numberOfMonths - 1) ~ ' months')|date('Y-m-01') -%}
{%- set _startMonth = (startMonth ?? _today|date_modify('-100 years'))|date('Y-m-01') -%}
{%- set _endMonth = (endMonth ?? _today|date_modify('+10 years'))|date('Y-m-01') -%}
{%- set _disabled = disabled|map(date => date|date('Y-m-d')) -%}
{%- set _minDate = minDate ? minDate|date('Y-m-d') : null -%}
{%- set _maxDate = maxDate ? maxDate|date('Y-m-d') : null -%}
{%- set _modifiers = {} -%}
{%- for _name, _dates in modifiers -%}
{%- set _modifiers = _modifiers|merge({(_name): _dates|map(date => date|date('Y-m-d'))}) -%}
{%- endfor -%}
{%- set _focusDate = _selected is not empty ? _selected|first : (_today >= _month and _today <= _lastMonth|date_modify('last day of this month')|date('Y-m-d') ? _today : _month) -%}
{%- set _dayClass = html_classes(
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)',
{
'[&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)': showWeekNumber,
'[&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)': not showWeekNumber,
},
'data-[today=true]:rounded-(--cell-radius) data-[today=true]:bg-muted data-[today=true]:text-foreground data-[today=true]:data-[selected=true]:rounded-none',
'data-[outside=true]:text-muted-foreground data-[outside=true]:data-[selected=true]:text-muted-foreground',
'data-[disabled=true]:text-muted-foreground data-[disabled=true]:opacity-50 data-[hidden=true]:invisible',
'data-[range-start=true]:isolate data-[range-start=true]:z-0 data-[range-start=true]:rounded-s-(--cell-radius) data-[range-start=true]:bg-muted data-[range-start=true]:after:absolute data-[range-start=true]:after:inset-y-0 data-[range-start=true]:after:end-0 data-[range-start=true]:after:w-4 data-[range-start=true]:after:bg-muted',
'data-[range-middle=true]:rounded-none',
'data-[range-end=true]:isolate data-[range-end=true]:z-0 data-[range-end=true]:rounded-e-(--cell-radius) data-[range-end=true]:bg-muted data-[range-end=true]:after:absolute data-[range-end=true]:after:inset-y-0 data-[range-end=true]:after:start-0 data-[range-end=true]:after:w-4 data-[range-end=true]:after:bg-muted',
) -%}
{%- set _dayButtonClass = html_classes(
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal',
'group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50',
'data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground',
'data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground',
'data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground',
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground',
'dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
) -%}
{# A known Sunday, so the weekday headers can be derived from `weekStartsOn` alone. #}
{%- set _weekdayReference = '2024-01-07' -%}
<div
role="group"
aria-label="{{ label }}"
data-slot="calendar"
data-mode="{{ mode }}"
data-calendar-mode-value="{{ mode }}"
data-calendar-locale-value="{{ _locale }}"
data-calendar-name-value="{{ name }}"
data-calendar-month-value="{{ _month }}"
data-calendar-selected-value="{{ _selected|json_encode }}"
data-calendar-disabled-value="{{ _disabled|json_encode }}"
data-calendar-modifiers-value="{{ _modifiers is empty ? '{}' : _modifiers|json_encode }}"
data-calendar-today-value="{{ _today }}"
data-calendar-min-date-value="{{ _minDate }}"
data-calendar-max-date-value="{{ _maxDate }}"
data-calendar-start-month-value="{{ _startMonth }}"
data-calendar-end-month-value="{{ _endMonth }}"
data-calendar-week-starts-on-value="{{ weekStartsOn }}"
data-calendar-number-of-months-value="{{ numberOfMonths }}"
data-calendar-show-outside-days-value="{{ showOutsideDays ? 'true' : 'false' }}"
data-calendar-show-week-number-value="{{ showWeekNumber ? 'true' : 'false' }}"
data-calendar-fixed-weeks-value="{{ fixedWeeks ? 'true' : 'false' }}"
{{ attributes.defaults({
class: 'group/calendar w-fit bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent'|tailwind_classes,
'data-controller': 'calendar',
'data-action': [
'keydown->calendar#handleKeydown',
'focusin->calendar#trackFocus',
'focusout->calendar#trackFocus',
'pointerover->calendar#previewRange',
'pointerleave->calendar#clearPreview',
]|join(' ')|html_attr_type('sst'),
}) }}
>
<div data-slot="calendar-months" class="relative flex flex-col gap-4 md:flex-row">
<nav data-slot="calendar-nav" class="absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1">
<twig:Button
variant="{{ buttonVariant }}"
data-calendar-target="previous"
data-action="click->calendar#previousMonth"
aria-disabled="{{ _month > _startMonth ? 'false' : 'true' }}"
class="size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<twig:ux:icon name="lucide:chevron-left" class="size-4 rtl:rotate-180" />
<span class="sr-only">Previous month</span>
</twig:Button>
<twig:Button
variant="{{ buttonVariant }}"
data-calendar-target="next"
data-action="click->calendar#nextMonth"
aria-disabled="{{ _lastMonth < _endMonth ? 'false' : 'true' }}"
class="size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<twig:ux:icon name="lucide:chevron-right" class="size-4 rtl:rotate-180" />
<span class="sr-only">Next month</span>
</twig:Button>
</nav>
{%- for monthOffset in 0..(numberOfMonths - 1) -%}
{%- set _current = _month|date_modify('+' ~ monthOffset ~ ' months')|date('Y-m-01') -%}
{%- set _lead = (_current|date('w') - weekStartsOn + 7) % 7 -%}
{%- set _gridStart = _current|date_modify('-' ~ _lead ~ ' days')|date('Y-m-d') -%}
<div data-slot="calendar-month" data-calendar-target="month" data-month="{{ _current }}" class="flex w-full flex-col gap-4">
<div data-slot="calendar-month-caption" class="flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)">
{%- if 'dropdown' == captionLayout -%}
<div data-slot="calendar-dropdowns" class="flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium">
<span data-slot="calendar-dropdown-root" class="relative rounded-(--cell-radius) border border-input has-focus:border-ring has-focus:ring-3 has-focus:ring-ring/50">
<select
aria-label="Month"
data-action="change->calendar#goToMonth"
data-calendar-index-param="{{ monthOffset }}"
class="absolute inset-0 bg-popover opacity-0"
>
{%- for monthNumber in 1..12 -%}
<option value="{{ monthNumber }}"{% if monthNumber == _current|date('n') %} selected{% endif %}>{{ ('2024-' ~ '%02d'|format(monthNumber) ~ '-01')|format_date(pattern: 'LLL', locale: _locale) }}</option>
{%- endfor -%}
</select>
<span data-slot="calendar-caption-label" class="flex h-6 items-center gap-1 rounded-(--cell-radius) ps-1.5 pe-1 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground">
{{- _current|format_date(pattern: 'LLL', locale: _locale) -}}
<twig:ux:icon name="lucide:chevron-down" />
</span>
</span>
<span data-slot="calendar-dropdown-root" class="relative rounded-(--cell-radius) border border-input has-focus:border-ring has-focus:ring-3 has-focus:ring-ring/50">
<select
aria-label="Year"
data-action="change->calendar#goToMonth"
data-calendar-index-param="{{ monthOffset }}"
class="absolute inset-0 bg-popover opacity-0"
>
{%- for year in _startMonth|date('Y')..(_endMonth|date('Y')) -%}
<option value="{{ year }}"{% if year == _current|date('Y') %} selected{% endif %}>{{ year }}</option>
{%- endfor -%}
</select>
<span data-slot="calendar-caption-label" class="flex h-6 items-center gap-1 rounded-(--cell-radius) ps-1.5 pe-1 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground">
{{- _current|format_date(pattern: 'y', locale: _locale) -}}
<twig:ux:icon name="lucide:chevron-down" />
</span>
</span>
</div>
{%- else -%}
<span data-slot="calendar-caption-label" class="text-sm font-medium select-none">{{ _current|format_date(pattern: 'LLLL y', locale: _locale) }}</span>
{%- endif -%}
</div>
<table role="grid" aria-label="{{ _current|format_date(pattern: 'LLLL y', locale: _locale) }}" data-slot="calendar-grid" class="w-full border-collapse">
<thead>
<tr class="flex">
{%- if showWeekNumber -%}
<th scope="col" data-slot="calendar-week-number-header" class="w-(--cell-size) select-none"><span class="sr-only">Week</span></th>
{%- endif -%}
{%- for weekdayOffset in 0..6 -%}
{%- set _weekday = _weekdayReference|date_modify('+' ~ ((weekStartsOn + weekdayOffset) % 7) ~ ' days')|date('Y-m-d') -%}
<th scope="col" aria-label="{{ _weekday|format_date(pattern: 'EEEE', locale: _locale) }}" data-slot="calendar-weekday" class="flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none">
{{- _weekday|format_date(pattern: 'EEEEEE', locale: _locale) -}}
</th>
{%- endfor -%}
</tr>
</thead>
<tbody>
{%- for week in 0..5 -%}
{%- set _weekStart = _gridStart|date_modify('+' ~ (week * 7) ~ ' days')|date('Y-m-d') -%}
{%- set _weekEnd = _weekStart|date_modify('+6 days')|date('Y-m-d') -%}
{%- set _weekOutside = _weekStart|date('Y-m') != _current|date('Y-m') and _weekEnd|date('Y-m') != _current|date('Y-m') -%}
<tr data-slot="calendar-week" data-hidden="{{ not fixedWeeks and _weekOutside ? 'true' : 'false' }}" class="mt-2 flex w-full data-[hidden=true]:hidden">
{%- if showWeekNumber -%}
<td data-slot="calendar-week-number" class="text-[0.8rem] text-muted-foreground select-none">
<div class="flex size-(--cell-size) items-center justify-center text-center">{{ _weekStart|date_modify('+' ~ ((1 - weekStartsOn + 7) % 7) ~ ' days')|date('W') }}</div>
</td>
{%- endif -%}
{%- for dayOffset in 0..6 -%}
{%- set _date = _weekStart|date_modify('+' ~ dayOffset ~ ' days')|date('Y-m-d') -%}
{%- set _outside = _date|date('Y-m') != _current|date('Y-m') -%}
{%- set _isDisabled = _date in _disabled or (_minDate and _date < _minDate) or (_maxDate and _date > _maxDate) -%}
{%- set _isRangeStart = _rangeStart and _date == _rangeStart -%}
{%- set _isRangeEnd = _rangeEnd and _date == _rangeEnd -%}
{%- set _isRangeMiddle = _rangeStart and _rangeEnd and _date > _rangeStart and _date < _rangeEnd -%}
{%- set _isSelected = 'range' == mode ? (_isRangeStart or _isRangeEnd or _isRangeMiddle) : _date in _selected -%}
<td
data-slot="calendar-day"
data-day="{{ _date }}"
data-outside="{{ _outside ? 'true' : 'false' }}"
data-today="{{ _date == _today ? 'true' : 'false' }}"
data-selected="{{ _isSelected ? 'true' : 'false' }}"
data-disabled="{{ _isDisabled ? 'true' : 'false' }}"
data-focused="false"
data-hidden="{{ _outside and not showOutsideDays ? 'true' : 'false' }}"
data-range-start="{{ _isRangeStart ? 'true' : 'false' }}"
data-range-middle="{{ _isRangeMiddle ? 'true' : 'false' }}"
data-range-end="{{ _isRangeEnd ? 'true' : 'false' }}"
aria-selected="{{ _isSelected ? 'true' : 'false' }}"
{%- for _name, _dates in _modifiers %} data-{{ _name }}="{{ _date in _dates ? 'true' : 'false' }}"{% endfor %}
class="{{ _dayClass }}"
>
<twig:Button
variant="ghost"
size="icon"
data-action="click->calendar#selectDate"
data-calendar-date-param="{{ _date }}"
data-day="{{ _date }}"
data-selected-single="{{ 'range' != mode and _isSelected ? 'true' : 'false' }}"
data-range-start="{{ _isRangeStart ? 'true' : 'false' }}"
data-range-middle="{{ _isRangeMiddle ? 'true' : 'false' }}"
data-range-end="{{ _isRangeEnd ? 'true' : 'false' }}"
aria-label="{{ _date|format_date(dateFormat: 'full', locale: _locale) }}"
tabindex="{{ _date == _focusDate ? '0' : '-1' }}"
class="{{ _dayButtonClass }}"
:disabled="_isDisabled"
>
{{- _date|format_date(pattern: 'd', locale: _locale) -}}
</twig:Button>
</td>
{%- endfor -%}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{%- endfor -%}
</div>
{%- if name -%}
{%- if 'range' == mode -%}
<input type="hidden" name="{{ name }}[from]" value="{{ _rangeStart }}" data-calendar-target="input" data-role="from" />
<input type="hidden" name="{{ name }}[to]" value="{{ _rangeEnd }}" data-calendar-target="input" data-role="to" />
{%- elseif 'multiple' == mode -%}
{%- for _date in _selected -%}
<input type="hidden" name="{{ name }}[]" value="{{ _date }}" data-calendar-target="input" />
{%- endfor -%}
{%- else -%}
<input type="hidden" name="{{ name }}" value="{{ _selected|first }}" data-calendar-target="input" />
{%- endif -%}
{%- endif -%}
{##- Optional content rendered below the month grids, such as a footer. -#}
{%- block content %}{% endblock -%}
</div>
{%- props
## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
variant = 'default',
## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
size = 'default',
## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
as = 'button'
-%}
{%- set style = html_cva(
base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
icon: 'size-8',
'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
) -%}
<{{ as }}
data-slot="button"
data-size="{{ size }}"
data-variant="{{ variant }}"
{{ attributes.defaults({
class: style.apply({variant: variant, size: size})|tailwind_classes,
type: as == 'button' ? 'button' : false,
}) }}
>
{##- The button label and/or icon. -#}
{%- block content %}{% endblock -%}
</{{ as }}>
Usage
<twig:Calendar
mode="single | multiple | range"
name="date"
selected="2026-03-15"
month="2026-03-01"
captionLayout="label | dropdown"
/>
Dates are exchanged as Y-m-d strings. When name is set, the selection is mirrored into hidden inputs (name, name[] in multiple mode, name[from] and name[to] in range mode) so the calendar can be submitted with a form. Selecting a date also dispatches a calendar:select event, and navigating to another month dispatches a calendar:month-change event; both bubble, so an ancestor element can listen for them. This is the way to react to the selection without reading the hidden inputs.
Examples
Basic
A calendar with no initial selection. Use class="rounded-lg border" to frame it.
<twig:Calendar
mode="single"
today="2026-03-15"
month="2026-03-01"
class="mx-auto rounded-lg border"
/>
Range Calendar
Use mode="range" to select a period. The first entry of selected is the start of the range, the second one its end.
<twig:Card class="mx-auto w-fit p-0">
<twig:Card:Content class="p-0">
<twig:Calendar
mode="range"
today="2026-03-15"
month="2026-01-01"
:selected="['2026-01-12', '2026-02-11']"
numberOfMonths="2"
minDate="1900-01-01"
maxDate="2026-03-15"
/>
</twig:Card:Content>
</twig:Card>
Multiple
Use mode="multiple" to select any number of individual dates.
<twig:Card class="mx-auto w-fit p-0">
<twig:Card:Content class="p-0">
<twig:Calendar
mode="multiple"
today="2026-03-15"
month="2026-03-01"
:selected="['2026-03-04', '2026-03-11', '2026-03-18']"
/>
</twig:Card:Content>
</twig:Card>
Month and Year Selector
Use captionLayout="dropdown" to replace the caption with month and year dropdowns. Their range is bounded by startMonth and endMonth.
<twig:Calendar
mode="single"
today="2026-03-15"
month="2026-03-01"
captionLayout="dropdown"
startMonth="2024-01-01"
endMonth="2028-12-01"
class="mx-auto rounded-lg border"
/>
Presets
Anything placed inside the calendar is rendered below the grid, and is within reach of the calendar controller: a button carrying data-action="click->calendar#selectDate" and a data-calendar-date-param selects that date and jumps to its month.
<twig:Card size="sm" class="mx-auto w-fit max-w-[300px]">
<twig:Card:Content>
<twig:Calendar
mode="single"
today="2026-03-15"
selected="2026-02-12"
fixedWeeks
class="p-0 [--cell-size:--spacing(9.5)]"
>
<div class="mt-3 flex flex-wrap gap-2 border-t pt-3">
{% for preset in [{label: 'Today', days: 0}, {label: 'Tomorrow', days: 1}, {label: 'In 3 days', days: 3}, {label: 'In a week', days: 7}, {label: 'In 2 weeks', days: 14}] %}
<twig:Button
variant="outline"
size="sm"
class="flex-1"
data-action="click->calendar#selectDate"
data-calendar-date-param="{{ '2026-03-15'|date_modify('+' ~ preset.days ~ ' days')|date('Y-m-d') }}"
>{{ preset.label }}</twig:Button>
{% endfor %}
</div>
</twig:Calendar>
</twig:Card:Content>
</twig:Card>
Date and Time Picker
<twig:Card size="sm" class="mx-auto w-fit">
<twig:Card:Content>
<twig:Calendar
mode="single"
today="2026-03-15"
selected="2026-03-12"
class="p-0"
/>
</twig:Card:Content>
<twig:Card:Footer class="border-t bg-card">
<twig:Field:Group>
<twig:Field>
<twig:Field:Label for="time-from">Start Time</twig:Field:Label>
<twig:InputGroup>
<twig:InputGroup:Input id="time-from" type="time" step="1" value="10:30:00" class="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" />
<twig:InputGroup:Addon>
<twig:ux:icon name="lucide:clock" class="text-muted-foreground" />
</twig:InputGroup:Addon>
</twig:InputGroup>
</twig:Field>
<twig:Field>
<twig:Field:Label for="time-to">End Time</twig:Field:Label>
<twig:InputGroup>
<twig:InputGroup:Input id="time-to" type="time" step="1" value="12:30:00" class="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" />
<twig:InputGroup:Addon>
<twig:ux:icon name="lucide:clock" class="text-muted-foreground" />
</twig:InputGroup:Addon>
</twig:InputGroup>
</twig:Field>
</twig:Field:Group>
</twig:Card:Footer>
</twig:Card>
Booked Dates
Every entry of modifiers is rendered as a data-<name> attribute on the matching days, which is enough to style them with an arbitrary utility.
{% set bookedDates = (0..14)|map(offset => '2026-02-12'|date_modify('+' ~ offset ~ ' days')|date('Y-m-d')) %}
<twig:Card class="mx-auto w-fit p-0">
<twig:Card:Content class="p-0">
<twig:Calendar
mode="single"
today="2026-03-15"
month="2026-02-01"
selected="2026-02-03"
:disabled="bookedDates"
:modifiers="{booked: bookedDates}"
class="[&_[data-booked=true][data-disabled=true]]:opacity-100 [&_[data-booked=true]>button]:line-through"
/>
</twig:Card:Content>
</twig:Card>
Custom Cell Size
Cells are sized with the --cell-size CSS variable, which can be overridden per breakpoint.
<twig:Card class="mx-auto w-fit p-0">
<twig:Card:Content class="p-0">
<twig:Calendar
mode="range"
today="2026-03-15"
month="2026-12-01"
:selected="['2026-12-08', '2026-12-18']"
captionLayout="dropdown"
class="[--cell-size:--spacing(10)] md:[--cell-size:--spacing(12)]"
/>
</twig:Card:Content>
</twig:Card>
Week Numbers
Use showWeekNumber to prepend a column with the ISO week number.
<twig:Card class="mx-auto w-fit p-0">
<twig:Card:Content class="p-0">
<twig:Calendar
mode="single"
today="2026-03-15"
month="2026-02-01"
selected="2026-02-03"
showWeekNumber
/>
</twig:Card:Content>
</twig:Card>
API
The calendar dispatches a calendar:select event on every selection change, with selected (the dates, as Y-m-d strings) and mode in its detail, and a calendar:month-change event on every navigation, with month in its detail. Both events bubble. The calendar-display controller shipped with this recipe listens to calendar:select and mirrors the selection into a visible read-only field.
<div class="mx-auto flex w-fit flex-col gap-4" data-controller="calendar-display" data-action="calendar:select->calendar-display#update">
<twig:Calendar
mode="range"
name="stay"
today="2026-03-15"
:selected="['2026-03-12', '2026-03-18']"
class="rounded-lg border"
/>
<twig:Field>
<twig:Field:Label for="calendar-api-summary">Selected dates</twig:Field:Label>
<twig:Input id="calendar-api-summary" data-calendar-display-target="output" value="2026-03-12, 2026-03-18" readonly />
</twig:Field>
</div>
RTL
To enable RTL support, set the dir="rtl" attribute on the root element. Pair it with locale so the month, weekday and day labels are formatted for that language, and with weekStartsOn so the week starts on the expected day.
The -u-nu- Unicode extension pins the numbering system the digits are drawn from. Worth setting explicitly for languages that have more than one in use: a bare ar resolves to Arabic-Indic digits or Latin ones depending on the ICU version, and the server and the browser do not necessarily ship the same one.
<div class="flex flex-col items-center gap-12">
{# Arabic #}
<twig:Calendar
dir="rtl"
locale="ar-u-nu-latn"
weekStartsOn="6"
mode="single"
today="2026-03-15"
selected="2026-03-15"
captionLayout="dropdown"
class="rounded-lg border [--cell-size:--spacing(9)]"
/>
{# Hebrew #}
<twig:Calendar
dir="rtl"
locale="he-u-nu-latn"
weekStartsOn="0"
mode="single"
today="2026-03-15"
selected="2026-03-15"
captionLayout="dropdown"
class="rounded-lg border [--cell-size:--spacing(9)]"
/>
</div>
Accessibility
- The root element renders
role="group"with anaria-labeltaken from thelabelprop (Calendarby default), and each displayed month is a<table role="grid">whosearia-labelis the localized month and year. - Weekday headers are
<th scope="col">showing a narrow abbreviation, with the full weekday name exposed througharia-label. Every day cell carriesaria-selected, and its button has anaria-labelholding the full localized date, so a screen reader announces the whole date rather than a bare number. - Only one day button per calendar is in the tab order (roving
tabindex): the selected day, today, or the first of the displayed month. From there, arrow keys move by one day and one week,HomeandEndjump to the edges of the week,PageUpandPageDownmove by one month, and left and right are swapped in RTL. Moving outside the displayed month navigates to it. - The previous and next month buttons are icon-only with a visually hidden label, hardcoded in the template, so translate them there. They use
aria-disabledrather thandisabledat the navigation bounds, so they stay reachable by keyboard and simply do nothing. - With
captionLayout="dropdown", the month and year<select>elements are transparent overlays over the visible caption, each named by anaria-label(MonthandYear). - With
showWeekNumber, the week-number column header is a visually hiddenWeek. - Month, weekday and day labels are localized through the
localeprop, falling back to the request locale, so the announced dates follow the user's language.
API Reference
<twig:Calendar>
| Prop | Type | Default |
|---|---|---|
mode The selection mode.
|
'single'|'multiple'|'range' |
'single' |
name The name of the hidden inputs holding the selection, so the calendar can be submitted with a form.
|
string|null |
null |
selected The initially selected dates, as
Y-m-d strings. In range mode, the first entry is the start and the second one the end. |
string|array<string>|null |
null |
month The month to display, as a
Y-m-d string. |
string|null |
null |
numberOfMonths The number of months displayed side by side.
|
int |
1 |
captionLayout Whether the caption shows a plain label or month and year dropdowns.
|
'label'|'dropdown' |
'label' |
showOutsideDays Whether the days of the surrounding months are displayed.
|
boolean |
true |
showWeekNumber Whether a leading column shows the ISO week number.
|
boolean |
false |
fixedWeeks Whether every month always displays six weeks.
|
boolean |
false |
weekStartsOn The first day of the week, from
0 for Sunday to 6 for Saturday. |
int |
0 |
locale The locale used to format the month, weekday and day labels, falling back to the current request locale.
|
string|null |
null |
today The date considered as today, as a
Y-m-d string. |
string|null |
null |
disabled The dates that cannot be selected, as
Y-m-d strings. |
array<string> |
[] |
minDate The earliest selectable date, as a
Y-m-d string. |
string|null |
null |
maxDate The latest selectable date, as a
Y-m-d string. |
string|null |
null |
startMonth The earliest month reachable through navigation, as a
Y-m-d string. |
string|null |
null |
endMonth The latest month reachable through navigation, as a
Y-m-d string. |
string|null |
null |
modifiers Extra flags keyed by name, each holding a list of
Y-m-d strings, rendered as data-<name> on the matching days. |
array<string,array<string>> |
[] |
buttonVariant The style variant of the navigation buttons.
|
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' |
'ghost' |
label The accessible name of the calendar.
|
string |
'Calendar' |
| Block | Description |
|---|---|
content |
Optional content rendered below the month grids, such as a footer. |
data-controller="calendar"
| Value | Type | Default |
|---|---|---|
data-calendar-mode-value The selection mode, one of
single, multiple or range. |
String |
'single' |
data-calendar-locale-value The locale used to format the caption and day labels, falling back to the document language.
|
String |
- |
data-calendar-name-value The name of the hidden inputs mirroring the selection.
|
String |
- |
data-calendar-month-value The first displayed month, as a
Y-m-d string. |
String |
- |
data-calendar-selected-value The selected dates, as
Y-m-d strings. |
Array |
- |
data-calendar-disabled-value The dates that cannot be selected, as
Y-m-d strings. |
Array |
- |
data-calendar-modifiers-value Extra flags keyed by name, each holding a list of
Y-m-d strings. |
Object |
- |
data-calendar-today-value The date considered as today, as a
Y-m-d string. |
String |
- |
data-calendar-min-date-value The earliest selectable date, as a
Y-m-d string. |
String |
- |
data-calendar-max-date-value The latest selectable date, as a
Y-m-d string. |
String |
- |
data-calendar-start-month-value The earliest month reachable through navigation, as a
Y-m-d string. |
String |
- |
data-calendar-end-month-value The latest month reachable through navigation, as a
Y-m-d string. |
String |
- |
data-calendar-week-starts-on-value The first day of the week, from
0 for Sunday to 6 for Saturday. |
Number |
- |
data-calendar-number-of-months-value The number of months displayed side by side.
|
Number |
1 |
data-calendar-show-outside-days-value Whether the days of the surrounding months are displayed.
|
Boolean |
true |
data-calendar-show-week-number-value Whether the leading week number column is displayed.
|
Boolean |
- |
data-calendar-fixed-weeks-value Whether every month always displays six weeks.
|
Boolean |
- |
| Target | Description |
|---|---|
month |
A displayed month, holding its caption and its grid. |
previous |
The button moving the calendar to the previous month. |
next |
The button moving the calendar to the next month. |
input |
A hidden input mirroring the selection. |
| Action | Description |
|---|---|
previousMonth |
Moves the calendar one month backwards. |
nextMonth |
Moves the calendar one month forwards. |
goToMonth |
Moves the calendar to the month and year picked in the dropdowns. |
selectDate |
Selects the date carried by the date param, or by the clicked day. |
handleKeydown |
Moves the focus between days with the arrow, home, end and page keys. |
trackFocus |
Reflects the focused day on its cell. |
previewRange |
Previews the range being drawn up to the hovered day. |
clearPreview |
Drops the range preview. |
data-controller="calendar-display"
| Target | Description |
|---|---|
output |
The form control reflecting the calendar selection. |
| Action | Description |
|---|---|
update |
Writes the calendar selection into the output target. |