#756 swipe gesture on calendar
This commit is contained in:
committed by
Benoit TELLIER
parent
b481eefed0
commit
3d07008a8c
@@ -134,3 +134,40 @@
|
||||
display flex
|
||||
flex-direction column
|
||||
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
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from './utils/calendarUtils'
|
||||
import { CALENDAR_VIEWS } from './utils/constants'
|
||||
import ViewMoreEvents from './ViewMoreEvents'
|
||||
import { useSwipeCalendar } from './hooks/useSwipeCalendar'
|
||||
|
||||
const localeMap: Record<string, LocaleInput | undefined> = {
|
||||
fr: frLocale,
|
||||
@@ -84,6 +85,7 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
return (): void => clearTimeout(t)
|
||||
}, [selectedDate])
|
||||
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date())
|
||||
const calendarWrapperRef = useRef<HTMLDivElement>(null)
|
||||
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
|
||||
const dispatch = useAppDispatch()
|
||||
const view = useAppSelector(state => state.settings.view)
|
||||
@@ -375,6 +377,13 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
errorHandler: errorHandler.current
|
||||
})
|
||||
|
||||
const { offsetX, isAnimating, onTouchStart, onTouchMove, onTouchEnd } =
|
||||
useSwipeCalendar({
|
||||
calendarRef,
|
||||
containerRef: calendarWrapperRef,
|
||||
isMobile
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
window.__calendarRef = calendarRef
|
||||
@@ -382,7 +391,6 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
}, [calendarRef])
|
||||
|
||||
const { t, lang } = useI18n()
|
||||
const calendarWrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useTouchListener(
|
||||
eventHandlers.handleDateSelect,
|
||||
@@ -430,7 +438,25 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
</Fab>
|
||||
)}
|
||||
{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
|
||||
key={hiddenDays.join(',')}
|
||||
ref={ref => {
|
||||
@@ -608,6 +634,9 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
eventDidMount={viewHandlers.handleEventDidMount}
|
||||
/>
|
||||
</div>
|
||||
<div className="calendar-page" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === 'search' && <SearchResultsPage />}
|
||||
<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 }
|
||||
}
|
||||
Reference in New Issue
Block a user