#756 make calendar easier to swipe

This commit is contained in:
lethemanh
2026-04-20 10:14:16 +07:00
committed by Benoit TELLIER
parent fd2282a65f
commit c8cab5d55d
6 changed files with 434 additions and 410 deletions
-37
View File
@@ -134,40 +134,3 @@
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
+171 -182
View File
@@ -38,6 +38,7 @@ import './Calendar.styl'
import './CustomCalendar.styl'
import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
import { useSwipeNavigation } from './hooks/useSwipeNavigation'
import Sidebar from './Sidebar/SideBar'
import TempSearchDialog from './TempSearchDialog'
import { useTouchListener } from './useTouchListener'
@@ -48,7 +49,6 @@ 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,
@@ -377,12 +377,6 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
errorHandler: errorHandler.current
})
const { offsetX, isAnimating } = useSwipeCalendar({
calendarRef,
containerRef: calendarWrapperRef,
isMobile
})
useEffect(() => {
if (process.env.NODE_ENV === 'test') {
window.__calendarRef = calendarRef
@@ -397,6 +391,8 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
calendarWrapperRef
)
const { handlers } = useSwipeNavigation(calendarRef, calendarWrapperRef)
return (
<main
className={`main-layout calendar-layout ${menubarProps?.isIframe ? ' isInIframe' : ''}`}
@@ -439,84 +435,78 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
{view === 'calendar' && (
<div
ref={calendarWrapperRef}
className="calendar-viewport"
style={{ height: '100%', touchAction: 'pan-y' }}
className={isTablet || isMobile ? 'calendar-swipe-container' : ''}
style={{
height: '100%',
...(isTablet || isMobile ? { touchAction: 'pan-y' } : {})
}}
{...(isTablet || isMobile ? handlers : {})}
>
<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'
<FullCalendar
key={hiddenDays.join(',')}
ref={ref => {
if (ref) {
calendarRef.current = ref.getApi()
}
}}
>
<div className="calendar-page" />
<div className="calendar-page">
<FullCalendar
key={hiddenDays.join(',')}
ref={ref => {
if (ref) {
calendarRef.current = ref.getApi()
}
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin
]}
initialView={
currentView ||
(isTablet
? CALENDAR_VIEWS.timeGridDay
: CALENDAR_VIEWS.timeGridWeek)
}
firstDay={1}
editable={true}
locale={localeMap[lang]}
hiddenDays={hiddenDays}
selectable={true}
timeZone={timezone}
height="100%"
select={eventHandlers.handleDateSelect}
nowIndicator
slotLabelClassNames={arg => [
updateSlotLabelVisibility(new Date(), arg, timezone)
]}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false}
views={{
timeGridWeek: {
titleFormat: { month: 'long', year: 'numeric' }
}
}}
dayMaxEvents={true}
moreLinkClick={handleMoreLinkClick}
events={eventToFullCalendarFormat(
filteredEvents,
filteredTempEvents,
userId,
userData?.email,
isPending,
calendars
)}
eventOrder={(a: EventApi, b: EventApi) =>
a.extendedProps.priority - b.extendedProps.priority
}
weekNumbers={
currentView === CALENDAR_VIEWS.timeGridWeek ||
currentView === CALENDAR_VIEWS.timeGridDay
}
weekNumberFormat={{ week: 'long' }}
weekNumberContent={arg => {
return (
<div className="weekSelector">
{displayWeekNumbers && (
<div>
{t('menubar.views.week')} {arg.num}
</div>
)}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin
]}
initialView={
currentView ||
(isTablet
? CALENDAR_VIEWS.timeGridDay
: CALENDAR_VIEWS.timeGridWeek)
}
initialDate={selectedDate}
firstDay={1}
editable={true}
locale={localeMap[lang]}
hiddenDays={hiddenDays}
selectable={true}
timeZone={timezone}
height="100%"
select={eventHandlers.handleDateSelect}
nowIndicator
slotLabelClassNames={arg => [
updateSlotLabelVisibility(new Date(), arg, timezone)
]}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false}
views={{
timeGridWeek: {
titleFormat: { month: 'long', year: 'numeric' }
}
}}
dayMaxEvents={true}
moreLinkClick={handleMoreLinkClick}
events={eventToFullCalendarFormat(
filteredEvents,
filteredTempEvents,
userId,
userData?.email,
isPending,
calendars
)}
eventOrder={(a: EventApi, b: EventApi) =>
a.extendedProps.priority - b.extendedProps.priority
}
weekNumbers={
currentView === CALENDAR_VIEWS.timeGridWeek ||
currentView === CALENDAR_VIEWS.timeGridDay
}
weekNumberFormat={{ week: 'long' }}
weekNumberContent={arg => {
return (
<div className="weekSelector">
{displayWeekNumbers && (
<>
<div>
{t('menubar.views.week')} {arg.num}
</div>
<TimezoneSelector
value={timezone}
referenceDate={
@@ -526,112 +516,111 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
dispatch(setTimeZone(newTimezone))
}
/>
</div>
)
}}
dayCellContent={arg => {
const month = arg.date.toLocaleDateString(t('locale'), {
month: 'short',
timeZone: timezone
})
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
return (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? 'current-date' : ''
}`}
>
{arg.dayNumberText === '1' ? month : ''}{' '}
{arg.dayNumberText}
</span>
)
}
}}
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
scrollTime="12:00:00"
unselectAuto={false}
allDayText=""
slotLabelFormat={{
hour: '2-digit',
minute: '2-digit',
hour12: false
}}
datesSet={arg => {
onViewChange?.(arg.view.type)
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start)
setDisplayedDateAndRange(calendarCurrentDate)
</>
)}
</div>
)
}}
dayCellContent={arg => {
const month = arg.date.toLocaleDateString(t('locale'), {
month: 'short',
timeZone: timezone
})
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
return (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? 'current-date' : ''
}`}
>
{arg.dayNumberText === '1' ? month : ''}{' '}
{arg.dayNumberText}
</span>
)
}
}}
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
scrollTime="12:00:00"
unselectAuto={false}
allDayText=""
slotLabelFormat={{
hour: '2-digit',
minute: '2-digit',
hour12: false
}}
datesSet={arg => {
onViewChange?.(arg.view.type)
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start)
setDisplayedDateAndRange(calendarCurrentDate)
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate)
} else {
setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate)
}
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate)
} else {
setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate)
}
// Notify parent about view change
setCurrentView(arg.view.type)
// Notify parent about view change
setCurrentView(arg.view.type)
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date())
}, 100)
}}
dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone)
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date())
}, 100)
}}
dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone)
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t('locale'), {
weekday: 'short',
timeZone: timezone
})
.toUpperCase()
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t('locale'), {
weekday: 'short',
timeZone: timezone
})
.toUpperCase()
return (
<div
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
return (
<div
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
>
<small>{weekDay}</small>
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
<span
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
>
<small>{weekDay}</small>
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
<span
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
>
{date}
</span>
)}
</div>
)
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
viewDidMount={viewHandlers.handleViewDidMount}
viewWillUnmount={viewHandlers.handleViewWillUnmount}
eventClick={eventHandlers.handleEventClick}
eventAllow={eventHandlers.handleEventAllow}
eventDrop={arg => {
void eventHandlers.handleEventDrop(arg)
}}
eventResize={arg => {
void eventHandlers.handleEventResize(arg)
}}
eventContent={viewHandlers.handleEventContent}
eventDidMount={viewHandlers.handleEventDidMount}
/>
</div>
<div className="calendar-page" />
</div>
{date}
</span>
)}
</div>
)
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
viewDidMount={viewHandlers.handleViewDidMount}
viewWillUnmount={viewHandlers.handleViewWillUnmount}
eventClick={eventHandlers.handleEventClick}
eventAllow={eventHandlers.handleEventAllow}
eventDrop={arg => {
void eventHandlers.handleEventDrop(arg)
}}
eventResize={arg => {
void eventHandlers.handleEventResize(arg)
}}
eventContent={viewHandlers.handleEventContent}
eventDidMount={viewHandlers.handleEventDidMount}
/>
</div>
)}
{view === 'search' && <SearchResultsPage />}
+13 -1
View File
@@ -380,4 +380,16 @@ li.MuiButtonBase-root.MuiMenuItem-root
background #f6f6f7
hr.MuiDivider-root.MuiDivider-fullWidth
margin 0
margin 0
.calendar-swipe-container
display grid
grid-template-areas "main"
height 100%
overflow hidden
touch-action pan-y
.calendar-swipe-container > div
grid-area main
width 100%
height 100%
@@ -1,51 +0,0 @@
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)
if (deltaX > 0) {
calendarRef.current?.prev()
} else {
calendarRef.current?.next()
}
setOffsetX(0)
setTimeout(() => {
setIsAnimating(false)
}, 300)
},
[calendarRef]
)
const cancelSwipe = useCallback(() => {
setIsAnimating(true)
setOffsetX(0)
setTimeout(() => {
setIsAnimating(false)
}, 150)
}, [])
return {
offsetX,
isAnimating,
setOffsetX,
setIsAnimating,
finalizeSwipe,
cancelSwipe
}
}
@@ -1,139 +0,0 @@
import { useRef, useState, useEffect, 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
}
const isHorizontalSwipe = (dx: number, dy: number): boolean =>
Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10
const getSwipeTarget = (
offsetX: number,
containerWidth: number
): number | null => {
if (Math.abs(offsetX) <= containerWidth * 0.1) return null
return offsetX > 0 ? containerWidth : -containerWidth
}
const handleTouchMove = (
e: TouchEvent,
touchStartRef: React.MutableRefObject<{ x: number; y: number } | null>,
isHorizontalRef: React.MutableRefObject<boolean>,
setOffsetX: (dx: number) => void
): void => {
if (!touchStartRef.current) return
const dx = e.touches[0].clientX - touchStartRef.current.x
const dy = e.touches[0].clientY - touchStartRef.current.y
if (!isHorizontalRef.current)
isHorizontalRef.current = isHorizontalSwipe(dx, dy)
if (isHorizontalRef.current && e.cancelable) {
e.preventDefault()
setOffsetX(dx)
}
}
const handleTouchEnd = ({
touchStartRef,
isHorizontalRef,
calendarRef,
offsetX,
containerWidth,
setOffsetX,
finalizeSwipe,
cancelSwipe
}: {
touchStartRef: React.MutableRefObject<{ x: number; y: number } | null>
isHorizontalRef: React.MutableRefObject<boolean>
calendarRef: MutableRefObject<CalendarApi | null>
offsetX: number
containerWidth: number
setOffsetX: (dx: number) => void
finalizeSwipe: (target: number, currentOffset: number) => void
cancelSwipe: () => void
}): void => {
const hasValidTouch = touchStartRef.current && calendarRef.current
if (!hasValidTouch) {
setOffsetX(0)
touchStartRef.current = null
isHorizontalRef.current = false
return
}
if (isHorizontalRef.current) {
const target = getSwipeTarget(offsetX, containerWidth)
if (target !== null) finalizeSwipe(target, offsetX)
else cancelSwipe()
}
touchStartRef.current = null
isHorizontalRef.current = false
}
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 isHorizontalRef = useRef<boolean>(false)
useEffect(() => {
const el = containerRef.current
if (!el || !isMobile) return
const onTouchStart = (e: TouchEvent): void => {
setContainerWidth(el.getBoundingClientRect().width)
touchStartRef.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY
}
isHorizontalRef.current = false
}
const onTouchMove = (e: TouchEvent): void =>
handleTouchMove(e, touchStartRef, isHorizontalRef, setOffsetX)
const onTouchEnd = (): void =>
handleTouchEnd({
touchStartRef,
isHorizontalRef,
calendarRef,
offsetX,
containerWidth,
setOffsetX,
finalizeSwipe,
cancelSwipe
})
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchmove', onTouchMove, { passive: false })
el.addEventListener('touchend', onTouchEnd, { passive: true })
return (): void => {
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('touchend', onTouchEnd)
}
}, [
containerRef,
isMobile,
offsetX,
containerWidth,
setOffsetX,
finalizeSwipe,
cancelSwipe,
calendarRef
])
return { offsetX, isAnimating }
}
@@ -0,0 +1,250 @@
import { CalendarApi } from '@fullcalendar/core'
import {
MutableRefObject,
TouchEvent,
useCallback,
useEffect,
useRef
} from 'react'
const nextFrame = (): Promise<void> =>
new Promise(resolve =>
requestAnimationFrame(resolve as unknown as FrameRequestCallback)
)
const delay = (ms: number): Promise<void> =>
new Promise((resolve: (value: void | PromiseLike<void>) => void) =>
setTimeout(resolve, ms)
)
function clearGestureTimer(state: GestureState): void {
if (state.timer) clearTimeout(state.timer)
state.timer = null
}
function resetGesture(state: GestureState, wrapper: HTMLDivElement): void {
state.swiping = false
state.decided = false
state.offset = 0
clearGestureTimer(state)
wrapper.style.transition = 'transform 0.15s cubic-bezier(0.25, 0.8, 0.25, 1)'
wrapper.style.transform = 'translateX(0px)'
}
function applySwipeOffset(
state: GestureState,
wrapper: HTMLDivElement,
dx: number
): void {
state.offset = dx
wrapper.style.transform = `translateX(${dx}px)`
wrapper.style.transition = 'none'
}
function decideGesture(state: GestureState, dx: number, dy: number): boolean {
if (state.decided) return state.swiping
if (Math.abs(dx) <= 10) {
if (Math.abs(dy) <= 10) return false
}
state.decided = true
state.swiping = Math.abs(dx) > Math.abs(dy)
clearGestureTimer(state)
return state.swiping
}
async function animatePagination(
dir: number,
api: CalendarApi,
wrapper: HTMLDivElement,
isMounted: () => boolean
): Promise<void> {
const isSafe = (): boolean => isMounted() && !!wrapper
wrapper.style.transition = 'none'
wrapper.style.transform = `translateX(${dir * window.innerWidth}px)`
await nextFrame()
if (!isSafe()) return
api[dir === 1 ? 'next' : 'prev']()
await nextFrame()
if (!isSafe()) return
wrapper.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1)'
wrapper.style.transform = 'translateX(0px)'
await delay(250)
if (isSafe()) wrapper.style.transition = 'none'
}
type GestureState = {
startX: number
startY: number
offset: number
decided: boolean
swiping: boolean
longPress: boolean
timer: ReturnType<typeof setTimeout> | null
}
function handleTouchStart(
e: TouchEvent<HTMLDivElement>,
state: GestureState,
wrapperRef: MutableRefObject<HTMLDivElement | null>
): void {
if (e.touches.length !== 1) return
state.startX = e.touches[0].clientX
state.startY = e.touches[0].clientY
state.offset = 0
state.decided = false
state.swiping = false
state.longPress = false
clearGestureTimer(state)
state.timer = setTimeout(() => {
state.longPress = true
}, 300)
if (wrapperRef.current) wrapperRef.current.style.transition = 'none'
}
function handleTouchMove(
e: TouchEvent<HTMLDivElement>,
state: GestureState,
wrapperRef: MutableRefObject<HTMLDivElement | null>
): void {
const wrapper = wrapperRef.current
if (state.longPress) return
if (!wrapper) return
if (e.touches.length !== 1) {
resetGesture(state, wrapper)
return
}
const touch = e.touches[0]
if (!touch) {
resetGesture(state, wrapper)
return
}
const dx = touch.clientX - state.startX
const dy = touch.clientY - state.startY
if (decideGesture(state, dx, dy)) {
applySwipeOffset(state, wrapper, dx)
}
}
function handleTouchEnd(
state: GestureState,
wrapperRef: MutableRefObject<HTMLDivElement | null>,
paginate: (dir: number) => Promise<void>
): void {
clearGestureTimer(state)
const isSwiping = state.swiping
const finalOffset = state.offset
state.offset = 0
state.decided = false
state.swiping = false
state.longPress = false
if (!isSwiping) return
if (finalOffset < -60) {
void paginate(1)
return
}
if (finalOffset > 60) {
void paginate(-1)
return
}
if (wrapperRef.current) {
wrapperRef.current.style.transition =
'transform 0.15s cubic-bezier(0.25, 0.8, 0.25, 1)'
wrapperRef.current.style.transform = 'translateX(0px)'
}
}
function useSwipePagination(
calendarRef: MutableRefObject<CalendarApi | null>,
wrapperRef: MutableRefObject<HTMLDivElement | null>
): (dir: number) => Promise<void> {
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return (): void => {
isMountedRef.current = false
}
}, [])
return useCallback(
async (dir: number): Promise<void> => {
const api = calendarRef.current
const wrapper = wrapperRef.current
if (!api) return
if (!wrapper) return
if (!isMountedRef.current) return
await animatePagination(dir, api, wrapper, () => isMountedRef.current)
},
[calendarRef, wrapperRef]
)
}
function useGestureTracking(
wrapperRef: MutableRefObject<HTMLDivElement | null>,
paginate: (dir: number) => Promise<void>
): {
onTouchStart: (e: TouchEvent<HTMLDivElement>) => void
onTouchMove: (e: TouchEvent<HTMLDivElement>) => void
onTouchEnd: () => void
} {
const state = useRef<GestureState>({
startX: 0,
startY: 0,
offset: 0,
decided: false,
swiping: false,
longPress: false,
timer: null
}).current
return {
onTouchStart: useCallback(
(e: TouchEvent<HTMLDivElement>) => handleTouchStart(e, state, wrapperRef),
[state, wrapperRef]
),
onTouchMove: useCallback(
(e: TouchEvent<HTMLDivElement>) => handleTouchMove(e, state, wrapperRef),
[state, wrapperRef]
),
onTouchEnd: useCallback(
() => handleTouchEnd(state, wrapperRef, paginate),
[state, wrapperRef, paginate]
)
}
}
export const useSwipeNavigation = (
calendarRef: MutableRefObject<CalendarApi | null>,
wrapperRef: MutableRefObject<HTMLDivElement | null>
): {
handlers: {
onTouchStart: (e: TouchEvent<HTMLDivElement>) => void
onTouchMove: (e: TouchEvent<HTMLDivElement>) => void
onTouchEnd: () => void
}
} => {
const paginate = useSwipePagination(calendarRef, wrapperRef)
const handlers = useGestureTracking(wrapperRef, paginate)
return { handlers }
}