DatePicker
componentThe date field of the browser, which works alone, and a button opening a month on a grid, in a popover.
Preview
Code
<twig:DatePicker id="start" name="start" label="Start" min="2026-01-01" />
Code
<twig:DatePicker id="start" name="start" label="Start" min="2026-01-01" />
Try it
Change the props; the example is rendered again by the server.
<twig:DatePicker id="start" name="start" label="Start" min="2026-01-01" />
<twig:DatePicker id="start" name="start" label="Start" min="2026-01-01" />
Variants
required
Whether a date has to be given.
disabled
Whether the field cannot be changed for now.
Installation
php bin/console ux:install date-picker --kit=github.com/parler-haut-interagir-librement/ux-kit
Props
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string|null |
null |
The name of the field. |
value |
string|null |
null |
The date, as YYYY-MM-DD. |
min |
string|null |
null |
The first date that can be chosen. |
max |
string|null |
null |
The last date that can be chosen. |
label |
string|null |
null |
What screen readers call the field, without a label beside it. |
locale |
string|null |
null |
The language of the month; that of the page when null. |
firstDay |
int|null |
null |
The first day of the week, 0 for Sunday; that of the language when null. |
openLabel |
string |
'Choose a date' |
The name of the button that opens the month. |
previousLabel |
string |
'Previous month' |
The name of the button that goes back a month. |
nextLabel |
string |
'Next month' |
The name of the button that goes forward a month. |
required |
bool |
false |
Whether a date has to be given. |
disabled |
bool |
false |
Whether the field cannot be changed for now. |
Accessibility
The dialog follows the date picker of WAI-ARIA: the arrows move by day and week, mirrored right to left, Home and End to the ends of the week, Page Up and Page Down by month, by year with Shift. Enter and the Space bar choose, Escape closes and gives the focus back. Days out of min and max are disabled, and each day is read as a whole date in the language of the page.
Files to copy
assets/controllers/date_picker_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* Where a box goes against its trigger: on the side asked for, or on the other one when it does not fit there and the
* other has more room; aligned along the cross axis, `start` and `end` following the reading direction, and pushed
* back inside the viewport. Every value is in viewport pixels, as `getBoundingClientRect()` gives them.
*/
export function placement(trigger, box, viewport, { side = 'bottom', align = 'center', gap = 6, rtl = false } = {}) {
const vertical = side === 'top' || side === 'bottom';
const size = vertical ? box.height : box.width;
const roomBefore = (vertical ? trigger.top : trigger.left) - gap;
const roomAfter = (vertical ? viewport.height - trigger.bottom : viewport.width - trigger.right) - gap;
const wantsBefore = side === 'top' || side === 'left';
const flipped = wantsBefore
? size > roomBefore && roomAfter > roomBefore
: size > roomAfter && roomBefore > roomAfter;
const before = wantsBefore !== flipped;
const main = before
? (vertical ? trigger.top : trigger.left) - gap - size
: (vertical ? trigger.bottom : trigger.right) + gap;
const crossStart = vertical ? trigger.left : trigger.top;
const crossEnd = vertical ? trigger.right : trigger.bottom;
const crossSize = vertical ? box.width : box.height;
const edge = vertical && rtl ? ({ start: 'end', end: 'start' }[align] ?? align) : align;
const cross =
edge === 'start' ? crossStart : edge === 'end' ? crossEnd - crossSize : (crossStart + crossEnd - crossSize) / 2;
const limit = (vertical ? viewport.width : viewport.height) - crossSize;
const clamped = Math.min(Math.max(0, cross), Math.max(0, limit));
const names = vertical ? ['top', 'bottom'] : ['left', 'right'];
return {
top: Math.round(vertical ? main : clamped),
left: Math.round(vertical ? clamped : main),
side: names[before ? 0 : 1],
};
}
const DAY = 86400000;
const WEEK = 7;
const CELLS = 42;
/** A date as `YYYY-MM-DD`, from a time of day at midnight UTC: no shift of the clock ever moves a day. */
export const iso = (time) => new Date(time).toISOString().slice(0, 10);
/** The time at midnight UTC of a `YYYY-MM-DD`; null for anything else. */
export function parse(text) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(text ?? '')) {
return null;
}
const time = Date.parse(`${text}T00:00:00Z`);
return Number.isNaN(time) ? null : time;
}
/** The six weeks a month is shown on, as times, starting on `firstDay` (0 for Sunday). */
export function weeks(time, firstDay) {
const date = new Date(time);
const first = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
const back = (new Date(first).getUTCDay() - firstDay + WEEK) % WEEK;
const start = first - back * DAY;
const rows = [];
for (let cell = 0; cell < CELLS; cell += WEEK) {
rows.push(Array.from({ length: WEEK }, (_, day) => start + (cell + day) * DAY));
}
return rows;
}
/** The same day some months away, or the last of its month when that month is shorter. */
export function addMonths(time, count) {
const date = new Date(time);
const last = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + count + 1, 0)).getUTCDate();
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + count, Math.min(date.getUTCDate(), last));
}
/*
* A month on a grid, in a popover opened by a button beside the date field: the date picker dialog of WAI-ARIA. The
* arrows move by day and week, mirrored right to left, Home and End to the ends of the week, Page Up and Page Down by
* month, and by year with Shift. Enter and the Space bar are those of the button of a day; the browser closes on
* Escape and gives the focus back.
*/
export default class extends Controller {
static targets = ['input', 'trigger', 'panel', 'heading', 'weekdays', 'weeks'];
static classes = ['day', 'weekday'];
static values = {
locale: String,
firstDay: { type: Number, default: -1 },
};
#focused = 0;
#follow = () => this.#place();
connect() {
this.triggerTarget.hidden = !('popover' in HTMLElement.prototype);
}
disconnect() {
this.#stopFollowing();
}
toggled(event) {
const open = event.newState === 'open';
this.triggerTarget.setAttribute('aria-expanded', String(open));
if (!open) {
this.#stopFollowing();
return;
}
this.#focused = this.#clamp(parse(this.inputTarget.value) ?? this.#today());
this.#render();
this.#place();
this.#day(this.#focused)?.focus();
window.addEventListener('scroll', this.#follow, { passive: true, capture: true });
window.addEventListener('resize', this.#follow, { passive: true });
}
previous() {
this.#focused = this.#clamp(addMonths(this.#focused, -1));
this.#render();
}
next() {
this.#focused = this.#clamp(addMonths(this.#focused, 1));
this.#render();
}
choose(event) {
const day = event.target.closest('[data-date]');
if (!day || day.disabled) {
return;
}
const input = this.inputTarget;
input.value = day.dataset.date;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
this.panelTarget.hidePopover();
this.triggerTarget.focus();
}
navigate(event) {
if (!event.target.closest('[data-date]')) {
return;
}
const target = this.#destination(event);
if (target === null) {
return;
}
event.preventDefault();
this.#focused = this.#clamp(target);
this.#render();
this.#day(this.#focused)?.focus();
}
#destination({ key, shiftKey }) {
const rtl = getComputedStyle(this.element).direction === 'rtl';
const weekday = (new Date(this.#focused).getUTCDay() - this.#firstDay() + WEEK) % WEEK;
const moves = {
ArrowLeft: () => this.#focused + (rtl ? DAY : -DAY),
ArrowRight: () => this.#focused + (rtl ? -DAY : DAY),
ArrowUp: () => this.#focused - WEEK * DAY,
ArrowDown: () => this.#focused + WEEK * DAY,
Home: () => this.#focused - weekday * DAY,
End: () => this.#focused + (WEEK - 1 - weekday) * DAY,
PageUp: () => addMonths(this.#focused, shiftKey ? -12 : -1),
PageDown: () => addMonths(this.#focused, shiftKey ? 12 : 1),
};
return moves[key]?.() ?? null;
}
#render() {
const month = this.#formatter({ month: 'long', year: 'numeric' });
const long = this.#formatter({ dateStyle: 'full' });
const number = this.#formatter({ day: 'numeric' });
const short = this.#formatter({ weekday: 'short' });
const full = this.#formatter({ weekday: 'long' });
const rows = weeks(this.#focused, this.#firstDay());
const shown = iso(this.#focused).slice(0, 7);
const selected = this.inputTarget.value;
const today = iso(this.#today());
this.headingTarget.textContent = month.format(this.#focused);
this.weekdaysTarget.replaceChildren(
...rows[0].map((time) => {
const cell = document.createElement('th');
cell.scope = 'col';
cell.abbr = full.format(time);
cell.textContent = short.format(time);
cell.classList.add(...this.weekdayClasses);
return cell;
}),
);
this.weeksTarget.replaceChildren(
...rows.map((week) => {
const row = document.createElement('tr');
row.append(...week.map((time) => this.#cell(time, { long, number, shown, selected, today })));
return row;
}),
);
}
#cell(time, { long, number, shown, selected, today }) {
const date = iso(time);
const cell = document.createElement('td');
const button = document.createElement('button');
button.type = 'button';
button.dataset.date = date;
button.textContent = number.format(time);
button.tabIndex = time === this.#focused ? 0 : -1;
button.disabled = !this.#allowed(time);
button.classList.add(...this.dayClasses);
button.setAttribute('aria-label', long.format(time));
button.setAttribute('aria-pressed', String(date === selected));
if (date === today) {
button.setAttribute('aria-current', 'date');
}
if (!date.startsWith(shown)) {
button.dataset.outside = 'true';
}
cell.setAttribute('role', 'gridcell');
cell.append(button);
return cell;
}
#day(time) {
return this.weeksTarget.querySelector(`[data-date="${iso(time)}"]`);
}
#place() {
const panel = this.panelTarget;
const { top, left } = placement(
this.triggerTarget.getBoundingClientRect(),
{ width: panel.offsetWidth, height: panel.offsetHeight },
{ width: document.documentElement.clientWidth, height: document.documentElement.clientHeight },
{ align: 'end', rtl: getComputedStyle(panel).direction === 'rtl' },
);
panel.style.inset = `${top}px auto auto ${left}px`;
}
#stopFollowing() {
window.removeEventListener('scroll', this.#follow, { capture: true });
window.removeEventListener('resize', this.#follow);
}
#clamp(time) {
return Math.min(
Math.max(time, parse(this.inputTarget.min) ?? Number.NEGATIVE_INFINITY),
parse(this.inputTarget.max) ?? Number.POSITIVE_INFINITY,
);
}
#allowed(time) {
const min = parse(this.inputTarget.min);
const max = parse(this.inputTarget.max);
return (min === null || time >= min) && (max === null || time <= max);
}
#today() {
const now = new Date();
return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
}
#locale() {
return this.localeValue || document.documentElement.lang || undefined;
}
#firstDay() {
if (this.firstDayValue >= 0) {
return this.firstDayValue % WEEK;
}
const locale = new Intl.Locale(this.#locale() ?? navigator.language);
return ((locale.getWeekInfo?.() ?? locale.weekInfo)?.firstDay ?? 1) % WEEK;
}
#formatter(options) {
return new Intl.DateTimeFormat(this.#locale(), { ...options, timeZone: 'UTC' });
}
}
templates/components/DatePicker.html.twig
{#-
# The date field of the browser, which works alone, and a button opening a month on a grid, in a popover, with the
# keyboard of the date picker of WAI-ARIA. The script writes the days, in the language of the page.
#}
{%- props
## string The identifier of the field, from which those of the panel are made.
id,
## string|null The name of the field.
name = null,
## string|null The date, as `YYYY-MM-DD`.
value = null,
## string|null The first date that can be chosen.
min = null,
## string|null The last date that can be chosen.
max = null,
## string|null What screen readers call the field, without a `label` beside it.
label = null,
## string|null The language of the month; that of the page when null.
locale = null,
## int|null The first day of the week, 0 for Sunday; that of the language when null.
firstDay = null,
## string The name of the button that opens the month.
openLabel = 'Choose a date',
## string The name of the button that goes back a month.
previousLabel = 'Previous month',
## string The name of the button that goes forward a month.
nextLabel = 'Next month',
## bool Whether a date has to be given.
required = false,
## bool Whether the field cannot be changed for now.
disabled = false
-%}
<div data-date-picker-locale-value="{{ locale ?? '' }}" data-date-picker-first-day-value="{{ firstDay ?? -1 }}" data-date-picker-day-class="isDatePickerDay" data-date-picker-weekday-class="isDatePickerWeekday"{{ attributes.defaults({class: 'isDatePicker', 'data-controller': 'date-picker'}) }}>
<input class="isDatePickerField" type="date" id="{{ id }}"{% if name is not null %} name="{{ name }}"{% endif %}{% if value is not null %} value="{{ value }}"{% endif %}{% if min is not null %} min="{{ min }}"{% endif %}{% if max is not null %} max="{{ max }}"{% endif %}{% if label is not null %} aria-label="{{ label }}"{% endif %}{% if required %} required{% endif %}{% if disabled %} disabled{% endif %} data-date-picker-target="input">
<button type="button" class="isDatePickerTrigger" hidden popovertarget="{{ id }}-panel" aria-label="{{ openLabel }}" aria-haspopup="dialog" aria-expanded="false"{% if disabled %} disabled{% endif %} data-date-picker-target="trigger"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false"><rect x="2" y="3" width="12" height="11" rx="1"></rect><path d="M2 7h12M5 1.5v3M11 1.5v3"></path></svg></button>
<div class="isDatePickerPanel" id="{{ id }}-panel" popover role="dialog" aria-labelledby="{{ id }}-heading" data-date-picker-target="panel" data-action="toggle->date-picker#toggled keydown->date-picker#navigate click->date-picker#choose">
<div class="isDatePickerHeader">
<button type="button" class="isDatePickerMonth" aria-label="{{ previousLabel }}" data-action="date-picker#previous"><span aria-hidden="true">‹</span></button>
<span class="isDatePickerHeading" id="{{ id }}-heading" aria-live="polite" data-date-picker-target="heading"></span>
<button type="button" class="isDatePickerMonth" aria-label="{{ nextLabel }}" data-action="date-picker#next"><span aria-hidden="true">›</span></button>
</div>
<table class="isDatePickerGrid" role="grid" aria-labelledby="{{ id }}-heading"><thead><tr data-date-picker-target="weekdays"></tr></thead><tbody data-date-picker-target="weeks"></tbody></table>
</div>
</div>