#756 swipe gesture on calendar
This commit is contained in:
committed by
Benoit TELLIER
parent
b481eefed0
commit
3d07008a8c
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user