feat(input-datepicker): added input-datepicker component

Co-authored-by: Erik Kroes <erik.kroes@ing.com>
Co-authored-by: Gerjan van Geest <gerjan.van.geest@ing.com>
Co-authored-by: Mikhail Bashkirov <mikhail.bashkirov@ing.com>
This commit is contained in:
Thijs Louisse 2019-05-15 17:01:24 +02:00 committed by Thomas Allmer
parent 144ebceb37
commit 9b110ca0f9
42 changed files with 1247 additions and 0 deletions

View file

@ -0,0 +1 @@
export { LionInputDatepicker } from './src/LionInputDatepicker.js';

View file

@ -0,0 +1,3 @@
import { LionInputDatepicker } from './src/LionInputDatepicker.js';
customElements.define('lion-input-datepicker', LionInputDatepicker);

View file

@ -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"
}
}

View file

@ -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`
<div class="calendar-overlay">
<div class="calendar-overlay__header">
<h1 class="calendar-overlay__heading">
<slot name="heading"></slot>
</h1>
<button
@click="${this.__dispatchCloseEvent}"
id="close-button"
title="${this.msgLit('lion-calendar-overlay-frame:close')}"
aria-label="${this.msgLit('lion-calendar-overlay-frame:close')}"
class="calendar-overlay__close-button"
>
<slot name="close-icon">&times;</slot>
</button>
</div>
<slot></slot>
</div>
`;
}
}

View file

@ -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`
<lion-calendar-overlay-frame>
<span slot="heading">${this.calendarHeading}</span>
${this._calendarTemplate()}
</lion-calendar-overlay-frame>
`;
}
/**
* Subclassers can replace this with their custom extension of
* LionCalendar, like `<my-calendar id="calendar"></my-calendar>`
*/
// eslint-disable-next-line class-methods-use-this
_calendarTemplate() {
return html`
<lion-calendar
id="calendar"
.selectedDate="${this.constructor.__getSyncDownValue(this.modelValue)}"
.minDate="${this.__calendarMinDate}"
.maxDate="${this.__calendarMaxDate}"
.disableDates="${ifDefined(this.__calendarDisableDates)}"
@user-selected-date-changed="${this._onCalendarUserSelectedChanged}"
></lion-calendar>
`;
}
/**
* Subclassers can replace this with their custom extension invoker,
* like `<my-button><calendar-icon></calendar-icon></my-button>`
*/
// 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`
<button
@click="${this.__openCalendarOverlay}"
id="${this.__invokerId}"
aria-haspopup="dialog"
aria-expanded="false"
aria-label="${this.msgLit('lion-input-datepicker:openDatepickerLabel')}"
title="${this.msgLit('lion-input-datepicker:openDatepickerLabel')}"
>
📅
</button>
`;
}
// 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;
}
});
}
}

View file

@ -0,0 +1,3 @@
import { LionCalendarOverlayFrame } from './LionCalendarOverlayFrame.js';
customElements.define('lion-calendar-overlay-frame', LionCalendarOverlayFrame);

View file

@ -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`
<lion-input-datepicker label="Date" .modelValue=${new Date('2017/06/15')}>
</lion-input-datepicker>
`,
)
.add(
'minMaxDateValidator',
() => html`
<lion-input-datepicker
label="MinMaxDate"
help-text="Enter a date between '2018/05/24' and '2018/06/24'"
.modelValue=${new Date('2018/05/30')}
.errorValidators=${[
minMaxDateValidator({ min: new Date('2018/05/24'), max: new Date('2018/06/24') }),
]}
>
</lion-input-datepicker>
`,
)
.add(
'disabledDatesValidator',
() => html`
<lion-input-datepicker
label="disabledDates"
help-text="You're not allowed to choose the 15th"
.errorValidators=${[isDateDisabledValidator(d => d.getDate() === 15)]}
>
</lion-input-datepicker>
`,
)
.add(
'With calendar-heading',
() => html`
<lion-input-datepicker
label="Date"
.calendarHeading="${'Custom heading'}"
.modelValue=${new Date()}
>
</lion-input-datepicker>
`,
)
.add(
'Disabled',
() => html`
<lion-input-datepicker disabled></lion-input-datepicker>
`,
);

View file

@ -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('<lion-input-datepicker>', () => {
beforeEach(() => {
localizeTearDown();
});
describe('Calendar Overlay', () => {
it('implements calendar-overlay Style component', async () => {
const el = await fixture(html`
<lion-input-datepicker></lion-input-datepicker>
`);
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`
<lion-input-datepicker></lion-input-datepicker>
`);
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`
<lion-input-datepicker
.label="${'Pick your date'}"
.modelValue="${new Date('2020-02-15')}"
></lion-input-datepicker>
`);
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`
<lion-input-datepicker
label="Pick your date"
calendar-heading="foo"
></lion-input-datepicker>
`);
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`
<lion-input-datepicker></lion-input-datepicker>
`);
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`
<lion-input-datepicker></lion-input-datepicker>
`);
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`
<lion-input-datepicker disabled></lion-input-datepicker>
`);
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`
<lion-input-datepicker .modelValue="${myDate}"></lion-input-datepicker>
`);
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`
<lion-input-datepicker></lion-input-datepicker>
`);
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`
<lion-input-datepicker></lion-input-datepicker>
`);
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 <lion-input-datepicker>:
* - 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`
<lion-input-datepicker .errorValidators="${[isDateDisabledValidator(no15thOr16th)]}">
</lion-input-datepicker>
`);
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`
<lion-input-datepicker .errorValidators=${[minDateValidator(myMinDate)]}>
</lion-input-date>`);
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`
<lion-input-datepicker .errorValidators=${[maxDateValidator(myMaxDate)]}>
</lion-input-datepicker>
`);
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`
<lion-input-datepicker
.errorValidators=${[minMaxDateValidator({ min: myMinDate, max: myMaxDate })]}
>
</lion-input-datepicker>
`);
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`
<lion-input-datepicker calendar-heading="foo"></lion-input-datepicker>
`);
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`
<lion-input-datepicker></lion-input-datepicker>
`);
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`
<lion-input-datepicker></lion-input-datepicker>
`);
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`
<my-button>Pick my date</my-button>
`;
}
},
);
const myEl = await fixture(`<${myTag}></${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}></${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`
<my-calendar id="calendar"></my-calendar>
`;
}
},
);
const myEl = await fixture(`<${myTag}></${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`
<div class="c-calendar-overlay">
<slot></slot>
<div class="c-calendar-overlay__footer">
<button id="cancel-button" class="c-calendar-overlay__cancel-button">
Cancel
</button>
</div>
</div>
`;
}
},
);
let myOverlayOpenedCbHandled = false;
let myUserSelectedChangedCbHandled = false;
const myTag = defineCE(
class extends LionInputDatepicker {
/** @override */
_calendarOverlayTemplate() {
return html`
<my-calendar-overlay-frame id="calendar-overlay">
<span slot="heading">${this.calendarHeading}</span>
${this._calendarTemplateConfig(this._calendarTemplate())}
</my-calendar-overlay-frame>
`;
}
/** @override */
_onCalendarOverlayOpened(...args) {
super._onCalendarOverlayOpened(...args);
myOverlayOpenedCbHandled = true;
}
/** @override */
_onCalendarUserSelectedChanged(...args) {
super._onCalendarUserSelectedChanged(...args);
myUserSelectedChangedCbHandled = true;
}
},
);
const myEl = await fixture(`<${myTag}></${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 () => {});
});
});
});

