[#710] adapt time and date pickers to mobile (#781)

* [#710] added custom mobile timePicker

* [#710] custom mobile datepicker
This commit is contained in:
Camille Moussu
2026-04-16 10:57:35 +02:00
committed by GitHub
parent 4cf4901f99
commit 1213c6a350
10 changed files with 490 additions and 64 deletions
@@ -1,14 +1,8 @@
import { DatePicker } from '@mui/x-date-pickers/DatePicker'
import { useMediaQuery } from '@linagora/twake-mui'
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'
import { DesktopDatePickerField } from './DesktopDatePickerField'
import { TouchDatePickerField } from './TouchDatePickerField'
export interface DatePickerFieldProps {
value: Dayjs | null
@@ -18,22 +12,11 @@ export interface DatePickerFieldProps {
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 }
}}
/>
)
export const DatePickerField: React.FC<DatePickerFieldProps> = props => {
const isTouch = useMediaQuery('(pointer: coarse)')
return isTouch ? (
<TouchDatePickerField {...props} />
) : (
<DesktopDatePickerField {...props} />
)
}
@@ -0,0 +1,30 @@
import { DatePicker } from '@mui/x-date-pickers/DatePicker'
import React from 'react'
import { LONG_DATE_FORMAT } from '../../utils/dateTimeFormatters'
import { ReadOnlyDateField } from '../ReadOnlyPickerField'
import {
dateCalendarLayoutSx,
getDateFieldSlotProps,
getDateSlotProps
} from './dateTimePickerSlotProps'
import { DatePickerFieldProps } from './DatePickerField'
export const DesktopDatePickerField: 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,35 @@
import { TimePicker } from '@mui/x-date-pickers/TimePicker'
import React from 'react'
import { EditableTimeField } from '../EditableTimeField'
import {
getTimeFieldSlotProps,
timePickerPopperSx
} from './dateTimePickerSlotProps'
import { TimePickerFieldProps } from './TimePickerField'
export const DesktopTimePickerField: 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)
}}
/>
)
@@ -1,12 +1,9 @@
import { useMediaQuery } from '@linagora/twake-mui'
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'
import { DesktopTimePickerField } from './DesktopTimePickerField'
import { TouchTimePickerField } from './TouchTimePickerField'
export interface TimePickerFieldProps {
value: Dayjs | null
@@ -17,29 +14,11 @@ export interface TimePickerFieldProps {
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)
}}
/>
)
export const TimePickerField: React.FC<TimePickerFieldProps> = props => {
const isTouch = useMediaQuery('(pointer: coarse)')
return isTouch ? (
<TouchTimePickerField {...props} />
) : (
<DesktopTimePickerField {...props} />
)
}
@@ -0,0 +1,193 @@
import CalendarTodayIcon from '@mui/icons-material/CalendarToday'
import EditIcon from '@mui/icons-material/Edit'
import {
Box,
Button,
Dialog,
IconButton,
Typography
} from '@linagora/twake-mui'
import { DateField, DatePicker } from '@mui/x-date-pickers'
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
import { PickerValue } from '@mui/x-date-pickers/internals'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import React, { useState } from 'react'
import { useI18n } from 'twake-i18n'
import { ReadOnlyDateField } from '../ReadOnlyPickerField'
import { DatePickerFieldProps } from './DatePickerField'
import {
dateCalendarLayoutSx,
getDateFieldSlotProps,
getDateSlotProps
} from './dateTimePickerSlotProps'
dayjs.extend(customParseFormat)
const DISPLAY_FORMAT = 'D MMMM, YYYY'
interface DatePickerDialogProps {
value: PickerValue
onChange: (value: PickerValue) => void
onAccept: (value: PickerValue) => void
onCancel: () => void
}
const DatePickerDialogContent: React.FC<DatePickerDialogProps> = ({
value,
onChange,
onAccept,
onCancel
}) => {
const { t } = useI18n()
const [viewMode, setViewMode] = useState<'calendar' | 'text'>('calendar')
const [internalValue, setInternalValue] = useState<PickerValue>(value)
const handleInternalChange = (newValue: PickerValue): void => {
setInternalValue(newValue)
onChange(newValue)
}
const handleToggleView = (): void => {
setViewMode(viewMode === 'calendar' ? 'text' : 'calendar')
}
const displayDate = internalValue?.isValid()
? internalValue.format(DISPLAY_FORMAT)
: t('dateTimeFields.pickADate')
return (
<Box sx={{ p: 2, width: 280 }}>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 1,
gap: 1
}}
>
<Typography variant="subtitle1" fontWeight={500}>
{displayDate}
</Typography>
<IconButton
size="small"
onClick={handleToggleView}
aria-label={
viewMode === 'calendar'
? t('dateTimeFields.switchToTextInput')
: t('dateTimeFields.switchToCalendar')
}
>
{viewMode === 'calendar' ? (
<EditIcon fontSize="small" />
) : (
<CalendarTodayIcon fontSize="small" />
)}
</IconButton>
</Box>
{/* Body */}
{viewMode === 'calendar' ? (
<DateCalendar
value={internalValue}
onChange={handleInternalChange}
sx={{ m: 0, width: '100%' }}
/>
) : (
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="caption" color="text.secondary">
{t('dateTimeFields.date')}
</Typography>
<DateField
fullWidth
inputMode="numeric"
size="small"
value={internalValue}
onChange={handleInternalChange}
autoFocus
slotProps={{
textField: {
inputProps: { inputMode: 'numeric', pattern: '[0-9]*' }
}
}}
/>
</Box>
)}
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
gap: 1
}}
>
<Button onClick={onCancel} variant="text">
{t('common.cancel')}
</Button>
<Button onClick={() => onAccept(internalValue)} variant="contained">
{t('common.ok')}
</Button>
</Box>
</Box>
)
}
export const TouchDatePickerField: React.FC<DatePickerFieldProps> = ({
value,
onChange,
testId,
label = 'Date',
hasError = false
}) => {
const [open, setOpen] = useState(false)
const [pendingValue, setPendingValue] = useState<PickerValue>(value)
const handleOpen = (): void => {
setPendingValue(value)
setOpen(true)
}
const handleAccept = (newValue: PickerValue): void => {
onChange(newValue)
setOpen(false)
}
const handleCancel = (): void => {
setPendingValue(value)
setOpen(false)
}
return (
<>
<DatePicker
label={label}
value={value}
onOpen={handleOpen}
onAccept={onChange}
open={false}
slots={{ field: ReadOnlyDateField }}
slotProps={{
openPickerButton: { sx: { display: 'none' } },
...getDateSlotProps(testId, hasError, label),
field: getDateFieldSlotProps(testId, hasError, label),
layout: { sx: dateCalendarLayoutSx }
}}
/>
<Dialog
open={open}
onClose={handleCancel}
slotProps={{ paper: { sx: { borderRadius: 2, overflow: 'hidden' } } }}
>
<DatePickerDialogContent
value={pendingValue}
onChange={setPendingValue}
onAccept={handleAccept}
onCancel={handleCancel}
/>
</Dialog>
</>
)
}
@@ -0,0 +1,190 @@
import {
Box,
Button,
Dialog,
DialogActions,
Divider,
IconButton,
SxProps,
Theme
} from '@linagora/twake-mui'
import AccessTime from '@mui/icons-material/AccessTime'
import KeyboardAltOutlined from '@mui/icons-material/KeyboardAltOutlined'
import {
MobileTimePicker,
renderTimeViewClock,
TimeField
} from '@mui/x-date-pickers'
import { PickerValue } from '@mui/x-date-pickers/internals'
import { StaticTimePicker } from '@mui/x-date-pickers/StaticTimePicker'
import { useState } from 'react'
import { useI18n } from 'twake-i18n'
import { EditableTimeField } from '../EditableTimeField'
import { getTimeFieldSlotProps } from './dateTimePickerSlotProps'
import { TimePickerFieldProps } from './TimePickerField'
const TIME_DISPLAY_SX = {
fontSize: '48px',
fontWeight: 400
} as const
const TIME_INPUT_SX: SxProps<Theme> = {
width: '100%',
'& .MuiPickersInputBase-sectionAfter': { mx: '16px' },
'& .MuiPickersSectionList-root': { justifyContent: 'end' },
'& .MuiPickersInputBase-root': {
...TIME_DISPLAY_SX,
color: 'text.secondary',
backgroundColor: 'transparent !important',
'&:before': { borderBottomColor: 'transparent !important' },
'&:after': { borderBottomColor: 'transparent !important' }
},
'& .MuiPickersInputBase-input': {
textAlign: 'center'
},
'& .MuiPickersInputBase-root.Mui-focused .MuiPickersInputBase-input': {
backgroundColor: 'transparent !important',
color: 'primary.main'
}
}
const STATIC_PICKER_SX: SxProps<Theme> = {
'& .MuiPickersToolbar-content': { justifyContent: 'center' },
'& .MuiTimePickerToolbar-hourMinuteLabel': {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '16px'
},
'& .MuiTimePickerToolbar-hourMinuteLabel .MuiPickersToolbarText-root[data-selected]':
{ color: 'primary.main' },
'& .MuiPickersToolbar-root .MuiTypography-root': TIME_DISPLAY_SX,
'& .MuiPickersToolbar-title': { display: 'none' },
'& .MuiPickersArrowSwitcher-root': { display: 'none' }
}
type DialogView = 'clock' | 'keyboard'
export const TouchTimePickerField: React.FC<TimePickerFieldProps> = ({
value,
onChange,
testId,
label,
hasError = false,
disabled = false
}) => {
const { t } = useI18n()
const [open, setOpen] = useState(false)
const [internalValue, setInternalValue] = useState<PickerValue>(value)
const [view, setView] = useState<DialogView>('clock')
const handleOpen = (): void => {
setInternalValue(value)
setOpen(true)
}
const handleAccept = (): void => {
onChange(internalValue)
setOpen(false)
}
const handleCancel = (): void => {
setOpen(false)
}
const toggleView = (): void =>
setView(view === 'clock' ? 'keyboard' : 'clock')
return (
<>
<MobileTimePicker
ampm={false}
value={value}
onAccept={onChange}
disabled={disabled}
open={false}
onOpen={handleOpen}
viewRenderers={{
hours: renderTimeViewClock,
minutes: renderTimeViewClock
}}
slots={{ field: EditableTimeField }}
slotProps={{
openPickerButton: { sx: { display: 'none' } },
field: {
...getTimeFieldSlotProps(testId, hasError, label),
onFocus: e => e.target.blur()
}
}}
/>
<Dialog open={open} onClose={handleCancel}>
{view === 'clock' && (
<StaticTimePicker
ampm={false}
value={internalValue}
onChange={setInternalValue}
viewRenderers={{
hours: renderTimeViewClock,
minutes: renderTimeViewClock
}}
slots={{ actionBar: () => null }}
sx={STATIC_PICKER_SX}
/>
)}
{view === 'keyboard' && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px'
}}
>
<TimeField
ampm={false}
value={internalValue}
onChange={setInternalValue}
autoFocus
sx={TIME_INPUT_SX}
slotProps={{
textField: {
variant: 'filled',
InputLabelProps: { shrink: false },
inputProps: { inputMode: 'numeric', pattern: '[0-9]*' }
}
}}
/>
</Box>
)}
<Divider />
<DialogActions
sx={{ display: 'flex', justifyContent: 'space-between' }}
>
<IconButton
onClick={toggleView}
aria-label={
view === 'clock'
? t('dateTimeFields.switchToTextInput')
: t('dateTimeFields.switchToClockView')
}
>
{view === 'clock' ? <KeyboardAltOutlined /> : <AccessTime />}
</IconButton>
<Box>
<Button onClick={handleCancel} variant="text">
{t('common.cancel')}
</Button>
<Button onClick={handleAccept} variant="contained">
{t('common.ok')}
</Button>
</Box>
</DialogActions>
</Dialog>
</>
)
}
+5 -1
View File
@@ -347,7 +347,11 @@
"startDate": "Start Date",
"startTime": "Start Time",
"endDate": "End Date",
"endTime": "End Time"
"endTime": "End Time",
"pickADate": "Pick a date",
"switchToTextInput": "Switch to text input",
"switchToCalendar": "Switch to calendar view",
"switchToClockView": "Switch to clock view"
},
"websocket": {
"browserOnline": "Youre back online. Reconnecting live updates…",
+5 -1
View File
@@ -348,7 +348,11 @@
"startDate": "Date du début",
"startTime": "Heure du début",
"endDate": "Date de la fin",
"endTime": "Heure de la fin"
"endTime": "Heure de la fin",
"pickADate": "Choisissez une date",
"switchToTextInput": "Passer à la saisie de texte",
"switchToCalendar": "Passer à la vue calendrier",
"switchToClockView": "Passer à la vue horloge"
},
"websocket": {
"browserOnline": "Vous êtes de nouveau en ligne. Reconnexion des mises à jour en temps réel…",
+5 -1
View File
@@ -348,7 +348,11 @@
"startDate": "Дата начала",
"startTime": "Время начала",
"endDate": "Дата окончания",
"endTime": "Время окончания"
"endTime": "Время окончания",
"pickADate": "Выберите дату",
"switchToTextInput": "Переключиться на текстовый ввод",
"switchToCalendar": "Переключиться на календарь",
"switchToClockView": "Переключиться на вид часов"
},
"websocket": {
"browserOnline": "Вы снова в сети. Подключаем обновления в реальном времени…",
+5 -1
View File
@@ -346,7 +346,11 @@
"startDate": "Ngày bắt đầu",
"startTime": "Giờ bắt đầu",
"endDate": "Ngày kết thúc",
"endTime": "Giờ kết thúc"
"endTime": "Giờ kết thúc",
"pickADate": "Chọn ngày",
"switchToTextInput": "Chuyển sang nhập văn bản",
"switchToCalendar": "Chuyển sang chế độ lịch",
"switchToClockView": "Chuyển sang chế độ đồng hồ"
},
"websocket": {
"browserOnline": "Bạn đã trực tuyến trở lại. Đang kết nối lại cập nhật thời gian thực…",