/* Date picker dialog (WAI-ARIA APG pattern). The field button opens a modal dialog with a role="grid" month.
Arrow keys move a day or a week, Home/End go to the start/end of the week, Page Up/Page Down change month,
Enter or Space picks the focused day, Escape closes, and Tab cycles inside the dialog. Focus returns to the field. */
(() => {
const root = document.querySelector('[data-dp]'); if (!root) return;
const field = root.querySelector('[aria-haspopup="dialog"]'), val = root.querySelector('[data-value]'), input = root.querySelector('input[type="hidden"]');
const dlg = root.querySelector('[role="dialog"]'), title = dlg.querySelector('[data-month]'), body = dlg.querySelector('tbody');
const CLS = { day: 'grid aspect-square w-full cursor-pointer place-items-center rounded-[9px] text-[.8rem] font-semibold text-[#2c3546] transition-colors duration-[120ms] hover:bg-[#eef2f8] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dp-accent aria-[current=date]:text-dp-accent aria-[current=date]:shadow-[inset_0_0_0_2px_var(--color-dp-accent)] aria-pressed:bg-dp-accent aria-pressed:text-white aria-pressed:shadow-none aria-pressed:hover:bg-dp-accent' };
const full = d => d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const same = (a, b) => !!a && !!b && a.toDateString() === b.toDateString();
const addMonths = (d, n) => { const t = new Date(d.getFullYear(), d.getMonth() + n, 1);
t.setDate(Math.min(d.getDate(), new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate())); return t; };
let view = new Date(), focus = new Date(), sel = null;
function render(moveFocus) {
const y = view.getFullYear(), m = view.getMonth(), today = new Date();
if (focus.getMonth() !== m || focus.getFullYear() !== y) focus = new Date(y, m, 1);
title.textContent = view.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const first = (new Date(y, m, 1).getDay() + 6) % 7, days = new Date(y, m + 1, 0).getDate();
let h = '';
for (let i = 0; i < Math.ceil((first + days) / 7) * 7; i++) {
const n = i - first + 1, d = new Date(y, m, n);
if (i % 7 === 0) h += '<tr>';
h += n < 1 || n > days ? '<td></td>'
: `<td><button type="button" class="${CLS.day}" data-day="${n}" tabindex="${same(d, focus) ? 0 : -1}" aria-label="${full(d)}" ` +
`aria-pressed="${same(d, sel)}"${same(d, today) ? ' aria-current="date"' : ''}>${n}</button></td>`;
if (i % 7 === 6) h += '</tr>';
}
body.innerHTML = h;
if (moveFocus) body.querySelector('[tabindex="0"]').focus();
}
function open() {
focus = sel ? new Date(sel) : new Date(); view = new Date(focus.getFullYear(), focus.getMonth(), 1);
dlg.hidden = false; field.setAttribute('aria-expanded', 'true'); render(true);
}
function close(refocus) { dlg.hidden = true; field.setAttribute('aria-expanded', 'false'); if (refocus) field.focus(); }
function choose(d) {
sel = d; input.value = d ? iso(d) : '';
val.textContent = d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Select a date';
val.toggleAttribute('data-empty', !d); close(true);
}
function go(d) { focus = d; view = new Date(d.getFullYear(), d.getMonth(), 1); render(true); }
field.addEventListener('click', () => (dlg.hidden ? open() : close(false)));
dlg.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.preventDefault(); close(true); return; }
if (e.key === 'Tab') { // keep focus inside the modal dialog
const f = [...dlg.querySelectorAll('button:not([tabindex="-1"])')], i = f.indexOf(document.activeElement);
if (e.shiftKey && i === 0) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
return;
}
if (!e.target.matches('button[data-day]')) return;
const f = focus, wd = (f.getDay() + 6) % 7, at = n => new Date(f.getFullYear(), f.getMonth(), f.getDate() + n);
const d = { ArrowLeft: () => at(-1), ArrowRight: () => at(1), ArrowUp: () => at(-7), ArrowDown: () => at(7),
Home: () => at(-wd), End: () => at(6 - wd), PageUp: () => addMonths(f, -1), PageDown: () => addMonths(f, 1) }[e.key];
if (!d) return;
e.preventDefault(); go(d());
});
body.addEventListener('click', e => {
const b = e.target.closest('button[data-day]');
if (b) choose(new Date(view.getFullYear(), view.getMonth(), Number(b.dataset.day)));
});
dlg.querySelectorAll('[data-move]').forEach(b => b.addEventListener('click', () => {
view.setMonth(view.getMonth() + Number(b.dataset.move)); render(false);
}));
dlg.querySelector('[data-clear]').addEventListener('click', () => choose(null));
dlg.querySelector('[data-today]').addEventListener('click', () => { const t = new Date(); choose(new Date(t.getFullYear(), t.getMonth(), t.getDate())); });
document.addEventListener('click', e => { if (!dlg.hidden && !root.contains(e.target) && e.target.isConnected) close(false); });
})();
About this set
A form field that opens a calendar, for booking forms, travel search, checkout delivery dates and admin filters. The field looks like a text input with a calendar icon and shows either a muted “Select a date” or the chosen date, such as “22 October 2026″. Pressing it opens a popup card with the month name, previous and next buttons, a month grid where today is ringed in violet and the selection filled, and Clear and Today actions at the bottom. It follows the WAI-ARIA date picker dialog pattern: the popup is a modal dialog, the month is a table with role=”grid”, and focus moves straight to the selected day or today. Arrow keys move by day or week, Home and End jump within the week, Page Up and Page Down change month, Enter or Space picks a day, and Escape closes; Tab cycles through the dialog’s controls, and focus returns to the field when the dialog closes. A hidden input holds the ISO date, so the value submits with a form. Everything runs on about seventy lines of vanilla JavaScript. The Bootstrap version styles the field as .form-control and the popup as a .card without Bootstrap’s JavaScript; the Tailwind version uses aria-expanded, aria-pressed and data variants.
What’s included
Field button that opens a modal date dialog
Full keyboard support: arrows, Home, End, Page keys, Enter, Escape
Focus trapped in the dialog and returned to the field
Hidden input submits an ISO date
Clear and Today shortcuts
Accessibility
The field is a button with aria-haspopup="dialog" and aria-expanded, named by its label and current value ("Departure date 22 October 2026"). The popup is role="dialog" with aria-modal and an aria-label; its grid has one roving tab stop, day buttons named with the full date, aria-pressed for the selection and aria-current="date" for today. The month title is a live region, the month buttons have aria-labels, every control shows a 2px violet focus outline, and transitions are removed under prefers-reduced-motion. The field border was darkened to reach 3:1.
Customise it
Set --dp-accent and --dp-muted on .dp and the 300px width. In Bootstrap the field is restyled through --bs-border-color on .form-control and the buttons through --bs-btn-* variables; in Tailwind edit --color-dp-accent and --color-dp-muted.