diff --git a/packages/calendar/README.md b/packages/calendar/README.md
new file mode 100644
index 000000000..1d4d7330d
--- /dev/null
+++ b/packages/calendar/README.md
@@ -0,0 +1,44 @@
+# Calendar
+
+[//]: # (AUTO INSERT HEADER PREPUBLISH)
+
+`lion-calendar` is a reusable and accessible calendar view.
+
+## Features
+
+- fully accessible keyboard navigation (Arrow Keys, PgUp, PgDn, ALT+PgUp, ALT+PgDn)
+- **minDate**: disables all dates before a given date
+- **maxDate**: disables all dates after a given date
+- **disableDates**: disables some dates within an available range
+- **selectedDate**: currently selected date
+- **centralDate**: date that determines the currently visible month and that will be focused when keyboard moves the focus to the month grid
+- **focusedDate**: (getter only) currently focused date (if there is any with real focus)
+- **focusDate(date)**: focus on a certain date
+- **focusSelectedDate()**: focus on the current selected date
+- **focusCentralDate()**: focus on the current central date
+- **firstDayOfWeek**: typically Sunday (default) or Monday
+- **weekdayHeaderNotation**: long/short/narrow for the current locale (e.g. Thursday/Thu/T)
+- **locale**: different locale for the current component only
+
+## How to use
+
+### Installation
+
+```sh
+npm i --save @lion/calendar
+```
+
+```js
+import '@lion/calendar/lion-calendar.js';
+```
+
+### Example
+
+```html
+ day.getDay() === 6 || day.getDay() === 0}
+>
+
+```
diff --git a/packages/calendar/index.js b/packages/calendar/index.js
new file mode 100644
index 000000000..0c43d6e22
--- /dev/null
+++ b/packages/calendar/index.js
@@ -0,0 +1 @@
+export { LionCalendar } from './src/LionCalendar.js';
diff --git a/packages/calendar/lion-calendar.js b/packages/calendar/lion-calendar.js
new file mode 100644
index 000000000..8ce7a637b
--- /dev/null
+++ b/packages/calendar/lion-calendar.js
@@ -0,0 +1,3 @@
+import { LionCalendar } from './src/LionCalendar.js';
+
+customElements.define('lion-calendar', LionCalendar);
diff --git a/packages/calendar/package.json b/packages/calendar/package.json
new file mode 100644
index 000000000..4b572f6b4
--- /dev/null
+++ b/packages/calendar/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@lion/calendar",
+ "version": "0.0.0",
+ "description": "Reusable calendar component",
+ "author": "ing-bank",
+ "homepage": "https://github.com/ing-bank/lion/",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ing-bank/lion.git",
+ "directory": "packages/calendar"
+ },
+ "scripts": {
+ "prepublishOnly": "../../scripts/npm-prepublish.js"
+ },
+ "keywords": [
+ "lion",
+ "web-components",
+ "calendar"
+ ],
+ "main": "index.js",
+ "module": "index.js",
+ "files": [
+ "src",
+ "stories",
+ "test",
+ "translations",
+ "*.js"
+ ],
+ "dependencies": {
+ "@lion/core": "^0.1.4",
+ "@lion/localize": "^0.1.7"
+ },
+ "devDependencies": {
+ "@lion/button": "^0.1.7",
+ "@open-wc/demoing-storybook": "^0.2.0",
+ "@open-wc/testing": "^0.11.1",
+ "sinon": "^7.2.2"
+ }
+}
diff --git a/packages/calendar/src/LionCalendar.js b/packages/calendar/src/LionCalendar.js
new file mode 100644
index 000000000..32ddf6fb0
--- /dev/null
+++ b/packages/calendar/src/LionCalendar.js
@@ -0,0 +1,547 @@
+import { html, LitElement } from '@lion/core';
+import { localize, getWeekdayNames, getMonthNames, LocalizeMixin } from '@lion/localize';
+import { createMultipleMonth } from './utils/createMultipleMonth.js';
+import { dayTemplate } from './utils/dayTemplate.js';
+import { dataTemplate } from './utils/dataTemplate.js';
+import { getFirstDayNextMonth } from './utils/getFirstDayNextMonth.js';
+import { getLastDayPreviousMonth } from './utils/getLastDayPreviousMonth.js';
+import { isSameDate } from './utils/isSameDate.js';
+import { calendarStyle } from './calendarStyle.js';
+import './utils/differentKeyNamesShimIE.js';
+import { createDay } from './utils/createDay.js';
+
+/**
+ * @customElement
+ */
+export class LionCalendar extends LocalizeMixin(LitElement) {
+ static get localizeNamespaces() {
+ return [
+ {
+ 'lion-calendar': locale => {
+ switch (locale) {
+ case 'bg-BG':
+ case 'bg':
+ return import('../translations/bg.js');
+ case 'cs-CZ':
+ case 'cs':
+ return import('../translations/cs.js');
+ case 'de-AT':
+ case 'de-DE':
+ case 'de':
+ return import('../translations/de.js');
+ case 'en-AU':
+ case 'en-GB':
+ case 'en-US':
+ case 'en':
+ return import('../translations/en.js');
+ case 'es-ES':
+ case 'es':
+ return import('../translations/es.js');
+ case 'fr-FR':
+ case 'fr-BE':
+ case 'fr':
+ return import('../translations/fr.js');
+ case 'hu-HU':
+ case 'hu':
+ return import('../translations/hu.js');
+ case 'it-IT':
+ case 'it':
+ return import('../translations/it.js');
+ case 'nl-BE':
+ case 'nl-NL':
+ case 'nl':
+ return import('../translations/nl.js');
+ case 'pl-PL':
+ case 'pl':
+ return import('../translations/pl.js');
+ case 'ro-RO':
+ case 'ro':
+ return import('../translations/ro.js');
+ case 'ru-RU':
+ case 'ru':
+ return import('../translations/ru.js');
+ case 'sk-SK':
+ case 'sk':
+ return import('../translations/sk.js');
+ case 'uk-UA':
+ case 'uk':
+ return import('../translations/uk.js');
+ default:
+ throw new Error(`Unknown locale: ${locale}`);
+ }
+ },
+ },
+ ...super.localizeNamespaces,
+ ];
+ }
+
+ static get properties() {
+ return {
+ /**
+ * Minimum date. All dates before will be disabled
+ */
+ minDate: { type: Date },
+
+ /**
+ * Maximum date. All dates after will be disabled
+ */
+ maxDate: { type: Date },
+
+ /**
+ * Disable certain dates
+ */
+ disableDates: { type: Function },
+
+ /**
+ * The selected date, usually synchronized with datepicker-input
+ * Not to be confused with the focused date (therefore not necessarily in active month view)
+ */
+ selectedDate: { type: Date },
+
+ /**
+ * The date that
+ * 1. determines the currently visible month
+ * 2. will be focused when the month grid gets focused by the keyboard
+ */
+ centralDate: { type: Date },
+
+ /**
+ * Weekday that will be displayed in first column of month grid.
+ * 0: sunday, 1: monday, 2: tuesday, 3: wednesday , 4: thursday, 5: friday, 6: saturday
+ * Default is 0
+ */
+ firstDayOfWeek: { type: Number },
+
+ /**
+ * Weekday header notation, based on Intl DatetimeFormat:
+ * - 'long' (e.g., Thursday)
+ * - 'short' (e.g., Thu)
+ * - 'narrow' (e.g., T).
+ * Default is 'short'
+ */
+ weekdayHeaderNotation: { type: String },
+
+ /**
+ * Different locale for this component scope
+ */
+ locale: { type: String },
+
+ /**
+ * The currently focused date (if any)
+ */
+ __focusedDate: { type: Date },
+
+ /**
+ * Data to render current month grid
+ */
+ __data: { type: Object },
+ };
+ }
+
+ constructor() {
+ super();
+ // Defaults
+ this.__data = {};
+ this.minDate = null;
+ this.maxDate = null;
+ this.dayPreprocessor = day => day;
+ this.disableDates = () => false;
+ this.firstDayOfWeek = 0;
+ this.weekdayHeaderNotation = 'short';
+ this.__today = new Date();
+ this.centralDate = this.__today;
+ this.__focusedDate = null;
+ this.__connectedCallbackDone = false;
+ }
+
+ static get styles() {
+ return [calendarStyle];
+ }
+
+ render() {
+ return html`
+
+ ${this.__renderHeader()} ${this.__renderData()}
+
+ `;
+ }
+
+ get focusedDate() {
+ return this.__focusedDate;
+ }
+
+ goToNextMonth() {
+ this.__modifyDate(1, { dateType: 'centralDate', type: 'Month', mode: 'both' });
+ }
+
+ goToPreviousMonth() {
+ this.__modifyDate(-1, { dateType: 'centralDate', type: 'Month', mode: 'both' });
+ }
+
+ async focusDate(date) {
+ this.centralDate = date;
+ await this.updateComplete;
+ this.focusCentralDate();
+ }
+
+ focusCentralDate() {
+ const button = this.shadowRoot.querySelector('button[tabindex="0"]');
+ button.focus();
+ this.__focusedDate = this.centralDate;
+ }
+
+ async focusSelectedDate() {
+ await this.focusDate(this.selectedDate);
+ }
+
+ connectedCallback() {
+ // eslint-disable-next-line wc/guard-super-call
+ super.connectedCallback();
+
+ this.__connectedCallbackDone = true;
+
+ this.__calculateInitialCentralDate();
+
+ // setup data for initial render
+ this.__data = this.__createData();
+ }
+
+ disconnectedCallback() {
+ if (super.disconnectedCallback) {
+ super.disconnectedCallback();
+ }
+ this.__removeEventDelegations();
+ }
+
+ firstUpdated() {
+ super.firstUpdated();
+ this.__contentWrapperElement = this.shadowRoot.getElementById('js-content-wrapper');
+
+ this.__addEventDelegationForClickDate();
+ this.__addEventDelegationForFocusDate();
+ this.__addEventDelegationForBlurDate();
+ this.__addEventForKeyboardNavigation();
+ }
+
+ updated(changed) {
+ if (changed.has('__focusedDate') && this.__focusedDate) {
+ this.focusCentralDate();
+ }
+ }
+
+ /**
+ * @override
+ */
+ _requestUpdate(name, oldValue) {
+ super._requestUpdate(name, oldValue);
+
+ const map = {
+ disableDates: () => this.__disableDatesChanged(),
+ centralDate: () => this.__centralDateChanged(),
+ __focusedDate: () => this.__focusedDateChanged(),
+ };
+ if (map[name]) {
+ map[name]();
+ }
+
+ const updateDataOn = ['centralDate', 'minDate', 'maxDate', 'selectedDate', 'disableDates'];
+
+ if (updateDataOn.includes(name) && this.__connectedCallbackDone) {
+ this.__data = this.__createData();
+ }
+ }
+
+ __calculateInitialCentralDate() {
+ if (this.centralDate === this.__today && this.selectedDate) {
+ // initialised with selectedDate only if user didn't provide another one
+ this.centralDate = this.selectedDate;
+ } else {
+ this.__ensureValidCentralDate();
+ }
+ }
+
+ __renderHeader() {
+ const month = getMonthNames({ locale: this.__getLocale() })[this.centralDate.getMonth()];
+ const year = this.centralDate.getFullYear();
+ return html`
+
+ `;
+ }
+
+ __renderData() {
+ return dataTemplate(this.__data, {
+ monthsLabels: getMonthNames({ locale: this.__getLocale() }),
+ weekdaysShort: getWeekdayNames({
+ locale: this.__getLocale(),
+ style: this.weekdayHeaderNotation,
+ firstDayOfWeek: this.firstDayOfWeek,
+ }),
+ weekdays: getWeekdayNames({
+ locale: this.__getLocale(),
+ style: 'long',
+ firstDayOfWeek: this.firstDayOfWeek,
+ }),
+ dayTemplate,
+ });
+ }
+
+ __renderPreviousButton() {
+ return html`
+
+ `;
+ }
+
+ __renderNextButton() {
+ return html`
+
+ `;
+ }
+
+ __coreDayPreprocessor(_day, { currentMonth = false } = {}) {
+ const day = createDay(new Date(_day.date), _day);
+ const today = new Date();
+ day.central = isSameDate(day.date, this.centralDate);
+ day.previousMonth = currentMonth && day.date.getMonth() < currentMonth.getMonth();
+ day.currentMonth = currentMonth && day.date.getMonth() === currentMonth.getMonth();
+ day.nextMonth = currentMonth && day.date.getMonth() > currentMonth.getMonth();
+ day.selected = this.selectedDate ? isSameDate(day.date, this.selectedDate) : false;
+ day.past = day.date < today;
+ day.today = isSameDate(day.date, today);
+ day.future = day.date > today;
+ day.disabled = this.disableDates(day.date);
+
+ if (this.minDate && day.date < this.minDate) {
+ day.disabled = true;
+ }
+ if (this.maxDate && day.date > this.maxDate) {
+ day.disabled = true;
+ }
+
+ return this.dayPreprocessor(day);
+ }
+
+ __createData(options) {
+ const data = createMultipleMonth(this.centralDate, {
+ firstDayOfWeek: this.firstDayOfWeek,
+ ...options,
+ });
+ data.months.forEach((month, monthi) => {
+ month.weeks.forEach((week, weeki) => {
+ week.days.forEach((day, dayi) => {
+ // eslint-disable-next-line no-unused-vars
+ const currentDay = data.months[monthi].weeks[weeki].days[dayi];
+ const currentMonth = data.months[monthi].weeks[0].days[6].date;
+ data.months[monthi].weeks[weeki].days[dayi] = this.__coreDayPreprocessor(currentDay, {
+ currentMonth,
+ });
+ });
+ });
+ });
+
+ this.isNextMonthDisabled =
+ this.maxDate && getFirstDayNextMonth(this.centralDate) > this.maxDate;
+ this.isPreviousMonthDisabled =
+ this.minDate && getLastDayPreviousMonth(this.centralDate) < this.minDate;
+
+ return data;
+ }
+
+ __disableDatesChanged() {
+ this.__ensureValidCentralDate();
+ }
+
+ __dateSelectedByUser(selectedDate) {
+ this.selectedDate = selectedDate;
+ this.__focusedDate = selectedDate;
+ this.dispatchEvent(
+ new CustomEvent('user-selected-date-changed', {
+ detail: {
+ selectedDate,
+ },
+ }),
+ );
+ }
+
+ __centralDateChanged() {
+ if (this.__connectedCallbackDone) {
+ this.__ensureValidCentralDate();
+ }
+ }
+
+ __focusedDateChanged() {
+ if (this.__focusedDate) {
+ this.centralDate = this.__focusedDate;
+ }
+ }
+
+ __ensureValidCentralDate() {
+ if (!this.__isEnabledDate(this.centralDate)) {
+ this.centralDate = this.__findBestEnabledDateFor(this.centralDate);
+ }
+ }
+
+ __isEnabledDate(date) {
+ const processedDay = this.__coreDayPreprocessor({ date });
+ return !processedDay.disabled;
+ }
+
+ /**
+ * @param {Date} date
+ * @param {Object} opts
+ * @param {String} [opts.mode] Find best date in `future/past/both`
+ */
+ __findBestEnabledDateFor(date, { mode = 'both' } = {}) {
+ const futureDate =
+ this.minDate && this.minDate > date ? new Date(this.minDate) : new Date(date);
+ const pastDate = this.maxDate && this.maxDate < date ? new Date(this.maxDate) : new Date(date);
+
+ let i = 0;
+ do {
+ i += 1;
+ if (mode === 'both' || mode === 'future') {
+ futureDate.setDate(futureDate.getDate() + 1);
+ if (this.__isEnabledDate(futureDate)) {
+ return futureDate;
+ }
+ }
+ if (mode === 'both' || mode === 'past') {
+ pastDate.setDate(pastDate.getDate() - 1);
+ if (this.__isEnabledDate(pastDate)) {
+ return pastDate;
+ }
+ }
+ } while (i < 750); // 2 years+
+
+ const year = date.getFullYear();
+ const month = date.getMonth() + 1;
+ const day = date.getDate();
+ throw new Error(
+ `Could not find a selectable date within +/- 750 day for ${year}/${month}/${day}`,
+ );
+ }
+
+ __addEventDelegationForClickDate() {
+ const isDayCellOrButton = el =>
+ el.classList.contains('calendar__day-cell') || el.classList.contains('calendar__day-button');
+ this.__clickDateDelegation = this.__contentWrapperElement.addEventListener('click', ev => {
+ const el = ev.composedPath()[0];
+ if (isDayCellOrButton(el)) {
+ this.__dateSelectedByUser(el.date);
+ }
+ });
+ }
+
+ __addEventDelegationForFocusDate() {
+ const isDayButton = el => el.classList.contains('calendar__day-button');
+ this.__focusDateDelegation = this.__contentWrapperElement.addEventListener(
+ 'focus',
+ () => {
+ if (!this.__focusedDate && isDayButton(this.shadowRoot.activeElement)) {
+ this.__focusedDate = this.shadowRoot.activeElement.date;
+ }
+ },
+ true,
+ );
+ }
+
+ __addEventDelegationForBlurDate() {
+ const isDayButton = el => el.classList.contains('calendar__day-button');
+ this.__blurDateDelegation = this.__contentWrapperElement.addEventListener(
+ 'blur',
+ () => {
+ setTimeout(() => {
+ if (this.shadowRoot.activeElement && !isDayButton(this.shadowRoot.activeElement)) {
+ this.__focusedDate = null;
+ }
+ }, 1);
+ },
+ true,
+ );
+ }
+
+ __removeEventDelegations() {
+ this.__contentWrapperElement.removeEventListener('click', this.__clickDateDelegation);
+ this.__contentWrapperElement.removeEventListener('focus', this.__focusDateDelegation);
+ this.__contentWrapperElement.removeEventListener('blur', this.__blurDateDelegation);
+ this.__contentWrapperElement.removeEventListener('keydown', this.__keyNavigationEvent);
+ }
+
+ __addEventForKeyboardNavigation() {
+ this.__keyNavigationEvent = this.__contentWrapperElement.addEventListener('keydown', ev => {
+ switch (ev.key) {
+ case 'ArrowUp':
+ this.__modifyDate(-7, { dateType: '__focusedDate', type: 'Date', mode: 'past' });
+ break;
+ case 'ArrowDown':
+ this.__modifyDate(7, { dateType: '__focusedDate', type: 'Date', mode: 'future' });
+ break;
+ case 'ArrowLeft':
+ this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Date', mode: 'past' });
+ break;
+ case 'ArrowRight':
+ this.__modifyDate(1, { dateType: '__focusedDate', type: 'Date', mode: 'future' });
+ break;
+ case 'PageDown':
+ if (ev.altKey === true) {
+ this.__modifyDate(1, { dateType: '__focusedDate', type: 'FullYear', mode: 'future' });
+ } else {
+ this.__modifyDate(1, { dateType: '__focusedDate', type: 'Month', mode: 'future' });
+ }
+ break;
+ case 'PageUp':
+ if (ev.altKey === true) {
+ this.__modifyDate(-1, { dateType: '__focusedDate', type: 'FullYear', mode: 'past' });
+ } else {
+ this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Month', mode: 'past' });
+ }
+ break;
+ case 'Tab':
+ this.__focusedDate = null;
+ break;
+ // no default
+ }
+ });
+ }
+
+ __modifyDate(modify, { dateType, type, mode } = {}) {
+ let tmpDate = new Date(this.centralDate);
+ tmpDate[`set${type}`](tmpDate[`get${type}`]() + modify);
+
+ if (!this.__isEnabledDate(tmpDate)) {
+ tmpDate = this.__findBestEnabledDateFor(tmpDate, { mode });
+ }
+
+ this[dateType] = tmpDate;
+ }
+
+ __getLocale() {
+ return this.locale || localize.locale;
+ }
+}
diff --git a/packages/calendar/src/calendarStyle.js b/packages/calendar/src/calendarStyle.js
new file mode 100644
index 000000000..5a718cfac
--- /dev/null
+++ b/packages/calendar/src/calendarStyle.js
@@ -0,0 +1,74 @@
+import { css } from '@lion/core';
+
+export const calendarStyle = css`
+ :host {
+ display: block;
+ }
+
+ .calendar {
+ display: block;
+ }
+
+ .calendar__header {
+ display: flex;
+ justify-content: space-between;
+ border-bottom: 1px solid #adadad;
+ padding: 0 8px;
+ }
+
+ .calendar__month-heading {
+ margin: 0.5em 0;
+ }
+
+ .calendar__previous-month-button,
+ .calendar__next-month-button {
+ background-color: #fff;
+ border: 0;
+ padding: 0;
+ min-width: 40px;
+ min-height: 40px;
+ }
+
+ .calendar__grid {
+ width: 100%;
+ padding: 8px 8px;
+ }
+
+ .calendar__weekday-header {
+ }
+
+ .calendar__day-cell {
+ text-align: center;
+ }
+
+ .calendar__day-button {
+ background-color: #fff;
+ border: 0;
+ padding: 0;
+ min-width: 40px;
+ min-height: 40px;
+ }
+
+ .calendar__day-button[today] {
+ text-decoration: underline;
+ }
+
+ .calendar__day-button[selected] {
+ background: #ccc;
+ }
+
+ .calendar__day-button[previous-month],
+ .calendar__day-button[next-month] {
+ color: #ddd;
+ }
+
+ .calendar__day-button:hover {
+ border: 1px solid green;
+ }
+
+ .calendar__day-button[disabled] {
+ background-color: #fff;
+ color: #eee;
+ outline: none;
+ }
+`;
diff --git a/packages/calendar/src/utils/createDay.js b/packages/calendar/src/utils/createDay.js
new file mode 100644
index 000000000..7118bbe05
--- /dev/null
+++ b/packages/calendar/src/utils/createDay.js
@@ -0,0 +1,29 @@
+export function createDay(
+ date = new Date(),
+ {
+ weekOrder,
+ central = false,
+ startOfWeek = false,
+ selected = false,
+ previousMonth = false,
+ currentMonth = false,
+ nextMonth = false,
+ past = false,
+ today = false,
+ future = false,
+ } = {},
+) {
+ return {
+ weekOrder,
+ central,
+ date,
+ startOfWeek,
+ selected,
+ previousMonth,
+ currentMonth,
+ nextMonth,
+ past,
+ today,
+ future,
+ };
+}
diff --git a/packages/calendar/src/utils/createMonth.js b/packages/calendar/src/utils/createMonth.js
new file mode 100644
index 000000000..cb7b23420
--- /dev/null
+++ b/packages/calendar/src/utils/createMonth.js
@@ -0,0 +1,25 @@
+import { createWeek } from './createWeek.js';
+
+export function createMonth(date, { firstDayOfWeek = 0 } = {}) {
+ if (Object.prototype.toString.call(date) !== '[object Date]') {
+ throw new Error('invalid date provided');
+ }
+ const firstDayOfMonth = new Date(date);
+ firstDayOfMonth.setDate(1);
+ const monthNumber = firstDayOfMonth.getMonth();
+ const weekOptions = { firstDayOfWeek };
+
+ const month = {
+ weeks: [],
+ };
+
+ let nextWeek = createWeek(firstDayOfMonth, weekOptions);
+ do {
+ month.weeks.push(nextWeek);
+ const firstDayOfNextWeek = new Date(nextWeek.days[6].date); // last day of current week
+ firstDayOfNextWeek.setDate(firstDayOfNextWeek.getDate() + 1); // make it first day of next week
+ nextWeek = createWeek(firstDayOfNextWeek, weekOptions);
+ } while (nextWeek.days[0].date.getMonth() === monthNumber);
+
+ return month;
+}
diff --git a/packages/calendar/src/utils/createMultipleMonth.js b/packages/calendar/src/utils/createMultipleMonth.js
new file mode 100644
index 000000000..8d932c260
--- /dev/null
+++ b/packages/calendar/src/utils/createMultipleMonth.js
@@ -0,0 +1,26 @@
+import { createMonth } from './createMonth.js';
+
+export function createMultipleMonth(
+ date,
+ { firstDayOfWeek = 0, pastMonths = 0, futureMonths = 0 } = {},
+) {
+ const multipleMonths = {
+ months: [],
+ };
+
+ for (let i = pastMonths; i > 0; i -= 1) {
+ const pastDate = new Date(date);
+ pastDate.setMonth(pastDate.getMonth() - i);
+ multipleMonths.months.push(createMonth(pastDate, { firstDayOfWeek }));
+ }
+
+ multipleMonths.months.push(createMonth(date, { firstDayOfWeek }));
+
+ for (let i = 0; i < futureMonths; i += 1) {
+ const futureDate = new Date(date);
+ futureDate.setMonth(futureDate.getMonth() + (i + 1));
+ multipleMonths.months.push(createMonth(futureDate, { firstDayOfWeek }));
+ }
+
+ return multipleMonths;
+}
diff --git a/packages/calendar/src/utils/createWeek.js b/packages/calendar/src/utils/createWeek.js
new file mode 100644
index 000000000..9135e8b11
--- /dev/null
+++ b/packages/calendar/src/utils/createWeek.js
@@ -0,0 +1,30 @@
+import { createDay } from './createDay.js';
+
+export function createWeek(date, { firstDayOfWeek = 0 } = {}) {
+ if (Object.prototype.toString.call(date) !== '[object Date]') {
+ throw new Error('invalid date provided');
+ }
+ let weekStartDate = new Date(date);
+
+ const tmpDate = new Date(date);
+ while (tmpDate.getDay() !== firstDayOfWeek) {
+ tmpDate.setDate(tmpDate.getDate() - 1);
+ weekStartDate = new Date(tmpDate);
+ }
+
+ const week = {
+ days: [],
+ };
+ for (let i = 0; i < 7; i += 1) {
+ if (i !== 0) {
+ weekStartDate.setDate(weekStartDate.getDate() + 1);
+ }
+ week.days.push(
+ createDay(new Date(weekStartDate), {
+ weekOrder: i,
+ startOfWeek: i === 0,
+ }),
+ );
+ }
+ return week;
+}
diff --git a/packages/calendar/src/utils/dataTemplate.js b/packages/calendar/src/utils/dataTemplate.js
new file mode 100644
index 000000000..12d975379
--- /dev/null
+++ b/packages/calendar/src/utils/dataTemplate.js
@@ -0,0 +1,51 @@
+import { html } from '@lion/core';
+import { dayTemplate as defaultDayTemplate } from './dayTemplate.js';
+
+export function dataTemplate(
+ data,
+ { weekdaysShort, weekdays, monthsLabels, dayTemplate = defaultDayTemplate } = {},
+) {
+ return html`
+
+ ${data.months.map(
+ month => html`
+
+
+
+ ${weekdaysShort.map(
+ (header, i) => html`
+
+ `,
+ )}
+
+
+
+ ${month.weeks.map(
+ week => html`
+
+ ${week.days.map(day =>
+ dayTemplate(day, { weekdaysShort, weekdays, monthsLabels }),
+ )}
+
+ `,
+ )}
+
+
+ `,
+ )}
+
+ `;
+}
diff --git a/packages/calendar/src/utils/dayTemplate.js b/packages/calendar/src/utils/dayTemplate.js
new file mode 100644
index 000000000..40ee869d0
--- /dev/null
+++ b/packages/calendar/src/utils/dayTemplate.js
@@ -0,0 +1,46 @@
+import { html, ifDefined } from '@lion/core';
+
+const defaultMonthLabels = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+];
+
+// TODO: remove as much logic as possible from this template and move to processor
+export function dayTemplate(day, { weekdays, monthsLabels = defaultMonthLabels } = {}) {
+ const dayNumber = day.date.getDate();
+ const monthName = monthsLabels[day.date.getMonth()];
+ const year = day.date.getFullYear();
+ const weekdayName = weekdays[day.weekOrder];
+ return html`
+
+
+ |
+ `;
+}
diff --git a/packages/calendar/src/utils/differentKeyNamesShimIE.js b/packages/calendar/src/utils/differentKeyNamesShimIE.js
new file mode 100644
index 000000000..65b6d49d1
--- /dev/null
+++ b/packages/calendar/src/utils/differentKeyNamesShimIE.js
@@ -0,0 +1,33 @@
+const event = KeyboardEvent.prototype;
+const descriptor = Object.getOwnPropertyDescriptor(event, 'key');
+if (descriptor) {
+ const keys = {
+ Win: 'Meta',
+ Scroll: 'ScrollLock',
+ Spacebar: ' ',
+
+ Down: 'ArrowDown',
+ Left: 'ArrowLeft',
+ Right: 'ArrowRight',
+ Up: 'ArrowUp',
+
+ Del: 'Delete',
+ Apps: 'ContextMenu',
+ Esc: 'Escape',
+
+ Multiply: '*',
+ Add: '+',
+ Subtract: '-',
+ Decimal: '.',
+ Divide: '/',
+ };
+ Object.defineProperty(event, 'key', {
+ // eslint-disable-next-line object-shorthand, func-names
+ get: function() {
+ const key = descriptor.get.call(this);
+
+ // eslint-disable-next-line no-prototype-builtins
+ return keys.hasOwnProperty(key) ? keys[key] : key;
+ },
+ });
+}
diff --git a/packages/calendar/src/utils/getFirstDayNextMonth.js b/packages/calendar/src/utils/getFirstDayNextMonth.js
new file mode 100644
index 000000000..9aa94a910
--- /dev/null
+++ b/packages/calendar/src/utils/getFirstDayNextMonth.js
@@ -0,0 +1,13 @@
+/**
+ * Gives the first day of the next month
+ *
+ * @param {Date} date
+ *
+ * returns {Date}
+ */
+export function getFirstDayNextMonth(date) {
+ const result = new Date(date);
+ result.setDate(1);
+ result.setMonth(date.getMonth() + 1);
+ return result;
+}
diff --git a/packages/calendar/src/utils/getLastDayPreviousMonth.js b/packages/calendar/src/utils/getLastDayPreviousMonth.js
new file mode 100644
index 000000000..ead8e3f55
--- /dev/null
+++ b/packages/calendar/src/utils/getLastDayPreviousMonth.js
@@ -0,0 +1,12 @@
+/**
+ * Gives the last day of the previous month
+ *
+ * @param {Date} date
+ *
+ * returns {Date}
+ */
+export function getLastDayPreviousMonth(date) {
+ const previous = new Date(date);
+ previous.setDate(0);
+ return new Date(previous);
+}
diff --git a/packages/calendar/src/utils/isSameDate.js b/packages/calendar/src/utils/isSameDate.js
new file mode 100644
index 000000000..61c6f96b2
--- /dev/null
+++ b/packages/calendar/src/utils/isSameDate.js
@@ -0,0 +1,17 @@
+/**
+ * Compares if two days are the same
+ *
+ * @param {Date} day1
+ * @param {Date} day2
+ *
+ * returns {boolean}
+ */
+export function isSameDate(day1, day2) {
+ return (
+ day1 instanceof Date &&
+ day2 instanceof Date &&
+ day1.getDate() === day2.getDate() &&
+ day1.getMonth() === day2.getMonth() &&
+ day1.getFullYear() === day2.getFullYear()
+ );
+}
diff --git a/packages/calendar/stories/index.stories.js b/packages/calendar/stories/index.stories.js
new file mode 100755
index 000000000..99822e3b9
--- /dev/null
+++ b/packages/calendar/stories/index.stories.js
@@ -0,0 +1,136 @@
+import { storiesOf, html } from '@open-wc/demoing-storybook';
+import { css } from '@lion/core';
+import '@lion/button/lion-button.js';
+
+import '../lion-calendar.js';
+
+const calendarDemoStyle = css`
+ .demo-calendar {
+ border: 1px solid #adadad;
+ box-shadow: 0 0 16px #ccc;
+ max-width: 500px;
+ }
+`;
+
+storiesOf('Calendar|Standalone', module)
+ .add(
+ 'default',
+ () => html`
+
+
+
+ `,
+ )
+ .add('selectedDate', () => {
+ const today = new Date();
+ const selectedDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
+ return html`
+
+
+
+ `;
+ })
+ .add('centralDate', () => {
+ const today = new Date();
+ const centralDate = new Date(today.getFullYear(), today.getMonth() + 1, today.getDate());
+ return html`
+
+
+
+
+ Use TAB to see which date will be focused first.
+ `;
+ })
+ .add('control focus', () => {
+ const today = new Date();
+ const selectedDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
+ const centralDate = new Date(today.getFullYear(), today.getMonth() + 1, today.getDate());
+ return html`
+
+
+
+
+
+ Focus:
+ document.querySelector('#js-demo-calendar').focusCentralDate()}"
+ >
+ Central date
+
+ document.querySelector('#js-demo-calendar').focusSelectedDate()}"
+ >
+ Selected date
+
+ document.querySelector('#js-demo-calendar').focusDate(today)}">
+ Today
+
+
+
+ Be aware that the central date changes when a new date is focused.
+ `;
+ })
+ .add('minDate', () => {
+ const today = new Date();
+ const minDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 2);
+ return html`
+
+
+
+ `;
+ })
+ .add('maxDate', () => {
+ const today = new Date();
+ const maxDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 2);
+ return html`
+
+
+
+ `;
+ })
+ .add(
+ 'disableDates',
+ () => html`
+
+
+ day.getDay() === 6 || day.getDay() === 0}
+ >
+ `,
+ )
+ .add('combined disabled dates', () => {
+ const today = new Date();
+ const maxDate = new Date(today.getFullYear(), today.getMonth() + 2, today.getDate());
+ return html`
+
+
+ day.getDay() === 6 || day.getDay() === 0}
+ .minDate="${new Date()}"
+ .maxDate="${maxDate}"
+ >
+ `;
+ });
diff --git a/packages/calendar/test/keyboardEventShimIE.js b/packages/calendar/test/keyboardEventShimIE.js
new file mode 100644
index 000000000..45e834217
--- /dev/null
+++ b/packages/calendar/test/keyboardEventShimIE.js
@@ -0,0 +1,49 @@
+if (typeof window.KeyboardEvent !== 'function') {
+ // e.g. is IE and needs "polyfill"
+ const KeyboardEvent = (event, _params) => {
+ // current spec for it https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/KeyboardEvent
+ const params = {
+ bubbles: false,
+ cancelable: false,
+ view: document.defaultView,
+ key: false,
+ location: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ metaKey: false,
+ repeat: false,
+ ..._params,
+ };
+ const modifiersListArray = [];
+ if (params.ctrlKey) {
+ modifiersListArray.push('Control');
+ }
+ if (params.shiftKey) {
+ modifiersListArray.push('Shift');
+ }
+ if (params.altKey) {
+ modifiersListArray.push('Alt');
+ }
+ if (params.metaKey) {
+ modifiersListArray.push('Meta');
+ }
+
+ const ev = document.createEvent('KeyboardEvent');
+ // IE Spec for it https://technet.microsoft.com/en-us/windows/ff975297(v=vs.60)
+ ev.initKeyboardEvent(
+ event,
+ params.bubbles,
+ params.cancelable,
+ params.view,
+ params.key,
+ params.location,
+ modifiersListArray.join(' '),
+ params.repeat ? 1 : 0,
+ params.locale,
+ );
+ return ev;
+ };
+ KeyboardEvent.prototype = window.Event.prototype;
+ window.KeyboardEvent = KeyboardEvent;
+}
diff --git a/packages/calendar/test/lion-calendar.test.js b/packages/calendar/test/lion-calendar.test.js
new file mode 100644
index 000000000..abb2bfd40
--- /dev/null
+++ b/packages/calendar/test/lion-calendar.test.js
@@ -0,0 +1,1074 @@
+import { expect, fixture } from '@open-wc/testing';
+import sinon from 'sinon';
+
+import { html } from '@lion/core';
+import { localize } from '@lion/localize';
+import { localizeTearDown } from '@lion/localize/test-helpers.js';
+
+import { CalendarObject, DayObject } from './test-utils.js';
+import './keyboardEventShimIE.js';
+
+import { isSameDate } from '../src/utils/isSameDate';
+import '../lion-calendar.js';
+
+describe('', () => {
+ beforeEach(() => {
+ localizeTearDown();
+ });
+
+ describe('Structure', () => {
+ it('implements BEM structure', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+
+ expect(el.shadowRoot.querySelector('.calendar')).to.exist;
+ expect(el.shadowRoot.querySelector('.calendar__header')).to.exist;
+ expect(el.shadowRoot.querySelector('.calendar__previous-month-button')).to.exist;
+ expect(el.shadowRoot.querySelector('.calendar__next-month-button')).to.exist;
+ expect(el.shadowRoot.querySelector('.calendar__month-heading')).to.exist;
+ expect(el.shadowRoot.querySelector('.calendar__grid')).to.exist;
+ });
+
+ it('has heading with month and year', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/01').getTime() });
+
+ const el = await fixture(
+ html`
+
+ `,
+ );
+
+ expect(el.shadowRoot.querySelector('.calendar__month-heading')).dom.to.equal(`
+
+ December 2000
+
+ `);
+
+ clock.restore();
+ });
+
+ it('has previous month button', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ expect(el.shadowRoot.querySelector('.calendar__previous-month-button')).dom.to.equal(`
+
+ `);
+ });
+
+ it('has next month button', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ expect(el.shadowRoot.querySelector('.calendar__next-month-button')).dom.to.equal(`
+
+ `);
+ });
+ });
+
+ describe('Public API', () => {
+ it('has property "centralDate" that determines currently visible month', async () => {
+ const elObj = new CalendarObject(
+ await fixture(html`
+
+ `),
+ );
+ expect(elObj.activeMonthAndYear).to.equal('May 2014');
+ });
+
+ it('sets "centralDate" to today by default', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2013/03/15').getTime() });
+
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(isSameDate(el.centralDate, new Date())).to.be.true;
+ expect(elObj.activeMonthAndYear).to.equal('March 2013');
+
+ clock.restore();
+ });
+
+ it('determines the date focusable with keyboard from "centralDate"', async () => {
+ const elObj = new CalendarObject(
+ await fixture(html`
+
+ `),
+ );
+ expect(
+ elObj.checkForAllDayObjs(o => o.buttonEl.getAttribute('tabindex') === '0', n => n === 5),
+ ).to.be.true;
+ expect(
+ elObj.checkForAllDayObjs(o => o.buttonEl.getAttribute('tabindex') === '-1', n => n !== 5),
+ ).to.be.true;
+ });
+
+ it('has property "selectedDate" for the selected date', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.getDayObj(15).isSelected).to.equal(true);
+ expect(elObj.getDayObj(16).isSelected).to.equal(false);
+ expect(elObj.getDayObj(14).isSelected).to.equal(false);
+ el.selectedDate = new Date('2019/06/16');
+ await el.updateComplete;
+ expect(elObj.getDayObj(15).isSelected).to.equal(false);
+ });
+
+ it('gives priority to "centralDate" to display the month when "selectedDate" is also defined', async () => {
+ const elObj = new CalendarObject(
+ await fixture(html`
+
+ `),
+ );
+ expect(elObj.activeMonthAndYear).to.equal('June 2018');
+ });
+
+ it('changes "centralDate" from default to "selectedDate" on first render if no other custom "centralDate" is provided', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2010/01/01').getTime() });
+
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(isSameDate(el.centralDate, new Date('2016/10/20'))).to.be.true;
+ expect(elObj.activeMonthAndYear).to.equal('October 2016');
+
+ clock.restore();
+ });
+
+ it('does not change "centralDate" to "selectedDate" when new date is provided programmatically', async () => {
+ const el = await fixture(html`
+
+ `);
+ expect(isSameDate(el.centralDate, new Date('2018/06/01'))).to.be.true;
+ el.selectedDate = new Date('2018/06/03');
+ await el.updateComplete;
+ expect(isSameDate(el.centralDate, new Date('2018/06/01'))).to.be.true;
+ });
+
+ it('does not set "selectedDate" by default', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ const today = new Date();
+ expect(el.selectedDate).to.equal(undefined);
+ expect(elObj.getDayObj(today.getDate()).isSelected).to.equal(false);
+ });
+
+ it('allows to reset "selectedDate"', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ el.selectedDate = undefined;
+ await el.updateComplete;
+ expect(elObj.selectedDayObj).to.be.undefined;
+ });
+
+ it('sends event "user-selected-date-changed" when user selects a date', async () => {
+ const dateChangedSpy = sinon.spy();
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(dateChangedSpy.called).to.equal(false);
+ elObj.getDayEl(15).click();
+ await el.updateComplete;
+ expect(dateChangedSpy.calledOnce).to.equal(true);
+ expect(
+ isSameDate(dateChangedSpy.args[0][0].detail.selectedDate, new Date('2000/12/15')),
+ ).to.equal(true);
+ });
+
+ it('exposes focusedDate getter', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(el.focusedDate).to.be.null;
+ elObj.getDayEl(15).click();
+ expect(isSameDate(el.focusedDate, new Date('2019/06/15'))).to.equal(true);
+ });
+
+ it('has a focusDate() method to focus an arbitrary date', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ await el.focusDate(new Date('2016/06/10'));
+ expect(isSameDate(el.focusedDate, new Date('2016/06/10'))).to.be.true;
+ expect(elObj.getDayObj(10).isFocused).to.be.true;
+ });
+
+ it('has a focusCentralDate() method to focus the central date', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ el.focusCentralDate();
+ expect(isSameDate(el.focusedDate, new Date('2015/12/02'))).to.be.true;
+ expect(elObj.getDayObj(2).isFocused).to.be.true;
+ });
+
+ it('has a focusSelectedDate() method to focus the selected date', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ await el.focusSelectedDate();
+ expect(isSameDate(el.focusedDate, new Date('2014/07/07'))).to.be.true;
+ expect(elObj.getDayObj(7).isFocused).to.be.true;
+ });
+
+ describe('Enabled Dates', () => {
+ it('disables all days before "minDate" property', async () => {
+ const el = await fixture(html`
+
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.getDayObj(15).isDisabled).to.equal(false);
+ elObj.dayEls.forEach((d, i) => {
+ const shouldBeDisabled = i < 8;
+ expect(new DayObject(d).isDisabled).to.equal(shouldBeDisabled);
+ });
+ });
+
+ it('disables all days after "maxDate" property', async () => {
+ const el = await fixture(html`
+
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.getDayObj(5).isDisabled).to.equal(false);
+ elObj.dayEls.forEach((d, i) => {
+ const shouldBeDisabled = i > 8;
+ expect(new DayObject(d).isDisabled).to.equal(shouldBeDisabled);
+ });
+ });
+
+ it('disables a date with disableDates function', async () => {
+ const disable15th = d => d.getDate() === 15;
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ const elObj = new CalendarObject(el);
+ elObj.dayEls.forEach((d, i) => {
+ const shouldBeDisabled = i === 15 - 1;
+ expect(new DayObject(d).isDisabled).to.equal(shouldBeDisabled);
+ });
+
+ el.selectedDate = new Date('2000/11/01'); // When month view updates, it should still work
+ await el.updateComplete;
+ elObj.dayEls.forEach((d, i) => {
+ const shouldBeDisabled = i === 15 - 1;
+ expect(new DayObject(d).isDisabled).to.equal(shouldBeDisabled);
+ });
+ });
+ });
+ });
+
+ describe('Calendar header (month navigation)', () => {
+ describe('Title', () => {
+ it('contains secondary title displaying the current month and year in focus', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+ });
+
+ it('updates the secondary title when the displayed month/year changes', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ const elObj = new CalendarObject(el);
+ el.centralDate = new Date('1999/10/12');
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('October 1999');
+ });
+
+ describe('Accessibility', () => {
+ it('has aria-live="polite" and aria-atomic="true" set on the secondary title', async () => {
+ const elObj = new CalendarObject(
+ await fixture(
+ html`
+
+ `,
+ ),
+ );
+ expect(elObj.monthHeadingEl.getAttribute('aria-live')).to.equal('polite');
+ expect(elObj.monthHeadingEl.getAttribute('aria-atomic')).to.equal('true');
+ });
+ });
+ });
+
+ describe('Navigation', () => {
+ it('has a button for navigation to previous month', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ const elObj = new CalendarObject(el);
+ expect(elObj.previousMonthButtonEl).not.to.equal(null);
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+
+ elObj.previousMonthButtonEl.click();
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+
+ elObj.previousMonthButtonEl.click();
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('November 2000');
+ });
+
+ it('has a button for navigation to next month', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ const elObj = new CalendarObject(el);
+ expect(elObj.nextMonthButtonEl).not.to.equal(null);
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+
+ elObj.nextMonthButtonEl.click();
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+
+ elObj.nextMonthButtonEl.click();
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('February 2001');
+ });
+
+ it('disables previousMonthButton and nextMonthButton based on disabled days accordingly', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+ expect(elObj.previousMonthButtonEl.hasAttribute('disabled')).to.equal(false);
+ expect(elObj.nextMonthButtonEl.hasAttribute('disabled')).to.equal(false);
+
+ el.minDate = new Date('2000/12/01');
+ el.maxDate = new Date('2000/12/31');
+ await el.updateComplete;
+
+ expect(elObj.previousMonthButtonEl.hasAttribute('disabled')).to.equal(true);
+ expect(elObj.nextMonthButtonEl.hasAttribute('disabled')).to.equal(true);
+
+ elObj.previousMonthButtonEl.click();
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+
+ elObj.previousMonthButtonEl.click();
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+ });
+
+ it('handles switch to previous month when dates are disabled', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() });
+
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+
+ el.minDate = new Date('2000/11/20');
+ await el.updateComplete;
+
+ expect(elObj.previousMonthButtonEl.hasAttribute('disabled')).to.equal(false);
+ expect(isSameDate(el.centralDate, new Date('2000/12/15'))).to.be.true;
+
+ elObj.previousMonthButtonEl.click();
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('November 2000');
+ expect(isSameDate(el.centralDate, new Date('2000/11/21'))).to.be.true;
+
+ clock.restore();
+ });
+
+ it('handles switch to next month when dates are disabled', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() });
+
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+
+ el.maxDate = new Date('2001/01/10');
+ await el.updateComplete;
+
+ expect(elObj.nextMonthButtonEl.hasAttribute('disabled')).to.equal(false);
+ expect(isSameDate(el.centralDate, new Date('2000/12/15'))).to.be.true;
+
+ elObj.nextMonthButtonEl.click();
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+ expect(isSameDate(el.centralDate, new Date('2001/01/09'))).to.be.true;
+
+ clock.restore();
+ });
+
+ describe('Accessibility', () => {
+ it('navigate buttons have a aria-label and title attribute with accessible label', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.previousMonthButtonEl.getAttribute('title')).to.equal('Previous month');
+ expect(elObj.previousMonthButtonEl.getAttribute('aria-label')).to.equal('Previous month');
+ expect(elObj.nextMonthButtonEl.getAttribute('title')).to.equal('Next month');
+ expect(elObj.nextMonthButtonEl.getAttribute('aria-label')).to.equal('Next month');
+ });
+ });
+ });
+ });
+
+ describe('Calendar body (months view)', () => {
+ it('renders the days of the week as table headers', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ const elObj = new CalendarObject(el);
+ expect(elObj.weekdayHeaderEls.map(h => h.textContent.trim())).to.deep.equal([
+ 'Sun',
+ 'Mon',
+ 'Tue',
+ 'Wed',
+ 'Thu',
+ 'Fri',
+ 'Sat',
+ ]);
+ });
+
+ describe('Day view', () => {
+ it('adds "today" attribute if date is today', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() });
+
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ const elObj = new CalendarObject(el);
+ expect(elObj.getDayEl(15).hasAttribute('today')).to.be.true;
+
+ expect(elObj.checkForAllDayObjs(d => d.isToday, [15])).to.equal(true);
+
+ clock.restore();
+ });
+
+ it('adds "selected" attribute to the selected date', async () => {
+ const el = await fixture(
+ html`
+
+ `,
+ );
+ const elObj = new CalendarObject(el);
+ expect(elObj.checkForAllDayObjs(obj => obj.el.hasAttribute('selected'), [12])).to.equal(
+ true,
+ );
+
+ el.selectedDate = new Date('2000/12/15');
+ await el.updateComplete;
+ expect(elObj.checkForAllDayObjs(obj => obj.el.hasAttribute('selected'), [15])).to.equal(
+ true,
+ );
+ });
+
+ it('adds "disabled" attribute to disabled dates', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() });
+
+ const el = await fixture(html`
+
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(
+ elObj.checkForAllDayObjs(d => d.el.hasAttribute('disabled'), [1, 2, 30, 31]),
+ ).to.equal(true);
+
+ clock.restore();
+ });
+ });
+
+ describe('User Interaction', () => {
+ // For implementation, base on: https://www.w3.org/TR/2017/NOTE-wai-aria-practices-1.1-20171214/examples/grid/js/dataGrid.js
+
+ describe('Keyboard Navigation', () => {
+ it('focused day is reachable via tab (tabindex="0")', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(
+ elObj.checkForAllDayObjs(d => d.buttonEl.getAttribute('tabindex') === '0', [12]),
+ ).to.equal(true);
+ });
+
+ it('non focused days are not reachable via tab (have tabindex="-1")', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(
+ elObj.checkForAllDayObjs(
+ d => d.buttonEl.getAttribute('tabindex') === '-1',
+ dayNumber => dayNumber !== 12,
+ ),
+ ).to.equal(true);
+ });
+
+ it('blocks navigation to disabled days', async () => {
+ const el = await fixture(html`
+
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(
+ elObj.checkForAllDayObjs(
+ d => d.buttonEl.getAttribute('tabindex') === '-1',
+ dayNumber => dayNumber < 9,
+ ),
+ ).to.equal(true);
+ });
+
+ it('navigates through months with [pageup] [pagedown] keys', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+
+ el.__contentWrapperElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' }));
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'PageDown' }),
+ );
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+ });
+
+ it('navigates through years with [alt + pageup] [alt + pagedown] keys', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'PageDown', altKey: true }),
+ );
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('January 2002');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'PageUp', altKey: true }),
+ );
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+ });
+
+ describe('Arrow keys', () => {
+ it('navigates (sets focus) to next row item via [arrow down] key', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown' }),
+ );
+ await el.updateComplete;
+ expect(elObj.focusedDayObj.monthday).to.equal(2 + 7);
+ });
+
+ it('navigates (sets focus) to previous row item via [arrow up] key', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowUp' }),
+ );
+ await el.updateComplete;
+ expect(elObj.focusedDayObj.monthday).to.equal(26); // of month before
+ });
+
+ it('navigates (sets focus) to previous column item via [arrow left] key', async () => {
+ // 2000-12-12 is Tuesday; at 2nd of row
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
+ );
+ await el.updateComplete;
+ expect(elObj.focusedDayObj.monthday).to.equal(12 - 1);
+ });
+
+ it('navigates (sets focus) to next column item via [arrow right] key', async () => {
+ // 2000-12-12 is Tuesday; at 2nd of row
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowRight' }),
+ );
+ await el.updateComplete;
+ expect(elObj.focusedDayObj.monthday).to.equal(12 + 1);
+ });
+
+ it('navigates (sets focus) to next selectable column item via [arrow right] key', async () => {
+ const el = await fixture(html`
+ day.getDate() === 3 || day.getDate() === 4}
+ >
+ `);
+ const elObj = new CalendarObject(el);
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowRight' }),
+ );
+ await el.updateComplete;
+ expect(elObj.focusedDayObj.monthday).to.equal(5);
+ });
+
+ it('navigates (sets focus) to next row via [arrow right] key if last item in row', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.centralDayObj.weekdayNameShort).to.equal('Sat');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowRight' }),
+ );
+ await el.updateComplete;
+ expect(elObj.focusedDayObj.monthday).to.equal(6);
+ expect(elObj.focusedDayObj.weekdayNameShort).to.equal('Sun');
+ });
+
+ it('navigates (sets focus) to previous row via [arrow left] key if first item in row', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.centralDayObj.weekdayNameShort).to.equal('Sun');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
+ );
+ await el.updateComplete;
+ expect(elObj.focusedDayObj.monthday).to.equal(5);
+ expect(elObj.focusedDayObj.weekdayNameShort).to.equal('Sat');
+ });
+
+ it('navigates to next month via [arrow right] key if last day of month', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowRight' }),
+ );
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+ expect(elObj.focusedDayObj.monthday).to.equal(1);
+ });
+
+ it('navigates to previous month via [arrow left] key if first day of month', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
+ );
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+ expect(elObj.focusedDayObj.monthday).to.equal(31);
+ });
+
+ it('navigates to next month via [arrow down] key if last row of month', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown' }),
+ );
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+ expect(elObj.focusedDayObj.monthday).to.equal(6);
+ });
+
+ it('navigates to previous month via [arrow up] key if first row of month', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonthAndYear).to.equal('January 2001');
+
+ el.__contentWrapperElement.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowUp' }),
+ );
+ await el.updateComplete;
+ expect(elObj.activeMonthAndYear).to.equal('December 2000');
+ expect(elObj.focusedDayObj.monthday).to.equal(26);
+ });
+ });
+ });
+
+ describe('Initial central', () => {
+ it('is based on "selectedDate"', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.centralDayObj.monthday).to.equal(15);
+ });
+
+ it('is today if no selected date is available', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() });
+
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.centralDayObj.monthday).to.equal(15);
+
+ clock.restore();
+ });
+
+ it('is on day closest to today, if today (and surrounding dates) is/are disabled', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.centralDayObj.monthday).to.equal(17);
+
+ el.disableDates = d => d.getDate() >= 12;
+ await el.updateComplete;
+ expect(elObj.centralDayObj.monthday).to.equal(11);
+ });
+
+ it('future dates take precedence over past dates when "distance" between dates is equal', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() });
+
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.centralDayObj.monthday).to.equal(16);
+
+ clock.restore();
+ });
+
+ it('will search 750 days in the past', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() });
+
+ const el = await fixture(html`
+
+ `);
+ expect(el.centralDate.getFullYear()).to.equal(1998);
+ expect(el.centralDate.getMonth()).to.equal(11);
+ expect(el.centralDate.getDate()).to.equal(31);
+
+ clock.restore();
+ });
+
+ it('will search 750 days in the future', async () => {
+ const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() });
+
+ const el = await fixture(html`
+
+ `);
+ expect(el.centralDate.getFullYear()).to.equal(2002);
+ expect(el.centralDate.getMonth()).to.equal(0);
+ expect(el.centralDate.getDate()).to.equal(1);
+
+ clock.restore();
+ });
+
+ it('throws if no available date can be found within +/- 750 days', async () => {
+ const el = await fixture(html`
+
+ `);
+
+ expect(() => {
+ el.centralDate = new Date('1900/01/01');
+ }).to.throw(Error, 'Could not find a selectable date within +/- 750 day for 1900/1/1');
+ });
+ });
+
+ /**
+ * Not in scope:
+ * - (virtual) scrolling: still under discussion. Wait for UX
+ */
+ });
+
+ describe('Accessibility', () => {
+ // Based on:
+ // - https://codepen.io/erikkroes/pen/jJEWpR
+
+ // Navigation and day labels based on:
+ // - https://dequeuniversity.com/library/aria/date-pickers/sf-date-picker
+ // (recommended in W3C Editors Draft)
+
+ // For implementation, base on:
+ // https://www.w3.org/TR/2017/NOTE-wai-aria-practices-1.1-20171214/examples/grid/js/dataGrid.js
+ // As an enhancement, we detect when grid boundaries day are exceeded, so we move to
+ // next/previous month.
+ it('has role="application" to activate keyboard navigation', async () => {
+ const elObj = new CalendarObject(
+ await fixture(
+ html`
+
+ `,
+ ),
+ );
+ expect(elObj.rootEl.getAttribute('role')).to.equal('application');
+ });
+
+ it(`renders the calendar as a table element with role="grid", aria-readonly="true" and
+ a caption (month + year)`, async () => {
+ const elObj = new CalendarObject(
+ await fixture(
+ html`
+
+ `,
+ ),
+ );
+ expect(elObj.gridEl.getAttribute('role')).to.equal('grid');
+ expect(elObj.gridEl.getAttribute('aria-readonly')).to.equal('true');
+ });
+
+ it('adds aria-labels to the weekday table headers', async () => {
+ const elObj = new CalendarObject(
+ await fixture(
+ html`
+
+ `,
+ ),
+ );
+ expect(elObj.weekdayHeaderEls.map(h => h.getAttribute('aria-label'))).to.eql([
+ 'Sunday',
+ 'Monday',
+ 'Tuesday',
+ 'Wednesday',
+ 'Thursday',
+ 'Friday',
+ 'Saturday',
+ ]);
+ });
+
+ it('renders each day as a button inside a table cell', async () => {
+ const elObj = new CalendarObject(
+ await fixture(
+ html`
+
+ `,
+ ),
+ );
+ const hasBtn = d => d.el.tagName === 'BUTTON';
+ expect(elObj.checkForAllDayObjs(hasBtn)).to.equal(true);
+ });
+
+ it('renders days for previous and next months', async () => {
+ const elObj = new CalendarObject(
+ await fixture(html`
+
+ `),
+ );
+ const { previousMonthDayObjs, nextMonthDayObjs } = elObj;
+
+ expect(previousMonthDayObjs.length).to.equal(3);
+ expect(previousMonthDayObjs[0].cellIndex).to.equal(0);
+ expect(previousMonthDayObjs[0].monthday).to.equal(29);
+ expect(previousMonthDayObjs[1].cellIndex).to.equal(1);
+ expect(previousMonthDayObjs[1].monthday).to.equal(30);
+ expect(previousMonthDayObjs[2].cellIndex).to.equal(2);
+ expect(previousMonthDayObjs[2].monthday).to.equal(31);
+
+ expect(nextMonthDayObjs.length).to.equal(2);
+ expect(nextMonthDayObjs[0].cellIndex).to.equal(5);
+ expect(nextMonthDayObjs[0].monthday).to.equal(1);
+ expect(nextMonthDayObjs[1].cellIndex).to.equal(6);
+ expect(nextMonthDayObjs[1].monthday).to.equal(2);
+ });
+
+ it('sets aria-current="date" to todays button', async () => {
+ const elObj = new CalendarObject(
+ await fixture(
+ html`
+
+ `,
+ ),
+ );
+ const hasAriaCurrent = d => d.buttonEl.getAttribute('aria-current') === 'date';
+ const monthday = new Date().getDate();
+ expect(elObj.checkForAllDayObjs(hasAriaCurrent, [monthday])).to.equal(true);
+ });
+
+ it('sets aria-selected="true" on selected date button', async () => {
+ const elObj = new CalendarObject(
+ await fixture(html`
+
+ `),
+ );
+ const hasAriaSelected = d => d.buttonEl.getAttribute('aria-selected') === 'true';
+ expect(elObj.checkForAllDayObjs(hasAriaSelected, [12])).to.equal(true);
+ });
+
+ // This implementation mentions "button" inbetween and doesn't mention table
+ // column and row. As an alternative, see Deque implementation below.
+ // it(`on focus on a day, the screen reader pronounces "day of the week", "day number"
+ // and "month" (in this order)', async () => {
+ // // implemented by labelelledby referencing row and column names
+ // const el = await fixture('');
+ // });
+
+ // Alternative: Deque implementation
+ it(`sets aria-label on button, that consists of
+ "{day number} {month name} {year} {weekday name}"`, async () => {
+ const elObj = new CalendarObject(
+ await fixture(html`
+
+ `),
+ );
+ expect(
+ elObj.checkForAllDayObjs(
+ d =>
+ d.buttonEl.getAttribute('aria-label') ===
+ `${d.monthday} November 2000 ${d.weekdayNameLong}`,
+ ),
+ ).to.equal(true);
+ });
+
+ /**
+ * Not in scope:
+ * - reads the new focused day on month navigation"
+ */
+ });
+
+ /**
+ * Not in scope:
+ * - show week numbers
+ */
+ });
+
+ describe('Localization', () => {
+ it('supports custom locale with a fallback to a global locale', async () => {
+ const el = await fixture(html`
+
+ `);
+ const elObj = new CalendarObject(el);
+ expect(elObj.activeMonth).to.equal('December');
+
+ el.locale = 'fr-FR';
+ await el.updateComplete;
+ expect(elObj.activeMonth).to.equal('décembre');
+
+ localize.locale = 'cs-CZ';
+ await el.updateComplete;
+ expect(elObj.activeMonth).to.equal('décembre');
+
+ el.locale = undefined;
+ await el.updateComplete;
+ expect(elObj.activeMonth).to.equal('prosinec');
+ });
+
+ it('displays the right translations according to locale', async () => {
+ const el = await fixture(html`
+
+ `);
+
+ const elObj = new CalendarObject(el);
+ expect(elObj.nextMonthButtonEl.getAttribute('aria-label')).to.equal('Next month');
+
+ localize.locale = 'nl-NL';
+ await el.updateComplete;
+ expect(elObj.nextMonthButtonEl.getAttribute('aria-label')).to.equal('Volgende maand');
+
+ /**
+ * TODO: add more tests, e.g. for:
+ * - weekdays
+ * - weekday abbreviations
+ * - month names
+ */
+ });
+ });
+});
diff --git a/packages/calendar/test/test-utils.js b/packages/calendar/test/test-utils.js
new file mode 100644
index 000000000..3da3d9699
--- /dev/null
+++ b/packages/calendar/test/test-utils.js
@@ -0,0 +1,221 @@
+export const weekdayNames = {
+ 'en-GB': {
+ Sunday: {
+ long: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
+ short: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+ },
+ },
+};
+
+/**
+ * Abstraction around calendar day DOM structure,
+ * allows for writing readable, 'DOM structure agnostic' tests
+ */
+export class DayObject {
+ constructor(dayEl) {
+ this.el = dayEl;
+ }
+
+ /**
+ * Node references
+ */
+
+ get calendarShadowRoot() {
+ return this.el.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode;
+ }
+
+ get cellEl() {
+ return this.el.parentElement;
+ }
+
+ get buttonEl() {
+ return this.el;
+ }
+
+ /**
+ * States
+ */
+
+ get isDisabled() {
+ return this.buttonEl.hasAttribute('disabled');
+ }
+
+ get isSelected() {
+ return this.buttonEl.hasAttribute('selected');
+ }
+
+ get isToday() {
+ return this.buttonEl.hasAttribute('today');
+ }
+
+ get isCentral() {
+ return this.buttonEl.getAttribute('tabindex') === '0';
+ }
+
+ get isFocused() {
+ this.calendarShadowRoot.activeElement;
+ this.buttonEl;
+ return this.calendarShadowRoot.activeElement === this.buttonEl;
+ }
+
+ get monthday() {
+ return Number(this.buttonEl.textContent);
+ }
+
+ /**
+ * Text
+ */
+
+ get weekdayNameShort() {
+ const weekdayEls = Array.from(
+ this.el.parentElement.parentElement.querySelectorAll('.calendar__day-cell'),
+ );
+ const dayIndex = weekdayEls.indexOf(this.el.parentElement);
+ return weekdayNames['en-GB'].Sunday.short[dayIndex];
+ }
+
+ get weekdayNameLong() {
+ const weekdayEls = Array.from(
+ this.el.parentElement.parentElement.querySelectorAll('.calendar__day-cell'),
+ );
+ const dayIndex = weekdayEls.indexOf(this.el.parentElement);
+ return weekdayNames['en-GB'].Sunday.long[dayIndex];
+ }
+
+ /**
+ * Other
+ */
+ get cellIndex() {
+ return Array.from(this.cellEl.parentElement.children).indexOf(this.cellEl);
+ }
+}
+
+/**
+ * Abstraction around calendar DOM structure,
+ * allows for writing readable, 'DOM structure agnostic' tests
+ */
+export class CalendarObject {
+ constructor(calendarEl) {
+ this.el = calendarEl;
+ }
+
+ /**
+ * Node references
+ */
+
+ get rootEl() {
+ return this.el.shadowRoot.querySelector('.calendar');
+ }
+
+ get headerEl() {
+ return this.el.shadowRoot.querySelector('.calendar__header');
+ }
+
+ get monthHeadingEl() {
+ return this.el.shadowRoot.querySelector('.calendar__month-heading');
+ }
+
+ get nextMonthButtonEl() {
+ return this.el.shadowRoot.querySelector('.calendar__next-month-button');
+ }
+
+ get previousMonthButtonEl() {
+ return this.el.shadowRoot.querySelector('.calendar__previous-month-button');
+ }
+
+ get gridEl() {
+ return this.el.shadowRoot.querySelector('.calendar__grid');
+ }
+
+ get weekdayHeaderEls() {
+ return [].slice.call(this.el.shadowRoot.querySelectorAll('.calendar__weekday-header'));
+ }
+
+ get dayEls() {
+ return [].slice.call(
+ this.el.shadowRoot.querySelectorAll('.calendar__day-button[current-month]'),
+ );
+ }
+
+ get previousMonthDayEls() {
+ return [].slice.call(
+ this.el.shadowRoot.querySelectorAll('.calendar__day-button[previous-month]'),
+ );
+ }
+
+ get nextMonthDayEls() {
+ return [].slice.call(this.el.shadowRoot.querySelectorAll('.calendar__day-button[next-month]'));
+ }
+
+ get dayObjs() {
+ return this.dayEls.map(d => new DayObject(d));
+ }
+
+ get previousMonthDayObjs() {
+ return this.previousMonthDayEls.map(d => new DayObject(d));
+ }
+
+ get nextMonthDayObjs() {
+ return this.nextMonthDayEls.map(d => new DayObject(d));
+ }
+
+ getDayEl(monthDayNumber) {
+ // Relies on the fact that empty cells don't have .calendar__day-button[current-month]
+ return this.el.shadowRoot.querySelectorAll('.calendar__day-button[current-month]')[
+ monthDayNumber - 1
+ ];
+ }
+
+ getDayObj(monthDayNumber) {
+ return new DayObject(this.getDayEl(monthDayNumber));
+ }
+
+ get selectedDayObj() {
+ return this.dayObjs.find(d => d.selected);
+ }
+
+ get centralDayObj() {
+ return this.dayObjs.find(d => d.isCentral);
+ }
+
+ get focusedDayObj() {
+ return this.dayObjs.find(d => d.el === this.el.shadowRoot.activeElement);
+ }
+
+ /**
+ * @desc Applies condition to all days, or days in filter
+ *
+ * @param {function} condition : condition that should apply for "filter" days
+ * - Example: "(dayObj) => dayObj.selected"
+ * @param {array|function} filter - month day numbers for which condition should apply.
+ * - Example 1: "[15, 20]"
+ * - Example 2: "(dayNumber) => dayNumber === 15" (1 based ,not zero based)
+ */
+ checkForAllDayObjs(condition, filter) {
+ return this.dayEls.every(d => {
+ const dayObj = new DayObject(d);
+ const dayNumber = dayObj.monthday;
+ let shouldApply = true;
+ if (filter !== undefined) {
+ shouldApply = filter instanceof Array ? filter.includes(dayNumber) : filter(dayNumber);
+ }
+ // for instance, should be 'disabled' for the 15th and 20th day
+ return !shouldApply || (condition(dayObj) && shouldApply);
+ });
+ }
+
+ /**
+ * States
+ */
+ get activeMonthAndYear() {
+ return this.monthHeadingEl.textContent.trim();
+ }
+
+ get activeMonth() {
+ return this.activeMonthAndYear.split(' ')[0];
+ }
+
+ get activeYear() {
+ return this.activeMonthAndYear.split(' ')[1];
+ }
+}
diff --git a/packages/calendar/test/utils/createMonth.test.js b/packages/calendar/test/utils/createMonth.test.js
new file mode 100644
index 000000000..0ae7cdf0f
--- /dev/null
+++ b/packages/calendar/test/utils/createMonth.test.js
@@ -0,0 +1,45 @@
+import { expect } from '@open-wc/testing';
+import { createMonth } from '../../src/utils/createMonth.js';
+import { createWeek } from '../../src/utils/createWeek.js';
+
+function compareMonth(obj) {
+ obj.weeks.forEach((week, weeki) => {
+ week.days.forEach((day, dayi) => {
+ // eslint-disable-next-line no-param-reassign
+ obj.weeks[weeki].days[dayi].date = obj.weeks[weeki].days[dayi].date.toISOString();
+ });
+ });
+ return obj;
+}
+
+describe('createMonth', () => {
+ it('creates month data with Sunday as first day of week by default', () => {
+ expect(compareMonth(createMonth(new Date('2018/12/01')))).to.deep.equal(
+ compareMonth({
+ weeks: [
+ createWeek(new Date('2018/11/25'), { firstDayOfWeek: 0 }),
+ createWeek(new Date('2018/12/02'), { firstDayOfWeek: 0 }),
+ createWeek(new Date('2018/12/09'), { firstDayOfWeek: 0 }),
+ createWeek(new Date('2018/12/16'), { firstDayOfWeek: 0 }),
+ createWeek(new Date('2018/12/23'), { firstDayOfWeek: 0 }),
+ createWeek(new Date('2018/12/30'), { firstDayOfWeek: 0 }),
+ ],
+ }),
+ );
+ });
+
+ it('can create month data for different first day of week', () => {
+ expect(compareMonth(createMonth(new Date('2018/12/01'), { firstDayOfWeek: 1 }))).to.deep.equal(
+ compareMonth({
+ weeks: [
+ createWeek(new Date('2018/11/26'), { firstDayOfWeek: 1 }),
+ createWeek(new Date('2018/12/03'), { firstDayOfWeek: 1 }),
+ createWeek(new Date('2018/12/10'), { firstDayOfWeek: 1 }),
+ createWeek(new Date('2018/12/17'), { firstDayOfWeek: 1 }),
+ createWeek(new Date('2018/12/24'), { firstDayOfWeek: 1 }),
+ createWeek(new Date('2018/12/31'), { firstDayOfWeek: 1 }),
+ ],
+ }),
+ );
+ });
+});
diff --git a/packages/calendar/test/utils/createMultipleMonth.test.js b/packages/calendar/test/utils/createMultipleMonth.test.js
new file mode 100644
index 000000000..30c0bd8c2
--- /dev/null
+++ b/packages/calendar/test/utils/createMultipleMonth.test.js
@@ -0,0 +1,71 @@
+import { expect } from '@open-wc/testing';
+import { createMultipleMonth } from '../../src/utils/createMultipleMonth.js';
+import { createMonth } from '../../src/utils/createMonth.js';
+
+function compareMultipleMonth(obj) {
+ obj.months.forEach((month, monthi) => {
+ month.weeks.forEach((week, weeki) => {
+ week.days.forEach((day, dayi) => {
+ // eslint-disable-next-line no-param-reassign
+ obj.months[monthi].weeks[weeki].days[dayi].date = obj.months[monthi].weeks[weeki].days[
+ dayi
+ ].date.toISOString();
+ });
+ });
+ });
+ return obj;
+}
+
+describe('createMultipleMonth', () => {
+ it('creates 1 month by default', () => {
+ expect(compareMultipleMonth(createMultipleMonth(new Date('2018/12/01')))).to.deep.equal(
+ compareMultipleMonth({
+ months: [createMonth(new Date('2018/12/01'))],
+ }),
+ );
+ });
+
+ it('can create extra months in the past', () => {
+ expect(
+ compareMultipleMonth(createMultipleMonth(new Date('2018/12/01'), { pastMonths: 2 })),
+ ).to.deep.equal(
+ compareMultipleMonth({
+ months: [
+ createMonth(new Date('2018/10/01')),
+ createMonth(new Date('2018/11/01')),
+ createMonth(new Date('2018/12/01')),
+ ],
+ }),
+ );
+ });
+
+ it('can create extra months in the future', () => {
+ expect(
+ compareMultipleMonth(createMultipleMonth(new Date('2018/12/01'), { futureMonths: 2 })),
+ ).to.deep.equal(
+ compareMultipleMonth({
+ months: [
+ createMonth(new Date('2018/12/01')),
+ createMonth(new Date('2019/01/01')),
+ createMonth(new Date('2019/02/01')),
+ ],
+ }),
+ );
+ });
+
+ it('can create extra months in the past and future', () => {
+ expect(
+ compareMultipleMonth(
+ createMultipleMonth(new Date('2018/12/01'), { pastMonths: 1, futureMonths: 1 }),
+ ),
+ ).to.deep.equal(
+ compareMultipleMonth({
+ months: [
+ createMonth(new Date('2018/11/01')),
+ createMonth(new Date('2018/12/01')),
+ createMonth(new Date('2019/01/01')),
+ ],
+ }),
+ );
+ });
+});
diff --git a/packages/calendar/test/utils/createWeek.test.js b/packages/calendar/test/utils/createWeek.test.js
new file mode 100644
index 000000000..2b74508f2
--- /dev/null
+++ b/packages/calendar/test/utils/createWeek.test.js
@@ -0,0 +1,47 @@
+import { expect } from '@open-wc/testing';
+import { createWeek } from '../../src/utils/createWeek.js';
+import { createDay } from '../../src/utils/createDay.js';
+
+function compareWeek(obj) {
+ for (let i = 0; i < 7; i += 1) {
+ // eslint-disable-next-line no-param-reassign
+ obj.days[i].date = obj.days[i].date.toISOString();
+ }
+ return obj;
+}
+
+describe('createWeek', () => {
+ it('creates week data starting from Sunday by default', () => {
+ // https://www.timeanddate.com/date/weeknumber.html?d1=30&m1=12&y1=2018&w2=&y2=&wncm=1&wncd=1&wncs=4&fdow=7
+ expect(compareWeek(createWeek(new Date('2018/12/30')))).to.deep.equal(
+ compareWeek({
+ days: [
+ createDay(new Date('2018/12/30'), { weekOrder: 0, startOfWeek: true }),
+ createDay(new Date('2018/12/31'), { weekOrder: 1 }),
+ createDay(new Date('2019/01/01'), { weekOrder: 2 }),
+ createDay(new Date('2019/01/02'), { weekOrder: 3 }),
+ createDay(new Date('2019/01/03'), { weekOrder: 4 }),
+ createDay(new Date('2019/01/04'), { weekOrder: 5 }),
+ createDay(new Date('2019/01/05'), { weekOrder: 6 }),
+ ],
+ }),
+ );
+ });
+
+ it('can create week data starting from different day', () => {
+ // https://www.timeanddate.com/date/weeknumber.html?d1=31&m1=12&y1=2018&w2=&y2=&wncm=1&wncd=1&wncs=4&fdow=0
+ expect(compareWeek(createWeek(new Date('2018/12/31'), { firstDayOfWeek: 1 }))).to.deep.equal(
+ compareWeek({
+ days: [
+ createDay(new Date('2018/12/31'), { weekOrder: 0, startOfWeek: true }),
+ createDay(new Date('2019/01/01'), { weekOrder: 1 }),
+ createDay(new Date('2019/01/02'), { weekOrder: 2 }),
+ createDay(new Date('2019/01/03'), { weekOrder: 3 }),
+ createDay(new Date('2019/01/04'), { weekOrder: 4 }),
+ createDay(new Date('2019/01/05'), { weekOrder: 5 }),
+ createDay(new Date('2019/01/06'), { weekOrder: 6 }),
+ ],
+ }),
+ );
+ });
+});
diff --git a/packages/calendar/test/utils/dataTemplate.test.js b/packages/calendar/test/utils/dataTemplate.test.js
new file mode 100644
index 000000000..7b7113eb8
--- /dev/null
+++ b/packages/calendar/test/utils/dataTemplate.test.js
@@ -0,0 +1,24 @@
+/* eslint-disable no-unused-expressions */
+import { expect, fixture } from '@open-wc/testing';
+
+import { createMultipleMonth } from '../../src/utils/createMultipleMonth.js';
+import { dataTemplate } from '../../src/utils/dataTemplate.js';
+import { weekdayNames } from '../test-utils.js';
+
+// eslint-disable-next-line camelcase
+import snapshot_enGB_Sunday_201812 from './snapshots/monthTemplate_en-GB_Sunday_2018-12.js';
+
+describe('dataTemplate', () => {
+ it('renders one month table', async () => {
+ const date = new Date('2018/12/01');
+ const month = createMultipleMonth(date, { firstDayOfWeek: 0 });
+ const el = await fixture(
+ dataTemplate(month, {
+ weekdaysShort: weekdayNames['en-GB'].Sunday.short,
+ weekdays: weekdayNames['en-GB'].Sunday.long,
+ }),
+ );
+
+ expect(el).dom.to.equal(snapshot_enGB_Sunday_201812);
+ });
+});
diff --git a/packages/calendar/test/utils/dayTemplate.test.js b/packages/calendar/test/utils/dayTemplate.test.js
new file mode 100644
index 000000000..61a7a777e
--- /dev/null
+++ b/packages/calendar/test/utils/dayTemplate.test.js
@@ -0,0 +1,28 @@
+/* eslint-disable no-unused-expressions */
+import { expect, fixture } from '@open-wc/testing';
+
+import { createDay } from '../../src/utils/createDay.js';
+import { dayTemplate } from '../../src/utils/dayTemplate.js';
+
+describe('dayTemplate', () => {
+ it('renders day cell', async () => {
+ const day = createDay(new Date('2019/04/19'), { weekOrder: 5 });
+ const el = await fixture(
+ dayTemplate(day, {
+ weekdays: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
+ }),
+ );
+ expect(el).dom.to.equal(`
+
+
+ |
+ `);
+ });
+});
diff --git a/packages/calendar/test/utils/getFirstDayNextMonth.test.js b/packages/calendar/test/utils/getFirstDayNextMonth.test.js
new file mode 100644
index 000000000..a2fe90d45
--- /dev/null
+++ b/packages/calendar/test/utils/getFirstDayNextMonth.test.js
@@ -0,0 +1,11 @@
+import { expect } from '@open-wc/testing';
+import { formatDate } from '../../../localize/src/date/formatDate.js';
+import { getFirstDayNextMonth } from '../../src/utils/getFirstDayNextMonth.js';
+
+describe('getFirstDayNextMonth', () => {
+ it('returns the first day of the next month', () => {
+ expect(formatDate(getFirstDayNextMonth(new Date('2001/01/01')))).to.be.equal('01/02/2001');
+ expect(formatDate(getFirstDayNextMonth(new Date('2001/10/10')))).to.be.equal('01/11/2001');
+ expect(formatDate(getFirstDayNextMonth(new Date('2000/03/10')))).to.be.equal('01/04/2000');
+ });
+});
diff --git a/packages/calendar/test/utils/getLastDayPreviousMonth.test.js b/packages/calendar/test/utils/getLastDayPreviousMonth.test.js
new file mode 100644
index 000000000..83125522a
--- /dev/null
+++ b/packages/calendar/test/utils/getLastDayPreviousMonth.test.js
@@ -0,0 +1,11 @@
+import { expect } from '@open-wc/testing';
+import { formatDate } from '../../../localize/src/date/formatDate.js';
+import { getLastDayPreviousMonth } from '../../src/utils/getLastDayPreviousMonth.js';
+
+describe('getLastDayPreviousMonth', () => {
+ it('returns the last day of the previous month', () => {
+ expect(formatDate(getLastDayPreviousMonth(new Date('2001/01/01')))).to.be.equal('31/12/2000');
+ expect(formatDate(getLastDayPreviousMonth(new Date('2001/10/10')))).to.be.equal('30/09/2001');
+ expect(formatDate(getLastDayPreviousMonth(new Date('2000/03/10')))).to.be.equal('29/02/2000');
+ });
+});
diff --git a/packages/calendar/test/utils/isSameDate.test.js b/packages/calendar/test/utils/isSameDate.test.js
new file mode 100644
index 000000000..972631551
--- /dev/null
+++ b/packages/calendar/test/utils/isSameDate.test.js
@@ -0,0 +1,19 @@
+import { expect } from '@open-wc/testing';
+import { isSameDate } from '../../src/utils/isSameDate.js';
+
+describe('isSameDate', () => {
+ it('returns true if the same date is given', () => {
+ const day1 = new Date('2001/01/01');
+ const day2 = new Date('2001/01/01');
+ const day3 = new Date('2002/02/02');
+ expect(isSameDate(day1, day2)).to.be.true;
+ expect(isSameDate(day1, day3)).to.be.false;
+ });
+
+ it('returns false if not a date is provided', () => {
+ const day = new Date('2001/01/01');
+ expect(isSameDate(day, undefined)).to.be.false;
+ expect(isSameDate(undefined, day)).to.be.false;
+ expect(isSameDate(undefined, undefined)).to.be.false;
+ });
+});
diff --git a/packages/calendar/test/utils/snapshots/monthTemplate_en-GB_Sunday_2018-12.js b/packages/calendar/test/utils/snapshots/monthTemplate_en-GB_Sunday_2018-12.js
new file mode 100644
index 000000000..969b7455e
--- /dev/null
+++ b/packages/calendar/test/utils/snapshots/monthTemplate_en-GB_Sunday_2018-12.js
@@ -0,0 +1,488 @@
+const html = strings => strings[0];
+
+export default html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+`;
diff --git a/packages/calendar/translations/bg.js b/packages/calendar/translations/bg.js
new file mode 100644
index 000000000..98e5924bf
--- /dev/null
+++ b/packages/calendar/translations/bg.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Следващ месец',
+ previousMonth: 'Предишен месец',
+};
diff --git a/packages/calendar/translations/cs.js b/packages/calendar/translations/cs.js
new file mode 100644
index 000000000..a3816b7e7
--- /dev/null
+++ b/packages/calendar/translations/cs.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Příští měsíc',
+ previousMonth: 'Předchozí měsíc',
+};
diff --git a/packages/calendar/translations/de.js b/packages/calendar/translations/de.js
new file mode 100644
index 000000000..8dfe0c87a
--- /dev/null
+++ b/packages/calendar/translations/de.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Nächster Monat',
+ previousMonth: 'Vorheriger Monat',
+};
diff --git a/packages/calendar/translations/en.js b/packages/calendar/translations/en.js
new file mode 100644
index 000000000..46fc92a75
--- /dev/null
+++ b/packages/calendar/translations/en.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Next month',
+ previousMonth: 'Previous month',
+};
diff --git a/packages/calendar/translations/es.js b/packages/calendar/translations/es.js
new file mode 100644
index 000000000..35961daa8
--- /dev/null
+++ b/packages/calendar/translations/es.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Mes siguiente',
+ previousMonth: 'Mes anterior',
+};
diff --git a/packages/calendar/translations/fr.js b/packages/calendar/translations/fr.js
new file mode 100644
index 000000000..af7112ac9
--- /dev/null
+++ b/packages/calendar/translations/fr.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Mois prochain',
+ previousMonth: 'Mois précédent',
+};
diff --git a/packages/calendar/translations/hu.js b/packages/calendar/translations/hu.js
new file mode 100644
index 000000000..2e4e5e13d
--- /dev/null
+++ b/packages/calendar/translations/hu.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Következő hónap',
+ previousMonth: 'Előző hónap',
+};
diff --git a/packages/calendar/translations/it.js b/packages/calendar/translations/it.js
new file mode 100644
index 000000000..8df8b8f84
--- /dev/null
+++ b/packages/calendar/translations/it.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Mese successivo',
+ previousMonth: 'Mese precedente',
+};
diff --git a/packages/calendar/translations/nl.js b/packages/calendar/translations/nl.js
new file mode 100644
index 000000000..cf85a4abb
--- /dev/null
+++ b/packages/calendar/translations/nl.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Volgende maand',
+ previousMonth: 'Vorige maand',
+};
diff --git a/packages/calendar/translations/pl.js b/packages/calendar/translations/pl.js
new file mode 100644
index 000000000..6d022f308
--- /dev/null
+++ b/packages/calendar/translations/pl.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Następny miesiąc',
+ previousMonth: 'Poprzedni miesiąc',
+};
diff --git a/packages/calendar/translations/ro.js b/packages/calendar/translations/ro.js
new file mode 100644
index 000000000..da42be6ef
--- /dev/null
+++ b/packages/calendar/translations/ro.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Luna viitoare',
+ previousMonth: 'Luna anterioară',
+};
diff --git a/packages/calendar/translations/ru.js b/packages/calendar/translations/ru.js
new file mode 100644
index 000000000..90d3b8d4f
--- /dev/null
+++ b/packages/calendar/translations/ru.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Следующий месяц',
+ previousMonth: 'Предыдущий месяц',
+};
diff --git a/packages/calendar/translations/sk.js b/packages/calendar/translations/sk.js
new file mode 100644
index 000000000..a8f1fbf4a
--- /dev/null
+++ b/packages/calendar/translations/sk.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Nasledujúci mesiac',
+ previousMonth: 'Predchádzajúci mesiac',
+};
diff --git a/packages/calendar/translations/uk.js b/packages/calendar/translations/uk.js
new file mode 100644
index 000000000..4290a0130
--- /dev/null
+++ b/packages/calendar/translations/uk.js
@@ -0,0 +1,4 @@
+export default {
+ nextMonth: 'Наступний місяць',
+ previousMonth: 'Попередній місяць',
+};
diff --git a/packages/localize/index.js b/packages/localize/index.js
index efb5d6cb3..1efbe8f3b 100644
--- a/packages/localize/index.js
+++ b/packages/localize/index.js
@@ -1,5 +1,7 @@
export { formatDate } from './src/date/formatDate.js';
export { getDateFormatBasedOnLocale } from './src/date/getDateFormatBasedOnLocale.js';
+export { getMonthNames } from './src/date/getMonthNames.js';
+export { getWeekdayNames } from './src/date/getWeekdayNames.js';
export { parseDate } from './src/date/parseDate.js';
export {
formatNumber,
diff --git a/packages/localize/src/date/getMonthNames.js b/packages/localize/src/date/getMonthNames.js
new file mode 100644
index 000000000..132b7c8fa
--- /dev/null
+++ b/packages/localize/src/date/getMonthNames.js
@@ -0,0 +1,25 @@
+import { normalizeDate } from './normalizeDate.js';
+
+const monthsLocaleCache = {};
+
+export function getMonthNames({ locale }) {
+ let months = monthsLocaleCache[locale];
+
+ if (months) {
+ return months;
+ }
+
+ months = [];
+
+ const formatter = new Intl.DateTimeFormat(locale, { month: 'long' });
+ for (let i = 0; i < 12; i += 1) {
+ const date = new Date(2019, i, 1);
+ const formattedDate = formatter.format(date);
+ const normalizedDate = normalizeDate(formattedDate);
+ months.push(normalizedDate);
+ }
+
+ monthsLocaleCache[locale] = months;
+
+ return months;
+}
diff --git a/packages/localize/src/date/getWeekdayNames.js b/packages/localize/src/date/getWeekdayNames.js
new file mode 100644
index 000000000..aec20bb12
--- /dev/null
+++ b/packages/localize/src/date/getWeekdayNames.js
@@ -0,0 +1,53 @@
+import { normalizeDate } from './normalizeDate.js';
+
+const weekdayNamesCache = {};
+
+/**
+ * @desc Return cached weekday names for locale for all styles ('long', 'short', 'narrow')
+ * @param {string} locale locale
+ * @returns {Object} like { long: ['Sunday', 'Monday'...], short: ['Sun', ...], narrow: ['S', ...] }
+ */
+function getCachedWeekdayNames(locale) {
+ let weekdays = weekdayNamesCache[locale];
+
+ if (weekdays) {
+ return weekdays;
+ }
+
+ weekdayNamesCache[locale] = { long: [], short: [], narrow: [] };
+
+ ['long', 'short', 'narrow'].forEach(style => {
+ weekdays = weekdayNamesCache[locale][style];
+ const formatter = new Intl.DateTimeFormat(locale, { weekday: style });
+
+ const date = new Date('2019/04/07'); // start from Sunday
+ for (let i = 0; i < 7; i += 1) {
+ const weekday = formatter.format(date);
+ const normalizedWeekday = normalizeDate(weekday);
+ weekdays.push(normalizedWeekday);
+ date.setDate(date.getDate() + 1);
+ }
+ });
+
+ return weekdayNamesCache[locale];
+}
+
+// TODO: consider using a database with information for the `firstDayOfWeek`?
+// https://github.com/unicode-cldr/cldr-core/blob/35.0.0/supplemental/weekData.json#L60
+// https://github.com/tc39/ecma402/issues/6#issuecomment-114079502
+
+/**
+ * @desc Returns weekday names for locale
+ * @param {string} options.locale locale
+ * @param {string} [options.style=long] long, short or narrow
+ * @param {number} [options.firstDayOfWeek=0] 0 (Sunday), 1 (Monday), etc...
+ * @returns {Array} like: ['Sunday', 'Monday', 'Tuesday', ...etc].
+ */
+export function getWeekdayNames({ locale, style = 'long', firstDayOfWeek = 0 } = {}) {
+ const weekdays = getCachedWeekdayNames(locale)[style];
+ const orderedWeekdays = [];
+ for (let i = firstDayOfWeek; i < firstDayOfWeek + 7; i += 1) {
+ orderedWeekdays.push(weekdays[i % 7]);
+ }
+ return orderedWeekdays;
+}
diff --git a/packages/localize/test/date/getMonthNames.test.js b/packages/localize/test/date/getMonthNames.test.js
new file mode 100644
index 000000000..cb2925b78
--- /dev/null
+++ b/packages/localize/test/date/getMonthNames.test.js
@@ -0,0 +1,21 @@
+import { expect } from '@open-wc/testing';
+
+import { getMonthNames } from '../../src/date/getMonthNames.js';
+
+function s(strings) {
+ return strings[0].split(' ');
+}
+
+describe('getMonthNames', () => {
+ it('generates month names for a given locale', () => {
+ expect(getMonthNames({ locale: 'en-GB' })).to.deep.equal(
+ s`January February March April May June July August September October November December`,
+ );
+ expect(getMonthNames({ locale: 'nl-NL' })).to.deep.equal(
+ s`januari februari maart april mei juni juli augustus september oktober november december`,
+ );
+ expect(getMonthNames({ locale: 'zh-CH' })).to.deep.equal(
+ s`一月 二月 三月 四月 五月 六月 七月 八月 九月 十月 十一月 十二月`,
+ );
+ });
+});
diff --git a/packages/localize/test/date/getWeekdayNames.test.js b/packages/localize/test/date/getWeekdayNames.test.js
new file mode 100644
index 000000000..ded662192
--- /dev/null
+++ b/packages/localize/test/date/getWeekdayNames.test.js
@@ -0,0 +1,65 @@
+import { expect } from '@open-wc/testing';
+
+import { getWeekdayNames } from '../../src/date/getWeekdayNames.js';
+
+function s(strings) {
+ return strings[0].split(' ');
+}
+
+describe('getWeekdayNames', () => {
+ it('generates weekday names for a given locale with defaults (from Sunday, long style)', () => {
+ expect(getWeekdayNames({ locale: 'en-GB' })).to.deep.equal(
+ s`Sunday Monday Tuesday Wednesday Thursday Friday Saturday`,
+ );
+ expect(getWeekdayNames({ locale: 'nl-NL' })).to.deep.equal(
+ s`zondag maandag dinsdag woensdag donderdag vrijdag zaterdag`,
+ );
+ expect(getWeekdayNames({ locale: 'zh-CH' })).to.deep.equal(
+ s`星期日 星期一 星期二 星期三 星期四 星期五 星期六`,
+ );
+ });
+
+ it('allows to specify a day when a week starts', () => {
+ expect(getWeekdayNames({ locale: 'en-GB', firstDayOfWeek: 7 })).to.deep.equal(
+ s`Sunday Monday Tuesday Wednesday Thursday Friday Saturday`,
+ );
+ expect(getWeekdayNames({ locale: 'en-GB', firstDayOfWeek: 1 })).to.deep.equal(
+ s`Monday Tuesday Wednesday Thursday Friday Saturday Sunday`,
+ );
+ expect(getWeekdayNames({ locale: 'en-GB', firstDayOfWeek: 2 })).to.deep.equal(
+ s`Tuesday Wednesday Thursday Friday Saturday Sunday Monday`,
+ );
+ expect(getWeekdayNames({ locale: 'en-GB', firstDayOfWeek: 3 })).to.deep.equal(
+ s`Wednesday Thursday Friday Saturday Sunday Monday Tuesday`,
+ );
+ expect(getWeekdayNames({ locale: 'en-GB', firstDayOfWeek: 4 })).to.deep.equal(
+ s`Thursday Friday Saturday Sunday Monday Tuesday Wednesday`,
+ );
+ expect(getWeekdayNames({ locale: 'en-GB', firstDayOfWeek: 5 })).to.deep.equal(
+ s`Friday Saturday Sunday Monday Tuesday Wednesday Thursday`,
+ );
+ expect(getWeekdayNames({ locale: 'en-GB', firstDayOfWeek: 6 })).to.deep.equal(
+ s`Saturday Sunday Monday Tuesday Wednesday Thursday Friday`,
+ );
+ });
+
+ it('supports "short" style', () => {
+ expect(getWeekdayNames({ locale: 'en-GB', style: 'short' })).to.deep.equal(
+ s`Sun Mon Tue Wed Thu Fri Sat`,
+ );
+ expect(getWeekdayNames({ locale: 'nl-NL', style: 'short' })).to.deep.equal(
+ s`zo ma di wo do vr za`,
+ );
+ expect(getWeekdayNames({ locale: 'zh-CH', style: 'short' })).to.deep.equal(
+ s`周日 周一 周二 周三 周四 周五 周六`,
+ );
+ });
+
+ it('supports "narrow" style', () => {
+ expect(getWeekdayNames({ locale: 'en-GB', style: 'narrow' })).to.deep.equal(s`S M T W T F S`);
+ expect(getWeekdayNames({ locale: 'nl-NL', style: 'narrow' })).to.deep.equal(s`Z M D W D V Z`);
+ expect(getWeekdayNames({ locale: 'zh-CH', style: 'narrow' })).to.deep.equal(
+ s`日 一 二 三 四 五 六`,
+ );
+ });
+});
diff --git a/stories/index.stories.js b/stories/index.stories.js
index 47fee97f5..2931f50e3 100755
--- a/stories/index.stories.js
+++ b/stories/index.stories.js
@@ -22,3 +22,4 @@ import '../packages/localize/stories/index.stories.js';
import '../packages/overlays/stories/index.stories.js';
import '../packages/popup/stories/index.stories.js';
import '../packages/tooltip/stories/index.stories.js';
+import '../packages/calendar/stories/index.stories.js';