APG date picker dialog
- <dialog>
- role="grid"
- aria-selected:
- aria-[current=date]:
- data-outside:
- backdrop:
Click the calendar button, then use the arrow keys, Page Up, Page Down, Enter and Escape.
The W3C APG date picker dialog pattern on a native dialog opened with showModal(), so Escape, the backdrop:bg-slate-900/40 overlay and the focus return are handled by the browser. The month is a role="grid" of gridcells with a roving tabindex: arrow keys move by day and week, Home and End jump to the week edges, Page Up and Page Down change the month (with Shift, the year), and Enter picks the date. Tailwind styles every state from attributes: aria-selected: for the chosen date, aria-[current=date]: for today and data-outside: for the neighboring months.
View code
<div class="w-full max-w-sm rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/5">
<label for="tcl-dp-input" class="block text-sm font-semibold text-slate-900">Delivery date</label>
<p id="tcl-dp-help" class="text-sm text-slate-600">Format: mm/dd/yyyy</p>
<div class="mt-2 flex gap-2">
<input id="tcl-dp-input" type="text" inputmode="numeric" autocomplete="off" aria-describedby="tcl-dp-help" value="06/15/2027" class="min-h-11 w-full min-w-0 rounded-lg border border-slate-300 bg-white px-3 text-slate-900 tabular-nums focus-visible:border-teal-700 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-teal-700">
<button type="button" id="tcl-dp-open" aria-label="Change date, Tuesday, June 15, 2027" class="grid size-11 shrink-0 place-items-center rounded-lg bg-teal-700 text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="size-5"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
</button>
</div>
<p class="mt-3 text-sm text-slate-600">We deliver Monday to Saturday between 8 AM and 6 PM.</p>
<dialog id="tcl-dp-dialog" aria-modal="true" aria-labelledby="tcl-dp-title" class="m-auto w-[min(22rem,calc(100vw-2rem))] rounded-2xl bg-white p-4 text-slate-900 shadow-xl backdrop:bg-slate-900/40">
<div class="flex items-center gap-1">
<button type="button" class="tcl-dp-nav grid size-9 place-items-center rounded-lg text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-teal-700" data-step="-12" aria-label="Previous year"><svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" class="size-5"><path d="m10 5-5 5 5 5M15 5l-5 5 5 5"/></svg></button>
<button type="button" class="tcl-dp-nav grid size-9 place-items-center rounded-lg text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-teal-700" data-step="-1" aria-label="Previous month"><svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" class="size-5"><path d="m12 5-5 5 5 5"/></svg></button>
<h2 id="tcl-dp-title" aria-live="polite" class="flex-1 text-center text-base font-semibold">June 2027</h2>
<button type="button" class="tcl-dp-nav grid size-9 place-items-center rounded-lg text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-teal-700" data-step="1" aria-label="Next month"><svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" class="size-5"><path d="m8 5 5 5-5 5"/></svg></button>
<button type="button" class="tcl-dp-nav grid size-9 place-items-center rounded-lg text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-teal-700" data-step="12" aria-label="Next year"><svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" class="size-5"><path d="m10 5 5 5-5 5M5 5l5 5-5 5"/></svg></button>
</div>
<div id="tcl-dp-grid" role="grid" aria-labelledby="tcl-dp-title" class="mt-3 grid gap-1"></div>
<div class="mt-4 flex justify-end gap-2 border-t border-slate-100 pt-3">
<button type="button" id="tcl-dp-cancel" class="min-h-10 rounded-lg px-4 text-sm font-semibold text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-teal-700">Cancel</button>
<button type="button" id="tcl-dp-ok" class="min-h-10 rounded-lg bg-teal-700 px-4 text-sm font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700">OK</button>
</div>
</dialog>
</div>(function () {
var input = document.getElementById('tcl-dp-input');
var openBtn = document.getElementById('tcl-dp-open');
var dialog = document.getElementById('tcl-dp-dialog');
var title = document.getElementById('tcl-dp-title');
var grid = document.getElementById('tcl-dp-grid');
var today = new Date(2027, 5, 15);
var selected = new Date(2027, 5, 15);
var focusDate = selected;
var monthFmt = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' });
var longFmt = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
var shortWd = new Intl.DateTimeFormat('en-US', { weekday: 'short' });
var longWd = new Intl.DateTimeFormat('en-US', { weekday: 'long' });
var dayClass = 'mx-auto grid size-9 cursor-pointer place-items-center rounded-full text-sm text-slate-900 tabular-nums select-none hover:bg-teal-50 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-700 sm:size-10 data-outside:text-slate-500 aria-[current=date]:font-bold aria-[current=date]:ring-1 aria-[current=date]:ring-teal-700 aria-selected:bg-teal-700 aria-selected:text-white aria-selected:hover:bg-teal-800';
function same(a, b) {
return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function addDays(d, n) { return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); }
function addMonths(d, n) {
var last = new Date(d.getFullYear(), d.getMonth() + n + 1, 0).getDate();
return new Date(d.getFullYear(), d.getMonth() + n, Math.min(d.getDate(), last));
}
function iso(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function fromIso(s) {
var p = s.split('-');
return new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
}
function nextFocus(e, d) {
switch (e.key) {
case 'ArrowLeft': return addDays(d, -1);
case 'ArrowRight': return addDays(d, 1);
case 'ArrowUp': return addDays(d, -7);
case 'ArrowDown': return addDays(d, 7);
case 'Home': return addDays(d, -d.getDay());
case 'End': return addDays(d, 6 - d.getDay());
case 'PageUp': return addMonths(d, e.shiftKey ? -12 : -1);
case 'PageDown': return addMonths(d, e.shiftKey ? 12 : 1);
}
return null;
}
function weekHeader(cls) {
var head = document.createElement('div');
head.setAttribute('role', 'row');
head.className = 'grid grid-cols-7';
for (var i = 0; i < 7; i++) {
var wd = new Date(2027, 5, 6 + i);
var th = document.createElement('div');
th.setAttribute('role', 'columnheader');
th.className = cls;
var abbr = document.createElement('span');
abbr.setAttribute('aria-hidden', 'true');
abbr.textContent = shortWd.format(wd).slice(0, 2);
var full = document.createElement('span');
full.className = 'sr-only';
full.textContent = longWd.format(wd);
th.append(abbr, full);
head.appendChild(th);
}
return head;
}
function pad(n) { return String(n).padStart(2, '0'); }
function toText(d) { return pad(d.getMonth() + 1) + '/' + pad(d.getDate()) + '/' + d.getFullYear(); }
function parse(v) {
var m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(v.trim());
if (!m) return null;
var d = new Date(Number(m[3]), Number(m[1]) - 1, Number(m[2]));
return d.getMonth() === Number(m[1]) - 1 ? d : null;
}
function render() {
var label = monthFmt.format(focusDate);
if (title.textContent !== label) title.textContent = label;
grid.textContent = '';
grid.appendChild(weekHeader('py-1 text-center text-xs font-semibold text-slate-600'));
var first = new Date(focusDate.getFullYear(), focusDate.getMonth(), 1);
var start = addDays(first, -first.getDay());
for (var w = 0; w < 6; w++) {
var row = document.createElement('div');
row.setAttribute('role', 'row');
row.className = 'grid grid-cols-7';
for (var c = 0; c < 7; c++) {
var d = addDays(start, w * 7 + c);
var cell = document.createElement('div');
cell.setAttribute('role', 'gridcell');
cell.className = dayClass;
cell.textContent = d.getDate();
cell.setAttribute('aria-label', longFmt.format(d));
cell.setAttribute('data-date', iso(d));
cell.tabIndex = same(d, focusDate) ? 0 : -1;
if (d.getMonth() !== focusDate.getMonth()) cell.setAttribute('data-outside', '');
if (same(d, today)) cell.setAttribute('aria-current', 'date');
if (same(d, selected)) cell.setAttribute('aria-selected', 'true');
row.appendChild(cell);
}
grid.appendChild(row);
}
}
function focusCell() {
var cell = grid.querySelector('[tabindex="0"]');
if (cell) cell.focus();
}
function moveTo(d, keepFocus) {
focusDate = d;
render();
if (!keepFocus) focusCell();
}
function choose(d) {
selected = d;
input.value = toText(d);
openBtn.setAttribute('aria-label', 'Change date, ' + longFmt.format(d));
dialog.close();
}
openBtn.addEventListener('click', function () {
selected = parse(input.value) || selected;
focusDate = selected;
render();
dialog.showModal();
focusCell();
});
dialog.addEventListener('close', function () { openBtn.focus(); });
grid.addEventListener('click', function (e) {
var cell = e.target.closest('[role="gridcell"]');
if (cell) choose(fromIso(cell.getAttribute('data-date')));
});
grid.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
choose(focusDate);
return;
}
var d = nextFocus(e, focusDate);
if (d) {
e.preventDefault();
moveTo(d);
}
});
document.querySelectorAll('.tcl-dp-nav').forEach(function (btn) {
btn.addEventListener('click', function () { moveTo(addMonths(focusDate, Number(btn.getAttribute('data-step'))), true); });
});
document.getElementById('tcl-dp-cancel').addEventListener('click', function () { dialog.close(); });
document.getElementById('tcl-dp-ok').addEventListener('click', function () { choose(focusDate); });
})();