From 9fc5488175cce22856718ce9cacc7e56a5cc3a10 Mon Sep 17 00:00:00 2001 From: Mikhail Bashkirov Date: Mon, 13 May 2019 10:37:50 +0200 Subject: [PATCH] feat(calendar): add reusable calendar Co-authored-by: Erik Kroes Co-authored-by: Gerjan van Geest Co-authored-by: Thijs Louisse Co-authored-by: Thomas Allmer --- packages/calendar/README.md | 44 + packages/calendar/index.js | 1 + packages/calendar/lion-calendar.js | 3 + packages/calendar/package.json | 43 + packages/calendar/src/LionCalendar.js | 547 +++++++++ packages/calendar/src/calendarStyle.js | 74 ++ packages/calendar/src/utils/createDay.js | 29 + packages/calendar/src/utils/createMonth.js | 25 + .../calendar/src/utils/createMultipleMonth.js | 26 + packages/calendar/src/utils/createWeek.js | 30 + packages/calendar/src/utils/dataTemplate.js | 51 + packages/calendar/src/utils/dayTemplate.js | 46 + .../src/utils/differentKeyNamesShimIE.js | 33 + .../src/utils/getFirstDayNextMonth.js | 13 + .../src/utils/getLastDayPreviousMonth.js | 12 + packages/calendar/src/utils/isSameDate.js | 17 + packages/calendar/stories/index.stories.js | 136 +++ packages/calendar/test/keyboardEventShimIE.js | 49 + packages/calendar/test/lion-calendar.test.js | 1074 +++++++++++++++++ packages/calendar/test/test-utils.js | 221 ++++ .../calendar/test/utils/createMonth.test.js | 45 + .../test/utils/createMultipleMonth.test.js | 71 ++ .../calendar/test/utils/createWeek.test.js | 47 + .../calendar/test/utils/dataTemplate.test.js | 24 + .../calendar/test/utils/dayTemplate.test.js | 28 + .../test/utils/getFirstDayNextMonth.test.js | 11 + .../utils/getLastDayPreviousMonth.test.js | 11 + .../calendar/test/utils/isSameDate.test.js | 19 + .../monthTemplate_en-GB_Sunday_2018-12.js | 488 ++++++++ packages/calendar/translations/bg.js | 4 + packages/calendar/translations/cs.js | 4 + packages/calendar/translations/de.js | 4 + packages/calendar/translations/en.js | 4 + packages/calendar/translations/es.js | 4 + packages/calendar/translations/fr.js | 4 + packages/calendar/translations/hu.js | 4 + packages/calendar/translations/it.js | 4 + packages/calendar/translations/nl.js | 4 + packages/calendar/translations/pl.js | 4 + packages/calendar/translations/ro.js | 4 + packages/calendar/translations/ru.js | 4 + packages/calendar/translations/sk.js | 4 + packages/calendar/translations/uk.js | 4 + stories/index.stories.js | 1 + 44 files changed, 3275 insertions(+) create mode 100644 packages/calendar/README.md create mode 100644 packages/calendar/index.js create mode 100644 packages/calendar/lion-calendar.js create mode 100644 packages/calendar/package.json create mode 100644 packages/calendar/src/LionCalendar.js create mode 100644 packages/calendar/src/calendarStyle.js create mode 100644 packages/calendar/src/utils/createDay.js create mode 100644 packages/calendar/src/utils/createMonth.js create mode 100644 packages/calendar/src/utils/createMultipleMonth.js create mode 100644 packages/calendar/src/utils/createWeek.js create mode 100644 packages/calendar/src/utils/dataTemplate.js create mode 100644 packages/calendar/src/utils/dayTemplate.js create mode 100644 packages/calendar/src/utils/differentKeyNamesShimIE.js create mode 100644 packages/calendar/src/utils/getFirstDayNextMonth.js create mode 100644 packages/calendar/src/utils/getLastDayPreviousMonth.js create mode 100644 packages/calendar/src/utils/isSameDate.js create mode 100755 packages/calendar/stories/index.stories.js create mode 100644 packages/calendar/test/keyboardEventShimIE.js create mode 100644 packages/calendar/test/lion-calendar.test.js create mode 100644 packages/calendar/test/test-utils.js create mode 100644 packages/calendar/test/utils/createMonth.test.js create mode 100644 packages/calendar/test/utils/createMultipleMonth.test.js create mode 100644 packages/calendar/test/utils/createWeek.test.js create mode 100644 packages/calendar/test/utils/dataTemplate.test.js create mode 100644 packages/calendar/test/utils/dayTemplate.test.js create mode 100644 packages/calendar/test/utils/getFirstDayNextMonth.test.js create mode 100644 packages/calendar/test/utils/getLastDayPreviousMonth.test.js create mode 100644 packages/calendar/test/utils/isSameDate.test.js create mode 100644 packages/calendar/test/utils/snapshots/monthTemplate_en-GB_Sunday_2018-12.js create mode 100644 packages/calendar/translations/bg.js create mode 100644 packages/calendar/translations/cs.js create mode 100644 packages/calendar/translations/de.js create mode 100644 packages/calendar/translations/en.js create mode 100644 packages/calendar/translations/es.js create mode 100644 packages/calendar/translations/fr.js create mode 100644 packages/calendar/translations/hu.js create mode 100644 packages/calendar/translations/it.js create mode 100644 packages/calendar/translations/nl.js create mode 100644 packages/calendar/translations/pl.js create mode 100644 packages/calendar/translations/ro.js create mode 100644 packages/calendar/translations/ru.js create mode 100644 packages/calendar/translations/sk.js create mode 100644 packages/calendar/translations/uk.js 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` +
+ ${this.__renderPreviousButton()} +

+ ${month} ${year} +

+ ${this.__renderNextButton()} +
+ `; + } + + __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 }), + )} + + `, + )} + +
+ ${header} +
+ `, + )} +
+ `; +} 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: + + Central date + + + Selected date + + + 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` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Sun + + Mon + + Tue + + Wed + + Thu + + Fri + + Sat +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+
+`; 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/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';