8b88faffe2
- Remove label prop from all DatePicker and TimePicker components - Add aria-label to inputProps for accessibility and testing - Auto-close TimePicker dropdown when selecting an option - Add 0.1s delay before calling time change handlers after closing dropdown - Add test case to verify aria-label attributes
589 lines
20 KiB
TypeScript
589 lines
20 KiB
TypeScript
import React, { useMemo } from "react";
|
|
import { Box, Typography } from "@mui/material";
|
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
|
import { TimePicker } from "@mui/x-date-pickers/TimePicker";
|
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|
import dayjs, { Dayjs } from "dayjs";
|
|
import customParseFormat from "dayjs/plugin/customParseFormat";
|
|
import { useI18n } from "twake-i18n";
|
|
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
|
|
import "dayjs/locale/fr";
|
|
import "dayjs/locale/en";
|
|
import "dayjs/locale/ru";
|
|
import "dayjs/locale/vi";
|
|
|
|
import { PickerValue } from "@mui/x-date-pickers/internals";
|
|
import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers";
|
|
import { ReadOnlyDateField } from "./ReadOnlyPickerField";
|
|
|
|
dayjs.extend(customParseFormat);
|
|
|
|
/**
|
|
* 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,
|
|
onToggleEndDate,
|
|
validation,
|
|
onStartDateChange,
|
|
onStartTimeChange,
|
|
onEndDateChange,
|
|
onEndTimeChange,
|
|
}) => {
|
|
const { t, lang } = useI18n();
|
|
|
|
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;
|
|
const shouldShowFullFieldsInNormal =
|
|
(!allday && hasEndDateChanged) || spansMultipleDays;
|
|
const showSingleDateField =
|
|
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
|
|
|
|
const startDateLabel = showSingleDateField
|
|
? t("dateTimeFields.date")
|
|
: t("dateTimeFields.startDate");
|
|
|
|
const handleStartDateChange = (value: PickerValue) => {
|
|
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) => {
|
|
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) => {
|
|
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) => {
|
|
if (!value || !value.isValid()) return;
|
|
|
|
isUserActionRef.current = true;
|
|
const newTimeStr = dtTime(value as Dayjs);
|
|
const newEnd = toDateTime(endDate, newTimeStr);
|
|
const currentStart = toDateTime(startDate, startTime);
|
|
|
|
// If end is before start, adjust start to maintain duration
|
|
if (newEnd.isBefore(currentStart)) {
|
|
const duration = initialDurationRef.current ?? getCurrentDuration();
|
|
const newStart = newEnd.subtract(duration, "minute");
|
|
|
|
const newStartDate = dtDate(newStart);
|
|
const newStartTime = dtTime(newStart);
|
|
|
|
if (newStartDate !== startDate) {
|
|
onStartDateChange(newStartDate);
|
|
}
|
|
if (newStartTime !== startTime) {
|
|
onStartTimeChange(newStartTime);
|
|
}
|
|
} else {
|
|
// Update duration when user changes end
|
|
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) => ({
|
|
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) => ({
|
|
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 [isStartTimeOpen, setIsStartTimeOpen] = React.useState(false);
|
|
const [isEndTimeOpen, setIsEndTimeOpen] = React.useState(false);
|
|
|
|
const handleTimeInputClick = (
|
|
event: React.MouseEvent,
|
|
setOpen: (open: boolean) => void
|
|
) => {
|
|
// Only open if clicking on the text (span)
|
|
// In MUI X v6+, the sections are rendered as spans
|
|
if ((event.target as HTMLElement).tagName === "SPAN") {
|
|
setOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleStartTimeChangeWithClose = (value: PickerValue) => {
|
|
setIsStartTimeOpen(false);
|
|
setTimeout(() => {
|
|
handleStartTimeChange(value);
|
|
}, 100);
|
|
};
|
|
|
|
const handleEndTimeChangeWithClose = (value: PickerValue) => {
|
|
setIsEndTimeOpen(false);
|
|
setTimeout(() => {
|
|
handleEndTimeChange(value);
|
|
}, 100);
|
|
};
|
|
|
|
return (
|
|
<LocalizationProvider
|
|
dateAdapter={AdapterDayjs}
|
|
adapterLocale={lang ?? "en"}
|
|
localeText={{
|
|
okButtonLabel: t("common.ok"),
|
|
cancelButtonLabel: t("common.cancel"),
|
|
todayButtonLabel: t("menubar.today"),
|
|
}}
|
|
>
|
|
<Box
|
|
display="flex"
|
|
flexDirection="column"
|
|
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
|
|
>
|
|
{isExpanded || shouldShowFullFieldsInNormal ? (
|
|
<>
|
|
<Box display="flex" gap={1} flexDirection="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")) as any,
|
|
}}
|
|
/>
|
|
</Box>
|
|
{!allday && (
|
|
<Box sx={{ width: "110px" }}>
|
|
<TimePicker
|
|
ampm={false}
|
|
value={startTimeValue}
|
|
onChange={handleStartTimeChangeWithClose}
|
|
open={isStartTimeOpen}
|
|
onClose={() => setIsStartTimeOpen(false)}
|
|
thresholdToRenderTimeInASingleColumn={48}
|
|
timeSteps={{ minutes: 30 }}
|
|
slots={{ actionBar: () => null }}
|
|
slotProps={{
|
|
...getSlotProps("start-time-input", false, t("dateTimeFields.startTime")),
|
|
openPickerButton: { sx: { display: "none" } },
|
|
popper: {
|
|
sx: {
|
|
"& .MuiMultiSectionDigitalClockSection-item": {
|
|
justifyContent: "flex-start",
|
|
width: "100%",
|
|
textAlign: "left",
|
|
},
|
|
},
|
|
},
|
|
textField: {
|
|
...getSlotProps("start-time-input", false, t("dateTimeFields.startTime")).textField,
|
|
onClick: (e) =>
|
|
handleTimeInputClick(e, setIsStartTimeOpen),
|
|
sx: {
|
|
"& .MuiPickersSectionList-section": {
|
|
cursor: "pointer",
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
<Box display="flex" gap={1} flexDirection="row" alignItems="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")
|
|
) as any,
|
|
}}
|
|
/>
|
|
</Box>
|
|
{!allday && (
|
|
<Box sx={{ width: "110px" }}>
|
|
<TimePicker
|
|
ampm={false}
|
|
value={endTimeValue}
|
|
onChange={handleEndTimeChangeWithClose}
|
|
open={isEndTimeOpen}
|
|
onClose={() => setIsEndTimeOpen(false)}
|
|
thresholdToRenderTimeInASingleColumn={48}
|
|
timeSteps={{ minutes: 30 }}
|
|
slots={{ actionBar: () => null }}
|
|
slotProps={{
|
|
...getSlotProps(
|
|
"end-time-input",
|
|
!!validation.errors.dateTime,
|
|
t("dateTimeFields.endTime")
|
|
),
|
|
openPickerButton: { sx: { display: "none" } },
|
|
popper: {
|
|
sx: {
|
|
"& .MuiMultiSectionDigitalClockSection-item": {
|
|
justifyContent: "flex-start",
|
|
width: "100%",
|
|
textAlign: "left",
|
|
},
|
|
},
|
|
},
|
|
textField: {
|
|
...getSlotProps(
|
|
"end-time-input",
|
|
!!validation.errors.dateTime,
|
|
t("dateTimeFields.endTime")
|
|
).textField,
|
|
onClick: (e) =>
|
|
handleTimeInputClick(e, setIsEndTimeOpen),
|
|
sx: {
|
|
"& .MuiPickersSectionList-section": {
|
|
cursor: "pointer",
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</>
|
|
) : shouldShowEndDateNormal ? (
|
|
<Box display="flex" gap={1} flexDirection="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")) as any,
|
|
}}
|
|
/>
|
|
</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")
|
|
) as any,
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
) : (
|
|
<Box display="flex" gap={1} flexDirection="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, startDateLabel),
|
|
field: getFieldSlotProps("start-date-input", false, startDateLabel) as any,
|
|
}}
|
|
/>
|
|
</Box>
|
|
<Box sx={{ maxWidth: "110px" }}>
|
|
<TimePicker
|
|
ampm={false}
|
|
value={startTimeValue}
|
|
onChange={handleStartTimeChangeWithClose}
|
|
disabled={allday}
|
|
open={isStartTimeOpen}
|
|
onClose={() => setIsStartTimeOpen(false)}
|
|
thresholdToRenderTimeInASingleColumn={48}
|
|
timeSteps={{ minutes: 30 }}
|
|
slots={{ actionBar: () => null }}
|
|
slotProps={{
|
|
...getSlotProps("start-time-input", false, t("dateTimeFields.startTime")),
|
|
openPickerButton: { sx: { display: "none" } },
|
|
popper: {
|
|
sx: {
|
|
"& .MuiMultiSectionDigitalClockSection-item": {
|
|
justifyContent: "flex-start",
|
|
width: "100%",
|
|
textAlign: "left",
|
|
},
|
|
},
|
|
},
|
|
textField: {
|
|
...getSlotProps("start-time-input", false, t("dateTimeFields.startTime")).textField,
|
|
onClick: (e) => handleTimeInputClick(e, setIsStartTimeOpen),
|
|
sx: {
|
|
"& .MuiPickersSectionList-section": {
|
|
cursor: "pointer",
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
<Box sx={{ maxWidth: "110px" }}>
|
|
<TimePicker
|
|
ampm={false}
|
|
value={endTimeValue}
|
|
onChange={handleEndTimeChangeWithClose}
|
|
disabled={allday}
|
|
open={isEndTimeOpen}
|
|
onClose={() => setIsEndTimeOpen(false)}
|
|
thresholdToRenderTimeInASingleColumn={48}
|
|
timeSteps={{ minutes: 30 }}
|
|
slots={{ actionBar: () => null }}
|
|
slotProps={{
|
|
textField: {
|
|
size: "small",
|
|
margin: "dense" as const,
|
|
fullWidth: true,
|
|
InputLabelProps: { shrink: true },
|
|
error: !!validation.errors.dateTime,
|
|
inputProps: {
|
|
"data-testid": "end-time-input",
|
|
"aria-label": t("dateTimeFields.endTime"),
|
|
},
|
|
onClick: (e) => handleTimeInputClick(e, setIsEndTimeOpen),
|
|
sx: {
|
|
width: "100%",
|
|
"& .MuiPickersSectionList-section": {
|
|
cursor: "pointer",
|
|
},
|
|
},
|
|
},
|
|
openPickerButton: { sx: { display: "none" } },
|
|
popper: {
|
|
sx: {
|
|
"& .MuiMultiSectionDigitalClockSection-item": {
|
|
justifyContent: "flex-start",
|
|
width: "100%",
|
|
textAlign: "left",
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</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>
|
|
);
|
|
};
|