fix: iOS scroll + sticky day headers — remove React onTouchStart from slots

Root cause: React registers onTouchStart props as non-passive listeners at the
app root. iOS Safari sees any non-passive touchstart on an ancestor and blocks
the scroll gesture on those elements, waiting for JS to finish. This is why
the time column (no handlers) scrolled freely but day columns (slots had
onTouchStart) were frozen.

Changes:
- Removed onTouchStart prop from all time-slot divs — no more React touch
  handlers in the time grid, so iOS never blocks scroll
- Added data-slot-blocked attribute to occupied/protected slots
- Moved slot long-press logic into the permanent native touchstart listener
  on document (passive: true), reading slot/date from data attributes
- Added touch-action: pan-y on .time-grid-wrapper — explicitly tells iOS
  that vertical pan = scroll even if child content has touch handlers
- CSS: align-items: start on .weekly-days-grid so sticky headers in each
  day column have a proper scroll context
- Reinforced position:sticky on .time-grid-wrapper .weekly-day-header

v1.77.3
This commit is contained in:
mARTin 2026-03-29 15:58:18 +02:00
parent b6e5303665
commit aef7c291b4
3 changed files with 66 additions and 47 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.77.2",
"version": "1.77.3",
"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

@ -2780,6 +2780,8 @@ h3 {
min-width: 0;
overflow: hidden;
position: relative;
/* Tell iOS Safari that vertical pan = scroll, even if child elements have JS touch handlers */
touch-action: pan-y;
}
/* Side Navigation Arrows (hover overlays) */
@ -3165,6 +3167,8 @@ h3 {
.time-grid-wrapper .weekly-days-grid {
flex: 1;
overflow: visible;
/* Align grid items to the top so sticky headers work correctly */
align-items: start;
}
.time-grid-wrapper .weekly-day-column {
@ -3176,10 +3180,16 @@ h3 {
.time-grid-wrapper .weekly-day-header {
flex-shrink: 0;
/* Sticky works because time-grid-wrapper is the overflow-y:auto ancestor */
position: sticky;
top: 0;
z-index: 20;
background-color: var(--weekly-bg, white);
}
.time-grid-wrapper .time-slots-container {
overflow: visible;
flex: 1;
}
/* All-Day Events Section */

View File

@ -4151,27 +4151,73 @@ export default function WeeklyView() {
}
}, [upgradeToDragListeners]);
// Permanent global passive touchmove + touchend listeners (attached once at mount)
// All touch listeners for the time-grid are attached ONCE at mount as native listeners.
// CRITICAL: do NOT use React onTouchStart on slot divs — React registers those as
// non-passive at the app root, causing iOS Safari to wait for JS before committing
// a scroll gesture, which blocks vertical scrolling on day columns entirely.
useEffect(() => {
// TOUCHSTART — passive, on document. Reads data attributes from slot element.
// Slot divs must have data-slot, data-slot-blocked, and their column must have data-date.
const handleTouchStart = (e: TouchEvent) => {
const target = e.target as Element;
const slotEl = target.closest('[data-slot]') as HTMLElement | null;
if (!slotEl) return;
if (slotEl.dataset.slotBlocked) return;
if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return;
const touch = e.touches[0];
const colEl = slotEl.closest('[data-date]') as HTMLElement | null;
if (!colEl?.dataset.date) return;
const slotDate = new Date(colEl.dataset.date);
const slotName = slotEl.dataset.slot!;
if (touchLongPressRef.current) {
clearTimeout(touchLongPressRef.current.timerId);
}
const timerId = setTimeout(() => {
if (navigator.vibrate) navigator.vibrate(50);
slotDragRef.current = {
active: true,
date: slotDate,
startSlot: slotName,
currentSlot: slotName,
startY: touch.clientY,
};
if (touchLongPressRef.current) {
touchLongPressRef.current.activated = true;
}
upgradeToDragListeners();
const ds = `${slotDate.getFullYear()}-${String(slotDate.getMonth() + 1).padStart(2, '0')}-${String(slotDate.getDate()).padStart(2, '0')}`;
setSlotDragSelection({ dateStr: ds, startSlot: slotName, endSlot: slotName });
}, 500);
touchLongPressRef.current = {
timerId,
startX: touch.clientX,
startY: touch.clientY,
date: slotDate,
slot: slotName,
activated: false,
};
};
// TOUCHMOVE — passive, cancels long-press if finger moves (user is scrolling)
const handlePassiveTouchMove = (e: TouchEvent) => {
const lp = touchLongPressRef.current;
if (!lp || lp.activated) return;
const touch = e.touches[0];
// Cancel long-press if finger moves more than 10px (user is scrolling)
if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) {
clearTimeout(lp.timerId);
touchLongPressRef.current = null;
}
};
// TOUCHEND — passive, completes drag or cancels
const handleTouchEnd = () => {
const lp = touchLongPressRef.current;
if (lp) {
clearTimeout(lp.timerId);
touchLongPressRef.current = null;
}
// Deactivate drag listener if it was upgraded
cancelDragListeners();
const drag = slotDragRef.current;
@ -4201,18 +4247,19 @@ export default function WeeklyView() {
});
};
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchmove', handlePassiveTouchMove, { passive: true });
document.addEventListener('touchend', handleTouchEnd, { passive: true });
document.addEventListener('touchcancel', handleTouchEnd, { passive: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handlePassiveTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
};
}, [cancelDragListeners]); // eslint-disable-line react-hooks/exhaustive-deps
}, [cancelDragListeners, upgradeToDragListeners]); // eslint-disable-line react-hooks/exhaustive-deps
// No-op: onTouchStart on slots only updates the ref — no DOM side effects here
const attachTouchListeners = useCallback(() => { /* listeners are permanent, see useEffect above */ }, []);
const attachTouchListeners = useCallback(() => { /* no-op — all listeners are permanent */ }, []);
// Handle recurring event drag confirm (this/all)
const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {
@ -8059,6 +8106,7 @@ export default function WeeklyView() {
<div
key={slot}
data-slot={slot}
data-slot-blocked={isProtected || isOccupiedByAnyTask ? "1" : undefined}
className={`time-slot ${isHourStart ? "hour-start" : ""} ${draggedTask && !isProtected && !isOccupiedByTask ? "drop-target" : ""} ${isActive ? "active" : ""} ${isInDragSelection ? "slot-drag-selected" : ""}`}
style={{
height: `${getSlotHeight(effectiveCellDuration)}px`,
@ -8081,45 +8129,6 @@ export default function WeeklyView() {
startY: e.clientY,
};
}}
onTouchStart={(e) => {
if (isProtected || isOccupiedByAnyTask) return;
const target = e.target as HTMLElement;
if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return;
const touch = e.touches[0];
const slotDate = new Date(date);
const slotName = slot;
// Cancel any existing long-press
if (touchLongPressRef.current) {
clearTimeout(touchLongPressRef.current.timerId);
}
const timerId = setTimeout(() => {
// Long-press activated — vibrate for feedback
if (navigator.vibrate) navigator.vibrate(50);
slotDragRef.current = {
active: true,
date: slotDate,
startSlot: slotName,
currentSlot: slotName,
startY: touch.clientY,
};
if (touchLongPressRef.current) {
touchLongPressRef.current.activated = true;
}
// Upgrade from passive to non-passive touchmove now that drag is active
upgradeToDragListeners();
const ds = `${slotDate.getFullYear()}-${String(slotDate.getMonth() + 1).padStart(2, '0')}-${String(slotDate.getDate()).padStart(2, '0')}`;
setSlotDragSelection({ dateStr: ds, startSlot: slotName, endSlot: slotName });
}, 500);
touchLongPressRef.current = {
timerId,
startX: touch.clientX,
startY: touch.clientY,
date: slotDate,
slot: slotName,
activated: false,
};
attachTouchListeners();
}}
onDragOver={(e) =>
!isProtected && !isOccupiedByTask &&
handleDragOver(e, date.getDay(), slot)