From e47f46f3c4daf246135ab2e21e6fd001269d1cde Mon Sep 17 00:00:00 2001 From: ShaneK Date: Fri, 9 Oct 2026 05:47:34 -0700 Subject: [PATCH 1/3] fix(datetime): discard unconfirmed selection on cancel or overlay dismiss --- core/src/components.d.ts | 2 +- core/src/components/datetime/datetime.tsx | 61 ++++- .../datetime/test/cancel/datetime.e2e.ts | 221 ++++++++++++++++++ 3 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 core/src/components/datetime/test/cancel/datetime.e2e.ts diff --git a/core/src/components.d.ts b/core/src/components.d.ts index cf24c5fcd4f..0675a18b9c0 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -912,7 +912,7 @@ export namespace Components { } interface IonDatetime { /** - * Emits the ionCancel event and optionally closes the popover or modal that the datetime was presented in. + * Emits the ionCancel event, discards any unconfirmed selection, and optionally closes the popover or modal that the datetime was presented in. * @param closeOverlay If `true`, closes the parent overlay. Defaults to `false`. */ "cancel": (closeOverlay?: boolean) => Promise; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index e9bb5d47086..1eafaa2b7cf 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -11,6 +11,7 @@ import { caretDownSharp, caretUpSharp, chevronBack, chevronDown, chevronForward import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; import type { Color, Mode, StyleEventDetail } from '../../interface'; +import type { OverlayEventDetail } from '../../utils/overlays-interface'; import type { DatetimePresentation, @@ -129,6 +130,7 @@ export class Datetime implements ComponentInterface { private destroyCalendarListener?: () => void; private destroyKeyboardMO?: () => void; + private destroyOverlayDismissListener?: () => void; // TODO(FW-2832): types (DatetimeParts causes some errors that need untangling) private minParts?: any; @@ -583,9 +585,10 @@ export class Datetime implements ComponentInterface { } /** - * Emits the ionCancel event and - * optionally closes the popover - * or modal that the datetime was + * Emits the ionCancel event, discards + * any unconfirmed selection, and + * optionally closes the popover or + * modal that the datetime was * presented in. * * @param closeOverlay If `true`, closes the parent overlay. Defaults to `false`. @@ -594,8 +597,13 @@ export class Datetime implements ComponentInterface { async cancel(closeOverlay = false) { this.ionCancel.emit(); - if (closeOverlay) { - this.closeParentOverlay(CANCEL_ROLE); + /** + * If a parent overlay is closed, the dismiss listener discards + * the selection once the overlay has animated out. Otherwise, + * or if there is no parent overlay, discard it now. + */ + if (!closeOverlay || !this.closeParentOverlay(CANCEL_ROLE)) { + this.processValue(this.value); } } @@ -665,6 +673,8 @@ export class Datetime implements ComponentInterface { if (popoverOrModal) { popoverOrModal.dismiss(undefined, role); } + + return popoverOrModal !== null; }; private setWorkingParts = (parts: DatetimeParts) => { @@ -1102,6 +1112,7 @@ export class Datetime implements ComponentInterface { this.loadTimeout = setTimeout(() => { this.ensureReadyIfVisible(); }, 100); + this.initializeOverlayDismissListener(); } disconnectedCallback() { @@ -1110,6 +1121,41 @@ export class Datetime implements ComponentInterface { this.clearFocusVisible = undefined; } this.loadTimeoutCleanup(); + + if (this.destroyOverlayDismissListener) { + this.destroyOverlayDismissListener(); + this.destroyOverlayDismissListener = undefined; + } + } + + /** + * A datetime in a modal or popover stays mounted between + * presentations, so an unconfirmed selection would still show + * the next time the overlay opens. Discard it on any dismissal + * other than confirm. + */ + private initializeOverlayDismissListener() { + const overlay = this.el.closest('ion-modal, ion-popover'); + if (!overlay) { + return; + } + + const eventName = overlay.tagName === 'ION-MODAL' ? 'ionModalDidDismiss' : 'ionPopoverDidDismiss'; + const onDismiss = (ev: Event) => { + // Ignore dismiss events bubbling up from nested overlays. + if (ev.target !== overlay) { + return; + } + + const { role } = (ev as CustomEvent).detail; + if (role !== CONFIRM_ROLE) { + // The overlay is already hidden, so skip the month animation. + this.processValue(this.value, false); + } + }; + + overlay.addEventListener(eventName, onDismiss); + this.destroyOverlayDismissListener = () => overlay.removeEventListener(eventName, onDismiss); } /** @@ -1363,7 +1409,7 @@ export class Datetime implements ComponentInterface { }); } - private processValue = (value?: string | string[] | null) => { + private processValue = (value?: string | string[] | null, animated = true) => { const hasValue = value !== null && value !== undefined && value !== '' && (!Array.isArray(value) || value.length > 0); const valueToProcess = hasValue ? parseDate(value) : this.defaultParts; @@ -1435,13 +1481,14 @@ export class Datetime implements ComponentInterface { const bodyIsVisible = el.classList.contains('datetime-ready'); const { isGridStyle, showMonthAndYear } = this; - if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) { + if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear && animated) { /** * Only animate if: * 1. We're using grid style (wheel style pickers should just jump to new value) * 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to) * 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example) * 4. The month/year picker is not open (since you wouldn't see the animation anyway) + * 5. The caller did not opt out (e.g. the parent overlay is already hidden) */ this.animateToDate(targetValue); } else { diff --git a/core/src/components/datetime/test/cancel/datetime.e2e.ts b/core/src/components/datetime/test/cancel/datetime.e2e.ts new file mode 100644 index 00000000000..676fc326645 --- /dev/null +++ b/core/src/components/datetime/test/cancel/datetime.e2e.ts @@ -0,0 +1,221 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +const getDay = (datetime: Locator, day: number, month = 5) => + datetime.locator(`.calendar-day[data-day="${day}"][data-month="${month}"]`); + +const expectSelectedDay = async (datetime: Locator, selected: number, unselected: number) => { + await expect(getDay(datetime, selected)).toHaveClass(/calendar-day-active/); + await expect(getDay(datetime, unselected)).not.toHaveClass(/calendar-day-active/); +}; + +const openDatetime = async (page: E2EPage, didPresent: string) => { + const ionDidPresent = await page.spyOnEvent(didPresent); + await page.locator('ion-datetime-button #date-button').click(); + await ionDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); +}; + +const dismissDatetime = async (page: E2EPage, didDismiss: string, dismiss: () => Promise) => { + const ionDidDismiss = await page.spyOnEvent(didDismiss); + await dismiss(); + await ionDidDismiss.next(); +}; + +const overlays = [ + { tag: 'ion-modal', didPresent: 'ionModalDidPresent', didDismiss: 'ionModalDidDismiss' }, + { tag: 'ion-popover', didPresent: 'ionPopoverDidPresent', didDismiss: 'ionPopoverDidDismiss' }, +]; + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { + test.describe(title('datetime: cancel'), () => { + overlays.forEach(({ tag, didPresent, didDismiss }) => { + test.describe(`in an ${tag}`, () => { + let datetime: Locator; + + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + <${tag}> + + + `, + config + ); + + datetime = page.locator('ion-datetime'); + }); + + test('should show the confirmed date when reopened after cancelling a new selection', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed date when reopened after dismissing without confirming', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => + page + .locator(tag) + .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => el.dismiss(undefined, 'backdrop')) + ); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed month when reopened after cancelling a selection in another month', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + const monthYear = datetime.locator('.calendar-month-year-toggle'); + const nextMonthButton = datetime.locator('.calendar-next-prev ion-button').nth(1); + + await openDatetime(page, didPresent); + await nextMonthButton.click(); + await expect(monthYear).toContainText('June 2022'); + await getDay(datetime, 10, 6).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + await openDatetime(page, didPresent); + + await expect(monthYear).toContainText('May 2022'); + await expect(getDay(datetime, 3)).toHaveClass(/calendar-day-active/); + + await nextMonthButton.click(); + await expect(monthYear).toContainText('June 2022'); + await expect(getDay(datetime, 10, 6)).not.toHaveClass(/calendar-day-active/); + }); + + test('should keep the new date when reopened after confirming a selection', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#confirm-button').click()); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + await expectSelectedDay(datetime, 10, 3); + }); + }); + }); + + test('should restore the confirmed date when the cancel button is clicked on an inline datetime', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await page.setContent( + '', + config + ); + + const datetime = page.locator('ion-datetime'); + await page.locator('ion-datetime.datetime-ready').waitFor(); + + await getDay(datetime, 10).click(); + await datetime.locator('#cancel-button').click(); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed date in the wheel picker when reopened after cancelling', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await page.setContent( + ` + + + + + `, + config + ); + + const datetime = page.locator('ion-datetime'); + const dayOptions = datetime.locator('.day-column ion-picker-column-option'); + const activeDay = datetime.locator('.day-column ion-picker-column-option.option-active'); + + await openDatetime(page, 'ionModalDidPresent'); + await dayOptions.filter({ hasText: /^10$/ }).click(); + await expect(activeDay).toHaveText('10'); + await dismissDatetime(page, 'ionModalDidDismiss', () => datetime.locator('#cancel-button').click()); + await openDatetime(page, 'ionModalDidPresent'); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expect(activeDay).toHaveText('3'); + }); + + test('should keep the selection when the time picker inside a popover is dismissed', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); + const parentPopover = page.locator('#parent-popover'); + const datetime = page.locator('ion-datetime'); + + await parentPopover.evaluate((el: HTMLIonPopoverElement) => el.present()); + await ionPopoverDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); + + await getDay(datetime, 10).click(); + + /** + * The time picker opens its own popover. Dismissing it + * must not discard the selection in the parent popover. + */ + await datetime.locator('.time-body').click(); + await ionPopoverDidPresent.next(); + await page.keyboard.press('Escape'); + await ionPopoverDidDismiss.next(); + await page.waitForChanges(); + + await expect(parentPopover).not.toHaveClass(/overlay-hidden/); + await expectSelectedDay(datetime, 10, 3); + }); + }); +}); From f22d591674b4214eb7bb9ba6e47b029d5a12e0da Mon Sep 17 00:00:00 2001 From: ShaneK Date: Fri, 16 Oct 2026 06:30:04 -0700 Subject: [PATCH 2/3] fix(datetime): discard unconfirmed selection on overlay present instead of dismiss --- core/src/components/datetime/datetime.tsx | 58 ++++++++------- .../datetime/test/cancel/datetime.e2e.ts | 72 ++++++++++++++++++- 2 files changed, 104 insertions(+), 26 deletions(-) diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 1eafaa2b7cf..376ebaf2400 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -11,7 +11,6 @@ import { caretDownSharp, caretUpSharp, chevronBack, chevronDown, chevronForward import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; import type { Color, Mode, StyleEventDetail } from '../../interface'; -import type { OverlayEventDetail } from '../../utils/overlays-interface'; import type { DatetimePresentation, @@ -130,7 +129,13 @@ export class Datetime implements ComponentInterface { private destroyCalendarListener?: () => void; private destroyKeyboardMO?: () => void; - private destroyOverlayDismissListener?: () => void; + private destroyOverlayPresentListener?: () => void; + /** + * Set when the user selects a value, cleared when it's confirmed + * or `processValue` runs (`reset()`, `value` changes). The overlay + * present listener only discards a selection while this is set. + */ + private hasUnconfirmedSelection = false; // TODO(FW-2832): types (DatetimeParts causes some errors that need untangling) private minParts?: any; @@ -567,6 +572,8 @@ export class Datetime implements ComponentInterface { } } + this.hasUnconfirmedSelection = false; + if (closeOverlay) { this.closeParentOverlay(CONFIRM_ROLE); } @@ -598,9 +605,8 @@ export class Datetime implements ComponentInterface { this.ionCancel.emit(); /** - * If a parent overlay is closed, the dismiss listener discards - * the selection once the overlay has animated out. Otherwise, - * or if there is no parent overlay, discard it now. + * When a parent overlay closes, the present listener discards + * the selection on the next open. Otherwise discard it now. */ if (!closeOverlay || !this.closeParentOverlay(CANCEL_ROLE)) { this.processValue(this.value); @@ -692,6 +698,8 @@ export class Datetime implements ComponentInterface { return; } + this.hasUnconfirmedSelection = true; + const { multiple, minParts, maxParts, activeParts } = this; /** @@ -1112,7 +1120,7 @@ export class Datetime implements ComponentInterface { this.loadTimeout = setTimeout(() => { this.ensureReadyIfVisible(); }, 100); - this.initializeOverlayDismissListener(); + this.initializeOverlayPresentListener(); } disconnectedCallback() { @@ -1122,40 +1130,38 @@ export class Datetime implements ComponentInterface { } this.loadTimeoutCleanup(); - if (this.destroyOverlayDismissListener) { - this.destroyOverlayDismissListener(); - this.destroyOverlayDismissListener = undefined; + if (this.destroyOverlayPresentListener) { + this.destroyOverlayPresentListener(); + this.destroyOverlayPresentListener = undefined; } } /** * A datetime in a modal or popover stays mounted between - * presentations, so an unconfirmed selection would still show - * the next time the overlay opens. Discard it on any dismissal - * other than confirm. + * presentations, so an unconfirmed selection would still show when + * it reopens. Discarding on present rather than dismiss lets app + * code that runs on dismiss still confirm or reset the selection, + * whatever order its listeners were added in. */ - private initializeOverlayDismissListener() { + private initializeOverlayPresentListener() { const overlay = this.el.closest('ion-modal, ion-popover'); if (!overlay) { return; } - const eventName = overlay.tagName === 'ION-MODAL' ? 'ionModalDidDismiss' : 'ionPopoverDidDismiss'; - const onDismiss = (ev: Event) => { - // Ignore dismiss events bubbling up from nested overlays. - if (ev.target !== overlay) { + const eventName = overlay.tagName === 'ION-MODAL' ? 'ionModalWillPresent' : 'ionPopoverWillPresent'; + const onPresent = (ev: Event) => { + // Ignore nested overlays and skip when there's nothing to discard. + if (ev.target !== overlay || !this.hasUnconfirmedSelection) { return; } - const { role } = (ev as CustomEvent).detail; - if (role !== CONFIRM_ROLE) { - // The overlay is already hidden, so skip the month animation. - this.processValue(this.value, false); - } + // The overlay is still hidden, so skip the month animation. + this.processValue(this.value, false); }; - overlay.addEventListener(eventName, onDismiss); - this.destroyOverlayDismissListener = () => overlay.removeEventListener(eventName, onDismiss); + overlay.addEventListener(eventName, onPresent); + this.destroyOverlayPresentListener = () => overlay.removeEventListener(eventName, onPresent); } /** @@ -1410,6 +1416,8 @@ export class Datetime implements ComponentInterface { } private processValue = (value?: string | string[] | null, animated = true) => { + this.hasUnconfirmedSelection = false; + const hasValue = value !== null && value !== undefined && value !== '' && (!Array.isArray(value) || value.length > 0); const valueToProcess = hasValue ? parseDate(value) : this.defaultParts; @@ -1488,7 +1496,7 @@ export class Datetime implements ComponentInterface { * 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to) * 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example) * 4. The month/year picker is not open (since you wouldn't see the animation anyway) - * 5. The caller did not opt out (e.g. the parent overlay is already hidden) + * 5. The caller did not opt out (e.g. the parent overlay is still hidden) */ this.animateToDate(targetValue); } else { diff --git a/core/src/components/datetime/test/cancel/datetime.e2e.ts b/core/src/components/datetime/test/cancel/datetime.e2e.ts index 676fc326645..beb7950e14d 100644 --- a/core/src/components/datetime/test/cancel/datetime.e2e.ts +++ b/core/src/components/datetime/test/cancel/datetime.e2e.ts @@ -125,6 +125,76 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { await expect(datetime).toHaveJSProperty('value', '2022-05-10'); await expectSelectedDay(datetime, 10, 3); }); + + test('should commit the selection when a dismiss handler confirms it after a custom role', async ({ page }) => { + await page + .locator(tag) + .evaluate( + (el: HTMLElement, eventName: string) => + el.addEventListener(eventName, () => el.querySelector('ion-datetime')!.confirm()), + didDismiss + ); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => + page + .locator(tag) + .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => el.dismiss(undefined, 'my-custom-role')) + ); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should commit the selection when confirmed after awaiting onDidDismiss with a custom role', async ({ + page, + }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + + await page.locator(tag).evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => { + el.onDidDismiss().then(() => el.querySelector('ion-datetime')!.confirm()); + }); + await dismissDatetime(page, didDismiss, () => + page + .locator(tag) + .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => el.dismiss(undefined, 'my-custom-role')) + ); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should keep the selection set by reset() after cancelling', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); + await openDatetime(page, didPresent); + + await expectSelectedDay(datetime, 20, 3); + }); + + test('should keep the selection set by reset() before dismissing without confirming', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); + await dismissDatetime(page, didDismiss, () => + page + .locator(tag) + .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => el.dismiss(undefined, 'backdrop')) + ); + + await openDatetime(page, didPresent); + + await expectSelectedDay(datetime, 20, 3); + }); }); }); @@ -206,7 +276,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { /** * The time picker opens its own popover. Dismissing it - * must not discard the selection in the parent popover. + * shouldn't discard the selection in the parent popover. */ await datetime.locator('.time-body').click(); await ionPopoverDidPresent.next(); From 45c522be9de856c3aac1b7f2bdc7213edb508786 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 19 Oct 2026 13:51:47 -0700 Subject: [PATCH 3/3] test(datetime): cover value changes after an unconfirmed dismiss and extract overlay dismiss helpers --- core/src/components/datetime/datetime.tsx | 5 +- .../datetime/test/cancel/datetime.e2e.ts | 90 +++++++++++++------ 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 376ebaf2400..4851f70df70 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -132,8 +132,9 @@ export class Datetime implements ComponentInterface { private destroyOverlayPresentListener?: () => void; /** * Set when the user selects a value, cleared when it's confirmed - * or `processValue` runs (`reset()`, `value` changes). The overlay - * present listener only discards a selection while this is set. + * or `processValue` runs (`reset()`, or `value` being set to a + * non-empty value). The overlay present listener only discards a + * selection while this is set. */ private hasUnconfirmedSelection = false; diff --git a/core/src/components/datetime/test/cancel/datetime.e2e.ts b/core/src/components/datetime/test/cancel/datetime.e2e.ts index beb7950e14d..f6b68dc6b2c 100644 --- a/core/src/components/datetime/test/cancel/datetime.e2e.ts +++ b/core/src/components/datetime/test/cancel/datetime.e2e.ts @@ -24,6 +24,20 @@ const dismissDatetime = async (page: E2EPage, didDismiss: string, dismiss: () => await ionDidDismiss.next(); }; +const dismissWithRole = (page: E2EPage, tag: string, role: string) => + page + .locator(tag) + .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement, role: string) => el.dismiss(undefined, role), role); + +const confirmOnDismiss = (page: E2EPage, tag: string, didDismiss: string) => + page + .locator(tag) + .evaluate( + (el: HTMLElement, eventName: string) => + el.addEventListener(eventName, () => el.querySelector('ion-datetime')!.confirm()), + didDismiss + ); + const overlays = [ { tag: 'ion-modal', didPresent: 'ionModalDidPresent', didDismiss: 'ionModalDidDismiss' }, { tag: 'ion-popover', didPresent: 'ionPopoverDidPresent', didDismiss: 'ionPopoverDidDismiss' }, @@ -79,11 +93,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { await openDatetime(page, didPresent); await getDay(datetime, 10).click(); - await dismissDatetime(page, didDismiss, () => - page - .locator(tag) - .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => el.dismiss(undefined, 'backdrop')) - ); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); await openDatetime(page, didPresent); await expect(datetime).toHaveJSProperty('value', '2022-05-03'); @@ -127,21 +137,11 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { }); test('should commit the selection when a dismiss handler confirms it after a custom role', async ({ page }) => { - await page - .locator(tag) - .evaluate( - (el: HTMLElement, eventName: string) => - el.addEventListener(eventName, () => el.querySelector('ion-datetime')!.confirm()), - didDismiss - ); + await confirmOnDismiss(page, tag, didDismiss); await openDatetime(page, didPresent); await getDay(datetime, 10).click(); - await dismissDatetime(page, didDismiss, () => - page - .locator(tag) - .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => el.dismiss(undefined, 'my-custom-role')) - ); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'my-custom-role')); await expect(datetime).toHaveJSProperty('value', '2022-05-10'); @@ -158,11 +158,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { await page.locator(tag).evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => { el.onDidDismiss().then(() => el.querySelector('ion-datetime')!.confirm()); }); - await dismissDatetime(page, didDismiss, () => - page - .locator(tag) - .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => el.dismiss(undefined, 'my-custom-role')) - ); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'my-custom-role')); await expect(datetime).toHaveJSProperty('value', '2022-05-10'); @@ -185,16 +181,56 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { await openDatetime(page, didPresent); await getDay(datetime, 10).click(); await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); - await dismissDatetime(page, didDismiss, () => - page - .locator(tag) - .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => el.dismiss(undefined, 'backdrop')) - ); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); await openDatetime(page, didPresent); await expectSelectedDay(datetime, 20, 3); }); + + test('should show the value set after dismissing without confirming', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2022-05-20')); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-20'); + await expectSelectedDay(datetime, 20, 10); + }); + + test('should clear the selection when the value is cleared after dismissing without confirming', async ({ + page, + }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = null)); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', null); + await expect(datetime.locator('.calendar-day-active')).toHaveCount(0); + }); + + /** + * Cancel defers discarding the selection until the overlay + * presents again, so an app that confirms on every dismiss + * commits the cancelled selection. This is intended. + */ + test('should commit a cancelled selection when a dismiss handler confirms it', async ({ page }) => { + await confirmOnDismiss(page, tag, didDismiss); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); }); });