From e47f46f3c4daf246135ab2e21e6fd001269d1cde Mon Sep 17 00:00:00 2001 From: ShaneK Date: Fri, 9 Oct 2026 05:47:34 -0700 Subject: [PATCH] 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); + }); + }); +});