From aef7c291b4508b457d132946ba3293cdace1436d Mon Sep 17 00:00:00 2001 From: mARTin Date: Sun, 29 Mar 2026 15:58:18 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20iOS=20scroll=20+=20sticky=20day=20header?= =?UTF-8?q?s=20=E2=80=94=20remove=20React=20onTouchStart=20from=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- src/app/globals.css | 10 ++++ src/components/WeeklyView.tsx | 101 ++++++++++++++++++---------------- 3 files changed, 66 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 6c9af5b..fd1e623 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/globals.css b/src/app/globals.css index acbad44..d531c86 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 */ diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index e7a1f9f..8b90d7e 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -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() {
{ - 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)