Date Picker
A date picker built by composing a Popover and a Calendar.
<div class="flex items-start justify-center pt-6" style="min-height: 400px">
<twig:DatePicker>
<twig:DatePicker:Trigger>
<twig:Button
variant="outline"
class="w-[212px] justify-between text-start font-normal data-[empty=true]:text-muted-foreground"
{{ ...date_picker_trigger_attrs }}
>
<twig:DatePicker:Value placeholder="Pick a date" />
<twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
</twig:Button>
</twig:DatePicker:Trigger>
<twig:DatePicker:Content>
<twig:Calendar mode="single" today="2026-03-15" />
</twig:DatePicker:Content>
</twig:DatePicker>
</div>
Installation
Available since UX Toolkit 3.5.
php bin/console ux:install date-picker --kit shadcn
Install the following Composer dependencies:
composer require twig/extra-bundle twig/html-extra:^3.24.0 twig/intl-extra symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0 symfony/ux-icons
Copy the following file(s) into your app:
import { Controller } from '@hotwired/stimulus';
/**
* Glue between the `Popover` and the `Calendar` composing the `DatePicker`.
*
* Both controllers live on the same element, so the popover is driven by writing its own value
* attribute rather than by holding a reference to it, and the calendar is reached through the
* Stimulus application registry.
*
* Dates are `Y-m-d` strings, the exchange format of the `Calendar`, and are only turned into a
* `Date` to be formatted or parsed, at UTC midnight so a time zone west of Greenwich can never
* move the day.
*
* @value locale The locale used to format the selection.
* @value dateStyle The length of the formatted selection, one of `full`, `long`, `medium` or `short`.
* @value separator The text placed between two formatted dates.
* @value closeOnSelect Whether completing the selection closes the popover.
* @target trigger The element opening the popover, flagged while nothing is selected.
* @target value The element displaying the formatted selection.
* @target input The text input mirroring the selection.
* @action select Reflects a calendar selection on the trigger, the value and the input.
* @action parseInput Moves the calendar to the date typed in the input.
* @action open Opens the popover.
*/
export default class extends Controller {
static targets = ['trigger', 'value', 'input'];
static values = {
locale: String,
dateStyle: { type: String, default: 'long' },
separator: { type: String, default: ' - ' },
closeOnSelect: { type: Boolean, default: true },
};
// Signature of the selection this controller just pushed onto the calendar, so the
// `calendar:select` it echoes back is not mistaken for a click on a day.
#pushed = null;
#formatter = null;
select(event) {
const { selected = [], mode = 'single' } = event.detail ?? {};
const echo = selected.join(',') === this.#pushed;
this.#pushed = null;
this.#reflect(selected, echo);
if (!echo && this.closeOnSelectValue && this.#isComplete(selected, mode)) {
this.#setOpen(false);
}
}
parseInput(event) {
const date = this.#parse(event.currentTarget.value);
if (null === date) {
return;
}
const calendar = this.#calendar;
if (null === calendar) {
return;
}
const month = `${date.slice(0, 7)}-01`;
if (calendar.monthValue !== month) {
calendar.monthValue = month;
}
if (calendar.selectedValue.join(',') !== date) {
this.#pushed = date;
calendar.selectedValue = [date];
}
}
open(event) {
event?.preventDefault();
this.#setOpen(true);
}
get #calendar() {
const element = this.element.querySelector('[data-slot="calendar"]');
if (null === element) {
return null;
}
return this.application.getControllerForElementAndIdentifier(element, 'calendar');
}
#setOpen(open) {
this.element.setAttribute('data-popover-open-value', String(open));
}
#reflect(selected, skipInput = false) {
const empty = 0 === selected.length;
const formatted = selected.map((date) => this.#format(date)).join(this.separatorValue);
this.element.dataset.empty = String(empty);
for (const trigger of this.triggerTargets) {
trigger.dataset.empty = String(empty);
}
for (const value of this.valueTargets) {
value.dataset.empty = String(empty);
value.textContent = empty ? (value.dataset.placeholder ?? '') : formatted;
}
if (skipInput) {
return;
}
for (const input of this.inputTargets) {
input.value = formatted;
}
}
#isComplete(selected, mode) {
if (0 === selected.length || 'multiple' === mode) {
return false;
}
return 'range' !== mode || selected.length > 1;
}
#format(date) {
this.#formatter ??= new Intl.DateTimeFormat(this.localeValue || document.documentElement.lang || undefined, {
dateStyle: this.dateStyleValue,
timeZone: 'UTC',
});
return this.#formatter.format(new Date(`${date}T00:00:00Z`));
}
#parse(text) {
const value = text.trim();
if ('' === value) {
return null;
}
// `Y-m-d` is parsed as UTC by `Date`, so it is validated as-is rather than read back
// through the local getters, which would shift the day west of Greenwich.
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const parsed = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(parsed.getTime()) && value === parsed.toISOString().slice(0, 10) ? value : null;
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : this.#toDateString(new Date(timestamp));
}
#toDateString(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
}
}
{%- props
## 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 locale used to format the selection.
locale = null,
## 'full'|'long'|'medium'|'short' The length of the formatted selection.
dateStyle = 'long',
## string The text placed between two formatted dates.
separator = ' - ',
## boolean Whether completing the selection closes the popover.
closeOnSelect = true,
## boolean Whether the popover is open on initial render.
open = false
-%}
{%- set _selected = null == selected ? [] : (selected is iterable ? selected|map(date => date|date('Y-m-d')) : [selected|date('Y-m-d')]) -%}
{%- set _formatted = _selected|map(date => date|format_date(dateFormat: dateStyle, locale: locale))|join(separator) -%}
{%- do provide('popover.open', open) -%}
{%- do provide('datePicker.empty', _selected is empty) -%}
{%- do provide('datePicker.formatted', _formatted) -%}
<div
data-slot="date-picker"
data-state="{{ open ? 'open' : 'closed' }}"
data-empty="{{ _selected is empty ? 'true' : 'false' }}"
data-popover-open-value="{{ open ? 'true' : 'false' }}"
data-date-picker-locale-value="{{ locale }}"
data-date-picker-date-style-value="{{ dateStyle }}"
data-date-picker-separator-value="{{ separator }}"
data-date-picker-close-on-select-value="{{ closeOnSelect ? 'true' : 'false' }}"
{{ attributes.defaults({
class: 'group/popover group/date-picker relative inline-block'|tailwind_classes,
'data-controller': 'popover date-picker',
'data-action': [
'popover:open@window->popover#handleGroupOpen',
'click@window->popover#handleOutsideClick',
'keydown.esc@window->popover#handleEscape',
'calendar:select->date-picker#select',
]|join(' ')|html_attr_type('sst'),
}) }}
>
{##- A `DatePicker:Trigger` and a `DatePicker:Content`. -#}
{%- block content %}{% endblock -%}
</div>
{%- props
## 'top'|'right'|'bottom'|'left' Which side of the trigger the calendar appears on.
side = 'bottom',
## 'start'|'center'|'end' Alignment of the calendar along the cross axis.
align = 'start'
-%}
<twig:Popover:Content
side="{{ side }}"
align="{{ align }}"
{{ ...attributes.defaults({class: 'w-auto overflow-hidden p-0'|tailwind_classes}) }}
>
{##- The popover body, typically a `Calendar`. -#}
{{- block(outerBlocks.content) -}}
</twig:Popover:Content>
{%- set _datePicker_formatted = inject('datePicker.formatted', '') -%}
{%- set date_picker_input_attrs = {
'data-date-picker-target': 'input',
'data-action': 'input->date-picker#parseInput keydown.down->date-picker#open'|html_attr_type('sst'),
value: _datePicker_formatted,
autocomplete: 'off',
} -%}
{##- The text input (e.g., an `Input` or an `InputGroup:Input`) mirroring the selection. -#}
{%- block content %}{% endblock -%}
{%- set _popover_open = inject('popover.open', false) -%}
{%- set _datePicker_empty = inject('datePicker.empty', true) -%}
{%- set date_picker_trigger_attrs = {
'data-slot': 'date-picker-trigger',
'data-popover-target': 'trigger',
'data-date-picker-target': 'trigger',
'data-action': 'click->popover#toggle'|html_attr_type('sst'),
'aria-haspopup': 'dialog',
'aria-expanded': _popover_open ? 'true' : 'false',
'data-state': _popover_open ? 'open' : 'closed',
'data-empty': _datePicker_empty ? 'true' : 'false',
} -%}
{##- The trigger element (e.g., a `Button`) that opens the date picker when clicked. -#}
{%- block content %}{% endblock -%}
{%- props
## string The text shown while nothing is selected.
placeholder = 'Pick a date'
-%}
{%- set _datePicker_formatted = inject('datePicker.formatted', '') -%}
{%- set _datePicker_empty = inject('datePicker.empty', true) -%}
<span
data-slot="date-picker-value"
data-date-picker-target="value"
data-empty="{{ _datePicker_empty ? 'true' : 'false' }}"
data-placeholder="{{ placeholder }}"
{{ attributes }}
>
{{- _datePicker_empty ? placeholder : _datePicker_formatted -}}
</span>
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['trigger', 'content'];
static values = {
open: { type: Boolean, default: false },
name: { type: String, default: '' },
};
#connected = false;
connect() {
this.updateState();
this.#connected = true;
}
toggle(event) {
event?.preventDefault();
this.openValue = !this.openValue;
}
close() {
this.openValue = false;
}
openValueChanged() {
this.updateState();
if (!this.openValue) {
return;
}
if (this.nameValue) {
window.dispatchEvent(
new CustomEvent('popover:open', {
detail: { name: this.nameValue, source: this.element },
})
);
}
// Skip on initial render so an `open` popover does not steal focus on page load.
if (this.#connected) {
this.#focusContent();
}
}
handleGroupOpen(event) {
if (!this.nameValue || !this.openValue) {
return;
}
if (event.detail.name !== this.nameValue || event.detail.source === this.element) {
return;
}
this.openValue = false;
}
handleOutsideClick(event) {
if (!this.openValue || this.element.contains(event.target)) {
return;
}
this.openValue = false;
}
handleEscape(event) {
if (!this.openValue || event.key !== 'Escape') {
return;
}
this.openValue = false;
this.triggerTargets[0]?.focus();
}
updateState() {
const open = this.openValue;
const state = open ? 'open' : 'closed';
this.element.dataset.state = state;
for (const trigger of this.triggerTargets) {
trigger.setAttribute('aria-expanded', String(open));
trigger.dataset.state = state;
}
for (const content of this.contentTargets) {
content.dataset.state = state;
content.setAttribute('aria-hidden', String(!open));
}
}
#focusContent() {
const content = this.contentTargets[0];
const focusable =
content?.querySelector('[autofocus]:not([disabled])') ??
content?.querySelector(
'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
);
if (!focusable) {
return;
}
// Wait two frames: `visibility` is transitioned, so the content computes as
// `visibility: hidden` (not focusable) on the first frame after opening and is
// only `visible` from the next one.
requestAnimationFrame(() => requestAnimationFrame(() => focusable.focus()));
}
}
{%- props
## string|null Group name shared by mutually exclusive popovers, so only one is open at a time.
name = null,
## boolean Whether the popover is open on initial render.
open = false
-%}
{%- do provide('popover.open', open) -%}
<div
data-slot="popover"
data-state="{{ open ? 'open' : 'closed' }}"
data-popover-open-value="{{ open ? 'true' : 'false' }}"
data-popover-name-value="{{ name }}"
{{ attributes.defaults({
class: 'group/popover relative inline-block'|tailwind_classes,
'data-controller': 'popover',
'data-action': [
'popover:open@window->popover#handleGroupOpen',
'click@window->popover#handleOutsideClick',
'keydown.esc@window->popover#handleEscape',
]|join(' ')|html_attr_type('sst'),
}) }}
>
{##- A `Popover:Trigger` and a `Popover:Content`. -#}
{%- block content %}{% endblock -%}
</div>
{%- props
## 'top'|'right'|'bottom'|'left' Which side of the trigger the content appears on.
side = 'bottom',
## 'start'|'center'|'end' Alignment of the content along the cross axis.
align = 'center'
-%}
{%- set _popover_open = inject('popover.open', false) -%}
{%- set style = html_cva(
base: 'invisible scale-95 opacity-0 transition-[opacity,scale,visibility] duration-150 ease-out group-data-[state=open]/popover:visible group-data-[state=open]/popover:scale-100 group-data-[state=open]/popover:opacity-100 absolute z-50 w-72 rounded-md border bg-popover p-4 text-sm text-popover-foreground shadow-md outline-none',
variants: {
side: {
top: 'bottom-full mb-2',
bottom: 'top-full mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2 origin-right',
right: 'left-full top-1/2 -translate-y-1/2 ml-2 origin-left',
},
},
compound_variants: [
{side: ['top'], align: ['start'], class: 'start-0 ltr:origin-bottom-left rtl:origin-bottom-right'},
{side: ['top'], align: ['center'], class: 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2 origin-bottom'},
{side: ['top'], align: ['end'], class: 'end-0 ltr:origin-bottom-right rtl:origin-bottom-left'},
{side: ['bottom'], align: ['start'], class: 'start-0 ltr:origin-top-left rtl:origin-top-right'},
{side: ['bottom'], align: ['center'], class: 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2 origin-top'},
{side: ['bottom'], align: ['end'], class: 'end-0 ltr:origin-top-right rtl:origin-top-left'},
],
) -%}
<div
role="dialog"
data-slot="popover-content"
data-popover-target="content"
data-side="{{ side }}"
data-align="{{ align }}"
data-state="{{ _popover_open ? 'open' : 'closed' }}"
aria-hidden="{{ _popover_open ? 'false' : 'true' }}"
{{ attributes.defaults({
class: style.apply({side: side, align: align})|tailwind_classes,
}) }}
>
{##- The content revealed when the popover is open. -#}
{%- block content %}{% endblock -%}
</div>
{%- set _popover_open = inject('popover.open', false) -%}
{%- set popover_trigger_attrs = {
'data-slot': 'popover-trigger',
'data-popover-target': 'trigger',
'data-action': 'click->popover#toggle'|html_attr_type('sst'),
'aria-haspopup': 'dialog',
'aria-expanded': _popover_open ? 'true' : 'false',
'data-state': _popover_open ? 'open' : 'closed',
} -%}
{##- The clickable trigger (e.g., a `Button`) that opens the popover. -#}
{%- block content %}{% endblock -%}
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:DatePicker
selected="2026-03-15"
dateStyle="full | long | medium | short"
:closeOnSelect="true"
>
<twig:DatePicker:Trigger>
<twig:Button variant="outline" {{ ...date_picker_trigger_attrs }}>
<twig:DatePicker:Value placeholder="Pick a date" />
</twig:Button>
</twig:DatePicker:Trigger>
<twig:DatePicker:Content side="top | right | bottom | left" align="start | center | end">
<twig:Calendar mode="single" name="date" selected="2026-03-15" />
</twig:DatePicker:Content>
</twig:DatePicker>
There is no date picker component upstream: it is a composition, and so is this recipe. DatePicker hosts the popover and date-picker controllers on a single element, DatePicker:Trigger and DatePicker:Input expose an attributes bag to spread onto your own element, and the selection itself belongs to the nested Calendar.
Dates are exchanged as Y-m-d strings, as in the Calendar. Set selected on both components: the Calendar owns the grid, while DatePicker needs it to render the formatted selection on the trigger server-side. Set name on the Calendar to mirror the selection into hidden inputs and submit it with a form.
Picking a day formats it with dateStyle and locale, writes it into every DatePicker:Value and DatePicker:Input, and closes the popover once the selection is complete — in range mode, only after both ends are set. Pass :closeOnSelect="false" to keep it open.
Examples
Basic
A date picker inside a Field, so it is labelled like any other form control.
<div class="flex items-start justify-center pt-6" style="min-height: 400px">
<twig:Field class="w-44">
<twig:Field:Label for="date-picker-basic">Date</twig:Field:Label>
<twig:DatePicker>
<twig:DatePicker:Trigger>
<twig:Button
id="date-picker-basic"
variant="outline"
class="w-full justify-start font-normal data-[empty=true]:text-muted-foreground"
{{ ...date_picker_trigger_attrs }}
>
<twig:DatePicker:Value placeholder="Pick a date" />
</twig:Button>
</twig:DatePicker:Trigger>
<twig:DatePicker:Content>
<twig:Calendar mode="single" today="2026-03-15" />
</twig:DatePicker:Content>
</twig:DatePicker>
</twig:Field>
</div>
Range Picker
Set mode="range" on the Calendar and pass both ends to selected. The two dates are joined by separator, and the popover only closes once the end date is picked.
<div class="flex items-start justify-center pt-6" style="min-height: 440px">
<twig:Field class="w-60">
<twig:Field:Label for="date-picker-range">Date Picker Range</twig:Field:Label>
<twig:DatePicker :selected="['2026-01-20', '2026-02-09']" dateStyle="medium">
<twig:DatePicker:Trigger>
<twig:Button
id="date-picker-range"
variant="outline"
class="w-full justify-start px-2.5 font-normal data-[empty=true]:text-muted-foreground"
{{ ...date_picker_trigger_attrs }}
>
<twig:ux:icon name="lucide:calendar" data-icon="inline-start" />
<twig:DatePicker:Value placeholder="Pick a date" />
</twig:Button>
</twig:DatePicker:Trigger>
<twig:DatePicker:Content>
<twig:Calendar
mode="range"
:selected="['2026-01-20', '2026-02-09']"
:numberOfMonths="2"
today="2026-03-15"
/>
</twig:DatePicker:Content>
</twig:DatePicker>
</twig:Field>
</div>
Date of Birth
Pair captionLayout="dropdown" with startMonth and endMonth so a distant year is a couple of clicks away rather than a hundred.
<div class="flex items-start justify-center pt-6" style="min-height: 400px">
<twig:Field class="w-44">
<twig:Field:Label for="date-picker-dob">Date of birth</twig:Field:Label>
<twig:DatePicker dateStyle="medium">
<twig:DatePicker:Trigger>
<twig:Button
id="date-picker-dob"
variant="outline"
class="w-full justify-start font-normal data-[empty=true]:text-muted-foreground"
{{ ...date_picker_trigger_attrs }}
>
<twig:DatePicker:Value placeholder="Select date" />
</twig:Button>
</twig:DatePicker:Trigger>
<twig:DatePicker:Content>
<twig:Calendar
mode="single"
captionLayout="dropdown"
startMonth="1926-01-01"
endMonth="2026-12-01"
today="2026-03-15"
/>
</twig:DatePicker:Content>
</twig:DatePicker>
</twig:Field>
</div>
Input
Spread date_picker_input_attrs onto a text input to keep it in sync with the calendar in both directions: it's rendered server-side already holding the current selection formatted, picking a day writes the formatted date back into it, and typing a date moves the calendar to match. ↓ opens the popover from the input.
<div class="flex items-start justify-center pt-6" style="min-height: 420px">
<twig:Field class="w-56">
<twig:Field:Label for="date-picker-input">Subscription Date</twig:Field:Label>
<twig:DatePicker selected="2026-06-01">
<twig:InputGroup>
<twig:DatePicker:Input>
<twig:InputGroup:Input
id="date-picker-input"
placeholder="June 1, 2026"
{{ ...date_picker_input_attrs }}
/>
</twig:DatePicker:Input>
<twig:InputGroup:Addon align="inline-end">
<twig:DatePicker:Trigger>
<twig:InputGroup:Button
variant="ghost"
size="icon-xs"
{{ ...date_picker_trigger_attrs }}
>
<twig:ux:icon name="lucide:calendar" />
<span class="sr-only">Select date</span>
</twig:InputGroup:Button>
</twig:DatePicker:Trigger>
</twig:InputGroup:Addon>
</twig:InputGroup>
<twig:DatePicker:Content align="end">
<twig:Calendar mode="single" selected="2026-06-01" today="2026-03-15" />
</twig:DatePicker:Content>
</twig:DatePicker>
</twig:Field>
</div>
Time Picker
The date picker only handles the date. Pair it with a native <twig:Input type="time" /> to collect a time alongside it.
<div class="flex items-start justify-center pt-6" style="min-height: 420px">
<twig:Field orientation="horizontal" class="w-auto items-start gap-4">
<twig:Field class="w-40">
<twig:Field:Label for="date-picker-time">Date</twig:Field:Label>
<twig:DatePicker dateStyle="medium">
<twig:DatePicker:Trigger>
<twig:Button
id="date-picker-time"
variant="outline"
class="w-full justify-between font-normal data-[empty=true]:text-muted-foreground"
{{ ...date_picker_trigger_attrs }}
>
<twig:DatePicker:Value placeholder="Select date" />
<twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
</twig:Button>
</twig:DatePicker:Trigger>
<twig:DatePicker:Content>
<twig:Calendar mode="single" captionLayout="dropdown" today="2026-03-15" />
</twig:DatePicker:Content>
</twig:DatePicker>
</twig:Field>
<twig:Field class="w-36">
<twig:Field:Label for="time-picker">Time</twig:Field:Label>
<twig:Input
type="time"
id="time-picker"
step="1"
value="10:30:00"
class="appearance-none bg-background [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</twig:Field>
</twig:Field>
</div>
RTL
To enable RTL support, set the dir="rtl" attribute on the root element. Pass the same locale to the DatePicker and to the Calendar, so the trigger label and the grid are formatted for the same language, and pin the numbering system with the -u-nu- extension as the Calendar documents.
<div class="flex flex-col items-center gap-6 pt-6" style="min-height: 500px">
{# Arabic #}
<twig:DatePicker dir="rtl" locale="ar-u-nu-latn" selected="2026-03-15">
<twig:DatePicker:Trigger>
<twig:Button
variant="outline"
class="w-[212px] justify-between text-start font-normal data-[empty=true]:text-muted-foreground"
{{ ...date_picker_trigger_attrs }}
>
<twig:DatePicker:Value placeholder="اختر تاريخًا" />
<twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
</twig:Button>
</twig:DatePicker:Trigger>
<twig:DatePicker:Content>
<twig:Calendar
locale="ar-u-nu-latn"
weekStartsOn="6"
mode="single"
selected="2026-03-15"
today="2026-03-15"
/>
</twig:DatePicker:Content>
</twig:DatePicker>
{# Hebrew #}
<twig:DatePicker dir="rtl" locale="he-u-nu-latn" selected="2026-03-15">
<twig:DatePicker:Trigger>
<twig:Button
variant="outline"
class="w-[212px] justify-between text-start font-normal data-[empty=true]:text-muted-foreground"
{{ ...date_picker_trigger_attrs }}
>
<twig:DatePicker:Value placeholder="בחר תאריך" />
<twig:ux:icon name="lucide:chevron-down" data-icon="inline-end" />
</twig:Button>
</twig:DatePicker:Trigger>
<twig:DatePicker:Content>
<twig:Calendar
locale="he-u-nu-latn"
weekStartsOn="0"
mode="single"
selected="2026-03-15"
today="2026-03-15"
/>
</twig:DatePicker:Content>
</twig:DatePicker>
</div>
Natural Language Input
DatePicker:Input reads what is typed with Date.parse(), so it accepts Y-m-d and the other formats the browser recognises, but not phrases such as tomorrow or in 2 days. Parsing those is a library's job, and chrono-node does it, in several languages.
Install it, either with importmap:require for AssetMapper, or npm for Webpack Encore:
# With AssetMapper
php bin/console importmap:require chrono-node
# With npm
npm install chrono-node
Then rewrite #parse() in date_picker_controller.js, the single place where typed text becomes a Y-m-d string:
import { Controller } from '@hotwired/stimulus';
import * as chrono from 'chrono-node';
export default class extends Controller {
// ...
#parse(text) {
const value = text.trim();
if ('' === value) {
return null;
}
// `forwardDate` resolves an ambiguous phrase such as `friday` to the next one.
const parsed = chrono.parseDate(value, undefined, { forwardDate: true });
return parsed ? this.#toDateString(parsed) : null;
}
}
chrono.parseDate() returns a local Date, or null when it finds no date in the text, and #toDateString() already turns that Date into the Y-m-d string the Calendar expects. Reach for a localized parser, chrono.fr, chrono.de, chrono.es, chrono.ja, to match the language of your application, and chrono.en.GB to read 25/12/2026 as day-first.
Accessibility
DatePicker:Triggerexposesaria-haspopup="dialog"and anaria-expandedreflecting whether the calendar is open, plus adata-emptyflag while nothing is selected.DatePicker:Contentwraps aPopover:Content, which rendersrole="dialog"with anaria-hiddenmirroring the open state.- Opening the picker moves focus into the popover: to the element carrying
[autofocus]if there is one, otherwise the first focusable control, which for a calendar is its focusable day. Escapecloses the picker and returns focus to the trigger. A click outside closes it too, but focus simply leaving it does not, so keyboard users should close withEscape.DatePicker:Valuerenders theplaceholdertext while nothing is selected, so a trigger built from it always has a visible and accessible name.DatePicker:Inputis a real text input, so give it aField:Label. Typing a date moves the calendar to that month, andArrowDownopens the picker.- Inside the popover the nested
Calendarkeeps its own keyboard handling: one day in the tab order, arrow keys,HomeandEnd,PageUpandPageDown.
API Reference
<twig:DatePicker>
| Prop | Type | Default |
|---|---|---|
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 |
locale The locale used to format the selection.
|
string|null |
null |
dateStyle The length of the formatted selection.
|
'full'|'long'|'medium'|'short' |
'long' |
separator The text placed between two formatted dates.
|
string |
' - ' |
closeOnSelect Whether completing the selection closes the popover.
|
boolean |
true |
open Whether the popover is open on initial render.
|
boolean |
false |
| Block | Description |
|---|---|
content |
A DatePicker:Trigger and a DatePicker:Content. |
<twig:DatePicker:Content>
| Prop | Type | Default |
|---|---|---|
side Which side of the trigger the calendar appears on.
|
'top'|'right'|'bottom'|'left' |
'bottom' |
align Alignment of the calendar along the cross axis.
|
'start'|'center'|'end' |
'start' |
| Block | Description |
|---|---|
content |
The popover body, typically a Calendar. |
<twig:DatePicker:Input>
| Block | Description |
|---|---|
content |
The text input (e.g., an Input or an InputGroup:Input) mirroring the selection. |
<twig:DatePicker:Trigger>
| Block | Description |
|---|---|
content |
The trigger element (e.g., a Button) that opens the date picker when clicked. |
<twig:DatePicker:Value>
| Prop | Type | Default |
|---|---|---|
placeholder The text shown while nothing is selected.
|
string |
'Pick a date' |
data-controller="date-picker"
| Value | Type | Default |
|---|---|---|
data-date-picker-locale-value The locale used to format the selection.
|
String |
- |
data-date-picker-date-style-value The length of the formatted selection, one of
full, long, medium or short. |
String |
'long' |
data-date-picker-separator-value The text placed between two formatted dates.
|
String |
' - ' |
data-date-picker-close-on-select-value Whether completing the selection closes the popover.
|
Boolean |
true |
| Target | Description |
|---|---|
trigger |
The element opening the popover, flagged while nothing is selected. |
value |
The element displaying the formatted selection. |
input |
The text input mirroring the selection. |
| Action | Description |
|---|---|
select |
Reflects a calendar selection on the trigger, the value and the input. |
parseInput |
Moves the calendar to the date typed in the input. |
open |
Opens the popover. |