Skip to content

Commit a58317a

Browse files
fix(datetime): smooth scroll-mode month window re-centre and adjacent-day navigation
- Fix scroll position restore to use scrollWindowCenter instead of stale workingParts (setWorkingParts is deferred in writeTask so workingParts is not yet updated when componentDidRender runs) - Capture the visual offset into the current month before the DOM re-render and re-apply it after, so varying month heights (4/5/6-week rows) no longer cause a visible positional jump on window re-centre - Set scrollTop synchronously in componentDidRender (not via writeTask/rAF) so the restore happens before the browser paints - Skip animateToDate in scroll mode for adjacent-day clicks and processValue since the target month is already visible in the continuous list - Guard scrollWindowCenter updates in processValue to only fire when the month/year actually changes, preventing scroll jumps on day/time changes - Add scroll mode examples to show-adjacent-days and first-day-of-week test pages
1 parent 42f11ba commit a58317a

3 files changed

Lines changed: 116 additions & 36 deletions

File tree

core/src/components/datetime/datetime.tsx

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,19 @@ export class Datetime implements ComponentInterface {
184184

185185
/**
186186
* Set to `true` by the scroll-mode scroll listener when the render window
187-
* re-centres (i.e. `scrollWindowCenter` changes). `componentDidRender` reads
188-
* this flag and restores `scrollTop` so the visible month stays in place.
187+
* re-centres. `componentDidRender` reads this flag and restores `scrollTop`
188+
* synchronously (before the browser paints) so the visible month stays in place.
189189
*/
190190
private scrollModeNeedsPositionRestore = false;
191191

192+
/**
193+
* How far (in px) the user was scrolled past the top of the target month
194+
* when the window re-centre was triggered. Captured before the re-render so
195+
* it can be re-applied afterward, preserving the exact visual position even
196+
* when months have different heights (4, 5, or 6 week rows).
197+
*/
198+
private scrollModeRestoreOffset = 0;
199+
192200
@State() showMonthAndYear = false;
193201

194202
/** Starting year of the currently displayed year page in the grid picker. */
@@ -1195,10 +1203,9 @@ export class Datetime implements ComponentInterface {
11951203
writeTask(() => this.setWorkingParts({ ...this.workingParts, month, year }));
11961204

11971205
/**
1198-
* Only re-centre the scroll window (and trigger a DOM re-render)
1199-
* when the user is within 1 month of either edge. This prevents
1200-
* the window from rebuilding on every month boundary, which would
1201-
* cause a jump even after the scrollTop restore.
1206+
* Only re-centre the scroll window when the user is within 2
1207+
* months of either edge. This avoids a DOM rebuild (and the
1208+
* associated scrollTop restore) on every single month boundary.
12021209
*/
12031210
const months = this.generateScrollModeMonths();
12041211
const first = months[0];
@@ -1213,6 +1220,18 @@ export class Datetime implements ComponentInterface {
12131220
(year === last.year && month === last.month) ||
12141221
(year === secondLastMonth.year && month === secondLastMonth.month);
12151222
if (nearStart || nearEnd) {
1223+
/**
1224+
* Capture how far past the top of the target month the user is
1225+
* scrolled RIGHT NOW (before the DOM re-render shifts things).
1226+
* Re-applying this offset after the re-render keeps the exact
1227+
* visual position stable even when months have different row counts.
1228+
*/
1229+
const targetEl = calendarBodyRef.querySelector<HTMLElement>(
1230+
`.calendar-month[data-month="${month}"][data-year="${year}"]`
1231+
);
1232+
this.scrollModeRestoreOffset = targetEl
1233+
? calendarBodyRef.scrollTop - targetEl.offsetTop
1234+
: 0;
12161235
this.scrollModeNeedsPositionRestore = true;
12171236
this.scrollWindowCenter = { ...this.workingParts, month, year };
12181237
}
@@ -1647,26 +1666,24 @@ export class Datetime implements ComponentInterface {
16471666
}
16481667

16491668
/**
1650-
* Scroll mode: after the ±6 window re-centres (workingParts changed via
1651-
* the scroll listener), the DOM has a new set of months. Restore scrollTop
1652-
* so the working month stays at the same visual position instead of jumping.
1669+
* Scroll mode: after the ±6 window re-centres, the DOM months have shifted.
1670+
* Restore scrollTop synchronously (before the browser paints) so the currently
1671+
* visible month stays in place.
1672+
*
1673+
* We use `scrollWindowCenter` (not `workingParts`) because `setWorkingParts`
1674+
* is deferred in a writeTask and is therefore stale at this point.
1675+
* `scrollWindowCenter` was set synchronously in the scroll listener and always
1676+
* holds the month/year that triggered the re-centre.
16531677
*/
16541678
if (this.scrollModeNeedsPositionRestore && this.monthNavigation === 'scroll' && calendarBodyRef) {
16551679
this.scrollModeNeedsPositionRestore = false;
1656-
/**
1657-
* The window re-centred around `scrollWindowCenter`. Restore scrollTop
1658-
* so `workingParts` (the month the user was viewing) stays at the same
1659-
* visual position instead of jumping to the top of the new render window.
1660-
*/
1661-
const { workingParts } = this;
1662-
writeTask(() => {
1663-
const workingMonthEl = calendarBodyRef.querySelector<HTMLElement>(
1664-
`.calendar-month[data-month="${workingParts.month}"][data-year="${workingParts.year}"]`
1665-
);
1666-
if (workingMonthEl) {
1667-
calendarBodyRef.scrollTop = workingMonthEl.offsetTop;
1668-
}
1669-
});
1680+
const { scrollWindowCenter, scrollModeRestoreOffset } = this;
1681+
const targetEl = calendarBodyRef.querySelector<HTMLElement>(
1682+
`.calendar-month[data-month="${scrollWindowCenter.month}"][data-year="${scrollWindowCenter.year}"]`
1683+
);
1684+
if (targetEl) {
1685+
calendarBodyRef.scrollTop = targetEl.offsetTop + scrollModeRestoreOffset;
1686+
}
16701687
}
16711688

16721689
if (prevPresentation === null) {
@@ -1776,13 +1793,15 @@ export class Datetime implements ComponentInterface {
17761793
const bodyIsVisible = el.classList.contains('datetime-ready');
17771794
const { isGridStyle, showMonthAndYear } = this;
17781795

1779-
if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) {
1796+
if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear && this.monthNavigation !== 'scroll') {
17801797
/**
17811798
* Only animate if:
17821799
* 1. We're using grid style (wheel style pickers should just jump to new value)
17831800
* 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to)
17841801
* 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example)
17851802
* 4. The month/year picker is not open (since you wouldn't see the animation anyway)
1803+
* 5. Not in scroll mode — scroll mode does not use the snap-based animation; the
1804+
* month is already visible in the continuous list so no programmatic scroll is needed.
17861805
*/
17871806
this.animateToDate(targetValue);
17881807
} else {
@@ -1796,11 +1815,14 @@ export class Datetime implements ComponentInterface {
17961815
};
17971816
this.setWorkingParts(newParts);
17981817
/**
1799-
* Also re-centre the scroll window so that programmatic value changes
1800-
* (and the initial load) put the correct month at the centre of the
1801-
* ±6 render window in scroll mode.
1818+
* Re-centre the scroll window when the month/year actually changed
1819+
* (e.g. programmatic value changes, initial load, reset). Skip when
1820+
* only the day/time changed so that clicking or deselecting a date
1821+
* within the already-visible month does not trigger a scroll jump.
18021822
*/
1803-
this.scrollWindowCenter = newParts;
1823+
if (didChangeMonth) {
1824+
this.scrollWindowCenter = newParts;
1825+
}
18041826
}
18051827
};
18061828

@@ -3139,7 +3161,11 @@ export class Datetime implements ComponentInterface {
31393161
this.setActiveParts(referenceParts);
31403162
} else {
31413163
this.activeParts = { ...activePart, ...referenceParts };
3142-
this.animateToDate(referenceParts);
3164+
// In scroll mode the neighboring month is already visible in the
3165+
// list, so animating to it would cause an unwanted jump.
3166+
if (this.monthNavigation !== 'scroll') {
3167+
this.animateToDate(referenceParts);
3168+
}
31433169
this.confirm();
31443170
}
31453171
} else {

core/src/components/datetime/test/first-day-of-week/index.html

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,37 @@
4747
<ion-content class="ion-padding">
4848
<div class="grid">
4949
<div class="grid-item">
50-
<h2>Default</h2>
51-
<ion-datetime first-day-of-week="1" value="2022-05-03"></ion-datetime>
52-
<button onclick="increase()">Increase firstDayOfWeek</button>
50+
<h2>Default (arrows)</h2>
51+
<ion-datetime id="datetime-arrows" first-day-of-week="1" value="2022-05-03"></ion-datetime>
52+
<button onclick="increase('datetime-arrows', 'start-of-week-arrows')">Increase firstDayOfWeek</button>
5353
<div>
54-
<span>FirstDayOfWeek: <span id="start-of-week">1</span></span>
54+
<span>FirstDayOfWeek: <span id="start-of-week-arrows">1</span></span>
55+
</div>
56+
</div>
57+
58+
<div class="grid-item">
59+
<h2>Scroll Mode</h2>
60+
<ion-datetime
61+
id="datetime-scroll"
62+
first-day-of-week="1"
63+
month-navigation="scroll"
64+
value="2022-05-03"
65+
></ion-datetime>
66+
<button onclick="increase('datetime-scroll', 'start-of-week-scroll')">Increase firstDayOfWeek</button>
67+
<div>
68+
<span>FirstDayOfWeek: <span id="start-of-week-scroll">1</span></span>
5569
</div>
5670
</div>
5771
</div>
5872
</ion-content>
5973
</ion-app>
6074
</body>
6175
<script>
62-
function increase() {
63-
const datetime = document.querySelector('ion-datetime');
76+
function increase(datetimeId, spanId) {
77+
const datetime = document.querySelector(`#${datetimeId}`);
6478
datetime.firstDayOfWeek = datetime.firstDayOfWeek + 1;
6579

66-
const span = document.getElementById('start-of-week');
80+
const span = document.getElementById(spanId);
6781
span.innerText = datetime.firstDayOfWeek;
6882
}
6983
</script>

core/src/components/datetime/test/show-adjacent-days/index.html

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,43 @@ <h2>Without Adjacent Days — Highlight stops at last visible day</h2>
231231
<ion-datetime locale="en-US" id="rangNoAdjacent" presentation="date" selection-mode="range"></ion-datetime>
232232
</div>
233233
</div>
234+
235+
<div class="grid">
236+
<div class="grid-item">
237+
<h2>Scroll Mode — Adjacent Days Visible at Month Boundary</h2>
238+
<ion-datetime
239+
locale="en-US"
240+
id="scrollAdjacent"
241+
presentation="date"
242+
month-navigation="scroll"
243+
show-adjacent-days="true"
244+
value="2022-06-15"
245+
></ion-datetime>
246+
</div>
247+
248+
<div class="grid-item">
249+
<h2>Scroll Mode — No Adjacent Days</h2>
250+
<ion-datetime
251+
locale="en-US"
252+
id="scrollNoAdjacent"
253+
presentation="date"
254+
month-navigation="scroll"
255+
value="2022-06-15"
256+
></ion-datetime>
257+
</div>
258+
259+
<div class="grid-item">
260+
<h2>Scroll Mode — Range + Adjacent Days</h2>
261+
<ion-datetime
262+
locale="en-US"
263+
id="scrollRangeAdjacent"
264+
presentation="date"
265+
month-navigation="scroll"
266+
selection-mode="range"
267+
show-adjacent-days="true"
268+
></ion-datetime>
269+
</div>
270+
</div>
234271
</ion-content>
235272
</ion-app>
236273

@@ -369,6 +406,9 @@ <h2>Without Adjacent Days — Highlight stops at last visible day</h2>
369406
document.querySelector('#rangeStartAdjacent').value = ['2022-05-31', '2022-06-10'];
370407
document.querySelector('#rangeEndAdjacent').value = ['2022-06-24', '2022-07-02'];
371408
document.querySelector('#rangNoAdjacent').value = ['2022-05-28', '2022-06-05'];
409+
410+
// Scroll mode + adjacent days
411+
document.querySelector('#scrollRangeAdjacent').value = ['2022-05-28', '2022-06-05'];
372412
</script>
373413
</body>
374414
</html>

0 commit comments

Comments
 (0)