Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down
61 changes: 54 additions & 7 deletions core/src/components/datetime/datetime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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`.
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -665,6 +673,8 @@ export class Datetime implements ComponentInterface {
if (popoverOrModal) {
popoverOrModal.dismiss(undefined, role);
}

return popoverOrModal !== null;
};

private setWorkingParts = (parts: DatetimeParts) => {
Expand Down Expand Up @@ -1102,6 +1112,7 @@ export class Datetime implements ComponentInterface {
this.loadTimeout = setTimeout(() => {
this.ensureReadyIfVisible();
}, 100);
this.initializeOverlayDismissListener();
}

disconnectedCallback() {
Expand All @@ -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<OverlayEventDetail>).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);
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
221 changes: 221 additions & 0 deletions core/src/components/datetime/test/cancel/datetime.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => {
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(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<${tag}>
<ion-datetime id="datetime" locale="en-US" presentation="date" show-default-buttons="true" value="2022-05-03"></ion-datetime>
</${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(
'<ion-datetime locale="en-US" presentation="date" show-default-buttons="true" value="2022-05-03"></ion-datetime>',
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(
`
<ion-datetime-button datetime="datetime"></ion-datetime-button>
<ion-modal>
<ion-datetime id="datetime" locale="en-US" presentation="date" prefer-wheel="true" show-default-buttons="true" value="2022-05-03"></ion-datetime>
</ion-modal>
`,
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(
`
<ion-popover id="parent-popover">
<ion-datetime locale="en-US" presentation="date-time" show-default-buttons="true" value="2022-05-03T13:00"></ion-datetime>
</ion-popover>
`,
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);
});
});
});
Loading