#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
+38 -1
View File
@@ -133,4 +133,41 @@
.weekSelector .weekSelector
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
+198 -169
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,183 +438,204 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
</Fab> </Fab>
)} )}
{view === 'calendar' && ( {view === 'calendar' && (
<div ref={calendarWrapperRef} style={{ height: '100%' }}> <div
<FullCalendar ref={calendarWrapperRef}
key={hiddenDays.join(',')} className="calendar-viewport"
ref={ref => { onTouchStart={onTouchStart}
if (ref) { onTouchMove={onTouchMove}
calendarRef.current = ref.getApi() 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'
}} }}
plugins={[ >
dayGridPlugin, <div className="calendar-page" />
timeGridPlugin, <div className="calendar-page">
interactionPlugin, <FullCalendar
momentTimezonePlugin key={hiddenDays.join(',')}
]} ref={ref => {
initialView={ if (ref) {
currentView || calendarRef.current = ref.getApi()
(isTablet }
? CALENDAR_VIEWS.timeGridDay }}
: CALENDAR_VIEWS.timeGridWeek) plugins={[
} dayGridPlugin,
firstDay={1} timeGridPlugin,
editable={true} interactionPlugin,
locale={localeMap[lang]} momentTimezonePlugin
hiddenDays={hiddenDays} ]}
selectable={true} initialView={
timeZone={timezone} currentView ||
height="100%" (isTablet
select={eventHandlers.handleDateSelect} ? CALENDAR_VIEWS.timeGridDay
nowIndicator : CALENDAR_VIEWS.timeGridWeek)
slotLabelClassNames={arg => [ }
updateSlotLabelVisibility(new Date(), arg, timezone) firstDay={1}
]} editable={true}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent} locale={localeMap[lang]}
headerToolbar={false} hiddenDays={hiddenDays}
views={{ selectable={true}
timeGridWeek: { timeZone={timezone}
titleFormat: { month: 'long', year: 'numeric' } height="100%"
} select={eventHandlers.handleDateSelect}
}} nowIndicator
dayMaxEvents={true} slotLabelClassNames={arg => [
moreLinkClick={handleMoreLinkClick} updateSlotLabelVisibility(new Date(), arg, timezone)
events={eventToFullCalendarFormat( ]}
filteredEvents, nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
filteredTempEvents, headerToolbar={false}
userId, views={{
userData?.email, timeGridWeek: {
isPending, titleFormat: { month: 'long', year: 'numeric' }
calendars }
)} }}
eventOrder={(a: EventApi, b: EventApi) => dayMaxEvents={true}
a.extendedProps.priority - b.extendedProps.priority moreLinkClick={handleMoreLinkClick}
} events={eventToFullCalendarFormat(
weekNumbers={ filteredEvents,
currentView === CALENDAR_VIEWS.timeGridWeek || filteredTempEvents,
currentView === CALENDAR_VIEWS.timeGridDay userId,
} userData?.email,
weekNumberFormat={{ week: 'long' }} isPending,
weekNumberContent={arg => { calendars
return ( )}
<div className="weekSelector"> eventOrder={(a: EventApi, b: EventApi) =>
{displayWeekNumbers && ( a.extendedProps.priority - b.extendedProps.priority
<div> }
{t('menubar.views.week')} {arg.num} 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={
calendarRef.current?.getDate() ?? new Date()
}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
</div> </div>
)} )
<TimezoneSelector }}
value={timezone} dayCellContent={arg => {
referenceDate={ const month = arg.date.toLocaleDateString(t('locale'), {
calendarRef.current?.getDate() ?? new Date() month: 'short',
} timeZone: timezone
onChange={(newTimezone: string) => })
dispatch(setTimeZone(newTimezone)) if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
} return (
/> <span
</div> className={`fc-daygrid-day-number ${
) arg.isToday ? 'current-date' : ''
}} }`}
dayCellContent={arg => { >
const month = arg.date.toLocaleDateString(t('locale'), { {arg.dayNumberText === '1' ? month : ''}{' '}
month: 'short', {arg.dayNumberText}
timeZone: timezone </span>
}) )
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { }
return ( }}
<span slotDuration="00:30:00"
className={`fc-daygrid-day-number ${ slotLabelInterval="01:00:00"
arg.isToday ? 'current-date' : '' scrollTime="12:00:00"
}`} unselectAuto={false}
> allDayText=""
{arg.dayNumberText === '1' ? month : ''}{' '} slotLabelFormat={{
{arg.dayNumberText} hour: '2-digit',
</span> minute: '2-digit',
) hour12: false
} }}
}} datesSet={arg => {
slotDuration="00:30:00" onViewChange?.(arg.view.type)
slotLabelInterval="01:00:00" const calendarCurrentDate =
scrollTime="12:00:00" calendarRef.current?.getDate() || new Date(arg.start)
unselectAuto={false} setDisplayedDateAndRange(calendarCurrentDate)
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) { if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
const start = new Date(arg.start).getTime() const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime() const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2 const middle = start + (end - start) / 2
setSelectedDate(new Date(middle)) setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate) setSelectedMiniDate(calendarCurrentDate)
} else { } else {
setSelectedDate(calendarCurrentDate) setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate) setSelectedMiniDate(calendarCurrentDate)
} }
// Always use the calendar's current date for consistency // Always use the calendar's current date for consistency
if (onDateChange) { if (onDateChange) {
onDateChange(calendarCurrentDate) onDateChange(calendarCurrentDate)
} }
// Notify parent about view change // Notify parent about view change
setCurrentView(arg.view.type) setCurrentView(arg.view.type)
// Update slot label visibility when view changes // Update slot label visibility when view changes
setTimeout(() => { setTimeout(() => {
updateSlotLabelVisibility(new Date()) updateSlotLabelVisibility(new Date())
}, 100) }, 100)
}} }}
dayHeaderContent={arg => { dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone) const m = moment.tz(arg.date, timezone)
const date = m.date() const date = m.date()
const weekDay = m const weekDay = m
.toDate() .toDate()
.toLocaleDateString(t('locale'), { .toLocaleDateString(t('locale'), {
weekday: 'short', weekday: 'short',
timeZone: timezone timeZone: timezone
}) })
.toUpperCase() .toUpperCase()
return ( return (
<div <div
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`} 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' : ''}`}
> >
{date} <small>{weekDay}</small>
</span> {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
)} <span
</div> className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
) >
}} {date}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount} </span>
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount} )}
viewDidMount={viewHandlers.handleViewDidMount} </div>
viewWillUnmount={viewHandlers.handleViewWillUnmount} )
eventClick={eventHandlers.handleEventClick} }}
eventAllow={eventHandlers.handleEventAllow} dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
eventDrop={arg => { dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
void eventHandlers.handleEventDrop(arg) viewDidMount={viewHandlers.handleViewDidMount}
}} viewWillUnmount={viewHandlers.handleViewWillUnmount}
eventResize={arg => { eventClick={eventHandlers.handleEventClick}
void eventHandlers.handleEventResize(arg) eventAllow={eventHandlers.handleEventAllow}
}} eventDrop={arg => {
eventContent={viewHandlers.handleEventContent} void eventHandlers.handleEventDrop(arg)
eventDidMount={viewHandlers.handleEventDidMount} }}
/> eventResize={arg => {
void eventHandlers.handleEventResize(arg)
}}
eventContent={viewHandlers.handleEventContent}
eventDidMount={viewHandlers.handleEventDidMount}
/>
</div>
<div className="calendar-page" />
</div>
</div> </div>
)} )}
{view === 'search' && <SearchResultsPage />} {view === 'search' && <SearchResultsPage />}
@@ -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 }
}