blob: 73a56788f4dd2e2dd874a04c2c6f532d8471bf08 [file] [log] [blame]
/**
* @module modules/calendar-sk
* @description <h2><code>calendar-sk</code></h2>
*
* Displays an accessible calendar, one month at a time, and allows selecting a
* single day. Offers the ability to navigate by both month and year. Also is
* themeable and offers keyboard navigation.
*
* Why not use input type="date"? It doesn't work on Safari, and the pop-up
* calendar isn't styleable.
*
* Why not use the Elix web component? It is not themeable (at least not
* easily), and it is also inaccessible.
*
* Accessibility advice was derived from this page:
* https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/datepicker-dialog.html
*
* @evt change - A CustomEvent with the selected Date in the detail.
*
* This element provides a keyboardHandler callback that should be attached and
* detached to/from the appropriate containing element when it is used, for
* example, a containing 'dialog' element.
*/
import { define } from 'elements-sk/define';
import { html, TemplateResult } from 'lit-html';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import 'elements-sk/styles/buttons';
import 'elements-sk/icon/navigate-before-icon-sk';
import 'elements-sk/icon/navigate-next-icon-sk';
/*
* Most of the Date[1] object's methods return zero-indexed values, with exceptions such as
* Date.prototype.getDate() and Date.prototype.getYear().
*
* To make things easier to follow, we suffix zero-indexed values returned by a
* Date object with "Index", e.g.:
*
* const dayIndex = date.getDay(); // (0-6).
* const monthIndex = date.getMonth(); // (0-11).
*
* [1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
*/
const getNumberOfDaysInMonth = (year: number, monthIndex: number) => {
// Jump forward one month, and back one day to get the last day of the month.
// Since days are 1-indexed, a value of 0 represents the last day of the
// previous month.
return new Date(year, monthIndex + 1, 0).getDate();
};
// Returns a day of the week [0-6]
const firstDayIndexOfMonth = (year: number, monthIndex: number): number => {
return new Date(year, monthIndex).getDay();
};
// Used in templates.
const sevenDaysInAWeek = [0, 1, 2, 3, 4, 5, 6];
// To display a month we need to display up to 6 weeks. Used in templates.
const sixWeeks = [0, 1, 2, 3, 4, 5];
// The dates that CalendarSk manipulates, always in local time.
class CalendarDate {
year: number;
monthIndex: number;
date: number;
constructor(d: Date) {
this.year = d.getFullYear();
this.monthIndex = d.getMonth();
this.date = d.getDate();
}
equal(d: CalendarDate) {
return (
this.year === d.year &&
this.monthIndex === d.monthIndex &&
this.date === d.date
);
}
}
export class CalendarSk extends ElementSk {
private static template = (ele: CalendarSk) => html`
<table class="calendar">
<tr>
<th>
<button
@click=${ele.decYear}
aria-label="Previous year"
title="Previous year"
id="previous-year"
>
<navigate-before-icon-sk></navigate-before-icon-sk>
</button>
</th>
<th colspan="5">
<h2 aria-live="polite" id="calendar-year">
${new Intl.DateTimeFormat(ele._locale, { year: 'numeric' }).format(
ele._displayDate
)}
</h2>
</th>
<th>
<button
@click=${ele.incYear}
aria-label="Next year"
title="Next year"
id="next-year"
>
<navigate-next-icon-sk></navigate-next-icon-sk>
</button>
</th>
</tr>
<tr>
<th>
<button
@click=${ele.decMonth}
aria-label="Previous month"
title="Previous month"
id="previous-month"
>
<navigate-before-icon-sk></navigate-before-icon-sk>
</button>
</th>
<th colspan="5">
<h2 aria-live="polite" id="calendar-month">
${new Intl.DateTimeFormat(ele._locale, { month: 'long' }).format(
ele._displayDate
)}
</h2>
</th>
<th>
<button
@click=${ele.incMonth}
aria-label="Next month"
title="Next month"
id="next-month"
>
<navigate-next-icon-sk></navigate-next-icon-sk>
</button>
</th>
</tr>
${ele._weekDayHeader}
${sixWeeks.map((i) => CalendarSk.rowTemplate(ele, i))}
</table>
`;
private static buttonForDateTemplate = (
ele: CalendarSk,
date: number,
daysInMonth: number,
selected: boolean
) => {
if (date < 1 || date > daysInMonth) {
return html``;
}
return html`
<button
@click=${ele.dateClick}
data-date=${date}
tabindex=${selected ? 0 : -1}
aria-selected=${selected}
>
${date}
</button>
`;
};
private static rowTemplate = (ele: CalendarSk, weekIndex: number) => {
const year = ele.displayYear();
const monthIndex = ele.displayMonthIndex();
const today = new CalendarDate(new Date());
// If June starts on a Tuesday then IndexOfTheFirstDayOfTheMonth = 2,
// which means if we are filling in the first row we want to leave the first
// two days (Sunday and Monday) blank.
const firstDayOfTheMonthIndex = firstDayIndexOfMonth(year, monthIndex);
const daysInMonth = getNumberOfDaysInMonth(year, monthIndex);
const selectedDate = ele._displayDate.getDate();
const currentDate = new CalendarDate(ele._displayDate);
return html`
<tr>
${sevenDaysInAWeek.map((i) => {
const date = 7 * weekIndex + i + 1 - firstDayOfTheMonthIndex;
currentDate.date = date;
const selected = selectedDate === date;
return html`
<td
class="
${currentDate.equal(today) ? 'today' : ''}
${selected ? 'selected' : ''}
"
>
${CalendarSk.buttonForDateTemplate(
ele,
date,
daysInMonth,
selected
)}
</td>
`;
})}
</tr>
`;
};
private _displayDate: Date = new Date();
private _weekDayHeader: TemplateResult = html``;
private _locale: string | string[] | undefined = undefined;
constructor() {
super(CalendarSk.template);
}
connectedCallback() {
super.connectedCallback();
this.buildWeekDayHeader();
this._render();
}
/**
* Attach this handler to the 'keydown' event on the appropriate elements
* parent, such as document or a 'dialog' element.
*
* Allows finer grained control of keyboard events on a page with more
* than one keyboard listener.
*/
keyboardHandler(e: KeyboardEvent) {
let keyHandled = true;
switch (e.code) {
case 'PageUp':
this.decMonth();
break;
case 'PageDown':
this.incMonth();
break;
case 'ArrowRight':
this.incDay();
break;
case 'ArrowLeft':
this.decDay();
break;
case 'ArrowUp':
this.decWeek();
break;
case 'ArrowDown':
this.incWeek();
break;
default:
keyHandled = false;
break;
}
if (keyHandled) {
e.stopPropagation();
e.preventDefault();
this.querySelector<HTMLButtonElement>(
'button[aria-selected="true"]'
)!.focus();
}
}
buildWeekDayHeader() {
// March 1, 2020 falls on a Sunday, use that to generate the week day headers.
const narrowFormatter = new Intl.DateTimeFormat(this._locale, {
weekday: 'narrow',
});
const longFormatter = new Intl.DateTimeFormat(this._locale, {
weekday: 'long',
});
this._weekDayHeader = html`
<tr class="weekdayHeader">
${sevenDaysInAWeek.map(
(i) =>
html`
<td>
<span abbr="${longFormatter.format(new Date(2020, 2, i + 1))}">
${narrowFormatter.format(new Date(2020, 2, i + 1))}
</span>
</td>
`
)}
</tr>
`;
}
private dateClick(e: MouseEvent) {
const d = new Date(this._displayDate);
d.setDate(+(e.target as HTMLButtonElement).dataset.date!);
this.dispatchEvent(
new CustomEvent<Date>('change', {
detail: d,
bubbles: true,
})
);
this._displayDate = d;
this._render();
}
private displayYear(): number {
return this._displayDate.getFullYear();
}
private displayMonthIndex(): number {
return this._displayDate.getMonth();
}
private incYear() {
const year = this.displayYear();
const monthIndex = this.displayMonthIndex();
let date = this._displayDate.getDate();
const daysInMonth = getNumberOfDaysInMonth(year + 1, monthIndex);
if (date > daysInMonth) {
date = daysInMonth;
}
this._displayDate = new Date(year + 1, monthIndex, date);
this._render();
}
private decYear() {
const year = this.displayYear();
const monthIndex = this.displayMonthIndex();
let date = this._displayDate.getDate();
const daysInMonth = getNumberOfDaysInMonth(year - 1, monthIndex);
if (date > daysInMonth) {
date = daysInMonth;
}
this._displayDate = new Date(year - 1, monthIndex, date);
this._render();
}
private incMonth() {
let year = this.displayYear();
let monthIndex = this.displayMonthIndex();
let date = this._displayDate.getDate();
monthIndex += 1;
if (monthIndex > 11) {
monthIndex = 0;
year += 1;
}
const daysInMonth = getNumberOfDaysInMonth(year, monthIndex);
if (date > daysInMonth) {
date = daysInMonth;
}
this._displayDate = new Date(year, monthIndex, date);
this._render();
}
private decMonth() {
let year = this.displayYear();
let monthIndex = this.displayMonthIndex();
let date = this._displayDate.getDate();
monthIndex -= 1;
if (monthIndex < 0) {
monthIndex = 11;
year -= 1;
}
const daysInMonth = getNumberOfDaysInMonth(year, monthIndex);
if (date > daysInMonth) {
date = daysInMonth;
}
this._displayDate = new Date(year, monthIndex, date);
this._render();
}
private incDay() {
const year = this.displayYear();
const monthIndex = this.displayMonthIndex();
const date = this._displayDate.getDate();
this._displayDate = new Date(year, monthIndex, date + 1);
this._render();
}
private decDay() {
const year = this.displayYear();
const monthIndex = this.displayMonthIndex();
const date = this._displayDate.getDate();
this._displayDate = new Date(year, monthIndex, date - 1);
this._render();
}
private incWeek() {
const year = this.displayYear();
const monthIndex = this.displayMonthIndex();
const date = this._displayDate.getDate();
this._displayDate = new Date(year, monthIndex, date + 7);
this._render();
}
private decWeek() {
const year = this.displayYear();
const monthIndex = this.displayMonthIndex();
const date = this._displayDate.getDate();
this._displayDate = new Date(year, monthIndex, date - 7);
this._render();
}
/** The date to display on the calendar. */
get displayDate() {
return this._displayDate;
}
set displayDate(v) {
this._displayDate = v;
this._render();
}
/**
* Leave as undefined to use the browser settings. Only really used for
* testing.
*/
public get locale() {
return this._locale;
}
public set locale(v) {
this._locale = v;
this.buildWeekDayHeader();
this._render();
}
}
define('calendar-sk', CalendarSk);