Skip to content

Commit 0aee328

Browse files
authored
fix(datetime): setting date async updates calendar grid (#26070)
resolves #25776
1 parent ab89679 commit 0aee328

File tree

3 files changed

+112
-82
lines changed

3 files changed

+112
-82
lines changed

core/src/components/datetime/datetime.tsx

Lines changed: 68 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,6 @@ export class Datetime implements ComponentInterface {
8585
private calendarBodyRef?: HTMLElement;
8686
private popoverRef?: HTMLIonPopoverElement;
8787
private clearFocusVisible?: () => void;
88-
89-
/**
90-
* Whether to highlight the active day with a solid circle (as opposed
91-
* to the outline circle around today). If you don't specify an initial
92-
* value for the datetime, it doesn't automatically init to a default to
93-
* avoid unwanted change events firing. If the solid circle were still
94-
* shown then, it would look like a date had already been selected, which
95-
* is misleading UX.
96-
*/
97-
private highlightActiveParts = false;
98-
9988
private parsedMinuteValues?: number[];
10089
private parsedHourValues?: number[];
10190
private parsedMonthValues?: number[];
@@ -115,18 +104,11 @@ export class Datetime implements ComponentInterface {
115104
* Duplicate reference to `activeParts` that does not trigger a re-render of the component.
116105
* Allows caching an instance of the `activeParts` in between render cycles.
117106
*/
118-
private activePartsClone!: DatetimeParts | DatetimeParts[];
107+
private activePartsClone: DatetimeParts | DatetimeParts[] = [];
119108

120109
@State() showMonthAndYear = false;
121110

122-
@State() activeParts: DatetimeParts | DatetimeParts[] = {
123-
month: 5,
124-
day: 28,
125-
year: 2021,
126-
hour: 13,
127-
minute: 52,
128-
ampm: 'pm',
129-
};
111+
@State() activeParts: DatetimeParts | DatetimeParts[] = [];
130112

131113
@State() workingParts: DatetimeParts = {
132114
month: 5,
@@ -506,16 +488,12 @@ export class Datetime implements ComponentInterface {
506488
*/
507489
@Method()
508490
async confirm(closeOverlay = false) {
509-
const { highlightActiveParts, isCalendarPicker, activeParts } = this;
491+
const { isCalendarPicker, activeParts } = this;
510492

511493
/**
512-
* We only update the value if the presentation is not a calendar picker,
513-
* or if `highlightActiveParts` is true; indicating that the user
514-
* has selected a date from the calendar picker.
515-
*
516-
* Otherwise "today" would accidentally be set as the value.
494+
* We only update the value if the presentation is not a calendar picker.
517495
*/
518-
if (highlightActiveParts || !isCalendarPicker) {
496+
if (activeParts !== undefined || !isCalendarPicker) {
519497
const activePartsIsArray = Array.isArray(activeParts);
520498
if (activePartsIsArray && activeParts.length === 0) {
521499
this.value = undefined;
@@ -573,6 +551,23 @@ export class Datetime implements ComponentInterface {
573551
}
574552
}
575553

554+
/**
555+
* Returns the DatetimePart interface
556+
* to use when rendering an initial set of
557+
* data. This should be used when rendering an
558+
* interface in an environment where the `value`
559+
* may not be set. This function works
560+
* by returning the first selected date in
561+
* "activePartsClone" and then falling back to
562+
* today's DatetimeParts if no active date is selected.
563+
*/
564+
private getDefaultPart = () => {
565+
const { activePartsClone, todayParts } = this;
566+
567+
const firstPart = Array.isArray(activePartsClone) ? activePartsClone[0] : activePartsClone;
568+
return firstPart ?? todayParts;
569+
};
570+
576571
private closeParentOverlay = () => {
577572
const popoverOrModal = this.el.closest('ion-modal, ion-popover') as
578573
| HTMLIonModalElement
@@ -590,7 +585,7 @@ export class Datetime implements ComponentInterface {
590585
};
591586

592587
private setActiveParts = (parts: DatetimeParts, removeDate = false) => {
593-
const { multiple, activePartsClone, highlightActiveParts } = this;
588+
const { multiple, activePartsClone } = this;
594589

595590
/**
596591
* When setting the active parts, it is possible
@@ -618,34 +613,15 @@ export class Datetime implements ComponentInterface {
618613
const activePartsArray = Array.isArray(activePartsClone) ? activePartsClone : [activePartsClone];
619614
if (removeDate) {
620615
this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts));
621-
} else if (highlightActiveParts) {
622-
this.activeParts = [...activePartsArray, validatedParts];
623616
} else {
624-
/**
625-
* If highlightActiveParts is false, that means we just have a
626-
* default value of today in activeParts; we need to replace that
627-
* rather than adding to it since it's just a placeholder.
628-
*/
629-
this.activeParts = [validatedParts];
617+
this.activeParts = [...activePartsArray, validatedParts];
630618
}
631619
} else {
632620
this.activeParts = {
633621
...validatedParts,
634622
};
635623
}
636624

637-
/**
638-
* Now that the user has interacted somehow to select something, we can
639-
* show the solid highlight. This needs to be done after checking it above,
640-
* but before the confirm call below.
641-
*
642-
* Note that for datetimes with confirm/cancel buttons, the value
643-
* isn't updated until you call confirm(). We need to bring the
644-
* solid circle back on day click for UX reasons, rather than only
645-
* show the circle if `value` is truthy.
646-
*/
647-
this.highlightActiveParts = true;
648-
649625
const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null;
650626
if (hasSlottedButtons || this.showDefaultButtons) {
651627
return;
@@ -1178,7 +1154,7 @@ export class Datetime implements ComponentInterface {
11781154
}
11791155

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

11841160
const { minParts, maxParts, multiple } = this;
@@ -1219,18 +1195,26 @@ export class Datetime implements ComponentInterface {
12191195
ampm,
12201196
});
12211197

1222-
if (Array.isArray(valueToProcess)) {
1223-
this.activeParts = [...valueToProcess];
1224-
} else {
1225-
this.activeParts = {
1226-
month,
1227-
day,
1228-
year,
1229-
hour,
1230-
minute,
1231-
tzOffset,
1232-
ampm,
1233-
};
1198+
/**
1199+
* Since `activeParts` indicates a value that
1200+
* been explicitly selected either by the
1201+
* user or the app, only update `activeParts`
1202+
* if the `value` property is set.
1203+
*/
1204+
if (hasValue) {
1205+
if (Array.isArray(valueToProcess)) {
1206+
this.activeParts = [...valueToProcess];
1207+
} else {
1208+
this.activeParts = {
1209+
month,
1210+
day,
1211+
year,
1212+
hour,
1213+
minute,
1214+
tzOffset,
1215+
ampm,
1216+
};
1217+
}
12341218
}
12351219
};
12361220

@@ -1747,13 +1731,15 @@ export class Datetime implements ComponentInterface {
17471731
}
17481732

17491733
private renderHourPickerColumn(hoursData: PickerColumnItem[]) {
1750-
const { workingParts, activePartsClone } = this;
1734+
const { workingParts } = this;
17511735
if (hoursData.length === 0) return [];
17521736

1737+
const activePart = this.getDefaultPart();
1738+
17531739
return (
17541740
<ion-picker-column-internal
17551741
color={this.color}
1756-
value={(activePartsClone as DatetimeParts).hour}
1742+
value={activePart.hour}
17571743
items={hoursData}
17581744
numericInput
17591745
onIonChange={(ev: CustomEvent) => {
@@ -1762,9 +1748,9 @@ export class Datetime implements ComponentInterface {
17621748
hour: ev.detail.value,
17631749
});
17641750

1765-
if (!Array.isArray(activePartsClone)) {
1751+
if (!Array.isArray(activePart)) {
17661752
this.setActiveParts({
1767-
...activePartsClone,
1753+
...activePart,
17681754
hour: ev.detail.value,
17691755
});
17701756
}
@@ -1775,13 +1761,15 @@ export class Datetime implements ComponentInterface {
17751761
);
17761762
}
17771763
private renderMinutePickerColumn(minutesData: PickerColumnItem[]) {
1778-
const { workingParts, activePartsClone } = this;
1764+
const { workingParts } = this;
17791765
if (minutesData.length === 0) return [];
17801766

1767+
const activePart = this.getDefaultPart();
1768+
17811769
return (
17821770
<ion-picker-column-internal
17831771
color={this.color}
1784-
value={(activePartsClone as DatetimeParts).minute}
1772+
value={activePart.minute}
17851773
items={minutesData}
17861774
numericInput
17871775
onIonChange={(ev: CustomEvent) => {
@@ -1790,9 +1778,9 @@ export class Datetime implements ComponentInterface {
17901778
minute: ev.detail.value,
17911779
});
17921780

1793-
if (!Array.isArray(activePartsClone)) {
1781+
if (!Array.isArray(activePart)) {
17941782
this.setActiveParts({
1795-
...activePartsClone,
1783+
...activePart,
17961784
minute: ev.detail.value,
17971785
});
17981786
}
@@ -1803,18 +1791,19 @@ export class Datetime implements ComponentInterface {
18031791
);
18041792
}
18051793
private renderDayPeriodPickerColumn(dayPeriodData: PickerColumnItem[]) {
1806-
const { workingParts, activePartsClone } = this;
1794+
const { workingParts } = this;
18071795
if (dayPeriodData.length === 0) {
18081796
return [];
18091797
}
18101798

1799+
const activePart = this.getDefaultPart();
18111800
const isDayPeriodRTL = isLocaleDayPeriodRTL(this.locale);
18121801

18131802
return (
18141803
<ion-picker-column-internal
18151804
style={isDayPeriodRTL ? { order: '-1' } : {}}
18161805
color={this.color}
1817-
value={(activePartsClone as DatetimeParts).ampm}
1806+
value={activePart.ampm}
18181807
items={dayPeriodData}
18191808
onIonChange={(ev: CustomEvent) => {
18201809
const hour = calculateHourFromAMPM(workingParts, ev.detail.value);
@@ -1825,9 +1814,9 @@ export class Datetime implements ComponentInterface {
18251814
hour,
18261815
});
18271816

1828-
if (!Array.isArray(activePartsClone)) {
1817+
if (!Array.isArray(activePart)) {
18291818
this.setActiveParts({
1830-
...activePartsClone,
1819+
...activePart,
18311820
ampm: ev.detail.value,
18321821
hour,
18331822
});
@@ -1901,7 +1890,6 @@ export class Datetime implements ComponentInterface {
19011890
);
19021891
}
19031892
private renderMonth(month: number, year: number) {
1904-
const { highlightActiveParts } = this;
19051893
const yearAllowed = this.parsedYearValues === undefined || this.parsedYearValues.includes(year);
19061894
const monthAllowed = this.parsedMonthValues === undefined || this.parsedMonthValues.includes(month);
19071895
const isCalMonthDisabled = !yearAllowed || !monthAllowed;
@@ -1979,7 +1967,7 @@ export class Datetime implements ComponentInterface {
19791967
class={{
19801968
'calendar-day-padding': day === null,
19811969
'calendar-day': true,
1982-
'calendar-day-active': isActive && highlightActiveParts,
1970+
'calendar-day-active': isActive,
19831971
'calendar-day-today': isToday,
19841972
}}
19851973
aria-selected={ariaSelected}
@@ -2004,7 +1992,7 @@ export class Datetime implements ComponentInterface {
20041992
day,
20051993
year,
20061994
},
2007-
isActive && highlightActiveParts
1995+
isActive
20081996
);
20091997
} else {
20101998
this.setActiveParts({
@@ -2052,6 +2040,8 @@ export class Datetime implements ComponentInterface {
20522040

20532041
private renderTimeOverlay() {
20542042
const use24Hour = is24Hour(this.locale, this.hourCycle);
2043+
const activePart = this.getDefaultPart();
2044+
20552045
return [
20562046
<div class="time-header">{this.renderTimeLabel()}</div>,
20572047
<button
@@ -2081,7 +2071,7 @@ export class Datetime implements ComponentInterface {
20812071
}
20822072
}}
20832073
>
2084-
{getLocalizedTime(this.locale, this.activePartsClone as DatetimeParts, use24Hour)}
2074+
{getLocalizedTime(this.locale, activePart, use24Hour)}
20852075
</button>,
20862076
<ion-popover
20872077
alignment="center"
@@ -2135,7 +2125,7 @@ export class Datetime implements ComponentInterface {
21352125
}
21362126
} else {
21372127
// for exactly 1 day selected (multiple set or not), show a formatted version of that
2138-
headerText = getMonthAndDay(this.locale, isArray ? activeParts[0] : activeParts);
2128+
headerText = getMonthAndDay(this.locale, this.getDefaultPart());
21392129
}
21402130

21412131
return (

core/src/components/datetime/test/set-value/datetime.e2e.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { expect } from '@playwright/test';
22
import { test } from '@utils/test/playwright';
33

44
test.describe('datetime: set-value', () => {
5-
test.beforeEach(async ({ page }) => {
5+
test.beforeEach(async ({ skip }) => {
6+
skip.rtl();
7+
});
8+
test('should update the active date when value is initially set', async ({ page }) => {
69
await page.goto('/src/components/datetime/test/set-value');
710
await page.waitForSelector('.datetime-ready');
8-
});
9-
test('should update the active date', async ({ page }) => {
11+
1012
const datetime = page.locator('ion-datetime');
1113
await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-11-25T12:40:00.000Z'));
1214

@@ -15,7 +17,10 @@ test.describe('datetime: set-value', () => {
1517
const activeDate = page.locator('ion-datetime .calendar-day-active');
1618
await expect(activeDate).toHaveText('25');
1719
});
18-
test('should update the active time', async ({ page }) => {
20+
test('should update the active time when value is initially set', async ({ page }) => {
21+
await page.goto('/src/components/datetime/test/set-value');
22+
await page.waitForSelector('.datetime-ready');
23+
1924
const datetime = page.locator('ion-datetime');
2025
await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-11-25T12:40:00.000Z'));
2126

@@ -24,4 +29,38 @@ test.describe('datetime: set-value', () => {
2429
const activeDate = page.locator('ion-datetime .time-body');
2530
await expect(activeDate).toHaveText('12:40 PM');
2631
});
32+
test('should update active item when value is not initially set', async ({ page }) => {
33+
await page.setContent(`
34+
<ion-datetime presentation="date" locale="en-US"></ion-datetime>
35+
`);
36+
await page.waitForSelector('.datetime-ready');
37+
38+
const datetime = page.locator('ion-datetime');
39+
const activeDayButton = page.locator('.calendar-day-active');
40+
const monthYearButton = page.locator('.calendar-month-year');
41+
const monthColumn = page.locator('.month-column');
42+
const yearColumn = page.locator('.year-column');
43+
44+
await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2021-10-05'));
45+
46+
// Open month/year picker
47+
await monthYearButton.click();
48+
await page.waitForChanges();
49+
50+
// Select October 2021
51+
await monthColumn.locator('.picker-item[data-value="10"]').click();
52+
await page.waitForChanges();
53+
54+
await yearColumn.locator('.picker-item[data-value="2021"]').click();
55+
await page.waitForChanges();
56+
57+
// Close month/year picker
58+
await monthYearButton.click();
59+
await page.waitForChanges();
60+
61+
// Check that correct day is highlighted
62+
await expect(activeDayButton).toHaveAttribute('data-day', '5');
63+
await expect(activeDayButton).toHaveAttribute('data-month', '10');
64+
await expect(activeDayButton).toHaveAttribute('data-year', '2021');
65+
});
2766
});

core/src/components/datetime/utils/parse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export function parseDate(val: string | string[] | undefined | null): DatetimePa
115115
hour: parse[4],
116116
minute: parse[5],
117117
tzOffset,
118+
ampm: parse[4] < 12 ? 'am' : 'pm',
118119
};
119120
}
120121

0 commit comments

Comments
 (0)