[#786] added hook to handle tap separatly from slide (#787)

* [#786] fully disable long press

* [#786] added hook to handle tap separatly from slide
This commit is contained in:
Camille Moussu
2026-04-16 10:12:31 +02:00
committed by GitHub
parent 98132e98f2
commit 17e86d7832
2 changed files with 258 additions and 169 deletions
+183 -169
View File
@@ -1,4 +1,5 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks' import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice'
import EventPopover from '@/features/Events/EventModal' import EventPopover from '@/features/Events/EventModal'
import EventPreviewModal from '@/features/Events/EventPreview' import EventPreviewModal from '@/features/Events/EventPreview'
import { CalendarEvent } from '@/features/Events/EventsTypes' import { CalendarEvent } from '@/features/Events/EventsTypes'
@@ -32,20 +33,20 @@ import { EventErrorSnackbar } from '../Error/ErrorSnackbar'
import { EventErrorHandler } from '../Error/EventErrorHandler' import { EventErrorHandler } from '../Error/EventErrorHandler'
import { EditModeDialog } from '../Event/EditModeDialog' import { EditModeDialog } from '../Event/EditModeDialog'
import { Menubar, MenubarProps } from '../Menubar/Menubar' import { Menubar, MenubarProps } from '../Menubar/Menubar'
import { TimezoneSelector } from '../Timezone/TimezoneSelector'
import './Calendar.styl' import './Calendar.styl'
import './CustomCalendar.styl' import './CustomCalendar.styl'
import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers' import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers' import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
import { TimezoneSelector } from '../Timezone/TimezoneSelector' import Sidebar from './Sidebar/SideBar'
import TempSearchDialog from './TempSearchDialog'
import { useTouchListener } from './useTouchListener'
import { import {
eventToFullCalendarFormat, eventToFullCalendarFormat,
extractEvents, extractEvents,
updateSlotLabelVisibility updateSlotLabelVisibility
} from './utils/calendarUtils' } from './utils/calendarUtils'
import { CALENDAR_VIEWS } from './utils/constants' import { CALENDAR_VIEWS } from './utils/constants'
import Sidebar from './Sidebar/SideBar'
import TempSearchDialog from './TempSearchDialog'
import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice'
import ViewMoreEvents from './ViewMoreEvents' import ViewMoreEvents from './ViewMoreEvents'
const localeMap: Record<string, LocaleInput | undefined> = { const localeMap: Record<string, LocaleInput | undefined> = {
@@ -381,6 +382,13 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
}, [calendarRef]) }, [calendarRef])
const { t, lang } = useI18n() const { t, lang } = useI18n()
const calendarWrapperRef = useRef<HTMLDivElement>(null)
useTouchListener(
eventHandlers.handleDateSelect,
isTablet || isMobile,
calendarWrapperRef
)
return ( return (
<main <main
@@ -405,7 +413,7 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
<div className="calendar"> <div className="calendar">
<ImportAlert /> <ImportAlert />
{menubarProps?.isIframe && <Menubar {...menubarProps} />} {menubarProps?.isIframe && <Menubar {...menubarProps} />}
{isTablet && ( {(isTablet || isMobile) && (
<Fab <Fab
color="primary" color="primary"
aria-label={t('event.createEvent')} aria-label={t('event.createEvent')}
@@ -422,178 +430,184 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
</Fab> </Fab>
)} )}
{view === 'calendar' && ( {view === 'calendar' && (
<FullCalendar <div ref={calendarWrapperRef} style={{ height: '100%' }}>
key={hiddenDays.join(',')} <FullCalendar
ref={ref => { key={hiddenDays.join(',')}
if (ref) { ref={ref => {
calendarRef.current = ref.getApi() if (ref) {
calendarRef.current = ref.getApi()
}
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin
]}
initialView={
currentView ||
(isTablet
? CALENDAR_VIEWS.timeGridDay
: CALENDAR_VIEWS.timeGridWeek)
} }
}} firstDay={1}
plugins={[ editable={true}
dayGridPlugin, locale={localeMap[lang]}
timeGridPlugin, hiddenDays={hiddenDays}
interactionPlugin, selectable={true}
momentTimezonePlugin timeZone={timezone}
]} height="100%"
initialView={ select={eventHandlers.handleDateSelect}
currentView || nowIndicator
(isTablet slotLabelClassNames={arg => [
? CALENDAR_VIEWS.timeGridDay updateSlotLabelVisibility(new Date(), arg, timezone)
: CALENDAR_VIEWS.timeGridWeek) ]}
} nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
firstDay={1} headerToolbar={false}
editable={true} views={{
locale={localeMap[lang]} timeGridWeek: {
hiddenDays={hiddenDays} titleFormat: { month: 'long', year: 'numeric' }
selectable={true} }
timeZone={timezone} }}
height="100%" dayMaxEvents={true}
select={eventHandlers.handleDateSelect} moreLinkClick={handleMoreLinkClick}
longPressDelay={100} events={eventToFullCalendarFormat(
nowIndicator filteredEvents,
slotLabelClassNames={arg => [ filteredTempEvents,
updateSlotLabelVisibility(new Date(), arg, timezone) userId,
]} userData?.email,
nowIndicatorContent={viewHandlers.handleNowIndicatorContent} isPending,
headerToolbar={false} calendars
views={{ )}
timeGridWeek: { titleFormat: { month: 'long', year: 'numeric' } } eventOrder={(a: EventApi, b: EventApi) =>
}} a.extendedProps.priority - b.extendedProps.priority
dayMaxEvents={true} }
moreLinkClick={handleMoreLinkClick} weekNumbers={
events={eventToFullCalendarFormat( currentView === CALENDAR_VIEWS.timeGridWeek ||
filteredEvents, currentView === CALENDAR_VIEWS.timeGridDay
filteredTempEvents, }
userId, weekNumberFormat={{ week: 'long' }}
userData?.email, weekNumberContent={arg => {
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={calendarRef.current?.getDate() ?? new Date()}
onChange={(newTimezone: string) =>
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 ( return (
<span <div className="weekSelector">
className={`fc-daygrid-day-number ${ {displayWeekNumbers && (
arg.isToday ? 'current-date' : '' <div>
}`} {t('menubar.views.week')} {arg.num}
> </div>
{arg.dayNumberText === '1' ? month : ''} {arg.dayNumberText} )}
</span> <TimezoneSelector
value={timezone}
referenceDate={
calendarRef.current?.getDate() ?? new Date()
}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
</div>
) )
} }}
}} dayCellContent={arg => {
slotDuration="00:30:00" const month = arg.date.toLocaleDateString(t('locale'), {
slotLabelInterval="01:00:00" month: 'short',
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)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate)
}
// 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)
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t('locale'), {
weekday: 'short',
timeZone: timezone timeZone: timezone
}) })
.toUpperCase() if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
return (
return (
<div
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
>
<small>{weekDay}</small>
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
<span <span
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`} className={`fc-daygrid-day-number ${
arg.isToday ? 'current-date' : ''
}`}
> >
{date} {arg.dayNumberText === '1' ? month : ''}{' '}
{arg.dayNumberText}
</span> </span>
)} )
</div> }
) }}
}} slotDuration="00:30:00"
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount} slotLabelInterval="01:00:00"
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount} scrollTime="12:00:00"
viewDidMount={viewHandlers.handleViewDidMount} unselectAuto={false}
viewWillUnmount={viewHandlers.handleViewWillUnmount} allDayText=""
eventClick={eventHandlers.handleEventClick} slotLabelFormat={{
eventAllow={eventHandlers.handleEventAllow} hour: '2-digit',
eventDrop={arg => { minute: '2-digit',
void eventHandlers.handleEventDrop(arg) hour12: false
}} }}
eventResize={arg => { datesSet={arg => {
void eventHandlers.handleEventResize(arg) onViewChange?.(arg.view.type)
}} const calendarCurrentDate =
eventContent={viewHandlers.handleEventContent} calendarRef.current?.getDate() || new Date(arg.start)
eventDidMount={viewHandlers.handleEventDidMount} 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)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate)
}
// 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)
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' : ''}`}
>
<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>
)} )}
{view === 'search' && <SearchResultsPage />} {view === 'search' && <SearchResultsPage />}
<EventPopover <EventPopover
@@ -0,0 +1,75 @@
import { DateSelectArg } from '@fullcalendar/core'
import { RefObject, useEffect } from 'react'
const MOVE_THRESHOLD = 10
const TAP_DURATION = 200
function isTapGesture(dx: number, dy: number, duration: number): boolean {
return dx < MOVE_THRESHOLD && dy < MOVE_THRESHOLD && duration < TAP_DURATION
}
function getTimeAtPoint(x: number, y: number): string | null {
const el = document.elementFromPoint(x, y)
return (
(el as HTMLElement)?.closest('[data-time]')?.getAttribute('data-time') ??
null
)
}
function getDateAtPoint(x: number): string | null {
const col = Array.from(document.querySelectorAll('[data-date]')).find(el => {
const rect = el.getBoundingClientRect()
return x >= rect.left && x <= rect.right
})
return col?.getAttribute('data-date') ?? null
}
function buildSelectArg(date: string, time: string): DateSelectArg {
const start = new Date(`${date}T${time}`)
const end = new Date(start.getTime() + 30 * 60 * 1000)
return { start, end, allDay: false } as DateSelectArg
}
export function useTouchListener(
handleDateSelect: (selectInfo: DateSelectArg | null) => void,
isTouch: boolean,
wrapperRef: RefObject<HTMLDivElement>
): void {
useEffect(() => {
const el = wrapperRef.current
if (!el || !isTouch) return
let startX = 0,
startY = 0,
startTime = 0
const onTouchStart = (e: TouchEvent): void => {
startX = e.touches[0].clientX
startY = e.touches[0].clientY
startTime = Date.now()
}
const onTouchEnd = (e: TouchEvent): void => {
const touch = e.changedTouches[0]
const dx = Math.abs(touch.clientX - startX)
const dy = Math.abs(touch.clientY - startY)
if (!isTapGesture(dx, dy, Date.now() - startTime)) return
const time = getTimeAtPoint(touch.clientX, touch.clientY)
const date = getDateAtPoint(touch.clientX)
if (time && date) {
handleDateSelect(buildSelectArg(date, time))
}
}
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchend', onTouchEnd, { passive: true })
return (): void => {
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchend', onTouchEnd)
}
}, [handleDateSelect, isTouch, wrapperRef])
}