Skip to content

Commit

Permalink
fix(datetime): setting date async updates calendar grid (#26070)
Browse files Browse the repository at this point in the history
resolves #25776
  • Loading branch information
liamdebeasi committed Oct 10, 2022
1 parent ab89679 commit 0aee328
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 82 deletions.
146 changes: 68 additions & 78 deletions core/src/components/datetime/datetime.tsx
Expand Up @@ -85,17 +85,6 @@ export class Datetime implements ComponentInterface {
private calendarBodyRef?: HTMLElement;
private popoverRef?: HTMLIonPopoverElement;
private clearFocusVisible?: () => void;

/**
* Whether to highlight the active day with a solid circle (as opposed
* to the outline circle around today). If you don't specify an initial
* value for the datetime, it doesn't automatically init to a default to
* avoid unwanted change events firing. If the solid circle were still
* shown then, it would look like a date had already been selected, which
* is misleading UX.
*/
private highlightActiveParts = false;

private parsedMinuteValues?: number[];
private parsedHourValues?: number[];
private parsedMonthValues?: number[];
Expand All @@ -115,18 +104,11 @@ export class Datetime implements ComponentInterface {
* Duplicate reference to `activeParts` that does not trigger a re-render of the component.
* Allows caching an instance of the `activeParts` in between render cycles.
*/
private activePartsClone!: DatetimeParts | DatetimeParts[];
private activePartsClone: DatetimeParts | DatetimeParts[] = [];

@State() showMonthAndYear = false;

@State() activeParts: DatetimeParts | DatetimeParts[] = {
month: 5,
day: 28,
year: 2021,
hour: 13,
minute: 52,
ampm: 'pm',
};
@State() activeParts: DatetimeParts | DatetimeParts[] = [];

@State() workingParts: DatetimeParts = {
month: 5,
Expand Down Expand Up @@ -506,16 +488,12 @@ export class Datetime implements ComponentInterface {
*/
@Method()
async confirm(closeOverlay = false) {
const { highlightActiveParts, isCalendarPicker, activeParts } = this;
const { isCalendarPicker, activeParts } = this;

/**
* We only update the value if the presentation is not a calendar picker,
* or if `highlightActiveParts` is true; indicating that the user
* has selected a date from the calendar picker.
*
* Otherwise "today" would accidentally be set as the value.
* We only update the value if the presentation is not a calendar picker.
*/
if (highlightActiveParts || !isCalendarPicker) {
if (activeParts !== undefined || !isCalendarPicker) {
const activePartsIsArray = Array.isArray(activeParts);
if (activePartsIsArray && activeParts.length === 0) {
this.value = undefined;
Expand Down Expand Up @@ -573,6 +551,23 @@ export class Datetime implements ComponentInterface {
}
}

/**
* Returns the DatetimePart interface
* to use when rendering an initial set of
* data. This should be used when rendering an
* interface in an environment where the `value`
* may not be set. This function works
* by returning the first selected date in
* "activePartsClone" and then falling back to
* today's DatetimeParts if no active date is selected.
*/
private getDefaultPart = () => {
const { activePartsClone, todayParts } = this;

const firstPart = Array.isArray(activePartsClone) ? activePartsClone[0] : activePartsClone;
return firstPart ?? todayParts;
};

private closeParentOverlay = () => {
const popoverOrModal = this.el.closest('ion-modal, ion-popover') as
| HTMLIonModalElement
Expand All @@ -590,7 +585,7 @@ export class Datetime implements ComponentInterface {
};

private setActiveParts = (parts: DatetimeParts, removeDate = false) => {
const { multiple, activePartsClone, highlightActiveParts } = this;
const { multiple, activePartsClone } = this;

/**
* When setting the active parts, it is possible
Expand Down Expand Up @@ -618,34 +613,15 @@ export class Datetime implements ComponentInterface {
const activePartsArray = Array.isArray(activePartsClone) ? activePartsClone : [activePartsClone];
if (removeDate) {
this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts));
} else if (highlightActiveParts) {
this.activeParts = [...activePartsArray, validatedParts];
} else {
/**
* If highlightActiveParts is false, that means we just have a
* default value of today in activeParts; we need to replace that
* rather than adding to it since it's just a placeholder.
*/
this.activeParts = [validatedParts];
this.activeParts = [...activePartsArray, validatedParts];
}
} else {
this.activeParts = {
...validatedParts,
};
}

/**
* Now that the user has interacted somehow to select something, we can
* show the solid highlight. This needs to be done after checking it above,
* but before the confirm call below.
*
* Note that for datetimes with confirm/cancel buttons, the value
* isn't updated until you call confirm(). We need to bring the
* solid circle back on day click for UX reasons, rather than only
* show the circle if `value` is truthy.
*/
this.highlightActiveParts = true;

const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null;
if (hasSlottedButtons || this.showDefaultButtons) {
return;
Expand Down Expand Up @@ -1178,7 +1154,7 @@ export class Datetime implements ComponentInterface {
}

private processValue = (value?: string | string[] | null) => {
const hasValue = (this.highlightActiveParts = value !== null && value !== undefined);
const hasValue = value !== null && value !== undefined;
let valueToProcess = parseDate(value ?? getToday());

const { minParts, maxParts, multiple } = this;
Expand Down Expand Up @@ -1219,18 +1195,26 @@ export class Datetime implements ComponentInterface {
ampm,
});

if (Array.isArray(valueToProcess)) {
this.activeParts = [...valueToProcess];
} else {
this.activeParts = {
month,
day,
year,
hour,
minute,
tzOffset,
ampm,
};
/**
* Since `activeParts` indicates a value that
* been explicitly selected either by the
* user or the app, only update `activeParts`
* if the `value` property is set.
*/
if (hasValue) {
if (Array.isArray(valueToProcess)) {
this.activeParts = [...valueToProcess];
} else {
this.activeParts = {
month,
day,
year,
hour,
minute,
tzOffset,
ampm,
};
}
}
};

Expand Down Expand Up @@ -1747,13 +1731,15 @@ export class Datetime implements ComponentInterface {
}

private renderHourPickerColumn(hoursData: PickerColumnItem[]) {
const { workingParts, activePartsClone } = this;
const { workingParts } = this;
if (hoursData.length === 0) return [];

const activePart = this.getDefaultPart();

return (
<ion-picker-column-internal
color={this.color}
value={(activePartsClone as DatetimeParts).hour}
value={activePart.hour}
items={hoursData}
numericInput
onIonChange={(ev: CustomEvent) => {
Expand All @@ -1762,9 +1748,9 @@ export class Datetime implements ComponentInterface {
hour: ev.detail.value,
});

if (!Array.isArray(activePartsClone)) {
if (!Array.isArray(activePart)) {
this.setActiveParts({
...activePartsClone,
...activePart,
hour: ev.detail.value,
});
}
Expand All @@ -1775,13 +1761,15 @@ export class Datetime implements ComponentInterface {
);
}
private renderMinutePickerColumn(minutesData: PickerColumnItem[]) {
const { workingParts, activePartsClone } = this;
const { workingParts } = this;
if (minutesData.length === 0) return [];

const activePart = this.getDefaultPart();

return (
<ion-picker-column-internal
color={this.color}
value={(activePartsClone as DatetimeParts).minute}
value={activePart.minute}
items={minutesData}
numericInput
onIonChange={(ev: CustomEvent) => {
Expand All @@ -1790,9 +1778,9 @@ export class Datetime implements ComponentInterface {
minute: ev.detail.value,
});

if (!Array.isArray(activePartsClone)) {
if (!Array.isArray(activePart)) {
this.setActiveParts({
...activePartsClone,
...activePart,
minute: ev.detail.value,
});
}
Expand All @@ -1803,18 +1791,19 @@ export class Datetime implements ComponentInterface {
);
}
private renderDayPeriodPickerColumn(dayPeriodData: PickerColumnItem[]) {
const { workingParts, activePartsClone } = this;
const { workingParts } = this;
if (dayPeriodData.length === 0) {
return [];
}

const activePart = this.getDefaultPart();
const isDayPeriodRTL = isLocaleDayPeriodRTL(this.locale);

return (
<ion-picker-column-internal
style={isDayPeriodRTL ? { order: '-1' } : {}}
color={this.color}
value={(activePartsClone as DatetimeParts).ampm}
value={activePart.ampm}
items={dayPeriodData}
onIonChange={(ev: CustomEvent) => {
const hour = calculateHourFromAMPM(workingParts, ev.detail.value);
Expand All @@ -1825,9 +1814,9 @@ export class Datetime implements ComponentInterface {
hour,
});

if (!Array.isArray(activePartsClone)) {
if (!Array.isArray(activePart)) {
this.setActiveParts({
...activePartsClone,
...activePart,
ampm: ev.detail.value,
hour,
});
Expand Down Expand Up @@ -1901,7 +1890,6 @@ export class Datetime implements ComponentInterface {
);
}
private renderMonth(month: number, year: number) {
const { highlightActiveParts } = this;
const yearAllowed = this.parsedYearValues === undefined || this.parsedYearValues.includes(year);
const monthAllowed = this.parsedMonthValues === undefined || this.parsedMonthValues.includes(month);
const isCalMonthDisabled = !yearAllowed || !monthAllowed;
Expand Down Expand Up @@ -1979,7 +1967,7 @@ export class Datetime implements ComponentInterface {
class={{
'calendar-day-padding': day === null,
'calendar-day': true,
'calendar-day-active': isActive && highlightActiveParts,
'calendar-day-active': isActive,
'calendar-day-today': isToday,
}}
aria-selected={ariaSelected}
Expand All @@ -2004,7 +1992,7 @@ export class Datetime implements ComponentInterface {
day,
year,
},
isActive && highlightActiveParts
isActive
);
} else {
this.setActiveParts({
Expand Down Expand Up @@ -2052,6 +2040,8 @@ export class Datetime implements ComponentInterface {

private renderTimeOverlay() {
const use24Hour = is24Hour(this.locale, this.hourCycle);
const activePart = this.getDefaultPart();

return [
<div class="time-header">{this.renderTimeLabel()}</div>,
<button
Expand Down Expand Up @@ -2081,7 +2071,7 @@ export class Datetime implements ComponentInterface {
}
}}
>
{getLocalizedTime(this.locale, this.activePartsClone as DatetimeParts, use24Hour)}
{getLocalizedTime(this.locale, activePart, use24Hour)}
</button>,
<ion-popover
alignment="center"
Expand Down Expand Up @@ -2135,7 +2125,7 @@ export class Datetime implements ComponentInterface {
}
} else {
// for exactly 1 day selected (multiple set or not), show a formatted version of that
headerText = getMonthAndDay(this.locale, isArray ? activeParts[0] : activeParts);
headerText = getMonthAndDay(this.locale, this.getDefaultPart());
}

return (
Expand Down
47 changes: 43 additions & 4 deletions core/src/components/datetime/test/set-value/datetime.e2e.ts
Expand Up @@ -2,11 +2,13 @@ import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';

test.describe('datetime: set-value', () => {
test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ skip }) => {
skip.rtl();
});
test('should update the active date when value is initially set', async ({ page }) => {
await page.goto('/src/components/datetime/test/set-value');
await page.waitForSelector('.datetime-ready');
});
test('should update the active date', async ({ page }) => {

const datetime = page.locator('ion-datetime');
await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-11-25T12:40:00.000Z'));

Expand All @@ -15,7 +17,10 @@ test.describe('datetime: set-value', () => {
const activeDate = page.locator('ion-datetime .calendar-day-active');
await expect(activeDate).toHaveText('25');
});
test('should update the active time', async ({ page }) => {
test('should update the active time when value is initially set', async ({ page }) => {
await page.goto('/src/components/datetime/test/set-value');
await page.waitForSelector('.datetime-ready');

const datetime = page.locator('ion-datetime');
await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-11-25T12:40:00.000Z'));

Expand All @@ -24,4 +29,38 @@ test.describe('datetime: set-value', () => {
const activeDate = page.locator('ion-datetime .time-body');
await expect(activeDate).toHaveText('12:40 PM');
});
test('should update active item when value is not initially set', async ({ page }) => {
await page.setContent(`
<ion-datetime presentation="date" locale="en-US"></ion-datetime>
`);
await page.waitForSelector('.datetime-ready');

const datetime = page.locator('ion-datetime');
const activeDayButton = page.locator('.calendar-day-active');
const monthYearButton = page.locator('.calendar-month-year');
const monthColumn = page.locator('.month-column');
const yearColumn = page.locator('.year-column');

await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-10-05'));

// Open month/year picker
await monthYearButton.click();
await page.waitForChanges();

// Select October 2021
await monthColumn.locator('.picker-item[data-value="10"]').click();
await page.waitForChanges();

await yearColumn.locator('.picker-item[data-value="2021"]').click();
await page.waitForChanges();

// Close month/year picker
await monthYearButton.click();
await page.waitForChanges();

// Check that correct day is highlighted
await expect(activeDayButton).toHaveAttribute('data-day', '5');
await expect(activeDayButton).toHaveAttribute('data-month', '10');
await expect(activeDayButton).toHaveAttribute('data-year', '2021');
});
});
1 change: 1 addition & 0 deletions core/src/components/datetime/utils/parse.ts
Expand Up @@ -115,6 +115,7 @@ export function parseDate(val: string | string[] | undefined | null): DatetimePa
hour: parse[4],
minute: parse[5],
tzOffset,
ampm: parse[4] < 12 ? 'am' : 'pm',
};
}

Expand Down

0 comments on commit 0aee328

Please sign in to comment.