diff --git a/packages/date-picker/src/vaadin-date-picker-mixin.js b/packages/date-picker/src/vaadin-date-picker-mixin.js index 2fb2de2ccf..0e88964fde 100644 --- a/packages/date-picker/src/vaadin-date-picker-mixin.js +++ b/packages/date-picker/src/vaadin-date-picker-mixin.js @@ -427,6 +427,20 @@ export const DatePickerMixin = (subclass) => return null; } + /** + * The input element's value when it cannot be parsed as a date, and an empty string otherwise. + * + * @return {string} + * @private + */ + get __unparsableValue() { + if (!this._inputElementValue || this.__parseDate(this._inputElementValue)) { + return ''; + } + + return this._inputElementValue; + } + /** * Override an event listener from `DelegateFocusMixin` * @protected @@ -645,27 +659,52 @@ export const DatePickerMixin = (subclass) => this._shouldKeepFocusRing = focused && this._keyboardActive; } - /** @private */ - __dispatchChange() { - this.validate(); - this.dispatchEvent(new CustomEvent('change', { bubbles: true })); + /** + * Depending on the nature of the value change that has occurred since + * the last commit attempt, triggers validation and fires an event: + * + * Value change | Event + * :------------------------|:------------------ + * empty => parsable | change + * empty => unparsable | unparsable-change + * parsable => empty | change + * parsable => parsable | change + * parsable => unparsable | change + * unparsable => empty | unparsable-change + * unparsable => parsable | change + * unparsable => unparsable | unparsable-change + * + * @private + */ + __commitValueChange() { + const unparsableValue = this.__unparsableValue; + + if (this.__committedValue !== this.value) { + this.validate(); + this.dispatchEvent(new CustomEvent('change', { bubbles: true })); + } else if (this.__committedUnparsableValue !== unparsableValue) { + this.validate(); + this.dispatchEvent(new CustomEvent('unparsable-change')); + } + + this.__committedValue = this.value; + this.__committedUnparsableValue = unparsableValue; } /** - * Sets the given date as the value and fires a change event - * if the value has changed. + * Sets the given date as the value and commits it. * * @param {Date} date * @private */ __commitDate(date) { - const prevValue = this.value; - + // Prevent the value observer from treating the following value change + // as initiated programmatically by the developer, and therefore + // from automatically committing it without a change event. + this.__keepCommittedValue = true; this._selectedDate = date; - - if (prevValue !== this.value) { - this.__dispatchChange(); - } + this.__keepCommittedValue = false; + this.__commitValueChange(); } /** @private */ @@ -801,6 +840,11 @@ export const DatePickerMixin = (subclass) => this._selectedDate = null; } + if (!this.__keepCommittedValue) { + this.__committedValue = this.value; + this.__committedUnparsableValue = ''; + } + this._toggleHasValue(this._hasValue); } @@ -892,9 +936,9 @@ export const DatePickerMixin = (subclass) => } /** - * Tries to parse the input element's value as a date. When succeeds, - * sets the resulting date as the value and fires a change event - * (if the value has changed). If no i18n parser is provided, sets + * Tries to parse the input element's value as a date. If the input value + * is parsable, commits the resulting date as the value. Otherwise, commits + * an empty string as the value. If no i18n parser is provided, commits * the focused date as the value. * * @private @@ -911,7 +955,6 @@ export const DatePickerMixin = (subclass) => } else { this.__keepInputValue = true; this.__commitDate(null); - this._selectedDate = null; this.__keepInputValue = false; } } else if (this._focusedDate) { @@ -927,7 +970,6 @@ export const DatePickerMixin = (subclass) => this.__showOthers(); this.__showOthers = null; } - window.removeEventListener('scroll', this._boundOnScroll, true); this.__commitParsedOrFocusedDate(); @@ -1080,16 +1122,12 @@ export const DatePickerMixin = (subclass) => * @override */ _onEnter(_event) { - const oldValue = this.value; if (this.opened) { // Closing will implicitly select parsed or focused date this.close(); } else { this.__commitParsedOrFocusedDate(); } - if (oldValue === this.value) { - this.validate(); - } } /** diff --git a/packages/date-picker/src/vaadin-date-picker.d.ts b/packages/date-picker/src/vaadin-date-picker.d.ts index 960f7b5d94..1812c09d38 100644 --- a/packages/date-picker/src/vaadin-date-picker.d.ts +++ b/packages/date-picker/src/vaadin-date-picker.d.ts @@ -16,6 +16,11 @@ export type DatePickerChangeEvent = Event & { target: DatePicker; }; +/** + * Fired when the user commits an unparsable value change and there is no change event. + */ +export type DatePickerUnparsableChangeEvent = CustomEvent; + /** * Fired when the `opened` property changes. */ @@ -43,6 +48,8 @@ export interface DatePickerCustomEventMap { 'value-changed': DatePickerValueChangedEvent; + 'unparsable-change': DatePickerUnparsableChangeEvent; + validated: DatePickerValidatedEvent; } @@ -148,7 +155,24 @@ export interface DatePickerEventMap extends HTMLElementEventMap, DatePickerCusto * * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation. * + * ### Change events + * + * Depending on the nature of the value change that the user attempts to commit e.g. by pressing Enter, + * the component can fire either a `change` event or an `unparsable-change` event: + * + * Value change | Event + * :------------------------|:------------------ + * empty => parsable | change + * empty => unparsable | unparsable-change + * parsable => empty | change + * parsable => parsable | change + * parsable => unparsable | change + * unparsable => empty | unparsable-change + * unparsable => parsable | change + * unparsable => unparsable | unparsable-change + * * @fires {Event} change - Fired when the user commits a value change. + * @fires {Event} unparsable-change Fired when the user commits an unparsable value change and there is no change event. * @fires {CustomEvent} invalid-changed - Fired when the `invalid` property changes. * @fires {CustomEvent} opened-changed - Fired when the `opened` property changes. * @fires {CustomEvent} value-changed - Fired when the `value` property changes. diff --git a/packages/date-picker/src/vaadin-date-picker.js b/packages/date-picker/src/vaadin-date-picker.js index e3b063f215..29977849c3 100644 --- a/packages/date-picker/src/vaadin-date-picker.js +++ b/packages/date-picker/src/vaadin-date-picker.js @@ -118,7 +118,24 @@ registerStyles('vaadin-date-picker', [inputFieldShared, datePickerStyles], { mod * * See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation. * + * ### Change events + * + * Depending on the nature of the value change that the user attempts to commit e.g. by pressing Enter, + * the component can fire either a `change` event or an `unparsable-change` event: + * + * Value change | Event + * :------------------------|:------------------ + * empty => parsable | change + * empty => unparsable | unparsable-change + * parsable => empty | change + * parsable => parsable | change + * parsable => unparsable | change + * unparsable => empty | unparsable-change + * unparsable => parsable | change + * unparsable => unparsable | unparsable-change + * * @fires {Event} change - Fired when the user commits a value change. + * @fires {Event} unparsable-change Fired when the user commits an unparsable value change and there is no change event. * @fires {CustomEvent} invalid-changed - Fired when the `invalid` property changes. * @fires {CustomEvent} opened-changed - Fired when the `opened` property changes. * @fires {CustomEvent} value-changed - Fired when the `value` property changes. diff --git a/packages/date-picker/test/value-commit-auto-open-disabled.common.js b/packages/date-picker/test/value-commit-auto-open-disabled.common.js index 154e613a6e..b0ec6d0e07 100644 --- a/packages/date-picker/test/value-commit-auto-open-disabled.common.js +++ b/packages/date-picker/test/value-commit-auto-open-disabled.common.js @@ -6,7 +6,7 @@ import sinon from 'sinon'; const TODAY_DATE = new Date().toISOString().split('T')[0]; describe('value commit - autoOpenDisabled', () => { - let datePicker, valueChangedSpy, validateSpy, changeSpy; + let datePicker, valueChangedSpy, validateSpy, changeSpy, unparsableChangeSpy; function expectNoValueCommit() { expect(valueChangedSpy).to.be.not.called; @@ -19,14 +19,25 @@ describe('value commit - autoOpenDisabled', () => { // TODO: Optimize the number of validation runs. expect(validateSpy).to.be.called; expect(validateSpy.firstCall).to.be.calledAfter(valueChangedSpy.firstCall); + expect(unparsableChangeSpy).to.be.not.called; expect(changeSpy).to.be.calledOnce; expect(changeSpy.firstCall).to.be.calledAfter(validateSpy.firstCall); expect(datePicker.value).to.equal(value); } + function expectUnparsableValueCommit() { + expect(valueChangedSpy).to.be.not.called; + // TODO: Optimize the number of validation runs. + expect(validateSpy).to.be.called; + expect(changeSpy).to.be.not.called; + expect(unparsableChangeSpy).to.be.calledOnce; + expect(unparsableChangeSpy).to.be.calledAfter(validateSpy); + } + function expectValidationOnly() { expect(valueChangedSpy).to.be.not.called; - expect(validateSpy).to.be.calledOnce; + // TODO: Optimize the number of validation runs. + expect(validateSpy).to.be.called; expect(changeSpy).to.be.not.called; } @@ -41,6 +52,9 @@ describe('value commit - autoOpenDisabled', () => { changeSpy = sinon.spy().named('changeSpy'); datePicker.addEventListener('change', changeSpy); + unparsableChangeSpy = sinon.spy().named('unparsableChangeSpy'); + datePicker.addEventListener('unparsable-change', unparsableChangeSpy); + datePicker.focus(); }); @@ -50,9 +64,9 @@ describe('value commit - autoOpenDisabled', () => { expectValidationOnly(); }); - it('should not commit but validate on Enter', async () => { + it('should not commit on Enter', async () => { await sendKeys({ press: 'Enter' }); - expectValidationOnly(); + expectNoValueCommit(); }); it('should not commit but validate on outside click', () => { @@ -130,21 +144,21 @@ describe('value commit - autoOpenDisabled', () => { await sendKeys({ type: 'foo' }); }); - it('should not commit but validate on blur', () => { + it('should commit as unparsable value change on blur', () => { datePicker.blur(); - expectValidationOnly(); + expectUnparsableValueCommit(); expect(datePicker.inputElement.value).to.equal('foo'); }); - it('should not commit but validate on Enter', async () => { + it('should commit as unparsable value change on Enter', async () => { await sendKeys({ press: 'Enter' }); - expectValidationOnly(); + expectUnparsableValueCommit(); expect(datePicker.inputElement.value).to.equal('foo'); }); - it('should not commit but validate on outside click', () => { + it('should commit as unparsable value change on outside click', () => { outsideClick(); - expectValidationOnly(); + expectUnparsableValueCommit(); expect(datePicker.inputElement.value).to.equal('foo'); }); @@ -160,6 +174,7 @@ describe('value commit - autoOpenDisabled', () => { await sendKeys({ type: 'foo' }); await sendKeys({ press: 'Enter' }); validateSpy.resetHistory(); + unparsableChangeSpy.resetHistory(); }); describe('input cleared with Backspace', () => { @@ -168,19 +183,46 @@ describe('value commit - autoOpenDisabled', () => { await sendKeys({ press: 'Backspace' }); }); - it('should not commit but validate on blur', () => { + it('should commit as unparsable value change on blur', () => { datePicker.blur(); - expectValidationOnly(); + expectUnparsableValueCommit(); }); - it('should not commit but validate on Enter', async () => { + it('should commit as unparsable value change on Enter', async () => { await sendKeys({ press: 'Enter' }); - expectValidationOnly(); + expectUnparsableValueCommit(); }); - it('should not commit but validate on outside click', () => { + it('should commit as unparsable value change on outside click', () => { outsideClick(); - expectValidationOnly(); + expectUnparsableValueCommit(); + }); + + it('should clear and commit as unparsable value change on Escape', async () => { + await sendKeys({ press: 'Escape' }); + expectUnparsableValueCommit(); + expect(datePicker.inputElement.value).to.equal(''); + }); + }); + + describe('unparsable input changed', () => { + beforeEach(async () => { + await sendKeys({ type: 'bar' }); + }); + + it('should commit as unparsable value change on blur', () => { + datePicker.blur(); + expectUnparsableValueCommit(); + }); + + it('should commit as unparsable value change on Enter', async () => { + await sendKeys({ press: 'Enter' }); + expectUnparsableValueCommit(); + }); + + it('should commit as unparsable value change on outside click', () => { + outsideClick(); + expectUnparsableValueCommit(); }); }); }); @@ -201,9 +243,9 @@ describe('value commit - autoOpenDisabled', () => { expectValidationOnly(); }); - it('should not commit but validate on Enter', async () => { + it('should not commit on Enter', async () => { await sendKeys({ press: 'Enter' }); - expectValidationOnly(); + expectNoValueCommit(); }); it('should not commit on Escape', async () => { @@ -246,6 +288,12 @@ describe('value commit - autoOpenDisabled', () => { await sendKeys({ type: 'foo' }); }); + it('should commit an empty value on blur', () => { + datePicker.blur(); + expectValueCommit(''); + expect(datePicker.inputElement.value).to.equal('foo'); + }); + it('should commit an empty value on Enter', async () => { await sendKeys({ press: 'Enter' }); expectValueCommit(''); diff --git a/packages/date-picker/test/value-commit.common.js b/packages/date-picker/test/value-commit.common.js index 5ccad26272..722ed79627 100644 --- a/packages/date-picker/test/value-commit.common.js +++ b/packages/date-picker/test/value-commit.common.js @@ -13,7 +13,7 @@ const TODAY_DATE = formatDateISO(new Date()); const YESTERDAY_DATE = formatDateISO(new Date(Date.now() - 3600 * 1000 * 24)); describe('value commit', () => { - let datePicker, valueChangedSpy, validateSpy, changeSpy; + let datePicker, valueChangedSpy, validateSpy, changeSpy, unparsableChangeSpy; function expectNoValueCommit() { expect(valueChangedSpy).to.be.not.called; @@ -26,14 +26,25 @@ describe('value commit', () => { // TODO: Optimize the number of validation runs. expect(validateSpy).to.be.called; expect(validateSpy.firstCall).to.be.calledAfter(valueChangedSpy.firstCall); + expect(unparsableChangeSpy).to.be.not.called; expect(changeSpy).to.be.calledOnce; expect(changeSpy.firstCall).to.be.calledAfter(validateSpy.firstCall); expect(datePicker.value).to.equal(value); } + function expectUnparsableValueCommit() { + expect(valueChangedSpy).to.be.not.called; + // TODO: Optimize the number of validation runs. + expect(validateSpy).to.be.called; + expect(changeSpy).to.be.not.called; + expect(unparsableChangeSpy).to.be.calledOnce; + expect(unparsableChangeSpy).to.be.calledAfter(validateSpy); + } + function expectValidationOnly() { expect(valueChangedSpy).to.be.not.called; - expect(validateSpy).to.be.calledOnce; + // TODO: Optimize the number of validation runs. + expect(validateSpy).to.be.called; expect(changeSpy).to.be.not.called; } @@ -48,6 +59,9 @@ describe('value commit', () => { changeSpy = sinon.spy().named('changeSpy'); datePicker.addEventListener('change', changeSpy); + unparsableChangeSpy = sinon.spy().named('unparsableChangeSpy'); + datePicker.addEventListener('unparsable-change', unparsableChangeSpy); + datePicker.focus(); }); @@ -57,9 +71,9 @@ describe('value commit', () => { expectValidationOnly(); }); - it('should not commit but validate on Enter', async () => { + it('should not commit on Enter', async () => { await sendKeys({ press: 'Enter' }); - expectValidationOnly(); + expectNoValueCommit(); }); it('should not commit on Escape', async () => { @@ -143,6 +157,24 @@ describe('value commit', () => { expectValueCommit(''); }); }); + + describe('value set programmatically', () => { + beforeEach(() => { + datePicker.value = TODAY_DATE; + valueChangedSpy.resetHistory(); + validateSpy.resetHistory(); + }); + + it('should not commit but validate on blur', () => { + datePicker.blur(); + expectValidationOnly(); + }); + + it('should not commit on Enter', async () => { + await sendKeys({ press: 'Enter' }); + expectNoValueCommit(); + }); + }); }); describe('unparsable input entered', () => { @@ -151,15 +183,15 @@ describe('value commit', () => { await waitForOverlayRender(); }); - it('should not commit but validate on Enter', async () => { + it('should commit as unparsable value change on Enter', async () => { await sendKeys({ press: 'Enter' }); - expectValidationOnly(); + expectUnparsableValueCommit(); expect(datePicker.inputElement.value).to.equal('foo'); }); - it('should not commit but validate on close with outside click', () => { + it('should commit as unparsable value change on close with outside click', () => { outsideClick(); - expectValidationOnly(); + expectUnparsableValueCommit(); expect(datePicker.inputElement.value).to.equal('foo'); }); @@ -176,6 +208,7 @@ describe('value commit', () => { await sendKeys({ press: 'Enter' }); await waitForOverlayRender(); validateSpy.resetHistory(); + unparsableChangeSpy.resetHistory(); }); describe('input cleared with Backspace', () => { @@ -184,14 +217,37 @@ describe('value commit', () => { await sendKeys({ press: 'Backspace' }); }); - it('should not commit but validate on Enter', async () => { + it('should commit as unparsable value change on Enter', async () => { await sendKeys({ press: 'Enter' }); - expectValidationOnly(); + expectUnparsableValueCommit(); }); - it('should not commit but validate on outside click', () => { + it('should commit as unparsable value change on outside click', () => { outsideClick(); - expectValidationOnly(); + expectUnparsableValueCommit(); + }); + }); + + describe('unparsable input changed', () => { + beforeEach(async () => { + await sendKeys({ type: 'bar' }); + await waitForOverlayRender(); + }); + + it('should commit as unparsable value change on Enter', async () => { + await sendKeys({ press: 'Enter' }); + expectUnparsableValueCommit(); + }); + + it('should commit as unparsable value change on close with outside click', () => { + outsideClick(); + expectUnparsableValueCommit(); + }); + + it('should clear and commit as unparsable value change on close with Escape', async () => { + await sendKeys({ press: 'Escape' }); + expectUnparsableValueCommit(); + expect(datePicker.inputElement.value).to.equal(''); }); }); }); @@ -300,9 +356,9 @@ describe('value commit', () => { expectValidationOnly(); }); - it('should not commit but validate on Enter', async () => { + it('should not commit on Enter', async () => { await sendKeys({ press: 'Enter' }); - expectValidationOnly(); + expectNoValueCommit(); }); it('should not commit on Escape', async () => {