709 adapt creation modal to mobile view (#744)

* [#709] adapt creation modal to mobile view
* [#709] extracted time and date pickers to relevant subcomponents
* [#709] add bottom sheet to creation modal
* [#709] set autocomplete to off in title and location
* [#709] remove autofocus in event update modal
This commit is contained in:
Camille Moussu
2026-04-13 09:19:54 +02:00
committed by GitHub
parent 200d69fd28
commit b98a78abeb
21 changed files with 1093 additions and 727 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import {
DateTimeFields,
DateTimeFieldsProps
} from '@/components/Event/components/DateTimeFields'
} from '@/components/Event/components/DateTimeFields/DateTimeFields'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+17 -2
View File
@@ -24,6 +24,7 @@
"@mui/x-date-pickers": "^8.14.0",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/react": "^8.55.1",
"classnames": "^2.5.1",
"cozy-external-bridge": "^1.2.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
@@ -3067,8 +3068,8 @@
},
"node_modules/@linagora/twake-mui": {
"version": "1.2.0",
"resolved": "https://npm.pkg.github.com/download/@linagora/twake-mui/1.2.0/7b3eb115d70693ba2936ceaac70a8df3bae92672",
"integrity": "sha512-wKdkuJE48sFE5q2HWbrgI+FIZmBZefz5X5GAwiYHwvAD+9Fr/M+k973ZNSdaxwogWsQ604lZEBoIl13vKdc9EQ==",
"resolved": "https://registry.npmjs.org/@linagora/twake-mui/-/twake-mui-1.2.0.tgz",
"integrity": "sha512-urkpVOeCWpKNK4rdNrPUuUHCLMA60FG/7PocNCDp1Nn8GyjFeAWoxZNw980TJ785FjgExxELw363PQ0X78/mKw==",
"license": "MIT",
"dependencies": {
"react": "^18.2.0",
@@ -3083,6 +3084,14 @@
"@mui/material": "^7.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"react": {
"optional": false
},
"react-dom": {
"optional": false
}
}
},
"node_modules/@lottiefiles/dotlottie-react": {
@@ -6284,6 +6293,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/cli-boxes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"@mui/x-date-pickers": "^8.14.0",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/react": "^8.55.1",
"classnames": "^2.5.1",
"cozy-external-bridge": "^1.2.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
+2 -2
View File
@@ -192,7 +192,7 @@ function ResponsiveDialog({
{...otherDialogProps}
>
<DialogTitle sx={titleSx} {...dialogTitleProps}>
{isExpanded && onExpandToggle ? (
{isExpanded && onExpandToggle && !isMobile ? (
<IconButton
onClick={onExpandToggle}
aria-label="show less"
@@ -209,7 +209,7 @@ function ResponsiveDialog({
>
<Box>{title}</Box>
<Box>
{onExpandToggle && (
{onExpandToggle && !isMobile && (
<IconButton
onClick={onExpandToggle}
aria-label="expand"
+4 -2
View File
@@ -1,3 +1,4 @@
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
import { TextField } from '@linagora/twake-mui'
import { Notes as NotesIcon } from '@mui/icons-material'
import React from 'react'
@@ -17,8 +18,9 @@ export function AddDescButton({
showMore: boolean
description: string
setDescription: (d: string) => void
}) {
}): JSX.Element {
const { t } = useI18n()
const { isTooSmall: isMobile } = useScreenSizeDetection()
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null)
React.useEffect(() => {
@@ -30,7 +32,7 @@ export function AddDescButton({
const descriptionField = (
<FieldWithLabel
label={t('event.form.description')}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
sx={{ padding: 0, margin: 0 }}
>
<TextField
+136 -85
View File
@@ -42,7 +42,7 @@ import { OwnerCaption } from '../Calendar/OwnerCaption'
import { SnackbarAlert } from '../Loading/SnackBarAlert'
import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete'
import { AddDescButton } from './AddDescButton'
import { DateTimeFields } from './components/DateTimeFields'
import { DateTimeFields } from './components/DateTimeFields/DateTimeFields'
import { DateTimeSummary } from './components/DateTimeSummary'
import { FieldWithLabel } from './components/FieldWithLabel'
import { SectionPreviewRow } from './components/SectionPreviewRow'
@@ -51,6 +51,10 @@ import { useAllDayToggle } from './hooks/useAllDayToggle'
import { combineDateTime, splitDateTime } from './utils/dateTimeHelpers'
import { validateEventForm } from './utils/formValidation'
import { Resource, ResourceSearch } from '../Attendees/ResourceSearch'
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
import { SmallTimezoneSelector } from '../Timezone/SmallTimeZoneSelector'
import ArrowDropDown from '@mui/icons-material/ArrowDropDown'
import { getTimezoneOffset } from '@/utils/timezone'
interface EventFormFieldsProps {
// Form state
@@ -171,9 +175,12 @@ export default function EventFormFields({
onHasEndDateChangedChange,
selectedResources,
setSelectedResources
}: EventFormFieldsProps) {
}: EventFormFieldsProps): JSX.Element {
const { t } = useI18n()
const { isTooSmall: isMobile } = useScreenSizeDetection()
const [timezoneDrawerOpen, setTimezoneDrawerOpen] = React.useState(false)
// Internal state for 4 separate fields
const [startDate, setStartDate] = React.useState('')
const [startTime, setStartTime] = React.useState('')
@@ -278,16 +285,16 @@ export default function EventFormFields({
// Auto-focus title field when modal opens (skip in test environment)
React.useEffect(() => {
if (isOpen) {
if (isOpen && !eventId) {
if (titleInputRef.current && process.env.NODE_ENV !== 'test') {
// Use setTimeout to ensure the dialog is fully rendered
const timer = setTimeout(() => {
titleInputRef.current?.focus()
}, 100)
return () => clearTimeout(timer)
return (): void => clearTimeout(timer)
}
}
}, [isOpen])
}, [isOpen, eventId])
// Auto-focus title field when toggling between normal and extended mode
React.useEffect(() => {
@@ -309,7 +316,7 @@ export default function EventFormFields({
// Update previous value before returning cleanup
prevShowMoreRef.current = showMore
return () => clearTimeout(timer)
return (): void => clearTimeout(timer)
}
}
@@ -441,7 +448,7 @@ export default function EventFormFields({
const validation = validateForm()
const handleAddVideoConference = () => {
const handleAddVideoConference = (): void => {
const newMeetingLink = generateMeetingLink()
const updatedDescription = addVideoConferenceToDescription(
description,
@@ -457,7 +464,7 @@ export default function EventFormFields({
const [openToast, setOpenToast] = React.useState(false)
const handleCopyMeetingLink = async () => {
const handleCopyMeetingLink = async (): Promise<void> => {
if (meetingLink) {
try {
await navigator.clipboard.writeText(meetingLink)
@@ -468,29 +475,38 @@ export default function EventFormFields({
}
}
const handleDeleteVideoConference = () => {
const handleDeleteVideoConference = (): void => {
setDescription(removeVideoConferenceFromDescription(description))
setHasVideoConference(false)
setMeetingLink(null)
}
const handleCalendarChange = (newCalendarId: string) => {
const handleCalendarChange = (newCalendarId: string): void => {
setCalendarid(newCalendarId)
onCalendarChange?.(newCalendarId)
}
const handleResourceChange = (resources: Resource[]) => {
const handleResourceChange = (resources: Resource[]): void => {
setSelectedResources(resources)
}
const offset = getTimezoneOffset(
timezone ? timezone : timezoneList.browserTz,
new Date(start)
)
const tzLabel = timezone
? timezone.split('/').pop()?.replace(/_/g, ' ')
: timezoneList.browserTz
return (
<>
<FieldWithLabel
label={showMore ? t('event.form.title') : ''}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
<TextField
fullWidth
autoComplete="off"
label=""
inputProps={{ 'aria-label': t('event.form.title') }}
placeholder={t('event.form.titlePlaceholder')}
@@ -510,7 +526,7 @@ export default function EventFormFields({
? ''
: t('event.form.dateTime')
}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
{!showMore && !hasClickedDateTimeSection ? (
<DateTimeSummary
@@ -554,76 +570,109 @@ export default function EventFormFields({
</FieldWithLabel>
{!(!showMore && !hasClickedDateTimeSection) && (
<FieldWithLabel label=" " isExpanded={showMore}>
<Box display="flex" gap={2} alignItems="center">
<FormControlLabel
control={
<Checkbox checked={allday} onChange={handleAllDayToggle} />
}
label={
<Typography variant="h6">{t('event.form.allDay')}</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={
showRepeat ||
(typeOfAction === 'solo' && !!repetition?.freq)
}
disabled={typeOfAction === 'solo'}
onChange={() => {
const newShowRepeat = !showRepeat
setShowRepeat(newShowRepeat)
if (newShowRepeat) {
const days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
const eventStartDate = new Date(start)
const jsDay = eventStartDate.getDay()
const icsDay = days[(jsDay + 6) % 7]
setRepetition({
freq: 'weekly',
interval: 1,
occurrences: 0,
endDate: '',
byday: [icsDay]
} as RepetitionObject)
} else {
setRepetition({
freq: '',
interval: 1,
occurrences: 0,
endDate: '',
byday: null
} as RepetitionObject)
<FieldWithLabel label=" " isExpanded={showMore && !isMobile}>
<Box
display="flex"
gap={2}
alignItems={isMobile ? 'start' : 'center'}
flexDirection={isMobile ? 'column' : 'row'}
>
<Box>
<FormControlLabel
control={
<Checkbox checked={allday} onChange={handleAllDayToggle} />
}
label={
<Typography variant="h6">{t('event.form.allDay')}</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={
showRepeat ||
(typeOfAction === 'solo' && !!repetition?.freq)
}
disabled={typeOfAction === 'solo'}
onChange={() => {
const newShowRepeat = !showRepeat
setShowRepeat(newShowRepeat)
if (newShowRepeat) {
const days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
const eventStartDate = new Date(start)
const jsDay = eventStartDate.getDay()
const icsDay = days[(jsDay + 6) % 7]
setRepetition({
freq: 'weekly',
interval: 1,
occurrences: 0,
endDate: '',
byday: [icsDay]
} as RepetitionObject)
} else {
setRepetition({
freq: '',
interval: 1,
occurrences: 0,
endDate: '',
byday: null
} as RepetitionObject)
}
}}
/>
}
label={
<Typography variant="h6">{t('event.form.repeat')}</Typography>
}
/>
</Box>
{isMobile ? (
<>
<Button
variant="text"
size="small"
onClick={() => setTimezoneDrawerOpen(true)}
sx={{ textTransform: 'none', px: 0, color: 'text.secondary' }}
>
<Typography variant="h6">
({offset}) {tzLabel}
</Typography>
<ArrowDropDown />
</Button>
<SmallTimezoneSelector
open={timezoneDrawerOpen}
onClose={() => setTimezoneDrawerOpen(false)}
value={timezone}
onChange={tz => {
setTimezone(tz)
setTimezoneDrawerOpen(false)
}}
referenceDate={new Date(start)}
/>
}
label={
<Typography variant="h6">{t('event.form.repeat')}</Typography>
}
/>
<TimezoneAutocomplete
value={timezone}
onChange={setTimezone}
zones={timezoneList.zones}
getTimezoneOffset={(tzName: string) =>
timezoneList.getTimezoneOffset(tzName, new Date(start))
}
showIcon={false}
width={220}
size="small"
placeholder={t('event.form.timezonePlaceholder')}
hideBorder
inputPadding="8px 65px 8px 0px"
/>
</>
) : (
<TimezoneAutocomplete
value={timezone}
onChange={setTimezone}
zones={timezoneList.zones}
getTimezoneOffset={(tzName: string) =>
timezoneList.getTimezoneOffset(tzName, new Date(start))
}
showIcon={false}
width={220}
size="small"
placeholder={t('event.form.timezonePlaceholder')}
hideBorder
inputPadding="8px 65px 8px 0px"
/>
)}
</Box>
</FieldWithLabel>
)}
{!(!showMore && !hasClickedDateTimeSection) &&
(showRepeat || (typeOfAction === 'solo' && repetition?.freq)) && (
<FieldWithLabel label=" " isExpanded={showMore}>
<FieldWithLabel label=" " isExpanded={showMore && !isMobile}>
<RepeatEvent
repetition={repetition}
eventStart={new Date(start)}
@@ -635,7 +684,7 @@ export default function EventFormFields({
<FieldWithLabel
label={showMore ? t('event.form.participants') : ''}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
<AttendeeSelector
attendees={attendees}
@@ -651,7 +700,7 @@ export default function EventFormFields({
<FieldWithLabel
label={showMore ? t('event.form.videoMeeting') : ''}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
{!showMore ? (
hasVideoConference && meetingLink ? (
@@ -671,7 +720,7 @@ export default function EventFormFields({
{t('event.form.joinVisioConference')}
</Button>
<IconButton
onClick={handleCopyMeetingLink}
onClick={() => void handleCopyMeetingLink()}
size="small"
sx={{ color: 'primary.main' }}
aria-label={t('event.form.copyMeetingLink')}
@@ -737,7 +786,7 @@ export default function EventFormFields({
{t('event.form.joinVisioConference')}
</Button>
<IconButton
onClick={handleCopyMeetingLink}
onClick={() => void handleCopyMeetingLink()}
size="small"
sx={{ color: 'primary.main' }}
aria-label={t('event.form.copyMeetingLink')}
@@ -766,13 +815,14 @@ export default function EventFormFields({
showMore={showMore}
description={description}
setDescription={setDescription}
isMobile={isMobile}
/>
<FieldWithLabel
label={
showMore || hasClickedLocationSection ? t('event.form.location') : ''
}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
{!showMore && !hasClickedLocationSection ? (
<SectionPreviewRow
@@ -783,6 +833,7 @@ export default function EventFormFields({
</SectionPreviewRow>
) : (
<TextField
autoComplete="off"
fullWidth
label=""
inputRef={locationInputRef}
@@ -800,7 +851,7 @@ export default function EventFormFields({
label={
showMore || hasClickedCalendarSection ? t('event.form.calendar') : ''
}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
{!showMore && !hasClickedCalendarSection ? (
<SectionPreviewRow
@@ -865,7 +916,7 @@ export default function EventFormFields({
<>
<FieldWithLabel
label={t('event.form.resource')}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
<FormControl fullWidth margin="dense" size="small">
<ResourceSearch
@@ -882,7 +933,7 @@ export default function EventFormFields({
<FieldWithLabel
label={t('event.form.notification')}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
<FormControl fullWidth margin="dense" size="small">
<Select
@@ -934,7 +985,7 @@ export default function EventFormFields({
<FieldWithLabel
label={t('event.form.showMeAs')}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
<FormControl fullWidth margin="dense" size="small">
<Select
@@ -950,12 +1001,12 @@ export default function EventFormFields({
<FieldWithLabel
label={t('event.form.visibleTo')}
isExpanded={showMore}
isExpanded={showMore && !isMobile}
>
<ToggleButtonGroup
value={eventClass}
exclusive
onChange={(e, newValue) => {
onChange={(_e, newValue: string) => {
if (newValue !== null) {
setEventClass(newValue)
}
@@ -1,633 +0,0 @@
import {
Box,
TextFieldProps,
Typography,
useMediaQuery,
useTheme
} from '@linagora/twake-mui'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import {
DatePicker,
DatePickerFieldProps,
DatePickerSlotProps
} from '@mui/x-date-pickers/DatePicker'
import { PickerValue } from '@mui/x-date-pickers/internals'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import {
TimePicker,
TimePickerFieldProps
} from '@mui/x-date-pickers/TimePicker'
import dayjs, { Dayjs } from 'dayjs'
import 'dayjs/locale/en'
import 'dayjs/locale/fr'
import 'dayjs/locale/ru'
import 'dayjs/locale/vi'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import React, { useMemo } from 'react'
import { useI18n } from 'twake-i18n'
import { LONG_DATE_FORMAT } from '../utils/dateTimeFormatters'
import { dtDate, dtTime, toDateTime } from '../utils/dateTimeHelpers'
import { EditableTimeField } from './EditableTimeField'
import { ReadOnlyDateField } from './ReadOnlyPickerField'
dayjs.extend(customParseFormat)
const timePickerPopperSx = {
'& .MuiPaper-root': {
width: '110px',
minWidth: '110px'
},
'& .MuiMultiSectionDigitalClockSection-item': {
justifyContent: 'flex-start',
width: '100%',
textAlign: 'left'
}
}
// twake-mui datePickerOverrides also uses this selector. Repeating ensures our override wins.
const dateCalendarLayoutSx = {
'& .MuiDateCalendar-root.MuiDateCalendar-root': {
width: '260px',
maxWidth: '260px',
padding: '0 15px'
}
}
/**
* Props for DateTimeFields component
*/
export interface DateTimeFieldsProps {
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
showMore: boolean
hasEndDateChanged: boolean
showEndDate: boolean
onToggleEndDate: () => void
validation: {
errors: {
dateTime: string
}
}
onStartDateChange: (date: string) => void
onStartTimeChange: (time: string) => void
onEndDateChange: (date: string, time?: string) => void
onEndTimeChange: (time: string) => void
}
/**
* DateTimeFields component - 4 separate date/time input fields
* Layout: Start Date (30%) | Start Time (20%) | End Time (20%) | End Date (30%)
*/
export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
startDate,
startTime,
endDate,
endTime,
allday,
showMore,
hasEndDateChanged,
showEndDate,
validation,
onStartDateChange,
onStartTimeChange,
onEndDateChange,
onEndTimeChange
}): JSX.Element => {
const { t, lang } = useI18n()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const initialDurationRef = React.useRef<number | null>(null)
const isUserActionRef = React.useRef(false)
const getCurrentDuration = React.useCallback((): number => {
const start = toDateTime(startDate, startTime)
const end = toDateTime(endDate, endTime)
if (allday) {
return Math.max(end.startOf('day').diff(start.startOf('day'), 'day'), 0)
} else {
return Math.max(end.diff(start, 'minute'), 0)
}
}, [startDate, startTime, endDate, endTime, allday])
React.useEffect(() => {
if (!isUserActionRef.current && startDate && endDate) {
initialDurationRef.current = getCurrentDuration()
}
queueMicrotask(() => {
isUserActionRef.current = false
})
}, [startDate, endDate, startTime, endTime, getCurrentDuration])
const spansMultipleDays = React.useMemo(() => {
return startDate !== endDate
}, [startDate, endDate])
const isExpanded = showMore
const shouldShowEndDateNormal = allday || showEndDate
// Show full 4 fields when:
// 1. Non-allday with hasEndDateChanged
// 2. Multiple days with hasEndDateChanged (supports drag from week/month view grid with allday checked)
// 3. Multiple days without allday (original behavior)
const shouldShowFullFieldsInNormal =
(!allday && hasEndDateChanged) ||
(hasEndDateChanged && spansMultipleDays) ||
(spansMultipleDays && !allday)
const showSingleDateField =
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal
// When shouldShowFullFieldsInNormal is true, show time fields even if allday is true
// This supports the case: drag from week/month view grid with multiple days
const shouldShowTimeFields = !allday || shouldShowFullFieldsInNormal
const startDateLabel = showSingleDateField
? t('dateTimeFields.date')
: t('dateTimeFields.startDate')
const handleStartDateChange = (value: PickerValue): void => {
if (!value || !value.isValid()) return
isUserActionRef.current = true
const newDateStr = dtDate(value as Dayjs)
const duration = initialDurationRef.current ?? getCurrentDuration()
onStartDateChange(newDateStr)
// Preserve duration by adjusting end
const newStart = toDateTime(newDateStr, startTime)
let newEnd: Dayjs
if (allday) {
newEnd = newStart.add(duration, 'day')
} else {
newEnd = newStart.add(duration, 'minute')
}
const newEndDate = dtDate(newEnd)
const newEndTime = dtTime(newEnd)
if (newEndDate !== endDate) {
onEndDateChange(newEndDate)
}
if (!allday && newEndTime !== endTime) {
onEndTimeChange(newEndTime)
}
}
const handleStartTimeChange = (value: PickerValue): void => {
if (!value || !value.isValid()) return
isUserActionRef.current = true
const newTimeStr = dtTime(value as Dayjs)
const duration = initialDurationRef.current ?? getCurrentDuration()
onStartTimeChange(newTimeStr)
const newStart = toDateTime(startDate, newTimeStr)
const newEnd = newStart.add(duration, 'minute')
const newEndDate = dtDate(newEnd)
const newEndTime = dtTime(newEnd)
if (newEndDate !== endDate && newEndTime !== endTime) {
onEndDateChange(newEndDate, newEndTime)
} else {
if (newEndDate !== endDate) {
onEndDateChange(newEndDate)
}
if (!allday && newEndTime !== endTime) {
onEndTimeChange(newEndTime)
}
}
}
const handleEndDateChange = (value: PickerValue): void => {
if (!value || !value.isValid()) return
isUserActionRef.current = true
const newDateStr = dtDate(value as Dayjs)
const newEnd = toDateTime(newDateStr, endTime)
const currentStart = toDateTime(startDate, startTime)
if (newEnd.isBefore(currentStart)) {
const duration = initialDurationRef.current ?? getCurrentDuration()
let newStart: Dayjs
if (allday) {
newStart = newEnd.subtract(duration, 'day')
} else {
newStart = newEnd.subtract(duration, 'minute')
}
const newStartDate = dtDate(newStart)
const newStartTime = dtTime(newStart)
if (newStartDate !== startDate) {
onStartDateChange(newStartDate)
}
if (!allday && newStartTime !== startTime) {
onStartTimeChange(newStartTime)
}
} else {
initialDurationRef.current = newEnd.diff(
currentStart,
allday ? 'day' : 'minute'
)
}
onEndDateChange(newDateStr)
}
const handleEndTimeChange = (value: PickerValue): void => {
if (!value || !value.isValid()) return
isUserActionRef.current = true
const newTimeStr = dtTime(value as Dayjs)
const newEnd = toDateTime(endDate, newTimeStr)
const currentStart = toDateTime(startDate, startTime)
// Always update duration ref to reflect current state, even if invalid (end < start)
// This prevents stale duration from being used when user edits start time
initialDurationRef.current = newEnd.diff(currentStart, 'minute')
onEndTimeChange(newTimeStr)
}
// Memoize parsed date/time values
const startDateValue = useMemo(
() => (startDate ? dayjs(startDate) : null),
[startDate]
)
const startTimeValue = useMemo(
() => (startTime ? dayjs(startTime, 'HH:mm') : null),
[startTime]
)
const endDateValue = useMemo(
() => (endDate ? dayjs(endDate) : null),
[endDate]
)
const endTimeValue = useMemo(
() => (endTime ? dayjs(endTime, 'HH:mm') : null),
[endTime]
)
const getSlotProps = (
testId: string,
hasError = false,
testLabel?: string
): Partial<DatePickerSlotProps<true>> => ({
textField: {
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: '100%' },
inputProps: {
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
}
})
const getFieldSlotProps = (
testId: string,
hasError = false,
testLabel?: string
): Partial<DatePickerFieldProps | TimePickerFieldProps> &
Pick<
TextFieldProps,
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => ({
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: '100%' },
inputProps: {
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
})
const getTimeFieldSlotProps = (
testId: string,
hasError = false,
testLabel?: string
): Partial<TimePickerFieldProps> &
Pick<
TextFieldProps,
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => ({
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: '100%' },
inputProps: {
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
})
return (
<LocalizationProvider
key={lang}
dateAdapter={AdapterDayjs}
adapterLocale={lang ?? 'en'}
localeText={{
okButtonLabel: t('common.ok'),
cancelButtonLabel: t('common.cancel'),
todayButtonLabel: t('menubar.today')
}}
>
<Box
display="flex"
flexDirection="column"
className={`date-time-group ${
isExpanded || shouldShowFullFieldsInNormal ? 'show-full-field' : ''
}`.trim()}
sx={{ maxWidth: showMore ? 'calc(100% - 145px)' : '100%' }}
>
{isExpanded || shouldShowFullFieldsInNormal ? (
<>
<Box
display="flex"
gap={1}
flexDirection={isMobile ? 'column' : 'row'}
alignItems="center"
>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
onChange={handleStartDateChange}
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
'start-date-input',
false,
t('dateTimeFields.startDate')
),
field: getFieldSlotProps(
'start-date-input',
false,
t('dateTimeFields.startDate')
),
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
{shouldShowTimeFields && (
<Box sx={{ width: '110px' }}>
<TimePicker
ampm={false}
value={startTimeValue}
onChange={handleStartTimeChange}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
'start-time-input',
false,
t('dateTimeFields.startTime')
)
}}
/>
</Box>
)}
</Box>
<Box
display="flex"
gap={1}
flexDirection={isMobile ? 'column' : 'row'}
alignItems={isMobile ? 'stretch' : 'center'}
>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={endDateValue}
onChange={handleEndDateChange}
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
'end-date-input',
!!validation.errors.dateTime,
t('dateTimeFields.endDate')
),
field: getFieldSlotProps(
'end-date-input',
!!validation.errors.dateTime,
t('dateTimeFields.endDate')
),
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
{shouldShowTimeFields && (
<Box sx={{ width: '110px' }}>
<TimePicker
ampm={false}
value={endTimeValue}
onChange={handleEndTimeChange}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
'end-time-input',
!!validation.errors.dateTime,
t('dateTimeFields.endTime')
)
}}
/>
</Box>
)}
</Box>
</>
) : shouldShowEndDateNormal ? (
<Box
display="flex"
gap={1}
flexDirection={isMobile ? 'column' : 'row'}
alignItems={isMobile ? 'stretch' : 'center'}
>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
onChange={handleStartDateChange}
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
'start-date-input',
false,
t('dateTimeFields.startDate')
),
field: getFieldSlotProps(
'start-date-input',
false,
t('dateTimeFields.startDate')
),
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={endDateValue}
onChange={handleEndDateChange}
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
'end-date-input',
!!validation.errors.dateTime,
t('dateTimeFields.endDate')
),
field: getFieldSlotProps(
'end-date-input',
!!validation.errors.dateTime,
t('dateTimeFields.endDate')
),
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
</Box>
) : (
<Box
display="flex"
gap={1}
flexDirection={isMobile ? 'column' : 'row'}
alignItems={isMobile ? 'stretch' : 'center'}
>
<Box
sx={isMobile ? undefined : { maxWidth: '300px', width: '48%' }}
>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
onChange={handleStartDateChange}
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps('start-date-input', false, startDateLabel),
field: getFieldSlotProps(
'start-date-input',
false,
startDateLabel
),
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
<Box display="flex" gap={1} flexDirection="row">
<Box sx={{ width: isMobile ? '100%' : '110px' }}>
<TimePicker
ampm={false}
value={startTimeValue}
onChange={handleStartTimeChange}
disabled={allday}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
'start-time-input',
false,
t('dateTimeFields.startTime')
)
}}
/>
</Box>
{!allday && (
<Typography
sx={{
alignSelf: 'center',
mx: 0.5,
mt: 0.5
}}
>
-
</Typography>
)}
<Box sx={{ width: isMobile ? '100%' : '110px' }}>
<TimePicker
ampm={false}
value={endTimeValue}
onChange={handleEndTimeChange}
disabled={allday}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
'end-time-input',
!!validation.errors.dateTime,
t('dateTimeFields.endTime')
)
}}
/>
</Box>
</Box>
</Box>
)}
{/* Second row: Error message */}
{validation.errors.dateTime && (
<Box display="flex" gap={1} flexDirection="row">
<Box
sx={{
width: '1%'
}}
/>
<Box>
<Typography variant="caption" sx={{ color: 'error.main' }}>
{validation.errors.dateTime}
</Typography>
</Box>
</Box>
)}
</Box>
</LocalizationProvider>
)
}
@@ -0,0 +1,61 @@
import { Box } from '@linagora/twake-mui'
import { PickerValue } from '@mui/x-date-pickers/internals'
import dayjs from 'dayjs'
import React from 'react'
import { useI18n } from 'twake-i18n'
import { DatePickerField } from './DatePickerField'
export interface AllDayDateLayoutProps {
startDateValue: ReturnType<typeof dayjs> | null
endDateValue: ReturnType<typeof dayjs> | null
hasError: boolean
isMobile: boolean
onStartDateChange: (value: PickerValue) => void
onEndDateChange: (value: PickerValue) => void
}
/**
* Side-by-side date pickers with no time fields.
* Used for all-day events or when showEndDate is true.
*/
export const AllDayDateLayout: React.FC<AllDayDateLayoutProps> = ({
startDateValue,
endDateValue,
hasError,
isMobile,
onStartDateChange,
onEndDateChange
}) => {
const { t } = useI18n()
const containerProps = {
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'center'
}
const dateBoxSx = isMobile
? { width: '100%' }
: { maxWidth: '300px', width: '48%' }
return (
<Box display="flex" gap={1} sx={containerProps}>
<Box sx={dateBoxSx}>
<DatePickerField
value={startDateValue}
onChange={onStartDateChange}
testId="start-date-input"
label={t('dateTimeFields.startDate')}
/>
</Box>
<Box sx={dateBoxSx}>
<DatePickerField
value={endDateValue}
onChange={onEndDateChange}
testId="end-date-input"
label={t('dateTimeFields.endDate')}
hasError={hasError}
/>
</Box>
</Box>
)
}
@@ -0,0 +1,97 @@
import { Box, Typography } from '@linagora/twake-mui'
import { PickerValue } from '@mui/x-date-pickers/internals'
import { Dayjs } from 'dayjs'
import React from 'react'
import { useI18n } from 'twake-i18n'
import { DatePickerField } from './DatePickerField'
import { TimePickerField } from './TimePickerField'
export interface CompactDateTimeLayoutProps {
startDateValue: Dayjs | null
startTimeValue: Dayjs | null
endTimeValue: Dayjs | null
hasError: boolean
isMobile: boolean
allday: boolean
startDateLabel: string
onStartDateChange: (value: PickerValue) => void
onStartTimeChange: (value: PickerValue) => void
onEndTimeChange: (value: PickerValue) => void
}
/**
* Default compact layout: one date field + start and end time pickers inline.
* Used when the event is not multi-day and has no explicit end date shown.
*/
export const CompactDateTimeLayout: React.FC<CompactDateTimeLayoutProps> = ({
startDateValue,
startTimeValue,
endTimeValue,
hasError,
isMobile,
allday,
startDateLabel,
onStartDateChange,
onStartTimeChange,
onEndTimeChange
}) => {
const { t } = useI18n()
const containerProps = {
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'center'
}
const dateBoxSx = isMobile
? { width: '100%' }
: { maxWidth: '300px', width: '48%' }
const timeRowSx = isMobile ? { width: '100%' } : undefined
const timeBoxSx = {
flex: isMobile ? 1 : undefined,
width: isMobile ? undefined : '110px'
}
return (
<Box display="flex" gap={1} sx={containerProps}>
<Box sx={dateBoxSx}>
<DatePickerField
value={startDateValue}
onChange={onStartDateChange}
testId="start-date-input"
label={startDateLabel}
/>
</Box>
<Box display="flex" gap={1} flexDirection="row" sx={timeRowSx}>
<Box sx={timeBoxSx}>
<TimePickerField
value={startTimeValue}
onChange={onStartTimeChange}
testId="start-time-input"
label={t('dateTimeFields.startTime')}
disabled={allday}
/>
</Box>
{!allday && (
<Typography sx={{ alignSelf: 'center', mx: 0.5, mt: 0.5 }}>
-
</Typography>
)}
<Box sx={timeBoxSx}>
<TimePickerField
value={endTimeValue}
onChange={onEndTimeChange}
testId="end-time-input"
label={t('dateTimeFields.endTime')}
hasError={hasError}
disabled={allday}
/>
</Box>
</Box>
</Box>
)
}
@@ -0,0 +1,39 @@
import { DatePicker } from '@mui/x-date-pickers/DatePicker'
import { PickerValue } from '@mui/x-date-pickers/internals'
import { Dayjs } from 'dayjs'
import React from 'react'
import { LONG_DATE_FORMAT } from '../../utils/dateTimeFormatters'
import { ReadOnlyDateField } from '../ReadOnlyPickerField'
import {
dateCalendarLayoutSx,
getDateFieldSlotProps,
getDateSlotProps
} from './dateTimePickerSlotProps'
export interface DatePickerFieldProps {
value: Dayjs | null
onChange: (value: PickerValue) => void
testId: string
label: string
hasError?: boolean
}
export const DatePickerField: React.FC<DatePickerFieldProps> = ({
value,
onChange,
testId,
label,
hasError = false
}) => (
<DatePicker
format={LONG_DATE_FORMAT}
value={value}
onChange={onChange}
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getDateSlotProps(testId, hasError, label),
field: getDateFieldSlotProps(testId, hasError, label),
layout: { sx: dateCalendarLayoutSx }
}}
/>
)
@@ -0,0 +1,22 @@
import { Box, Typography } from '@linagora/twake-mui'
import React from 'react'
export interface DateTimeErrorProps {
message: string
}
export const DateTimeError: React.FC<DateTimeErrorProps> = ({ message }) => {
if (!message) {
return null
}
return (
<Box display="flex" gap={1} flexDirection="row">
<Box sx={{ width: '1%' }} />
<Box>
<Typography variant="caption" sx={{ color: 'error.main' }}>
{message}
</Typography>
</Box>
</Box>
)
}
@@ -0,0 +1,165 @@
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
import { Box } from '@linagora/twake-mui'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import classNames from 'classnames'
import dayjs from 'dayjs'
import 'dayjs/locale/en'
import 'dayjs/locale/fr'
import 'dayjs/locale/ru'
import 'dayjs/locale/vi'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import React, { useMemo } from 'react'
import { useI18n } from 'twake-i18n'
import { DateTimeError } from './DateTimeError'
import { DateTimeLayoutContent } from './DateTimeLayoutContent'
import { useDateTimeHandlers } from './useDateTimeHandlers'
import { useDateTimeLayout } from './useDateTimeLayout'
import { useDisplayFlags } from './useDisplayFlags'
dayjs.extend(customParseFormat)
export interface DateTimeFieldsProps {
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
showMore: boolean
hasEndDateChanged: boolean
showEndDate: boolean
onToggleEndDate: () => void
validation: {
errors: {
dateTime: string
}
}
onStartDateChange: (date: string) => void
onStartTimeChange: (time: string) => void
onEndDateChange: (date: string, time?: string) => void
onEndTimeChange: (time: string) => void
}
/**
* DateTimeFields component - orchestrates date/time layout variants.
* Handles state, duration-preserving event handlers, and selects the
* appropriate layout sub-component based on the current mode.
*/
export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
startDate,
startTime,
endDate,
endTime,
allday,
showMore,
hasEndDateChanged,
showEndDate,
validation,
onStartDateChange,
onStartTimeChange,
onEndDateChange,
onEndTimeChange
}) => {
const { t, lang } = useI18n()
const { isTooSmall: isMobile } = useScreenSizeDetection()
const {
spansMultipleDays,
shouldShowFullFieldsInNormal,
showSingleDateField,
shouldShowTimeFields
} = useDisplayFlags({
allday,
hasEndDateChanged,
startDate,
endDate,
showEndDate,
isExpanded: showMore
})
const startDateLabel = showSingleDateField
? t('dateTimeFields.date')
: t('dateTimeFields.startDate')
const layoutMode = useDateTimeLayout({
hasEndDateChanged,
allday,
spansMultipleDays,
showMore,
showEndDate
})
const {
handleStartDateChange,
handleStartTimeChange,
handleEndDateChange,
handleEndTimeChange
} = useDateTimeHandlers({
startDate,
startTime,
endDate,
endTime,
allday,
onStartDateChange,
onStartTimeChange,
onEndDateChange,
onEndTimeChange
})
const startDateValue = useMemo(
() => (startDate ? dayjs(startDate) : null),
[startDate]
)
const startTimeValue = useMemo(
() => (startTime ? dayjs(startTime, 'HH:mm') : null),
[startTime]
)
const endDateValue = useMemo(
() => (endDate ? dayjs(endDate) : null),
[endDate]
)
const endTimeValue = useMemo(
() => (endTime ? dayjs(endTime, 'HH:mm') : null),
[endTime]
)
const hasError = !!validation.errors.dateTime
const showFullField = showMore || shouldShowFullFieldsInNormal
const containerClassName = classNames('date-time-group', {
'show-full-field': showFullField
})
return (
<LocalizationProvider
key={lang}
dateAdapter={AdapterDayjs}
adapterLocale={lang ?? 'en'}
localeText={{
okButtonLabel: t('common.ok'),
cancelButtonLabel: t('common.cancel'),
todayButtonLabel: t('menubar.today')
}}
>
<Box display="flex" flexDirection="column" className={containerClassName}>
<DateTimeLayoutContent
layoutMode={layoutMode}
startDateValue={startDateValue}
startTimeValue={startTimeValue}
endDateValue={endDateValue}
endTimeValue={endTimeValue}
hasError={hasError}
isMobile={isMobile}
allday={allday}
startDateLabel={startDateLabel}
shouldShowTimeFields={shouldShowTimeFields}
onStartDateChange={handleStartDateChange}
onStartTimeChange={handleStartTimeChange}
onEndDateChange={handleEndDateChange}
onEndTimeChange={handleEndTimeChange}
/>
<DateTimeError message={validation.errors.dateTime} />
</Box>
</LocalizationProvider>
)
}
@@ -0,0 +1,42 @@
import { PickerValue } from '@mui/x-date-pickers/internals'
import { Dayjs } from 'dayjs'
import { AllDayDateLayout } from './AllDayDateLayout'
import { CompactDateTimeLayout } from './CompactDateTimeLayout'
import { LayoutMode, LAYOUT_MODE } from './useDateTimeLayout'
import { ExpandedDateTimeLayout } from './ExpandedDateTimeLayout'
interface StartDateTime {
startDateValue: Dayjs | null
startTimeValue: Dayjs | null
onStartDateChange: (v: PickerValue) => void
onStartTimeChange: (v: PickerValue) => void
}
interface EndDateTime {
endDateValue: Dayjs | null
endTimeValue: Dayjs | null
onEndDateChange: (v: PickerValue) => void
onEndTimeChange: (v: PickerValue) => void
}
export interface DateTimeLayoutContentProps extends StartDateTime, EndDateTime {
layoutMode: LayoutMode
hasError: boolean
isMobile: boolean
allday: boolean
startDateLabel: string
shouldShowTimeFields: boolean
}
export function DateTimeLayoutContent({
layoutMode,
...props
}: DateTimeLayoutContentProps): JSX.Element {
if (layoutMode === LAYOUT_MODE.EXPANDED) {
return <ExpandedDateTimeLayout {...props} />
}
if (layoutMode === LAYOUT_MODE.ALL_DAY) {
return <AllDayDateLayout {...props} />
}
return <CompactDateTimeLayout {...props} />
}
@@ -0,0 +1,97 @@
import { Box } from '@linagora/twake-mui'
import { PickerValue } from '@mui/x-date-pickers/internals'
import dayjs from 'dayjs'
import React from 'react'
import { useI18n } from 'twake-i18n'
import { DatePickerField } from './DatePickerField'
import { TimePickerField } from './TimePickerField'
export interface ExpandedDateTimeLayoutProps {
startDateValue: ReturnType<typeof dayjs> | null
startTimeValue: ReturnType<typeof dayjs> | null
endDateValue: ReturnType<typeof dayjs> | null
endTimeValue: ReturnType<typeof dayjs> | null
hasError: boolean
isMobile: boolean
shouldShowTimeFields: boolean
onStartDateChange: (value: PickerValue) => void
onStartTimeChange: (value: PickerValue) => void
onEndDateChange: (value: PickerValue) => void
onEndTimeChange: (value: PickerValue) => void
}
/**
* Two-row layout used when showMore (expanded) or shouldShowFullFieldsInNormal is true.
* Row 1: start date + start time
* Row 2: end date + end time
*/
export const ExpandedDateTimeLayout: React.FC<ExpandedDateTimeLayoutProps> = ({
startDateValue,
startTimeValue,
endDateValue,
endTimeValue,
hasError,
isMobile,
shouldShowTimeFields,
onStartDateChange,
onStartTimeChange,
onEndDateChange,
onEndTimeChange
}) => {
const { t } = useI18n()
const containerProps = {
alignItems: isMobile ? 'stretch' : 'center'
}
const dateBoxSx = isMobile
? { width: '100%' }
: { maxWidth: '300px', width: '48%' }
return (
<>
<Box display="flex" gap={1} sx={containerProps}>
<Box sx={dateBoxSx}>
<DatePickerField
value={startDateValue}
onChange={onStartDateChange}
testId="start-date-input"
label={t('dateTimeFields.startDate')}
/>
</Box>
{shouldShowTimeFields && (
<Box width="110px">
<TimePickerField
value={startTimeValue}
onChange={onStartTimeChange}
testId="start-time-input"
label={t('dateTimeFields.startTime')}
/>
</Box>
)}
</Box>
<Box display="flex" gap={1} sx={containerProps}>
<Box sx={dateBoxSx}>
<DatePickerField
value={endDateValue}
onChange={onEndDateChange}
testId="end-date-input"
label={t('dateTimeFields.endDate')}
hasError={hasError}
/>
</Box>
{shouldShowTimeFields && (
<Box width="110px">
<TimePickerField
value={endTimeValue}
onChange={onEndTimeChange}
testId="end-time-input"
label={t('dateTimeFields.endTime')}
hasError={hasError}
/>
</Box>
)}
</Box>
</>
)
}
@@ -0,0 +1,45 @@
import { PickerValue } from '@mui/x-date-pickers/internals'
import { TimePicker } from '@mui/x-date-pickers/TimePicker'
import { Dayjs } from 'dayjs'
import React from 'react'
import { EditableTimeField } from '../EditableTimeField'
import {
getTimeFieldSlotProps,
timePickerPopperSx
} from './dateTimePickerSlotProps'
export interface TimePickerFieldProps {
value: Dayjs | null
onChange: (value: PickerValue) => void
testId: string
label: string
hasError?: boolean
disabled?: boolean
}
export const TimePickerField: React.FC<TimePickerFieldProps> = ({
value,
onChange,
testId,
label,
hasError = false,
disabled = false
}) => (
<TimePicker
ampm={false}
value={value}
onChange={onChange}
disabled={disabled}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(testId, hasError, label)
}}
/>
)
@@ -0,0 +1,94 @@
import { TextFieldProps } from '@linagora/twake-mui'
import {
DatePickerFieldProps,
DatePickerSlotProps
} from '@mui/x-date-pickers/DatePicker'
import { TimePickerFieldProps } from '@mui/x-date-pickers/TimePicker'
export const dateCalendarLayoutSx = {
'& .MuiDateCalendar-root.MuiDateCalendar-root': {
width: '260px',
maxWidth: '260px',
padding: '0 15px'
}
}
export const timePickerPopperSx = {
'& .MuiPaper-root': {
width: '110px',
minWidth: '110px'
},
'& .MuiMultiSectionDigitalClockSection-item': {
justifyContent: 'flex-start',
width: '100%',
textAlign: 'left'
}
}
const baseFieldProps = (
testId: string,
hasError = false,
label?: string
): Pick<
TextFieldProps,
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => ({
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: '100%' },
inputProps: {
'data-testid': testId,
...(label ? { 'aria-label': label } : {})
}
})
export const getDateSlotProps = (
testId: string,
hasError = false,
label?: string
): Partial<DatePickerSlotProps<true>> => ({
textField: {
...baseFieldProps(testId, hasError, label)
}
})
export const getDateFieldSlotProps = (
testId: string,
hasError = false,
label?: string
): Partial<DatePickerFieldProps> &
Pick<
TextFieldProps,
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => baseFieldProps(testId, hasError, label)
export const getTimeFieldSlotProps = (
testId: string,
hasError = false,
label?: string
): Partial<TimePickerFieldProps> &
Pick<
TextFieldProps,
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => baseFieldProps(testId, hasError, label)
@@ -0,0 +1,176 @@
import { PickerValue } from '@mui/x-date-pickers/internals'
import { Dayjs } from 'dayjs'
import React from 'react'
import { dtDate, dtTime, toDateTime } from '../../utils/dateTimeHelpers'
type DurationUnit = 'day' | 'minute'
export interface DateTimeHandlersProps {
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
onStartDateChange: (date: string) => void
onStartTimeChange: (time: string) => void
onEndDateChange: (date: string, time?: string) => void
onEndTimeChange: (time: string) => void
}
function applyDateTimeUpdate({
newDate,
newTime,
currentDate,
currentTime,
onDateChange,
onTimeChange
}: {
newDate: string
newTime: string
currentDate: string
currentTime: string
onDateChange: (date: string) => void
onTimeChange?: (time: string) => void
}): void {
if (newDate !== currentDate) onDateChange(newDate)
if (onTimeChange && newTime !== currentTime) onTimeChange(newTime)
}
export function useDateTimeHandlers(props: DateTimeHandlersProps): {
handleStartDateChange: (value: PickerValue) => void
handleStartTimeChange: (value: PickerValue) => void
handleEndDateChange: (value: PickerValue) => void
handleEndTimeChange: (value: PickerValue) => void
} {
const {
startDate,
startTime,
endDate,
endTime,
allday,
onStartDateChange,
onStartTimeChange,
onEndDateChange,
onEndTimeChange
} = props
const initialDurationRef = React.useRef<number | null>(null)
const isUserActionRef = React.useRef(false)
const durationUnit: DurationUnit = allday ? 'day' : 'minute'
const getCurrentDuration = React.useCallback((): number => {
const start = toDateTime(startDate, startTime)
const end = toDateTime(endDate, endTime)
const duration = allday
? end.startOf('day').diff(start.startOf('day'), 'day')
: end.diff(start, 'minute')
return Math.max(duration, 0)
}, [startDate, startTime, endDate, endTime, allday])
React.useEffect(() => {
const isExternalUpdate = !isUserActionRef.current
const hasBothDates = startDate && endDate
if (isExternalUpdate && hasBothDates) {
initialDurationRef.current = getCurrentDuration()
}
queueMicrotask(() => {
isUserActionRef.current = false
})
}, [startDate, endDate, startTime, endTime, getCurrentDuration])
const handleStartDateChange = (value: PickerValue): void => {
if (!value?.isValid()) return
isUserActionRef.current = true
const newDateStr = dtDate(value as Dayjs)
const duration = initialDurationRef.current ?? getCurrentDuration()
onStartDateChange(newDateStr)
const newStart = toDateTime(newDateStr, startTime)
const newEnd = newStart.add(duration, durationUnit)
applyDateTimeUpdate({
newDate: dtDate(newEnd),
newTime: dtTime(newEnd),
currentDate: endDate,
currentTime: endTime,
onDateChange: onEndDateChange,
onTimeChange: allday ? undefined : onEndTimeChange
})
}
const handleStartTimeChange = (value: PickerValue): void => {
if (!value?.isValid()) return
isUserActionRef.current = true
const newTimeStr = dtTime(value as Dayjs)
const duration = initialDurationRef.current ?? getCurrentDuration()
onStartTimeChange(newTimeStr)
const newStart = toDateTime(startDate, newTimeStr)
const newEnd = newStart.add(duration, 'minute')
const newEndDate = dtDate(newEnd)
const newEndTime = dtTime(newEnd)
const hasDateChanged = newEndDate !== endDate
const hasTimeChanged = newEndTime !== endTime
if (hasDateChanged && hasTimeChanged) {
onEndDateChange(newEndDate, newEndTime)
return
}
if (hasDateChanged) onEndDateChange(newEndDate)
if (!allday && hasTimeChanged) onEndTimeChange(newEndTime)
}
const handleEndDateChange = (value: PickerValue): void => {
if (!value?.isValid()) return
isUserActionRef.current = true
const newDateStr = dtDate(value as Dayjs)
const newEnd = toDateTime(newDateStr, endTime)
const currentStart = toDateTime(startDate, startTime)
if (!newEnd.isBefore(currentStart)) {
initialDurationRef.current = newEnd.diff(currentStart, durationUnit)
onEndDateChange(newDateStr)
return
}
const duration = initialDurationRef.current ?? getCurrentDuration()
const newStart = newEnd.subtract(duration, durationUnit)
applyDateTimeUpdate({
newDate: dtDate(newStart),
newTime: dtTime(newStart),
currentDate: startDate,
currentTime: startTime,
onDateChange: onStartDateChange,
onTimeChange: allday ? undefined : onStartTimeChange
})
onEndDateChange(newDateStr)
}
const handleEndTimeChange = (value: PickerValue): void => {
if (!value?.isValid()) return
isUserActionRef.current = true
const newTimeStr = dtTime(value as Dayjs)
const newEnd = toDateTime(endDate, newTimeStr)
const currentStart = toDateTime(startDate, startTime)
initialDurationRef.current = newEnd.diff(currentStart, 'minute')
onEndTimeChange(newTimeStr)
}
return {
handleStartDateChange,
handleStartTimeChange,
handleEndDateChange,
handleEndTimeChange
}
}
@@ -0,0 +1,34 @@
import { useMemo } from 'react'
export const LAYOUT_MODE = {
EXPANDED: 'expanded',
ALL_DAY: 'allday',
COMPACT: 'compact'
} as const
export type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
export interface DateTimeLayoutProps {
hasEndDateChanged: boolean
allday: boolean
spansMultipleDays: boolean
showMore: boolean
showEndDate: boolean
}
export function useDateTimeLayout({
hasEndDateChanged,
allday,
spansMultipleDays,
showMore,
showEndDate
}: DateTimeLayoutProps): LayoutMode {
return useMemo(() => {
const isSpecialRange = hasEndDateChanged || (!allday && spansMultipleDays)
if (showMore || isSpecialRange) {
return LAYOUT_MODE.EXPANDED
}
if (allday || showEndDate) return LAYOUT_MODE.ALL_DAY
return LAYOUT_MODE.COMPACT
}, [hasEndDateChanged, allday, spansMultipleDays, showMore, showEndDate])
}
@@ -0,0 +1,54 @@
import { useMemo } from 'react'
export interface DisplayFlagsProps {
allday: boolean
hasEndDateChanged: boolean
startDate: string
endDate: string
showEndDate: boolean
isExpanded: boolean
}
export function useDisplayFlags({
allday,
hasEndDateChanged,
startDate,
endDate,
showEndDate,
isExpanded
}: DisplayFlagsProps): {
spansMultipleDays: boolean
shouldShowFullFieldsInNormal: boolean
showSingleDateField: boolean
shouldShowTimeFields: boolean
} {
const spansMultipleDays = useMemo(
() => startDate !== endDate,
[startDate, endDate]
)
const isTimedEvent = useMemo(() => !allday, [allday])
const shouldShowEndDateNormal = useMemo(
() => allday || showEndDate,
[allday, showEndDate]
)
const shouldShowFullFieldsInNormal = useMemo(
() => hasEndDateChanged || (spansMultipleDays && isTimedEvent),
[hasEndDateChanged, spansMultipleDays, isTimedEvent]
)
const showSingleDateField = useMemo(
() =>
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal,
[isExpanded, shouldShowEndDateNormal, shouldShowFullFieldsInNormal]
)
const shouldShowTimeFields = useMemo(
() => isTimedEvent || shouldShowFullFieldsInNormal,
[isTimedEvent, shouldShowFullFieldsInNormal]
)
return {
spansMultipleDays,
shouldShowFullFieldsInNormal,
showSingleDateField,
shouldShowTimeFields
}
}
@@ -4,7 +4,8 @@ import {
Box,
TextField,
List,
InputAdornment
InputAdornment,
useTheme
} from '@linagora/twake-mui'
import { Search as SearchIcon } from '@mui/icons-material'
import React, { useState, useMemo, useEffect, useRef } from 'react'
@@ -34,6 +35,8 @@ export const SmallTimezoneSelector: React.FC<
> = ({ value, onChange, referenceDate, onClose, open }) => {
const { t } = useI18n()
const theme = useTheme()
const [searchQuery, setSearchQuery] = useState('')
const timezoneList = useTimeZoneList()
@@ -118,6 +121,7 @@ export const SmallTimezoneSelector: React.FC<
sx: { height: '90%', maxHeight: '90dvh' }
}
}}
sx={{ zIndex: theme.zIndex.modal + 100 }}
>
<Box sx={{ px: 2 }}>
<TextField
@@ -1,6 +1,6 @@
import { formatEventChipTitle } from '@/components/Calendar/utils/calendarUtils'
import { ResponsiveDialog } from '@/components/Dialog'
import { DateTimeFields } from '@/components/Event/components/DateTimeFields'
import { DateTimeFields } from '@/components/Event/components/DateTimeFields/DateTimeFields'
import { FieldWithLabel } from '@/components/Event/components/FieldWithLabel'
import { splitDateTime } from '@/components/Event/utils/dateTimeHelpers'
import { Box, Button, TextField, Typography } from '@linagora/twake-mui'