View file

@ -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;
}
}

View file

@ -0,0 +1,5 @@
import bg from './bg.js';
export default {
...bg,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Избор на отворена дата',
};

View file

@ -0,0 +1,5 @@
import cs from './cs.js';
export default {
...cs,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Otevřete pro výběr data',
};

View file

@ -0,0 +1,5 @@
import de from './de.js';
export default {
...de,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Datumswähler öffnen',
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Open date picker',
};

View file

@ -0,0 +1,5 @@
import es from './es.js';
export default {
...es,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Selector de fecha abierta',
};

View file

@ -0,0 +1,5 @@
import fr from './fr.js';
export default {
...fr,
};

View file

@ -0,0 +1,5 @@
import fr from './fr.js';
export default {
...fr,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Ouvrir le sélecteur de dates',
};

View file

@ -0,0 +1,5 @@
import hu from './hu.js';
export default {
...hu,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Dátumválasztó megnyitása',
};

View file

@ -0,0 +1,5 @@
import it from './it.js';
export default {
...it,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Raccoglitore di data aperta',
};

View file

@ -0,0 +1,5 @@
import nl from './nl.js';
export default {
...nl,
};

View file

@ -0,0 +1,5 @@
import nl from './nl.js';
export default {
...nl,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Open kalender',
};

View file

@ -0,0 +1,5 @@
import pl from './pl.js';
export default {
...pl,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Otwórz pole daty',
};

View file

@ -0,0 +1,5 @@
import ro from './ro.js';
export default {
...ro,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Deschidere selector dată',
};

View file

@ -0,0 +1,5 @@
import ru from './ru.js';
export default {
...ru,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Открыть модуль выбора даты',
};

View file

@ -0,0 +1,5 @@
import sk from './sk.js';
export default {
...sk,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Otvoriť nástroj na výber dátumu',
};

View file

@ -0,0 +1,5 @@
import uk from './uk.js';
export default {
...uk,
};

View file

@ -0,0 +1,3 @@
export default {
openDatepickerLabel: 'Відкрити модуль вибору дати',
};

View file

@ -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';