fix: eliminate iOS scroll bounce by using single scroll container for time grid

The time grid previously had two separate overflow-y:auto scroll containers
(the time-column labels + the day columns grid), synced via JS scrollTop
assignment. On iOS Safari, this caused severe desync and bounce effects:
momentum scroll continued after touch-end, and the programmatic scrollTop
correction fought against native momentum, making the grid stutter/bounce.

Changes:
- time-grid-wrapper is now the SINGLE overflow-y:auto scroll container
  (ref=gridRef, onScroll handler moved here from weekly-days-grid)
- time-column and weekly-days-grid have overflow:visible — they scroll
  with their parent wrapper without fighting each other
- Removed handleTimeColumnScroll, timeColumnRef, timeGridWrapperRef
  (all sync logic now redundant)
- jumpToHour and initial scroll position now set only gridRef.scrollTop
- CSS: weekly-days-grid overflow changed to visible
- CSS: re-enabled position:sticky on .weekly-day-header for mobile —
  sticky now works correctly since scroll container is an overflow-y parent
- CSS: hid time-column-header on mobile (saves vertical space)

v1.76.2
This commit is contained in:
mARTin 2026-03-29 11:32:47 +02:00
parent ba17025bb9
commit bf61733317
3 changed files with 20 additions and 62 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.76.1",
"version": "1.76.2",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -2703,9 +2703,8 @@ h3 {
flex: none !important;
}
/* Ensure grid respects viewDays columns on mobile landscape */
/* time-grid-wrapper is the single scroll container — keep overflow-y from inline style */
.time-grid-wrapper {
overflow: visible;
min-width: 0;
}
.time-grid-wrapper .weekly-days-grid {
@ -3161,11 +3160,10 @@ h3 {
}
/* Make day columns scrollable when using time grid */
/* Day columns: no overflow — scroll is handled by time-grid-wrapper (single scroll container) */
.time-grid-wrapper .weekly-days-grid {
flex: 1;
overflow-y: auto; /* Unified scroll container */
overflow-x: hidden;
overflow: visible;
}
.time-grid-wrapper .weekly-day-column {
@ -4974,17 +4972,10 @@ h3 {
}
@media (max-width: 768px) {
/* Day headers are not sticky on mobile CSS sticky doesn't work in a CSS grid scroll container.
The mobile-sticky-day-bar overlay (fixed position) handles this instead. */
.time-grid-on .weekly-day-header {
position: relative !important;
top: auto !important;
}
/* On mobile, time-column-header should NOT be sticky — it wastes vertical space */
/* time-grid-wrapper is now the single scroll container, so position:sticky works.
time-column-header is hidden on mobile to save vertical space. */
.time-grid-on .time-column-header {
position: relative !important;
top: auto !important;
z-index: auto !important;
display: none;
}
}

View File

@ -2492,8 +2492,7 @@ export default function WeeklyView() {
// Translation helper
const t = translations[language] || translations["en"];
// Refs for scroll synchronization
const timeColumnRef = useRef<HTMLDivElement>(null);
// Refs for scroll
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
const isScrollSyncing = useRef(false);
const isInitialScrollDone = useRef(false);
@ -2538,30 +2537,11 @@ export default function WeeklyView() {
const somedaySectionRef = useRef<HTMLElement | null>(null);
// Unified scroll sync handlers
const handleTimeColumnScroll = (e: React.UIEvent<HTMLDivElement>) => {
const scrollTop = e.currentTarget.scrollTop;
if (isScrollSyncing.current) return;
isScrollSyncing.current = true;
if (gridRef.current) {
gridRef.current.scrollTop = scrollTop;
}
setTimeout(() => {
isScrollSyncing.current = false;
}, 50);
};
// handleTimeColumnScroll no longer needed — single scroll container via time-grid-wrapper
const handleGridScroll = (e: React.UIEvent<HTMLElement>) => {
const scrollTop = e.currentTarget.scrollTop;
if (isScrollSyncing.current) return;
isScrollSyncing.current = true;
if (timeColumnRef.current) {
timeColumnRef.current.scrollTop = scrollTop;
}
setTimeout(() => {
isScrollSyncing.current = false;
}, 50);
const handleGridScroll = (_e: React.UIEvent<HTMLElement>) => {
// Single scroll container — no sync needed
// Mobile sticky day is handled by the native scroll listener in the useEffect
};
const jumpToHour = (hour: number) => {
@ -2577,9 +2557,7 @@ export default function WeeklyView() {
intendedScrollTop.current = scrollOffset;
const perform = () => {
if (timeGridWrapperRef.current) timeGridWrapperRef.current.scrollTop = scrollOffset;
if (gridRef.current) gridRef.current.scrollTop = scrollOffset;
if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollOffset;
};
// Repeated enforcement
@ -3140,20 +3118,15 @@ export default function WeeklyView() {
const slotHeight = getSlotHeight(cellDuration);
const scrollOffset = workingHoursStart * slotsPerHour * slotHeight;
// Scroll the time-grid-wrapper (the scrollable viewport)
if (timeGridWrapperRef.current) timeGridWrapperRef.current.scrollTop = scrollOffset;
// Also scroll inner refs as fallback
// Single scroll container: gridRef points to time-grid-wrapper
if (gridRef.current) gridRef.current.scrollTop = scrollOffset;
if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollOffset;
intendedScrollTop.current = scrollOffset;
// On initial load, re-enforce for 2s to fight browser auto-scroll restoration
if (!isInitialScrollDone.current) {
const interval = setInterval(() => {
if (timeGridWrapperRef.current) timeGridWrapperRef.current.scrollTop = scrollOffset;
if (gridRef.current) gridRef.current.scrollTop = scrollOffset;
if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollOffset;
}, 50);
setTimeout(() => {
@ -4523,7 +4496,6 @@ export default function WeeklyView() {
// Navigation handlers with CSS class-based slide animation (works in all browsers)
const gridRef = useRef<HTMLElement>(null);
const timeGridWrapperRef = useRef<HTMLDivElement>(null);
const allSlideClasses = ["slide-animate-next", "slide-animate-prev", "slide-animate-week-next", "slide-animate-week-prev"];
const navigate = (
newDate: Date,
@ -7641,19 +7613,18 @@ export default function WeeklyView() {
{/* Main Grid with Time Column */}
{viewStyle !== "kanban" && <div style={{ position: "relative", flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div className="time-grid-wrapper" ref={timeGridWrapperRef} style={showTimeGrid ? {
maxHeight: '100%',
<div className="time-grid-wrapper" ref={gridRef as React.Ref<HTMLDivElement>} onScroll={handleGridScroll} style={showTimeGrid ? {
height: '100%',
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch' as any,
} : undefined}>
{/* Time Column */}
{showTimeGrid && (
<div
className="time-column"
ref={timeColumnRef}
onScroll={handleTimeColumnScroll}
<div
className="time-column"
style={{
height: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + measuredHeaderHeight}px`,
maxHeight: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + measuredHeaderHeight}px`,
overflowY: 'auto',
flex: 'none',
alignSelf: "flex-start",
position: 'relative'
@ -7761,17 +7732,13 @@ export default function WeeklyView() {
{/* Day Columns */}
<main
ref={gridRef}
className={`weekly-days-grid cols-${viewDays}`}
data-slide-direction={slideDirection}
data-nav-type={viewDays > 1 ? "week" : "day"}
onScroll={handleGridScroll}
style={showTimeGrid ? {
height: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + getHeaderHeight(effectiveCellDuration)}px`,
maxHeight: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + getHeaderHeight(effectiveCellDuration)}px`,
flex: 1,
alignSelf: "flex-start",
overflowY: 'auto'
} : {
flex: 1,
}}