From 3d07008a8cfa9687614f015305cfb0ac510c17cd Mon Sep 17 00:00:00 2001 From: lethemanh Date: Thu, 16 Apr 2026 15:25:59 +0700 Subject: [PATCH] #756 swipe gesture on calendar --- src/components/Calendar/Calendar.styl | 39 +- src/components/Calendar/Calendar.tsx | 367 ++++++++++-------- .../Calendar/hooks/useSwipeAnimation.ts | 62 +++ .../Calendar/hooks/useSwipeCalendar.ts | 102 +++++ 4 files changed, 400 insertions(+), 170 deletions(-) create mode 100644 src/components/Calendar/hooks/useSwipeAnimation.ts create mode 100644 src/components/Calendar/hooks/useSwipeCalendar.ts diff --git a/src/components/Calendar/Calendar.styl b/src/components/Calendar/Calendar.styl index fb8ab56..094431d 100644 --- a/src/components/Calendar/Calendar.styl +++ b/src/components/Calendar/Calendar.styl @@ -133,4 +133,41 @@ .weekSelector display flex flex-direction column - align-items center \ No newline at end of file + 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 diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 05a61d4..bb8e1ec 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -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 = { fr: frLocale, @@ -84,6 +85,7 @@ const CalendarApp: React.FC = ({ return (): void => clearTimeout(t) }, [selectedDate]) const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()) + const calendarWrapperRef = useRef(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 = ({ 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 = ({ }, [calendarRef]) const { t, lang } = useI18n() - const calendarWrapperRef = useRef(null) useTouchListener( eventHandlers.handleDateSelect, @@ -430,183 +438,204 @@ const CalendarApp: React.FC = ({ )} {view === 'calendar' && ( -
- { - if (ref) { - calendarRef.current = ref.getApi() - } +
+
[ - 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 ( -
- {displayWeekNumbers && ( -
- {t('menubar.views.week')} {arg.num} + > +
+
+ { + 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 ( +
+ {displayWeekNumbers && ( +
+ {t('menubar.views.week')} {arg.num} +
+ )} + + dispatch(setTimeZone(newTimezone)) + } + />
- )} - - dispatch(setTimeZone(newTimezone)) - } - /> -
- ) - }} - dayCellContent={arg => { - const month = arg.date.toLocaleDateString(t('locale'), { - month: 'short', - timeZone: timezone - }) - if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { - return ( - - {arg.dayNumberText === '1' ? month : ''}{' '} - {arg.dayNumberText} - - ) - } - }} - 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) + ) + }} + dayCellContent={arg => { + const month = arg.date.toLocaleDateString(t('locale'), { + month: 'short', + timeZone: timezone + }) + if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { + return ( + + {arg.dayNumberText === '1' ? month : ''}{' '} + {arg.dayNumberText} + + ) + } + }} + 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 ( -
- {weekDay} - {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && ( - - {date} - - )} -
- ) - }} - 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} - /> + {weekDay} + {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && ( + + {date} + + )} +
+ ) + }} + 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} + /> +
+
+
)} {view === 'search' && } diff --git a/src/components/Calendar/hooks/useSwipeAnimation.ts b/src/components/Calendar/hooks/useSwipeAnimation.ts new file mode 100644 index 0000000..e575b2d --- /dev/null +++ b/src/components/Calendar/hooks/useSwipeAnimation.ts @@ -0,0 +1,62 @@ +import { useState, useCallback, MutableRefObject } from 'react' +import { CalendarApi } from '@fullcalendar/core' + +export const useSwipeAnimation = ( + calendarRef: MutableRefObject +): { + 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 + } +} diff --git a/src/components/Calendar/hooks/useSwipeCalendar.ts b/src/components/Calendar/hooks/useSwipeCalendar.ts new file mode 100644 index 0000000..eea7c37 --- /dev/null +++ b/src/components/Calendar/hooks/useSwipeCalendar.ts @@ -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 + containerRef: RefObject + 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 } +}