Aller au contenu

DatePicker

composant

The date field of the browser, which works alone, and a button opening a month on a grid, in a popover.

Aperçu

Clair

Code
<twig:DatePicker id="start" name="start" label="Start" min="2026-01-01" />

Sombre

Code
<twig:DatePicker id="start" name="start" label="Start" min="2026-01-01" />

Essayer

Changez les propriétés ; le serveur rend l’exemple de nouveau.

<twig:DatePicker id="start" name="start" label="Start" min="2026-01-01" />

Variantes

required

Whether a date has to be given.

required="false"
required="true"

disabled

Whether the field cannot be changed for now.

disabled="false"
disabled="true"

Installation

php bin/console ux:install date-picker --kit=github.com/parler-haut-interagir-librement/ux-kit

Propriétés

PropriétéTypePar défautDescription
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.

Fichiers à copier

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">&lsaquo;</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">&rsaquo;</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>