-
Notifications
You must be signed in to change notification settings - Fork 13.4k
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 18 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 |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
|
@@ -141,6 +137,16 @@ export class Datetime implements ComponentInterface { | |
| @State() isPresented = false; | ||
| @State() isTimePopoverOpen = false; | ||
|
|
||
| /** | ||
| * When non-null, will force the datetime to render the month | ||
| * containing the specified date. This enables animating the | ||
| * transition to a new value, and should be reset to null once | ||
| * the transition is finished and the forced month is now in view. | ||
| * | ||
| * Applies to grid-style datetimes only. | ||
| */ | ||
| @State() forceRenderDate: DatetimeParts | null = null; | ||
|
|
||
| /** | ||
| * The color to use from your application's color palette. | ||
| * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. | ||
|
|
@@ -222,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. | ||
| */ | ||
|
|
@@ -303,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. | ||
|
|
@@ -358,53 +365,10 @@ export class Datetime implements ComponentInterface { | |
| */ | ||
| @Watch('value') | ||
| protected valueChanged() { | ||
| const { value, minParts, maxParts, workingParts } = this; | ||
| 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, true); | ||
| } | ||
|
|
||
| this.emitStyle(); | ||
|
|
@@ -597,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 = () => { | ||
|
|
@@ -628,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 | ||
|
|
@@ -644,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 { | ||
|
|
@@ -912,6 +867,29 @@ 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, and we've scrolled to | ||
| * that month, return that. | ||
| * | ||
| * Checking that we've actually scrolled to the forced month | ||
| * is mostly for safety; in theory, if there's a forced month, | ||
| * that means a new value was manually set, so we should have | ||
| * automatically animated directly to it. | ||
| */ | ||
| const { forceRenderDate } = this; | ||
| const firstDayEl = month.querySelector('.calendar-day'); | ||
| const dataMonth = firstDayEl?.getAttribute('data-month'); | ||
| const dataYear = firstDayEl?.getAttribute('data-year'); | ||
| if ( | ||
| forceRenderDate !== null && | ||
averyjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| dataMonth && | ||
| dataYear && | ||
averyjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| parseInt(dataMonth) === forceRenderDate.month && | ||
| parseInt(dataYear) === forceRenderDate.year | ||
| ) { | ||
| 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. | ||
|
|
@@ -980,6 +958,10 @@ export class Datetime implements ComponentInterface { | |
|
|
||
| calendarBodyRef.scrollLeft = workingMonth.clientWidth * (isRTL(this.el) ? -1 : 1); | ||
| calendarBodyRef.style.removeProperty('overflow'); | ||
|
|
||
| if (this.resolveForceDateScrolling) { | ||
| this.resolveForceDateScrolling(); | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
|
|
@@ -1196,11 +1178,11 @@ export class Datetime implements ComponentInterface { | |
| }); | ||
| } | ||
|
|
||
| private processValue = (value?: string | string[] | null) => { | ||
| private processValue = async (value?: string | string[] | null, animate = false) => { | ||
liamdebeasi marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const hasValue = value !== null && value !== undefined; | ||
| const valueToProcess = hasValue ? parseDate(value) : this.defaultParts; | ||
|
|
||
| const { minParts, maxParts } = this; | ||
| const { minParts, maxParts, workingParts, el } = this; | ||
|
|
||
| this.warnIfIncorrectValueUsage(); | ||
|
|
||
|
|
@@ -1222,19 +1204,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 | ||
|
|
@@ -1262,6 +1236,58 @@ 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 (otherwise there's nothing to animate to) | ||
| * 3. The datetime is visible (prevents animation when in collapsed datetime-button, for example) | ||
| */ | ||
| const didChangeMonth = month !== workingParts.month || year !== workingParts.year; | ||
| const elIsVisible = el.offsetParent !== null; | ||
averyjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (animate && this.isGridStyle && didChangeMonth && elIsVisible) { | ||
| /** | ||
| * 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 = { month, year, day }; | ||
averyjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * 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: Promise<void> = new Promise((resolve) => { | ||
averyjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| this.resolveForceDateScrolling = resolve; | ||
| }); | ||
|
|
||
| /** | ||
| * Animate smoothly to the forced month. This will also update | ||
| * workingParts and correct the surrounding months for us. | ||
| */ | ||
| const targetMonthIsEarlier = isBefore(targetValue, workingParts); | ||
averyjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| targetMonthIsEarlier ? this.prevMonth() : this.nextMonth(); | ||
| await forceDateScrollingPromise; | ||
averyjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| this.resolveForceDateScrolling = undefined; | ||
| this.forceRenderDate = null; | ||
| } else { | ||
| /** | ||
| * We only need to do this if we didn't just animate to a new month, | ||
| * since prevMonth/nextMonth call setWorkingParts for us. | ||
| */ | ||
| this.setWorkingParts({ | ||
averyjohnston marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| month, | ||
| day, | ||
| year, | ||
| hour, | ||
| minute, | ||
| ampm, | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| componentWillLoad() { | ||
|
|
@@ -2046,7 +2072,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, | ||
|
|
@@ -2155,7 +2181,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> | ||
|
|
@@ -2384,7 +2410,6 @@ export class Datetime implements ComponentInterface { | |
| 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); | ||
|
|
||
|
|
@@ -2404,7 +2429,7 @@ export class Datetime implements ComponentInterface { | |
| [`datetime-presentation-${presentation}`]: true, | ||
| [`datetime-size-${size}`]: true, | ||
| [`datetime-prefer-wheel`]: hasWheelVariant, | ||
| [`datetime-grid`]: hasGrid, | ||
| [`datetime-grid`]: this.isGridStyle, | ||
averyjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }), | ||
| }} | ||
| > | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.