-
Notifications
You must be signed in to change notification settings - Fork 13.5k
fix(datetime): scroll to newly selected date when value changes #27806
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ce4c2eb
39d6871
5c13d59
e6a9ed5
5ae63ec
27d76c7
237f015
e79a558
59e0bde
db126be
2877537
21e3648
f90bcc4
79b8e92
69267e4
ab9bf27
cbfc8e9
4cf6270
fd9508e
10b70ed
dda4f0b
7a3f927
3dd1732
607f43c
c57c6fe
f3679c1
3fe32dc
53e288e
692a270
17224a1
a077005
bf67e71
07426c6
d2cd559
aa9d1da
24061b6
cf4128f
5c842cf
90a92d1
bf0fc24
50703df
8936c02
cb3f835
b1ccb75
fbf2f9c
b85c7df
3d79ec9
419b12a
129b370
d884163
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -206,6 +206,10 @@ export class DatetimeButton implements ComponentInterface { | |
*/ | ||
const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : [getToday()]); | ||
|
||
if (!parsedDatetimes) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unsure if this is a bug, but if I open the month/year picker as the view datetime begins to animate, the animation gets cancelled and the view never updates to the new value. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm actually seeing it update; maybe I'm just not doing it quickly enough? (Screencast below.) Even if there is a specific timing that borks it, though, I'm not too worried as long as the highlighted date is correct once you do scroll to it. Seems like a small edge case that could be addressed separately. 2023-08-16.14-18-57.mp4There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I had outdated code as I can no longer repro the issue I mentioned. However, another bug exists: Switching to the month/year picker as the animation starts seems to break date selection altogether. datetime-break.movTested on Chrome 116. I added the following code to setTimeout(() => {
datetime.value = '2023-10-23T16:30'
}, 2000); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm able to trigger that on 2023-08-18.14-17-38.mp4There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah that works for me 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ticket created: https://ionic-cloud.atlassian.net/browse/FW-4997 |
||
return; | ||
} | ||
|
||
/** | ||
* If developers incorrectly use multiple="true" | ||
* with non "date" datetimes, then just select | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -117,11 +117,7 @@ export class Datetime implements ComponentInterface { | |
|
||
private prevPresentation: string | null = null; | ||
|
||
/** | ||
* 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 resolveForceDateScrolling?: () => void; | ||
|
||
@State() showMonthAndYear = false; | ||
|
||
|
@@ -140,6 +136,17 @@ export class Datetime implements ComponentInterface { | |
|
||
@State() isTimePopoverOpen = false; | ||
|
||
/** | ||
* When defined, will force the datetime to render the month | ||
* containing the specified date. Currently, this should only | ||
* be used to enable immediately auto-scrolling to the new month, | ||
* and should then be reset to undefined once the transition is | ||
* finished and the forced month is now in view. | ||
* | ||
* Applies to grid-style datetimes only. | ||
*/ | ||
@State() forceRenderDate?: DatetimeParts; | ||
|
||
/** | ||
* The color to use from your application's color palette. | ||
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. | ||
|
@@ -221,6 +228,12 @@ export class Datetime implements ComponentInterface { | |
*/ | ||
@Prop() presentation: DatetimePresentation = 'date-time'; | ||
|
||
private get isGridStyle() { | ||
const { presentation, preferWheel } = this; | ||
const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date'; | ||
return hasDatePresentation && !preferWheel; | ||
} | ||
|
||
/** | ||
* The text to display on the picker's cancel button. | ||
*/ | ||
|
@@ -302,11 +315,6 @@ export class Datetime implements ComponentInterface { | |
this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues); | ||
} | ||
|
||
@Watch('activeParts') | ||
protected activePartsChanged() { | ||
this.activePartsClone = this.activeParts; | ||
} | ||
|
||
/** | ||
* The locale to use for `ion-datetime`. This | ||
* impacts month and day name formatting. | ||
|
@@ -356,54 +364,11 @@ export class Datetime implements ComponentInterface { | |
* Update the datetime value when the value changes | ||
*/ | ||
@Watch('value') | ||
protected valueChanged() { | ||
const { value, minParts, maxParts, workingParts } = this; | ||
protected async valueChanged() { | ||
const { value } = this; | ||
|
||
if (this.hasValue()) { | ||
this.warnIfIncorrectValueUsage(); | ||
|
||
/** | ||
* Clones the value of the `activeParts` to the private clone, to update | ||
* the date display on the current render cycle without causing another render. | ||
* | ||
* This allows us to update the current value's date/time display without | ||
* refocusing or shifting the user's display (leaves the user in place). | ||
*/ | ||
const valueDateParts = parseDate(value); | ||
if (valueDateParts) { | ||
warnIfValueOutOfBounds(valueDateParts, minParts, maxParts); | ||
|
||
if (Array.isArray(valueDateParts)) { | ||
this.activePartsClone = [...valueDateParts]; | ||
} else { | ||
const { month, day, year, hour, minute } = valueDateParts; | ||
const ampm = hour != null ? (hour >= 12 ? 'pm' : 'am') : undefined; | ||
|
||
this.activePartsClone = { | ||
...this.activeParts, | ||
month, | ||
day, | ||
year, | ||
hour, | ||
minute, | ||
ampm, | ||
}; | ||
|
||
/** | ||
* The working parts am/pm value must be updated when the value changes, to | ||
* ensure the time picker hour column values are generated correctly. | ||
* | ||
* Note that we don't need to do this if valueDateParts is an array, since | ||
* multiple="true" does not apply to time pickers. | ||
*/ | ||
this.setWorkingParts({ | ||
...workingParts, | ||
ampm, | ||
}); | ||
} | ||
} else { | ||
printIonWarning(`Unable to parse date string: ${value}. Please provide a valid ISO 8601 datetime string.`); | ||
} | ||
this.processValue(value); | ||
} | ||
|
||
this.emitStyle(); | ||
|
@@ -596,18 +561,18 @@ export class Datetime implements ComponentInterface { | |
* 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 | ||
* defaultParts if no active date is selected. | ||
* by returning the first selected date and then | ||
* falling back to defaultParts if no active date | ||
* is selected. | ||
*/ | ||
private getActivePartsWithFallback = () => { | ||
const { defaultParts } = this; | ||
return this.getActivePart() ?? defaultParts; | ||
}; | ||
|
||
private getActivePart = () => { | ||
const { activePartsClone } = this; | ||
return Array.isArray(activePartsClone) ? activePartsClone[0] : activePartsClone; | ||
const { activeParts } = this; | ||
return Array.isArray(activeParts) ? activeParts[0] : activeParts; | ||
}; | ||
|
||
private closeParentOverlay = () => { | ||
|
@@ -627,7 +592,7 @@ export class Datetime implements ComponentInterface { | |
}; | ||
|
||
private setActiveParts = (parts: DatetimeParts, removeDate = false) => { | ||
const { multiple, minParts, maxParts, activePartsClone } = this; | ||
const { multiple, minParts, maxParts, activeParts } = this; | ||
|
||
/** | ||
* When setting the active parts, it is possible | ||
|
@@ -643,16 +608,7 @@ export class Datetime implements ComponentInterface { | |
this.setWorkingParts(validatedParts); | ||
|
||
if (multiple) { | ||
/** | ||
* We read from activePartsClone here because valueChanged() only updates that, | ||
* so it's the more reliable source of truth. If we read from activeParts, then | ||
* if you click July 1, manually set the value to July 2, and then click July 3, | ||
* the new value would be [July 1, July 3], ignoring the value set. | ||
* | ||
* We can then pass the new value to activeParts (rather than activePartsClone) | ||
* since the clone will be updated automatically by activePartsChanged(). | ||
*/ | ||
const activePartsArray = Array.isArray(activePartsClone) ? activePartsClone : [activePartsClone]; | ||
const activePartsArray = Array.isArray(activeParts) ? activeParts : [activeParts]; | ||
if (removeDate) { | ||
this.activeParts = activePartsArray.filter((p) => !isSameDay(p, validatedParts)); | ||
} else { | ||
|
@@ -908,6 +864,20 @@ export class Datetime implements ComponentInterface { | |
const monthBox = month.getBoundingClientRect(); | ||
if (Math.abs(monthBox.x - box.x) > 2) return; | ||
|
||
/** | ||
* If we're force-rendering a month, assume we've | ||
* scrolled to that and return it. | ||
* | ||
* If forceRenderDate is ever used in a context where the | ||
* forced month is not immediately auto-scrolled to, this | ||
* should be updated to also check whether `month` has the | ||
* same month and year as the forced date. | ||
*/ | ||
const { forceRenderDate } = this; | ||
if (forceRenderDate !== undefined) { | ||
return { month: forceRenderDate.month, year: forceRenderDate.year, day: forceRenderDate.day }; | ||
liamdebeasi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
* From here, we can determine if the start | ||
* month or the end month was scrolled into view. | ||
|
@@ -976,6 +946,10 @@ export class Datetime implements ComponentInterface { | |
|
||
calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1); | ||
calendarBodyRef.style.removeProperty('overflow'); | ||
|
||
if (this.resolveForceDateScrolling) { | ||
this.resolveForceDateScrolling(); | ||
} | ||
}); | ||
}; | ||
|
||
|
@@ -1193,13 +1167,21 @@ export class Datetime implements ComponentInterface { | |
} | ||
|
||
private processValue = (value?: string | string[] | null) => { | ||
const hasValue = value !== null && value !== undefined; | ||
const hasValue = value !== null && value !== undefined && (!Array.isArray(value) || value.length > 0); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change avoids a crash when manually setting a multiple selection datetime's value to I believe we weren't seeing the test crash before because |
||
const valueToProcess = hasValue ? parseDate(value) : this.defaultParts; | ||
|
||
const { minParts, maxParts } = this; | ||
const { minParts, maxParts, workingParts, el } = this; | ||
|
||
this.warnIfIncorrectValueUsage(); | ||
|
||
/** | ||
* Return early if the value wasn't parsed correctly, such as | ||
* if an improperly formatted date string was provided. | ||
*/ | ||
if (!valueToProcess) { | ||
return; | ||
} | ||
|
||
/** | ||
* Datetime should only warn of out of bounds values | ||
* if set by the user. If the `value` is undefined, | ||
|
@@ -1218,19 +1200,11 @@ export class Datetime implements ComponentInterface { | |
* that the values don't necessarily have to be in order. | ||
*/ | ||
const singleValue = Array.isArray(valueToProcess) ? valueToProcess[0] : valueToProcess; | ||
const targetValue = clampDate(singleValue, minParts, maxParts); | ||
|
||
const { month, day, year, hour, minute } = clampDate(singleValue, minParts, maxParts); | ||
const { month, day, year, hour, minute } = targetValue; | ||
const ampm = parseAmPm(hour!); | ||
|
||
this.setWorkingParts({ | ||
month, | ||
day, | ||
year, | ||
hour, | ||
minute, | ||
ampm, | ||
}); | ||
|
||
/** | ||
* Since `activeParts` indicates a value that | ||
* been explicitly selected either by the | ||
|
@@ -1258,6 +1232,67 @@ export class Datetime implements ComponentInterface { | |
*/ | ||
this.activeParts = []; | ||
} | ||
|
||
/** | ||
* 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) | ||
*/ | ||
const didChangeMonth = | ||
(month !== undefined && month !== workingParts.month) || (year !== undefined && year !== workingParts.year); | ||
const bodyIsVisible = el.classList.contains('datetime-ready'); | ||
const { isGridStyle, showMonthAndYear } = this; | ||
if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you set datetime to a time it still scrolls to another month and then abruptly resets to the selected month. Example: <ion-datetime value="2022-02-22T16:30:00"></ion-datetime>
<script>
setTimeout(() => {
datetime.value = '16:30';
}, 2000);
</script> There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I fixed the wonky scrolling in fbf2f9c. The datetime now errors out if you do this, but it looks like this case (setting the value to just a time when the 2023-08-08.11-12-18.mp4There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ticket created: https://ionic-cloud.atlassian.net/browse/FW-4996 |
||
this.animateToDate(targetValue); | ||
} else { | ||
/** | ||
* We only need to do this if we didn't just animate to a new month, | ||
* since that calls prevMonth/nextMonth which calls setWorkingParts for us. | ||
*/ | ||
this.setWorkingParts({ | ||
averyjohnston marked this conversation as resolved.
Show resolved
Hide resolved
|
||
month, | ||
day, | ||
year, | ||
hour, | ||
minute, | ||
ampm, | ||
}); | ||
} | ||
}; | ||
|
||
private animateToDate = async (targetValue: DatetimeParts) => { | ||
const { workingParts } = this; | ||
|
||
/** | ||
* Tell other render functions that we need to force the | ||
* target month to appear in place of the actual next/prev month. | ||
* Because this is a State variable, a rerender will be triggered | ||
* automatically, updating the rendered months. | ||
*/ | ||
this.forceRenderDate = targetValue; | ||
|
||
/** | ||
* Flag that we've started scrolling to the forced date. | ||
* The resolve function will be called by the datetime's | ||
* scroll listener when it's done updating everything. | ||
* This is a replacement for making prev/nextMonth async, | ||
* since the logic we're waiting on is in a listener. | ||
*/ | ||
const forceDateScrollingPromise = new Promise<void>((resolve) => { | ||
this.resolveForceDateScrolling = resolve; | ||
}); | ||
|
||
/** | ||
* Animate smoothly to the forced month. This will also update | ||
* workingParts and correct the surrounding months for us. | ||
*/ | ||
const targetMonthIsBefore = isBefore(targetValue, workingParts); | ||
targetMonthIsBefore ? this.prevMonth() : this.nextMonth(); | ||
await forceDateScrollingPromise; | ||
this.resolveForceDateScrolling = undefined; | ||
this.forceRenderDate = undefined; | ||
}; | ||
|
||
componentWillLoad() { | ||
|
@@ -1286,16 +1321,18 @@ export class Datetime implements ComponentInterface { | |
} | ||
} | ||
|
||
this.processMinParts(); | ||
this.processMaxParts(); | ||
const hourValues = (this.parsedHourValues = convertToArrayOfNumbers(this.hourValues)); | ||
const minuteValues = (this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues)); | ||
const monthValues = (this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues)); | ||
const yearValues = (this.parsedYearValues = convertToArrayOfNumbers(this.yearValues)); | ||
const dayValues = (this.parsedDayValues = convertToArrayOfNumbers(this.dayValues)); | ||
|
||
const todayParts = (this.todayParts = parseDate(getToday())); | ||
const todayParts = (this.todayParts = parseDate(getToday())!); | ||
this.defaultParts = getClosestValidDate(todayParts, monthValues, dayValues, yearValues, hourValues, minuteValues); | ||
|
||
this.processMinParts(); | ||
this.processMaxParts(); | ||
Comment on lines
+1333
to
+1334
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I moved these further down to avoid an error where if a I'm unsure why this wasn't causing problems before, but it was causing the minmax test to crash now. |
||
|
||
this.processValue(this.value); | ||
|
||
this.emitStyle(); | ||
|
@@ -2042,7 +2079,7 @@ export class Datetime implements ComponentInterface { | |
const { isActive, isToday, ariaLabel, ariaSelected, disabled, text } = getCalendarDayState( | ||
this.locale, | ||
referenceParts, | ||
this.activePartsClone, | ||
this.activeParts, | ||
this.todayParts, | ||
this.minParts, | ||
this.maxParts, | ||
|
@@ -2151,7 +2188,7 @@ export class Datetime implements ComponentInterface { | |
private renderCalendarBody() { | ||
return ( | ||
<div class="calendar-body ion-focusable" ref={(el) => (this.calendarBodyRef = el)} tabindex="0"> | ||
{generateMonths(this.workingParts).map(({ month, year }) => { | ||
{generateMonths(this.workingParts, this.forceRenderDate).map(({ month, year }) => { | ||
return this.renderMonth(month, year); | ||
})} | ||
</div> | ||
|
@@ -2360,15 +2397,26 @@ export class Datetime implements ComponentInterface { | |
} | ||
|
||
render() { | ||
const { name, value, disabled, el, color, readonly, showMonthAndYear, preferWheel, presentation, size } = this; | ||
const { | ||
name, | ||
value, | ||
disabled, | ||
el, | ||
color, | ||
readonly, | ||
showMonthAndYear, | ||
preferWheel, | ||
presentation, | ||
size, | ||
isGridStyle, | ||
} = this; | ||
const mode = getIonMode(this); | ||
const isMonthAndYearPresentation = | ||
presentation === 'year' || presentation === 'month' || presentation === 'month-year'; | ||
const shouldShowMonthAndYear = showMonthAndYear || isMonthAndYearPresentation; | ||
const monthYearPickerOpen = showMonthAndYear && !isMonthAndYearPresentation; | ||
const hasDatePresentation = presentation === 'date' || presentation === 'date-time' || presentation === 'time-date'; | ||
const hasWheelVariant = hasDatePresentation && preferWheel; | ||
const hasGrid = hasDatePresentation && !preferWheel; | ||
|
||
renderHiddenInput(true, el, name, formatValue(value), disabled); | ||
|
||
|
@@ -2387,7 +2435,7 @@ export class Datetime implements ComponentInterface { | |
[`datetime-presentation-${presentation}`]: true, | ||
[`datetime-size-${size}`]: true, | ||
[`datetime-prefer-wheel`]: hasWheelVariant, | ||
[`datetime-grid`]: hasGrid, | ||
[`datetime-grid`]: isGridStyle, | ||
}), | ||
}} | ||
> | ||
|
Uh oh!
There was an error while loading. Please reload this page.