diff --git a/packages/input-datepicker/index.js b/packages/input-datepicker/index.js new file mode 100644 index 000000000..5267e28da --- /dev/null +++ b/packages/input-datepicker/index.js @@ -0,0 +1 @@ +export { LionInputDatepicker } from './src/LionInputDatepicker.js'; diff --git a/packages/input-datepicker/lion-input-datepicker.js b/packages/input-datepicker/lion-input-datepicker.js new file mode 100644 index 000000000..619dbc90d --- /dev/null +++ b/packages/input-datepicker/lion-input-datepicker.js @@ -0,0 +1,3 @@ +import { LionInputDatepicker } from './src/LionInputDatepicker.js'; + +customElements.define('lion-input-datepicker', LionInputDatepicker); diff --git a/packages/input-datepicker/package.json b/packages/input-datepicker/package.json new file mode 100644 index 000000000..d066aeba4 --- /dev/null +++ b/packages/input-datepicker/package.json @@ -0,0 +1,49 @@ +{ + "name": "@lion/input-datepicker", + "version": "0.0.0", + "description": "Provide a way for users to fill in a date via a calendar overlay", + "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/input-datepicker" + }, + "scripts": { + "prepublishOnly": "../../scripts/npm-prepublish.js" + }, + "keywords": [ + "lion", + "web-components", + "input-date", + "input-datepicker", + "calendar", + "datepicker" + ], + "main": "index.js", + "module": "index.js", + "files": [ + "src", + "stories", + "test", + "*.js" + ], + "dependencies": { + "@lion/core": "^0.1.3", + "@lion/validate": "^0.1.3", + "@lion/input-date": "^0.1.3", + "@lion/overlays": "^0.1.3", + "@lion/calendar": "^0.1.2", + "@lion/localize": "^0.1.6" + }, + "devDependencies": { + "@open-wc/demoing-storybook": "^0.2.0", + "@open-wc/testing": "^0.11.1", + "@lion/button": "^0.1.3", + "@polymer/iron-test-helpers": "^3.0.1" + } +} diff --git a/packages/input-datepicker/src/LionCalendarOverlayFrame.js b/packages/input-datepicker/src/LionCalendarOverlayFrame.js new file mode 100644 index 000000000..fc2cfd3da --- /dev/null +++ b/packages/input-datepicker/src/LionCalendarOverlayFrame.js @@ -0,0 +1,149 @@ +import { html, css, LitElement, DomHelpersMixin } from '@lion/core'; +import { LocalizeMixin } from '@lion/localize'; + +export class LionCalendarOverlayFrame extends LocalizeMixin(DomHelpersMixin(LitElement)) { + static get styles() { + return [ + css` + :host { + display: inline-block; + background: white; + position: relative; + } + + .calendar-overlay__header { + display: flex; + } + + .calendar-overlay__heading { + padding: 16px 16px 8px; + flex: 1; + } + + .calendar-overlay__heading > .calendar-overlay__close-button { + flex: none; + } + + .calendar-overlay__close-button { + min-width: 40px; + min-height: 32px; + border-width: 0; + padding: 0; + font-size: 24px; + } + `, + ]; + } + + static get localizeNamespaces() { + return [ + { + /* FIXME: This awful switch statement is used to make sure it works with polymer build.. */ + 'lion-calendar-overlay-frame': locale => { + switch (locale) { + case 'bg-BG': + return import('@lion/overlays/translations/bg-BG.js'); + case 'bg': + return import('@lion/overlays/translations/bg.js'); + case 'cs-CZ': + return import('@lion/overlays/translations/cs-CZ.js'); + case 'cs': + return import('@lion/overlays/translations/cs.js'); + case 'de-DE': + return import('@lion/overlays/translations/de-DE.js'); + case 'de': + return import('@lion/overlays/translations/de.js'); + case 'en-AU': + return import('@lion/overlays/translations/en-AU.js'); + case 'en-GB': + return import('@lion/overlays/translations/en-GB.js'); + case 'en-US': + return import('@lion/overlays/translations/en-US.js'); + case 'en': + return import('@lion/overlays/translations/en.js'); + case 'es-ES': + return import('@lion/overlays/translations/es-ES.js'); + case 'es': + return import('@lion/overlays/translations/es.js'); + case 'fr-FR': + return import('@lion/overlays/translations/fr-FR.js'); + case 'fr-BE': + return import('@lion/overlays/translations/fr-BE.js'); + case 'fr': + return import('@lion/overlays/translations/fr.js'); + case 'hu-HU': + return import('@lion/overlays/translations/hu-HU.js'); + case 'hu': + return import('@lion/overlays/translations/hu.js'); + case 'it-IT': + return import('@lion/overlays/translations/it-IT.js'); + case 'it': + return import('@lion/overlays/translations/it.js'); + case 'nl-BE': + return import('@lion/overlays/translations/nl-BE.js'); + case 'nl-NL': + return import('@lion/overlays/translations/nl-NL.js'); + case 'nl': + return import('@lion/overlays/translations/nl.js'); + case 'pl-PL': + return import('@lion/overlays/translations/pl-PL.js'); + case 'pl': + return import('@lion/overlays/translations/pl.js'); + case 'ro-RO': + return import('@lion/overlays/translations/ro-RO.js'); + case 'ro': + return import('@lion/overlays/translations/ro.js'); + case 'ru-RU': + return import('@lion/overlays/translations/ru-RU.js'); + case 'ru': + return import('@lion/overlays/translations/ru.js'); + case 'sk-SK': + return import('@lion/overlays/translations/sk-SK.js'); + case 'sk': + return import('@lion/overlays/translations/sk.js'); + case 'uk-UA': + return import('@lion/overlays/translations/uk-UA.js'); + case 'uk': + return import('@lion/overlays/translations/uk.js'); + default: + throw new Error(`Unknown locale: ${locale}`); + } + }, + }, + ...super.localizeNamespaces, + ]; + } + + constructor() { + super(); + this.__dispatchCloseEvent = this.__dispatchCloseEvent.bind(this); + } + + __dispatchCloseEvent() { + // Designed to work in conjunction with ModalDialogController + this.dispatchEvent(new CustomEvent('dialog-close'), { bubbles: true, composed: true }); + } + + render() { + // eslint-disable-line class-methods-use-this + return html` +
+
+

+ +

+ +
+ +
+ `; + } +} diff --git a/packages/input-datepicker/src/LionInputDatepicker.js b/packages/input-datepicker/src/LionInputDatepicker.js new file mode 100644 index 000000000..e0083f188 --- /dev/null +++ b/packages/input-datepicker/src/LionInputDatepicker.js @@ -0,0 +1,365 @@ +import { html, render, ifDefined } from '@lion/core'; +import { LionInputDate } from '@lion/input-date'; +import { overlays, ModalDialogController } from '@lion/overlays'; +import { Unparseable, isValidatorApplied } from '@lion/validate'; +import '@lion/calendar/lion-calendar.js'; +import './lion-calendar-overlay-frame.js'; + +/** + * @customElement + * @extends {LionInputDate} + */ +export class LionInputDatepicker extends LionInputDate { + static get properties() { + return { + ...super.properties, + /** + * The heading to be added on top of the calendar overlay. + * Naming chosen from an Application Developer perspective. + * For a Subclasser 'calendarOverlayHeading' would be more appropriate + */ + calendarHeading: { + type: String, + attribute: 'calendar-heading', + }, + /** + * The slot to put the invoker button in. Can be 'prefix', 'suffix', 'before' and 'after'. + * Default will be 'suffix'. + */ + _calendarInvokerSlot: { + type: String, + }, + + /** + * TODO: [delegation of disabled] move this to LionField (or FormControl) level + */ + disabled: { + type: Boolean, + }, + + __calendarMinDate: { + type: Date, + }, + + __calendarMaxDate: { + type: Date, + }, + + __calendarDisableDates: { + type: Function, + }, + }; + } + + get slots() { + return { + ...super.slots, + [this._calendarInvokerSlot]: () => this.__createPickerAndReturnInvokerNode(), + }; + } + + static get localizeNamespaces() { + return [ + { + /* FIXME: This awful switch statement is used to make sure it works with polymer build.. */ + 'lion-input-datepicker': locale => { + switch (locale) { + case 'bg-BG': + return import('../translations/bg-BG.js'); + case 'bg': + return import('../translations/bg.js'); + case 'cs-CZ': + return import('../translations/cs-CZ.js'); + case 'cs': + return import('../translations/cs.js'); + case 'de-DE': + return import('../translations/de-DE.js'); + case 'de': + return import('../translations/de.js'); + case 'en-AU': + return import('../translations/en-AU.js'); + case 'en-GB': + return import('../translations/en-GB.js'); + case 'en-US': + return import('../translations/en-US.js'); + case 'en': + return import('../translations/en.js'); + case 'es-ES': + return import('../translations/es-ES.js'); + case 'es': + return import('../translations/es.js'); + case 'fr-FR': + return import('../translations/fr-FR.js'); + case 'fr-BE': + return import('../translations/fr-BE.js'); + case 'fr': + return import('../translations/fr.js'); + case 'hu-HU': + return import('../translations/hu-HU.js'); + case 'hu': + return import('../translations/hu.js'); + case 'it-IT': + return import('../translations/it-IT.js'); + case 'it': + return import('../translations/it.js'); + case 'nl-BE': + return import('../translations/nl-BE.js'); + case 'nl-NL': + return import('../translations/nl-NL.js'); + case 'nl': + return import('../translations/nl.js'); + case 'pl-PL': + return import('../translations/pl-PL.js'); + case 'pl': + return import('../translations/pl.js'); + case 'ro-RO': + return import('../translations/ro-RO.js'); + case 'ro': + return import('../translations/ro.js'); + case 'ru-RU': + return import('../translations/ru-RU.js'); + case 'ru': + return import('../translations/ru.js'); + case 'sk-SK': + return import('../translations/sk-SK.js'); + case 'sk': + return import('../translations/sk.js'); + case 'uk-UA': + return import('../translations/uk-UA.js'); + case 'uk': + return import('../translations/uk.js'); + default: + throw new Error(`Unknown locale: ${locale}`); + } + }, + }, + ...super.localizeNamespaces, + ]; + } + + get _invokerElement() { + return this.querySelector(`#${this.__invokerId}`); + } + + get _calendarOverlayElement() { + return this._overlayCtrl._container.firstElementChild; + } + + get _calendarElement() { + return this._calendarOverlayElement.querySelector('#calendar'); + } + + constructor() { + super(); + // Create a unique id for the invoker, since it is placed in light dom for a11y. + this.__invokerId = this.__createUniqueIdForA11y(); + this._calendarInvokerSlot = 'suffix'; + + // Configuration flags for subclassers + this._focusCentralDateOnCalendarOpen = true; + this._hideOnUserSelect = true; + this._syncOnUserSelect = true; + + this.__openCalendarOverlay = this.__openCalendarOverlay.bind(this); + this._onCalendarUserSelectedChanged = this._onCalendarUserSelectedChanged.bind(this); + } + + __createUniqueIdForA11y() { + return `${this.localName}-${Math.random() + .toString(36) + .substr(2, 10)}`; + } + + /** + * Problem: we need to create a getter for disabled that puts disabled attrs on the invoker + * button. + * The DelegateMixin creates getters and setters regardless of what's defined on the prototype, + * thats why we need to move it out from parent delegations config, in order to make our own + * getters and setters work. + * + * TODO: [delegation of disabled] fix this on a global level: + * - LionField + * - move all delegations of attrs and props to static get props for docs + * - DelegateMixin needs to be refactored, so that it: + * - gets config from static get properties + * - hooks into _requestUpdate + */ + get delegations() { + return { + ...super.delegations, + properties: super.delegations.properties.filter(p => p !== 'disabled'), + attributes: super.delegations.attributes.filter(p => p !== 'disabled'), + }; + } + + /** + * TODO: [delegation of disabled] move this to LionField (or FormControl) level + */ + _requestUpdate(name, oldValue) { + super._requestUpdate(name, oldValue); + if (name === 'disabled') { + this.__delegateDisabled(); + } + } + + /** + * TODO: [delegation of disabled] move this to LionField (or FormControl) level + */ + __delegateDisabled() { + if (this.delegations.target()) { + this.delegations.target().disabled = this.disabled; + } + if (this._invokerElement) { + this._invokerElement.disabled = this.disabled; + } + } + + /** + * TODO: [delegation of disabled] move this to LionField (or FormControl) level + */ + firstUpdated(c) { + super.firstUpdated(c); + this.__delegateDisabled(); + } + + /** + * @override + * @param {Map} c - changed properties + */ + updated(c) { + super.updated(c); + + if (c.has('errorValidators') || c.has('warningValidators')) { + const validators = [...(this.warningValidators || []), ...(this.errorValidators || [])]; + this.__syncDisabledDates(validators); + } + if (c.has('label')) { + this.calendarHeading = this.calendarHeading || this.label; + } + } + + _calendarOverlayTemplate() { + return html` + + ${this.calendarHeading} + ${this._calendarTemplate()} + + `; + } + + /** + * Subclassers can replace this with their custom extension of + * LionCalendar, like `` + */ + // eslint-disable-next-line class-methods-use-this + _calendarTemplate() { + return html` + + `; + } + + /** + * Subclassers can replace this with their custom extension invoker, + * like `` + */ + // eslint-disable-next-line class-methods-use-this + _invokerTemplate() { + // TODO: aria-expanded should be toggled by Overlay system, to allow local overlays + // (a.k.a. dropdowns) as well + return html` + + `; + } + + // Renders the invoker button + the calendar overlay invoked by this button + __createPickerAndReturnInvokerNode() { + const renderParent = document.createElement('div'); + render(this._invokerTemplate(), renderParent); + const invokerNode = renderParent.firstElementChild; + + // TODO: ModalDialogController should be replaced by a more flexible + // overlay, allowing the overlay to switch on smaller screens, for instance from dropdown to + // bottom sheet (working name for this controller: ResponsiveOverlayController) + this._overlayCtrl = overlays.add( + new ModalDialogController({ + contentTemplate: () => this._calendarOverlayTemplate(), + elementToFocusAfterHide: invokerNode, + }), + ); + return invokerNode; + } + + async __openCalendarOverlay() { + this._overlayCtrl.show(); + await this._calendarElement.updateComplete; + this._onCalendarOverlayOpened(); + } + + /** + * Lifecycle callback for subclassers + */ + _onCalendarOverlayOpened() { + if (this._focusCentralDateOnCalendarOpen) { + this._calendarElement.focusCentralDate(); + } + } + + _onCalendarUserSelectedChanged({ target: { selectedDate } }) { + if (this._hideOnUserSelect) { + this._overlayCtrl.hide(); + } + if (this._syncOnUserSelect) { + // Synchronize new selectedDate value to input + this.modelValue = selectedDate; + } + } + + /** + * The LionCalendar shouldn't know anything about the modelValue; + * it can't handle Unparseable dates, but does handle 'undefined' + * @returns {Date|undefined} a 'guarded' modelValue + */ + static __getSyncDownValue(modelValue) { + return modelValue instanceof Unparseable ? undefined : modelValue; + } + + /** + * Validators contain the information to synchronize the input with + * the min, max and enabled dates of the calendar. + * @param {Array} validators - errorValidators or warningValidators array + */ + __syncDisabledDates(validators) { + // On every validator change, synchronize disabled dates: this means + // we need to extract minDate, maxDate, minMaxDate and disabledDates validators + validators.forEach(([fn, param]) => { + const d = new Date(); + + if (isValidatorApplied('minDate', fn, d)) { + this.__calendarMinDate = param; + } else if (isValidatorApplied('maxDate', fn, d)) { + this.__calendarMaxDate = param; + } else if (isValidatorApplied('minMaxDate', fn, { min: d, max: d })) { + this.__calendarMinDate = param.min; + this.__calendarMaxDate = param.max; + } else if (isValidatorApplied('isDateDisabled', fn, () => true)) { + this.__calendarDisableDates = param; + } + }); + } +} diff --git a/packages/input-datepicker/src/lion-calendar-overlay-frame.js b/packages/input-datepicker/src/lion-calendar-overlay-frame.js new file mode 100644 index 000000000..ebe505e60 --- /dev/null +++ b/packages/input-datepicker/src/lion-calendar-overlay-frame.js @@ -0,0 +1,3 @@ +import { LionCalendarOverlayFrame } from './LionCalendarOverlayFrame.js'; + +customElements.define('lion-calendar-overlay-frame', LionCalendarOverlayFrame); diff --git a/packages/input-datepicker/stories/index.stories.js b/packages/input-datepicker/stories/index.stories.js new file mode 100644 index 000000000..7ab913cdf --- /dev/null +++ b/packages/input-datepicker/stories/index.stories.js @@ -0,0 +1,54 @@ +import { storiesOf, html } from '@open-wc/demoing-storybook'; +import { isDateDisabledValidator, minMaxDateValidator } from '@lion/validate'; +import '../lion-input-datepicker.js'; + +storiesOf('Forms|Input Datepicker', module) + .add( + 'Default', + () => html` + + + `, + ) + .add( + 'minMaxDateValidator', + () => html` + + + `, + ) + .add( + 'disabledDatesValidator', + () => html` + d.getDate() === 15)]} + > + + `, + ) + .add( + 'With calendar-heading', + () => html` + + + `, + ) + .add( + 'Disabled', + () => html` + + `, + ); diff --git a/packages/input-datepicker/test/lion-input-datepicker.test.js b/packages/input-datepicker/test/lion-input-datepicker.test.js new file mode 100644 index 000000000..b912714c8 --- /dev/null +++ b/packages/input-datepicker/test/lion-input-datepicker.test.js @@ -0,0 +1,425 @@ +import { expect, fixture, aTimeout, defineCE } from '@open-wc/testing'; +import { localizeTearDown } from '@lion/localize/test-helpers.js'; +import { html, LitElement } from '@lion/core'; +import { + maxDateValidator, + minDateValidator, + minMaxDateValidator, + isDateDisabledValidator, +} from '@lion/validate'; +import { keyCodes } from '@lion/overlays/src/utils/key-codes.js'; +import { keyUpOn } from '@polymer/iron-test-helpers/mock-interactions.js'; +import { LionCalendar } from '@lion/calendar'; +import { isSameDate } from '@lion/calendar/src/utils/isSameDate.js'; +import { DatepickerInputObject } from './test-utils.js'; +import { LionInputDatepicker } from '../src/LionInputDatepicker.js'; +import '../lion-input-datepicker.js'; + +describe('', () => { + beforeEach(() => { + localizeTearDown(); + }); + + describe('Calendar Overlay', () => { + it('implements calendar-overlay Style component', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + + expect(elObj.overlayEl.shadowRoot.querySelector('.calendar-overlay')).not.to.equal(null); + expect(elObj.overlayEl.shadowRoot.querySelector('.calendar-overlay__header')).not.to.equal( + null, + ); + expect(elObj.overlayEl.shadowRoot.querySelector('.calendar-overlay__heading')).not.to.equal( + null, + ); + expect( + elObj.overlayEl.shadowRoot.querySelector('.calendar-overlay__close-button'), + ).not.to.equal(null); + }); + + it.skip('activates full screen mode on mobile screens', async () => { + // TODO: should this be part of globalOverlayController as option? + }); + + it('has a close button, with a tooltip "Close"', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + // Since tooltip not ready, use title which can be progressively enhanced in extension layers. + expect(elObj.overlayCloseButtonEl.getAttribute('title')).to.equal('Close'); + expect(elObj.overlayCloseButtonEl.getAttribute('aria-label')).to.equal('Close'); + }); + + it('has a default title based on input label', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + expect( + elObj.overlayHeadingEl.querySelector('slot[name="heading"]').assignedNodes()[0], + ).lightDom.to.equal('Pick your date'); + }); + + it('can have a custom heading', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + expect( + elObj.overlayHeadingEl.querySelector('slot[name="heading"]').assignedNodes()[0], + ).lightDom.to.equal('foo'); + }); + + // TODO: fix the Overlay system, so that the backdrop/body cannot be focused + it('closes the calendar on [esc] key', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + expect(elObj.overlayController.isShown).to.equal(true); + // Mimic user input: should fire the 'selected-date-changed' event + // Make sure focus is inside the calendar/overlay + keyUpOn(elObj.calendarEl, keyCodes.escape); + expect(elObj.overlayController.isShown).to.equal(false); + }); + + /** + * Not in scope: + * - centralDate can be overridden + */ + }); + + describe('Calendar Invoker', () => { + it('adds invoker button that toggles the overlay on click in suffix slot ', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + expect(elObj.invokerEl).not.to.equal(null); + expect(elObj.overlayController.isShown).to.be.false; + await elObj.openCalendar(); + expect(elObj.overlayController.isShown).to.equal(true); + }); + + // Relies on delegation of disabled property to invoker. + // TODO: consider making this (delegation to interactive child nodes) generic functionality + // inside LionField/FormControl. Or, for maximum flexibility, add a config attr + // to the invoker node like 'data-disabled-is-delegated' + it('delegates disabled state of host input', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + expect(elObj.overlayController.isShown).to.equal(false); + await elObj.openCalendar(); + expect(elObj.overlayController.isShown).to.equal(false); + }); + }); + + describe('Input - calendar synchronization', () => { + it('syncs modelValue with lion-calendar', async () => { + const myDate = new Date('2019/06/15'); + const myOtherDate = new Date('2019/06/28'); + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + expect(elObj.calendarEl.selectedDate).to.equal(myDate); + await elObj.selectMonthDay(myOtherDate.getDate()); + expect(isSameDate(el.modelValue, myOtherDate)).to.be.true; + }); + + it('closes the calendar overlay on "user-selected-date-changed"', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + // Make sure the calendar overlay is opened + await elObj.openCalendar(); + expect(elObj.overlayController.isShown).to.equal(true); + // Mimic user input: should fire the 'user-selected-date-changed' event + await elObj.selectMonthDay(12); + expect(elObj.overlayController.isShown).to.equal(false); + }); + + it('focuses interactable date on opening of calendar', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + await aTimeout(); + expect(elObj.calendarObj.focusedDayObj.el).not.to.equal(null); + }); + + describe('Validators', () => { + /** + * Validators are the Application Developer facing API in : + * - setting restrictions on min/max/disallowed dates will be done via validators + * - all validators will be translated under the hood to enabledDates and passed to + * lion-calendar + */ + it('converts isDateDisabledValidator to "disableDates" property', async () => { + const no15th = d => d.getDate() !== 15; + const no16th = d => d.getDate() !== 16; + const no15thOr16th = d => no15th(d) && no16th(d); + const el = await fixture(html` + + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + + expect(elObj.calendarEl.disableDates).to.equal(no15thOr16th); + }); + + it('converts minDateValidator to "minDate" property', async () => { + const myMinDate = new Date('2019/06/15'); + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + + expect(elObj.calendarEl.minDate).to.equal(myMinDate); + }); + + it('converts maxDateValidator to "maxDate" property', async () => { + const myMaxDate = new Date('2030/06/15'); + const el = await fixture(html` + + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + + expect(elObj.calendarEl.maxDate).to.equal(myMaxDate); + }); + + it('converts minMaxDateValidator to "minDate" and "maxDate" property', async () => { + const myMinDate = new Date('2019/06/15'); + const myMaxDate = new Date('2030/06/15'); + const el = await fixture(html` + + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + + expect(elObj.calendarEl.minDate).to.equal(myMinDate); + expect(elObj.calendarEl.maxDate).to.equal(myMaxDate); + }); + + /** + * Not in scope: + * - min/max attr (like platform has): could be added in future if observers needed + */ + }); + }); + + describe('Accessibility', () => { + it('has a heading of level 1', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + + const hNode = elObj.overlayHeadingEl; + const headingIsLevel1 = + hNode.tagName === 'H1' || + (hNode.getAttribute('role') === 'heading' && hNode.getAttribute('aria-level') === '1'); + expect(headingIsLevel1).to.be.true; + }); + + it('adds accessible label to invoker button', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + await elObj.openCalendar(); + + expect(elObj.invokerEl.getAttribute('title')).to.equal('Open date picker'); + expect(elObj.invokerEl.getAttribute('aria-label')).to.equal('Open date picker'); + }); + + // TODO: move this functionality to GlobalOverlay + it('adds aria-haspopup="dialog" and aria-expanded="true" to invoker button', async () => { + const el = await fixture(html` + + `); + const elObj = new DatepickerInputObject(el); + + expect(elObj.invokerEl.getAttribute('aria-haspopup')).to.equal('dialog'); + expect(elObj.invokerEl.getAttribute('aria-expanded')).to.equal('false'); + }); + }); + + describe.skip('Subclassers', () => { + describe('Providing a custom invoker', () => { + it('can override the invoker template', async () => { + const myTag = defineCE( + class extends LionInputDatepicker { + /** @override */ + _invokerTemplate() { + return html` + Pick my date + `; + } + }, + ); + + const myEl = await fixture(`<${myTag}>`); + const myElObj = new DatepickerInputObject(myEl); + expect(myElObj.invokerEl.tagName.toLowerCase()).to.equal('my-button'); + + // All other tests will still pass. Small checkup: + expect(myElObj.invokerEl.getAttribute('title')).to.equal('Open date picker'); + expect(myElObj.invokerEl.getAttribute('aria-label')).to.equal('Open date picker'); + expect(myElObj.invokerEl.getAttribute('aria-expanded')).to.equal('false'); + expect(myElObj.invokerEl.getAttribute('aria-haspopup')).to.equal('dialog'); + expect(myElObj.invokerEl.getAttribute('slot')).to.equal('suffix'); + expect(myElObj.invokerEl.getAttribute('id')).to.equal(myEl.__invokerId); + await myElObj.openCalendar(); + expect(myElObj.overlayController.isShown).to.equal(true); + }); + + it('can allocate the picker in a different slot supported by LionField', async () => { + /** + * It's important that this api is used instead of Subclassers providing a slot. + * When the input-datepicker knows where the calendar invoker is, it can attach + * the right logic, localization and accessibility functionality. + */ + const myTag = defineCE( + class extends LionInputDatepicker { + constructor() { + super(); + this._calendarInvokerSlot = 'prefix'; + } + }, + ); + + const myEl = await fixture(`<${myTag}>`); + const myElObj = new DatepickerInputObject(myEl); + expect(myElObj.invokerEl.getAttribute('slot')).to.equal('prefix'); + }); + }); + + describe('Providing a custom calendar', () => { + it('can override the calendar template', async () => { + customElements.define( + 'my-calendar', + class extends LionCalendar { + constructor() { + super(); + // Change some defaults + this.firstDayOfWeek = 1; // Start on Mondays instead of Sundays + this.weekdayHeaderNotation = 'narrow'; // 'T' instead of 'Thu' + } + }, + ); + + const myTag = defineCE( + class extends LionInputDatepicker { + _calendarTemplate() { + return html` + + `; + } + }, + ); + + const myEl = await fixture(`<${myTag}>`); + const myElObj = new DatepickerInputObject(myEl); + + // All other tests will still pass. Small checkup: + await myElObj.openCalendar(); + expect(myElObj.calendarEl.tagName.toLowerCase()).to.equal('my-calendar'); + }); + }); + + describe('Providing a custom overlay', () => { + it('can override the overlay template', async () => { + // Keep in mind there is no logic inside this overlay frame; it only handles visuals. + // All interaction should be delegated to parent, which interacts with the calendar + // component + customElements.define( + 'my-calendar-overlay-frame', + class extends LitElement { + render() { + // eslint-disable-line class-methods-use-this + return html` +
+ + +
+ `; + } + }, + ); + + let myOverlayOpenedCbHandled = false; + let myUserSelectedChangedCbHandled = false; + + const myTag = defineCE( + class extends LionInputDatepicker { + /** @override */ + _calendarOverlayTemplate() { + return html` + + ${this.calendarHeading} + ${this._calendarTemplateConfig(this._calendarTemplate())} + + `; + } + + /** @override */ + _onCalendarOverlayOpened(...args) { + super._onCalendarOverlayOpened(...args); + myOverlayOpenedCbHandled = true; + } + + /** @override */ + _onCalendarUserSelectedChanged(...args) { + super._onCalendarUserSelectedChanged(...args); + myUserSelectedChangedCbHandled = true; + } + }, + ); + + const myEl = await fixture(`<${myTag}>`); + const myElObj = new DatepickerInputObject(myEl); + + // All other tests will still pass. Small checkup: + await myElObj.openCalendar(); + expect(myElObj.overlayEl.tagName.toLowerCase()).to.equal('my-calendar-overlay-frame'); + expect(myOverlayOpenedCbHandled).to.be.true; + await myElObj.selectMonthDay(1); + expect(myUserSelectedChangedCbHandled).to.be.true; + }); + + it.skip('can configure the overlay presentation based on media query switch', async () => {}); + }); + }); +}); diff --git a/packages/input-datepicker/test/test-utils.js b/packages/input-datepicker/test/test-utils.js new file mode 100644 index 000000000..f098851ae --- /dev/null +++ b/packages/input-datepicker/test/test-utils.js @@ -0,0 +1,65 @@ +import { CalendarObject } from '@lion/calendar/test/test-utils.js'; + +// TODO: refactor CalendarObject to this approach (only methods when arguments are needed) +export class DatepickerInputObject { + constructor(el) { + this.el = el; + } + + /** + * Methods mimicing User Interaction + */ + + async openCalendar() { + // Make sure the calendar is opened, not closed/toggled; + this.overlayController.hide(); + this.invokerEl.click(); + return this.calendarEl ? this.calendarEl.updateComplete : false; + } + + async selectMonthDay(day) { + this.overlayController.show(); + await this.calendarEl.updateComplete; + this.calendarObj.getDayEl(day).click(); + return true; + } + + /** + * Node references + */ + + get invokerEl() { + return this.el._invokerElement; + } + + get overlayEl() { + return this.el._overlayCtrl._container && this.el._overlayCtrl._container.firstElementChild; + } + + get overlayHeadingEl() { + return this.overlayEl && this.overlayEl.shadowRoot.querySelector('.calendar-overlay__heading'); + } + + get overlayCloseButtonEl() { + return this.calendarEl && this.overlayEl.shadowRoot.querySelector('#close-button'); + } + + get calendarEl() { + return this.overlayEl && this.overlayEl.querySelector('#calendar'); + } + + /** + * @property {CalendarObject} + */ + get calendarObj() { + return this.calendarEl && new CalendarObject(this.calendarEl); + } + + /** + * Object references + */ + + get overlayController() { + return this.el._overlayCtrl; + } +} diff --git a/packages/input-datepicker/translations/bg-BG.js b/packages/input-datepicker/translations/bg-BG.js new file mode 100644 index 000000000..00f5e5f61 --- /dev/null +++ b/packages/input-datepicker/translations/bg-BG.js @@ -0,0 +1,5 @@ +import bg from './bg.js'; + +export default { + ...bg, +}; diff --git a/packages/input-datepicker/translations/bg.js b/packages/input-datepicker/translations/bg.js new file mode 100644 index 000000000..2e6f21338 --- /dev/null +++ b/packages/input-datepicker/translations/bg.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Избор на отворена дата', +}; diff --git a/packages/input-datepicker/translations/cs-CZ.js b/packages/input-datepicker/translations/cs-CZ.js new file mode 100644 index 000000000..2cac51aa8 --- /dev/null +++ b/packages/input-datepicker/translations/cs-CZ.js @@ -0,0 +1,5 @@ +import cs from './cs.js'; + +export default { + ...cs, +}; diff --git a/packages/input-datepicker/translations/cs.js b/packages/input-datepicker/translations/cs.js new file mode 100644 index 000000000..fbb1d48c6 --- /dev/null +++ b/packages/input-datepicker/translations/cs.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Otevřete pro výběr data', +}; diff --git a/packages/input-datepicker/translations/de-DE.js b/packages/input-datepicker/translations/de-DE.js new file mode 100644 index 000000000..8e3fb7c86 --- /dev/null +++ b/packages/input-datepicker/translations/de-DE.js @@ -0,0 +1,5 @@ +import de from './de.js'; + +export default { + ...de, +}; diff --git a/packages/input-datepicker/translations/de.js b/packages/input-datepicker/translations/de.js new file mode 100644 index 000000000..ed1a57cbe --- /dev/null +++ b/packages/input-datepicker/translations/de.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Datumswähler öffnen', +}; diff --git a/packages/input-datepicker/translations/en-AU.js b/packages/input-datepicker/translations/en-AU.js new file mode 100644 index 000000000..e164dab30 --- /dev/null +++ b/packages/input-datepicker/translations/en-AU.js @@ -0,0 +1,5 @@ +import en from './en.js'; + +export default { + ...en, +}; diff --git a/packages/input-datepicker/translations/en-GB.js b/packages/input-datepicker/translations/en-GB.js new file mode 100644 index 000000000..e164dab30 --- /dev/null +++ b/packages/input-datepicker/translations/en-GB.js @@ -0,0 +1,5 @@ +import en from './en.js'; + +export default { + ...en, +}; diff --git a/packages/input-datepicker/translations/en-US.js b/packages/input-datepicker/translations/en-US.js new file mode 100644 index 000000000..e164dab30 --- /dev/null +++ b/packages/input-datepicker/translations/en-US.js @@ -0,0 +1,5 @@ +import en from './en.js'; + +export default { + ...en, +}; diff --git a/packages/input-datepicker/translations/en.js b/packages/input-datepicker/translations/en.js new file mode 100644 index 000000000..965ecaa11 --- /dev/null +++ b/packages/input-datepicker/translations/en.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Open date picker', +}; diff --git a/packages/input-datepicker/translations/es-ES.js b/packages/input-datepicker/translations/es-ES.js new file mode 100644 index 000000000..94a009944 --- /dev/null +++ b/packages/input-datepicker/translations/es-ES.js @@ -0,0 +1,5 @@ +import es from './es.js'; + +export default { + ...es, +}; diff --git a/packages/input-datepicker/translations/es.js b/packages/input-datepicker/translations/es.js new file mode 100644 index 000000000..2a2c70d1a --- /dev/null +++ b/packages/input-datepicker/translations/es.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Selector de fecha abierta', +}; diff --git a/packages/input-datepicker/translations/fr-BE.js b/packages/input-datepicker/translations/fr-BE.js new file mode 100644 index 000000000..da02615de --- /dev/null +++ b/packages/input-datepicker/translations/fr-BE.js @@ -0,0 +1,5 @@ +import fr from './fr.js'; + +export default { + ...fr, +}; diff --git a/packages/input-datepicker/translations/fr-FR.js b/packages/input-datepicker/translations/fr-FR.js new file mode 100644 index 000000000..da02615de --- /dev/null +++ b/packages/input-datepicker/translations/fr-FR.js @@ -0,0 +1,5 @@ +import fr from './fr.js'; + +export default { + ...fr, +}; diff --git a/packages/input-datepicker/translations/fr.js b/packages/input-datepicker/translations/fr.js new file mode 100644 index 000000000..b14c19357 --- /dev/null +++ b/packages/input-datepicker/translations/fr.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Ouvrir le sélecteur de dates', +}; diff --git a/packages/input-datepicker/translations/hu-HU.js b/packages/input-datepicker/translations/hu-HU.js new file mode 100644 index 000000000..130ba8f66 --- /dev/null +++ b/packages/input-datepicker/translations/hu-HU.js @@ -0,0 +1,5 @@ +import hu from './hu.js'; + +export default { + ...hu, +}; diff --git a/packages/input-datepicker/translations/hu.js b/packages/input-datepicker/translations/hu.js new file mode 100644 index 000000000..2829fe4ff --- /dev/null +++ b/packages/input-datepicker/translations/hu.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Dátumválasztó megnyitása', +}; diff --git a/packages/input-datepicker/translations/it-IT.js b/packages/input-datepicker/translations/it-IT.js new file mode 100644 index 000000000..397b5a03b --- /dev/null +++ b/packages/input-datepicker/translations/it-IT.js @@ -0,0 +1,5 @@ +import it from './it.js'; + +export default { + ...it, +}; diff --git a/packages/input-datepicker/translations/it.js b/packages/input-datepicker/translations/it.js new file mode 100644 index 000000000..9f514be03 --- /dev/null +++ b/packages/input-datepicker/translations/it.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Raccoglitore di data aperta', +}; diff --git a/packages/input-datepicker/translations/nl-BE.js b/packages/input-datepicker/translations/nl-BE.js new file mode 100644 index 000000000..93467cea6 --- /dev/null +++ b/packages/input-datepicker/translations/nl-BE.js @@ -0,0 +1,5 @@ +import nl from './nl.js'; + +export default { + ...nl, +}; diff --git a/packages/input-datepicker/translations/nl-NL.js b/packages/input-datepicker/translations/nl-NL.js new file mode 100644 index 000000000..93467cea6 --- /dev/null +++ b/packages/input-datepicker/translations/nl-NL.js @@ -0,0 +1,5 @@ +import nl from './nl.js'; + +export default { + ...nl, +}; diff --git a/packages/input-datepicker/translations/nl.js b/packages/input-datepicker/translations/nl.js new file mode 100644 index 000000000..2b12f81ff --- /dev/null +++ b/packages/input-datepicker/translations/nl.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Open kalender', +}; diff --git a/packages/input-datepicker/translations/pl-PL.js b/packages/input-datepicker/translations/pl-PL.js new file mode 100644 index 000000000..cb0d0b8b6 --- /dev/null +++ b/packages/input-datepicker/translations/pl-PL.js @@ -0,0 +1,5 @@ +import pl from './pl.js'; + +export default { + ...pl, +}; diff --git a/packages/input-datepicker/translations/pl.js b/packages/input-datepicker/translations/pl.js new file mode 100644 index 000000000..ef75a7756 --- /dev/null +++ b/packages/input-datepicker/translations/pl.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Otwórz pole daty', +}; diff --git a/packages/input-datepicker/translations/ro-RO.js b/packages/input-datepicker/translations/ro-RO.js new file mode 100644 index 000000000..8acc92b29 --- /dev/null +++ b/packages/input-datepicker/translations/ro-RO.js @@ -0,0 +1,5 @@ +import ro from './ro.js'; + +export default { + ...ro, +}; diff --git a/packages/input-datepicker/translations/ro.js b/packages/input-datepicker/translations/ro.js new file mode 100644 index 000000000..9cdf530e7 --- /dev/null +++ b/packages/input-datepicker/translations/ro.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Deschidere selector dată', +}; diff --git a/packages/input-datepicker/translations/ru-RU.js b/packages/input-datepicker/translations/ru-RU.js new file mode 100644 index 000000000..e5f8f2aa1 --- /dev/null +++ b/packages/input-datepicker/translations/ru-RU.js @@ -0,0 +1,5 @@ +import ru from './ru.js'; + +export default { + ...ru, +}; diff --git a/packages/input-datepicker/translations/ru.js b/packages/input-datepicker/translations/ru.js new file mode 100644 index 000000000..39beda04c --- /dev/null +++ b/packages/input-datepicker/translations/ru.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Открыть модуль выбора даты', +}; diff --git a/packages/input-datepicker/translations/sk-SK.js b/packages/input-datepicker/translations/sk-SK.js new file mode 100644 index 000000000..3000b323f --- /dev/null +++ b/packages/input-datepicker/translations/sk-SK.js @@ -0,0 +1,5 @@ +import sk from './sk.js'; + +export default { + ...sk, +}; diff --git a/packages/input-datepicker/translations/sk.js b/packages/input-datepicker/translations/sk.js new file mode 100644 index 000000000..761f6dde0 --- /dev/null +++ b/packages/input-datepicker/translations/sk.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Otvoriť nástroj na výber dátumu', +}; diff --git a/packages/input-datepicker/translations/uk-UA.js b/packages/input-datepicker/translations/uk-UA.js new file mode 100644 index 000000000..e255cc021 --- /dev/null +++ b/packages/input-datepicker/translations/uk-UA.js @@ -0,0 +1,5 @@ +import uk from './uk.js'; + +export default { + ...uk, +}; diff --git a/packages/input-datepicker/translations/uk.js b/packages/input-datepicker/translations/uk.js new file mode 100644 index 000000000..dc255e057 --- /dev/null +++ b/packages/input-datepicker/translations/uk.js @@ -0,0 +1,3 @@ +export default { + openDatepickerLabel: 'Відкрити модуль вибору дати', +}; diff --git a/stories/index.stories.js b/stories/index.stories.js index 2931f50e3..00329be36 100755 --- a/stories/index.stories.js +++ b/stories/index.stories.js @@ -6,6 +6,7 @@ import '../packages/input/stories/localize.stories.js'; import '../packages/textarea/stories/index.stories.js'; import '../packages/input-amount/stories/index.stories.js'; import '../packages/input-date/stories/index.stories.js'; +import '../packages/input-datepicker/stories/index.stories.js'; import '../packages/input-email/stories/index.stories.js'; import '../packages/input-iban/stories/index.stories.js'; import '../packages/select/stories/index.stories.js';