#756 swipe gesture on calendar

This commit is contained in:
lethemanh
2026-04-16 15:25:59 +07:00
committed by Benoit TELLIER
parent b481eefed0
commit 3d07008a8c
4 changed files with 400 additions and 170 deletions
+37
View File
@@ -134,3 +134,40 @@
display flex display flex
flex-direction column flex-direction column
align-items center align-items center
.calendar-viewport
width 100%
height 100%
overflow hidden
position relative
.calendar-track
display flex
width 300%
height 100%
margin-left -100%
will-change transform
.calendar-track--animate
transition transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)
.calendar-page
flex 0 0 33.333%
width 33.333%
height 100%
overflow hidden
position relative
background-color inherit
.calendar-page-placeholder
width 100%
height 100%
display flex
align-items center
justify-content center
background-color #f8f9fa
.placeholder-content
text-align center
[data-theme="dark"] .calendar-page-placeholder
background-color #1e1e1e
+31 -2
View File
@@ -48,6 +48,7 @@ import {
} from './utils/calendarUtils' } from './utils/calendarUtils'
import { CALENDAR_VIEWS } from './utils/constants' import { CALENDAR_VIEWS } from './utils/constants'
import ViewMoreEvents from './ViewMoreEvents' import ViewMoreEvents from './ViewMoreEvents'
import { useSwipeCalendar } from './hooks/useSwipeCalendar'
const localeMap: Record<string, LocaleInput | undefined> = { const localeMap: Record<string, LocaleInput | undefined> = {
fr: frLocale, fr: frLocale,
@@ -84,6 +85,7 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
return (): void => clearTimeout(t) return (): void => clearTimeout(t)
}, [selectedDate]) }, [selectedDate])
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()) const [selectedMiniDate, setSelectedMiniDate] = useState(new Date())
const calendarWrapperRef = useRef<HTMLDivElement>(null)
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? '' const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const view = useAppSelector(state => state.settings.view) const view = useAppSelector(state => state.settings.view)
@@ -375,6 +377,13 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
errorHandler: errorHandler.current errorHandler: errorHandler.current
}) })
const { offsetX, isAnimating, onTouchStart, onTouchMove, onTouchEnd } =
useSwipeCalendar({
calendarRef,
containerRef: calendarWrapperRef,
isMobile
})
useEffect(() => { useEffect(() => {
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
window.__calendarRef = calendarRef window.__calendarRef = calendarRef
@@ -382,7 +391,6 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
}, [calendarRef]) }, [calendarRef])
const { t, lang } = useI18n() const { t, lang } = useI18n()
const calendarWrapperRef = useRef<HTMLDivElement>(null)
useTouchListener( useTouchListener(
eventHandlers.handleDateSelect, eventHandlers.handleDateSelect,
@@ -430,7 +438,25 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
</Fab> </Fab>
)} )}
{view === 'calendar' && ( {view === 'calendar' && (
<div ref={calendarWrapperRef} style={{ height: '100%' }}> <div
ref={calendarWrapperRef}
className="calendar-viewport"
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{ height: '100%' }}
>
<div
className={`calendar-track ${isAnimating ? 'calendar-track--animate' : ''}`}
style={{
transform: `translateX(${offsetX}px)`,
transition: isAnimating
? 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
: 'none'
}}
>
<div className="calendar-page" />
<div className="calendar-page">
<FullCalendar <FullCalendar
key={hiddenDays.join(',')} key={hiddenDays.join(',')}
ref={ref => { ref={ref => {
@@ -608,6 +634,9 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
eventDidMount={viewHandlers.handleEventDidMount} eventDidMount={viewHandlers.handleEventDidMount}
/> />
</div> </div>
<div className="calendar-page" />
</div>
</div>
)} )}
{view === 'search' && <SearchResultsPage />} {view === 'search' && <SearchResultsPage />}
<EventPopover <EventPopover
@@ -0,0 +1,62 @@
import { useState, useCallback, MutableRefObject } from 'react'
import { CalendarApi } from '@fullcalendar/core'
export const useSwipeAnimation = (
calendarRef: MutableRefObject<CalendarApi | null>
): {
offsetX: number
isAnimating: boolean
setOffsetX: (offset: number) => void
finalizeSwipe: (targetOffset: number, deltaX: number) => void
cancelSwipe: () => void
setIsAnimating: (isAnimating: boolean) => void
} => {
const [offsetX, setOffsetX] = useState(0)
const [isAnimating, setIsAnimating] = useState(false)
const finalizeSwipe = useCallback(
(targetOffset: number, deltaX: number) => {
setIsAnimating(true)
setOffsetX(targetOffset)
setTimeout(() => {
setIsAnimating(false)
setOffsetX(-targetOffset)
// Apply date change
if (deltaX > 0) {
calendarRef.current?.prev()
} else {
calendarRef.current?.next()
}
// Slide back to center (requesting animation state)
requestAnimationFrame(() => {
setIsAnimating(true)
setOffsetX(0)
})
// Immediate reset to preserve "snappy" behavior as per latest user tweak
setIsAnimating(false)
}, 100)
},
[calendarRef]
)
const cancelSwipe = useCallback(() => {
setIsAnimating(true)
setOffsetX(0)
setTimeout(() => {
setIsAnimating(false)
}, 150)
}, [])
return {
offsetX,
isAnimating,
setOffsetX,
setIsAnimating,
finalizeSwipe,
cancelSwipe
}
}
@@ -0,0 +1,102 @@
import {
useRef,
useState,
useCallback,
MutableRefObject,
RefObject
} from 'react'
import { CalendarApi } from '@fullcalendar/core'
import { useSwipeAnimation } from './useSwipeAnimation'
interface UseSwipeCalendarProps {
calendarRef: MutableRefObject<CalendarApi | null>
containerRef: RefObject<HTMLElement>
isMobile: boolean
}
interface SwipeCalendarReturn {
offsetX: number
isAnimating: boolean
onTouchStart: (e: React.TouchEvent) => void
onTouchMove: (e: React.TouchEvent) => void
onTouchEnd: () => void
}
const isHorizontalSwipe = (deltaX: number, deltaY: number): boolean => {
return Math.abs(deltaX) > Math.abs(deltaY) || Math.abs(deltaX) > 10
}
const getTargetOffset = (
deltaX: number,
containerWidth: number,
threshold: number
): number | null => {
if (Math.abs(deltaX) <= threshold) return null
return deltaX > 0 ? containerWidth : -containerWidth
}
const preventSwipping = (): void => {}
export const useSwipeCalendar = ({
calendarRef,
containerRef,
isMobile
}: UseSwipeCalendarProps): SwipeCalendarReturn => {
const { offsetX, isAnimating, setOffsetX, finalizeSwipe, cancelSwipe } =
useSwipeAnimation(calendarRef)
const [containerWidth, setContainerWidth] = useState(0)
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
if (containerRef.current)
setContainerWidth(containerRef.current.getBoundingClientRect().width)
touchStartRef.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY
}
},
[containerRef]
)
const onTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!touchStartRef.current) return
const dx = e.touches[0].clientX - touchStartRef.current.x
const dy = e.touches[0].clientY - touchStartRef.current.y
if (isHorizontalSwipe(dx, dy)) setOffsetX(dx)
},
[setOffsetX]
)
const onTouchEnd = useCallback(() => {
if (!touchStartRef.current || !calendarRef.current) return setOffsetX(0)
const target = getTargetOffset(
offsetX,
containerWidth,
containerWidth * 0.1
)
if (target !== null) finalizeSwipe(target, offsetX)
else cancelSwipe()
touchStartRef.current = null
}, [
calendarRef,
offsetX,
containerWidth,
finalizeSwipe,
cancelSwipe,
setOffsetX
])
if (!isMobile) {
return {
offsetX: 0,
isAnimating: false,
onTouchStart: preventSwipping,
onTouchMove: preventSwipping,
onTouchEnd: preventSwipping
}
}
return { offsetX, isAnimating, onTouchStart, onTouchMove, onTouchEnd }
}