diff --git a/.changeset/popular-bugs-roll.md b/.changeset/popular-bugs-roll.md new file mode 100644 index 000000000..acc3eaf2b --- /dev/null +++ b/.changeset/popular-bugs-roll.md @@ -0,0 +1,6 @@ +--- +'@lion/calendar': patch +'@lion/input-datepicker': patch +--- + +Fix an issue with events being added more than once in datepicker overlay. Also fix a bug where useCapture resulted in weird click behavior when clicking dates in previous or next month. diff --git a/packages/calendar/src/LionCalendar.js b/packages/calendar/src/LionCalendar.js index 224a8a21f..cec3d4cfb 100644 --- a/packages/calendar/src/LionCalendar.js +++ b/packages/calendar/src/LionCalendar.js @@ -162,6 +162,7 @@ export class LionCalendar extends LocalizeMixin(LitElement) { /** @type {Date | null} */ this.__focusedDate = null; this.__connectedCallbackDone = false; + this.__eventsAdded = false; this.locale = ''; } @@ -237,16 +238,58 @@ export class LionCalendar extends LocalizeMixin(LitElement) { * this acts as a firstUpdated that runs on every reconnect as well */ await this.updateComplete; - this.__contentWrapperElement = this.shadowRoot?.getElementById('js-content-wrapper'); - this.__addEventDelegationForClickDate(); - this.__addEventDelegationForFocusDate(); - this.__addEventDelegationForBlurDate(); - this.__addEventForKeyboardNavigation(); + + /** + * Flow goes like: + * 1) first connectedCallback before updateComplete + * 2) disconnectedCallback + * 3) second connectedCallback before updateComplete + * 4) first connectedCallback after updateComplete + * 5) second connectedCallback after updateComplete + * + * The __eventsAdded property tracks whether events are added / removed and here + * we can guard against adding events twice + */ + if (!this.__eventsAdded) { + this.__contentWrapperElement = /** @type {HTMLButtonElement} */ (this.shadowRoot?.getElementById( + 'js-content-wrapper', + )); + this.__contentWrapperElement.addEventListener('click', this.__clickDateDelegation.bind(this)); + this.__contentWrapperElement.addEventListener('focus', this.__focusDateDelegation.bind(this)); + this.__contentWrapperElement.addEventListener('blur', this.__blurDateDelegation.bind(this)); + this.__contentWrapperElement.addEventListener( + 'keydown', + this.__keyboardNavigationEvent.bind(this), + ); + this.__eventsAdded = true; + } } disconnectedCallback() { super.disconnectedCallback(); - this.__removeEventDelegations(); + if (this.__contentWrapperElement) { + this.__contentWrapperElement.removeEventListener( + 'click', + this.__clickDateDelegation.bind(this), + ); + this.__contentWrapperElement.removeEventListener( + 'focus', + this.__focusDateDelegation.bind(this), + true, + ); + this.__contentWrapperElement.removeEventListener( + 'blur', + this.__blurDateDelegation.bind(this), + true, + ); + + this.__contentWrapperElement.removeEventListener( + 'keydown', + this.__keyboardNavigationEvent.bind(this), + ); + + this.__eventsAdded = false; + } } /** @param {import('lit-element').PropertyValues } changedProperties */ @@ -618,124 +661,88 @@ export class LionCalendar extends LocalizeMixin(LitElement) { ); } - __addEventDelegationForClickDate() { + /** + * @param {Event} ev + */ + __clickDateDelegation(ev) { const isDayButton = /** @param {HTMLElement} el */ el => el.classList.contains('calendar__day-button'); - this.__clickDateDelegation = /** @param {Event} ev */ ev => { - const el = /** @type {HTMLElement & { date: Date }} */ (ev.target); - if (isDayButton(el)) { - this.__dateSelectedByUser(el.date); - } - }; - - const contentWrapper = /** @type {HTMLButtonElement} */ (this.__contentWrapperElement); - contentWrapper.addEventListener('click', this.__clickDateDelegation); - } - - __addEventDelegationForFocusDate() { - const isDayButton = /** @param {HTMLElement} el */ el => - el.classList.contains('calendar__day-button'); - - this.__focusDateDelegation = () => { - if ( - !this.__focusedDate && - isDayButton(/** @type {HTMLElement} el */ (this.shadowRoot?.activeElement)) - ) { - this.__focusedDate = /** @type {HTMLButtonElement & { date: Date }} */ (this.shadowRoot - ?.activeElement).date; - } - }; - - const contentWrapper = /** @type {HTMLButtonElement} */ (this.__contentWrapperElement); - contentWrapper.addEventListener('focus', this.__focusDateDelegation, true); - } - - __addEventDelegationForBlurDate() { - const isDayButton = /** @param {HTMLElement} el */ el => - el.classList.contains('calendar__day-button'); - - this.__blurDateDelegation = () => { - setTimeout(() => { - if ( - this.shadowRoot?.activeElement && - !isDayButton(/** @type {HTMLElement} el */ (this.shadowRoot?.activeElement)) - ) { - this.__focusedDate = null; - } - }, 1); - }; - - const contentWrapper = /** @type {HTMLButtonElement} */ (this.__contentWrapperElement); - contentWrapper.addEventListener('blur', this.__blurDateDelegation, true); - } - - __removeEventDelegations() { - if (!this.__contentWrapperElement) { - return; + const el = /** @type {HTMLElement & { date: Date }} */ (ev.target); + if (isDayButton(el)) { + this.__dateSelectedByUser(el.date); } - this.__contentWrapperElement.removeEventListener( - 'click', - /** @type {EventListener} */ (this.__clickDateDelegation), - ); - this.__contentWrapperElement.removeEventListener( - 'focus', - /** @type {EventListener} */ (this.__focusDateDelegation), - ); - this.__contentWrapperElement.removeEventListener( - 'blur', - /** @type {EventListener} */ (this.__blurDateDelegation), - ); - this.__contentWrapperElement.removeEventListener( - 'keydown', - /** @type {EventListener} */ (this.__keyNavigationEvent), - ); } - __addEventForKeyboardNavigation() { - this.__keyNavigationEvent = /** @param {KeyboardEvent} ev */ ev => { - const preventedKeys = ['ArrowUp', 'ArrowDown', 'PageDown', 'PageUp']; + __focusDateDelegation() { + const isDayButton = /** @param {HTMLElement} el */ el => + el.classList.contains('calendar__day-button'); - if (preventedKeys.includes(ev.key)) { - ev.preventDefault(); + if ( + !this.__focusedDate && + isDayButton(/** @type {HTMLElement} el */ (this.shadowRoot?.activeElement)) + ) { + this.__focusedDate = /** @type {HTMLButtonElement & { date: Date }} */ (this.shadowRoot + ?.activeElement).date; + } + } + + __blurDateDelegation() { + const isDayButton = /** @param {HTMLElement} el */ el => + el.classList.contains('calendar__day-button'); + + setTimeout(() => { + if ( + this.shadowRoot?.activeElement && + !isDayButton(/** @type {HTMLElement} el */ (this.shadowRoot?.activeElement)) + ) { + this.__focusedDate = null; } + }, 1); + } - switch (ev.key) { - case 'ArrowUp': - this.__modifyDate(-7, { dateType: '__focusedDate', type: 'Date', mode: 'past' }); - break; - case 'ArrowDown': - this.__modifyDate(7, { dateType: '__focusedDate', type: 'Date', mode: 'future' }); - break; - case 'ArrowLeft': - this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Date', mode: 'past' }); - break; - case 'ArrowRight': - this.__modifyDate(1, { dateType: '__focusedDate', type: 'Date', mode: 'future' }); - break; - case 'PageDown': - if (ev.altKey === true) { - this.__modifyDate(1, { dateType: '__focusedDate', type: 'FullYear', mode: 'future' }); - } else { - this.__modifyDate(1, { dateType: '__focusedDate', type: 'Month', mode: 'future' }); - } - break; - case 'PageUp': - if (ev.altKey === true) { - this.__modifyDate(-1, { dateType: '__focusedDate', type: 'FullYear', mode: 'past' }); - } else { - this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Month', mode: 'past' }); - } - break; - case 'Tab': - this.__focusedDate = null; - break; - // no default - } - }; + /** + * @param {KeyboardEvent} ev + */ + __keyboardNavigationEvent(ev) { + const preventedKeys = ['ArrowUp', 'ArrowDown', 'PageDown', 'PageUp']; - const contentWrapper = /** @type {HTMLButtonElement} */ (this.__contentWrapperElement); - contentWrapper.addEventListener('keydown', this.__keyNavigationEvent); + if (preventedKeys.includes(ev.key)) { + ev.preventDefault(); + } + + switch (ev.key) { + case 'ArrowUp': + this.__modifyDate(-7, { dateType: '__focusedDate', type: 'Date', mode: 'past' }); + break; + case 'ArrowDown': + this.__modifyDate(7, { dateType: '__focusedDate', type: 'Date', mode: 'future' }); + break; + case 'ArrowLeft': + this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Date', mode: 'past' }); + break; + case 'ArrowRight': + this.__modifyDate(1, { dateType: '__focusedDate', type: 'Date', mode: 'future' }); + break; + case 'PageDown': + if (ev.altKey === true) { + this.__modifyDate(1, { dateType: '__focusedDate', type: 'FullYear', mode: 'future' }); + } else { + this.__modifyDate(1, { dateType: '__focusedDate', type: 'Month', mode: 'future' }); + } + break; + case 'PageUp': + if (ev.altKey === true) { + this.__modifyDate(-1, { dateType: '__focusedDate', type: 'FullYear', mode: 'past' }); + } else { + this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Month', mode: 'past' }); + } + break; + case 'Tab': + this.__focusedDate = null; + break; + // no default + } } /** diff --git a/packages/input-datepicker/test/lion-input-datepicker.test.js b/packages/input-datepicker/test/lion-input-datepicker.test.js index aa014523e..2996bfd95 100644 --- a/packages/input-datepicker/test/lion-input-datepicker.test.js +++ b/packages/input-datepicker/test/lion-input-datepicker.test.js @@ -270,6 +270,59 @@ describe('', () => { }); }); + describe('Calendar smoke tests', () => { + it('responds properly to keyboard events', async () => { + const el = await fixture(html` + + `); + const calendarEl = /** @type {LionCalendar} */ (el.shadowRoot?.querySelector( + '[data-tag-name="lion-calendar"]', + )); + // First set a fixed date as if selected by a user + calendarEl.__dateSelectedByUser(new Date('December 17, 2020 03:24:00 GMT+0000')); + await el.updateComplete; + const elObj = new DatepickerInputObject(el); + + // Open the calendar + await elObj.openCalendar(); + + // Move focus to 18th of December + calendarEl.shadowRoot + ?.querySelector('#js-content-wrapper') + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + + expect(/** @type {Date} */ (calendarEl.focusedDate).getTime()).to.equal( + new Date('December 18, 2020 03:24:00 GMT+0000').getTime(), + ); + }); + + it('responds properly to click events', async () => { + const el = await fixture(html` + + `); + const calendarEl = /** @type {LionCalendar} */ (el.shadowRoot?.querySelector( + '[data-tag-name="lion-calendar"]', + )); + // First set a fixed date as if selected by a user + calendarEl.__dateSelectedByUser(new Date('December 17, 2020 03:24:00 GMT+0000')); + await el.updateComplete; + const elObj = new DatepickerInputObject(el); + + // Open the calendar + await elObj.openCalendar(); + + // Select the first date button, which is 29th of previous month (November) + const firstDateBtn = /** @type {HTMLButtonElement} */ (calendarEl?.shadowRoot?.querySelector( + '.calendar__day-button', + )); + firstDateBtn.click(); + + expect(/** @type {Date} */ (el.modelValue).getTime()).to.equal( + new Date('November 29, 2020 03:24:00 GMT+0000').getTime(), + ); + }); + }); + describe('Accessibility', () => { it('has a heading of level 1', async () => { const el = await fixture(html`