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:
parent
b6e5303665
commit
aef7c291b4
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -2780,6 +2780,8 @@ h3 {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
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) */
|
/* Side Navigation Arrows (hover overlays) */
|
||||||
@ -3165,6 +3167,8 @@ h3 {
|
|||||||
.time-grid-wrapper .weekly-days-grid {
|
.time-grid-wrapper .weekly-days-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
/* Align grid items to the top so sticky headers work correctly */
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-grid-wrapper .weekly-day-column {
|
.time-grid-wrapper .weekly-day-column {
|
||||||
@ -3176,10 +3180,16 @@ h3 {
|
|||||||
|
|
||||||
.time-grid-wrapper .weekly-day-header {
|
.time-grid-wrapper .weekly-day-header {
|
||||||
flex-shrink: 0;
|
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 {
|
.time-grid-wrapper .time-slots-container {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* All-Day Events Section */
|
/* All-Day Events Section */
|
||||||
|
|||||||
@ -4151,27 +4151,73 @@ export default function WeeklyView() {
|
|||||||
}
|
}
|
||||||
}, [upgradeToDragListeners]);
|
}, [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(() => {
|
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 handlePassiveTouchMove = (e: TouchEvent) => {
|
||||||
const lp = touchLongPressRef.current;
|
const lp = touchLongPressRef.current;
|
||||||
if (!lp || lp.activated) return;
|
if (!lp || lp.activated) return;
|
||||||
const touch = e.touches[0];
|
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) {
|
if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) {
|
||||||
clearTimeout(lp.timerId);
|
clearTimeout(lp.timerId);
|
||||||
touchLongPressRef.current = null;
|
touchLongPressRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TOUCHEND — passive, completes drag or cancels
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
const lp = touchLongPressRef.current;
|
const lp = touchLongPressRef.current;
|
||||||
if (lp) {
|
if (lp) {
|
||||||
clearTimeout(lp.timerId);
|
clearTimeout(lp.timerId);
|
||||||
touchLongPressRef.current = null;
|
touchLongPressRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deactivate drag listener if it was upgraded
|
|
||||||
cancelDragListeners();
|
cancelDragListeners();
|
||||||
|
|
||||||
const drag = slotDragRef.current;
|
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('touchmove', handlePassiveTouchMove, { passive: true });
|
||||||
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||||
document.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
document.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart);
|
||||||
document.removeEventListener('touchmove', handlePassiveTouchMove);
|
document.removeEventListener('touchmove', handlePassiveTouchMove);
|
||||||
document.removeEventListener('touchend', handleTouchEnd);
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
document.removeEventListener('touchcancel', 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(() => { /* no-op — all listeners are permanent */ }, []);
|
||||||
const attachTouchListeners = useCallback(() => { /* listeners are permanent, see useEffect above */ }, []);
|
|
||||||
|
|
||||||
// Handle recurring event drag confirm (this/all)
|
// Handle recurring event drag confirm (this/all)
|
||||||
const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {
|
const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => {
|
||||||
@ -8059,6 +8106,7 @@ export default function WeeklyView() {
|
|||||||
<div
|
<div
|
||||||
key={slot}
|
key={slot}
|
||||||
data-slot={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" : ""}`}
|
className={`time-slot ${isHourStart ? "hour-start" : ""} ${draggedTask && !isProtected && !isOccupiedByTask ? "drop-target" : ""} ${isActive ? "active" : ""} ${isInDragSelection ? "slot-drag-selected" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
height: `${getSlotHeight(effectiveCellDuration)}px`,
|
height: `${getSlotHeight(effectiveCellDuration)}px`,
|
||||||
@ -8081,45 +8129,6 @@ export default function WeeklyView() {
|
|||||||
startY: e.clientY,
|
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) =>
|
onDragOver={(e) =>
|
||||||
!isProtected && !isOccupiedByTask &&
|
!isProtected && !isOccupiedByTask &&
|
||||||
handleDragOver(e, date.getDay(), slot)
|
handleDragOver(e, date.getDay(), slot)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user