feat(DateTimeFields): implement free typing time input with dropdown

- Replace default TimePicker field with custom EditableTimeField
- Allow free typing with multiple formats (HH:mm, HHmm, H, etc.)
- Show dropdown on click while maintaining typing capability
- Center-align time input text
This commit is contained in:
lenhanphung
2025-12-10 11:47:05 +07:00
committed by Benoit TELLIER
parent 12776d36a4
commit 627e9bf44b
13 changed files with 605 additions and 223 deletions
+7 -1
View File
@@ -66,6 +66,7 @@ describe("DateTimeFields", () => {
const startTimeInput = screen.getByTestId("start-time-input"); const startTimeInput = screen.getByTestId("start-time-input");
fireEvent.change(startTimeInput, { target: { value: "12:00" } }); fireEvent.change(startTimeInput, { target: { value: "12:00" } });
fireEvent.blur(startTimeInput);
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("12:00") expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("12:00")
@@ -109,6 +110,7 @@ describe("DateTimeFields", () => {
const endTimeInput = screen.getByTestId("end-time-input"); const endTimeInput = screen.getByTestId("end-time-input");
fireEvent.change(endTimeInput, { target: { value: "08:00" } }); fireEvent.change(endTimeInput, { target: { value: "08:00" } });
fireEvent.blur(endTimeInput);
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("08:00") expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("08:00")
@@ -208,11 +210,14 @@ describe("DateTimeFields", () => {
const startTimeInput = screen.getByTestId("start-time-input"); const startTimeInput = screen.getByTestId("start-time-input");
fireEvent.change(startTimeInput, { target: { value: "09:30" } }); fireEvent.change(startTimeInput, { target: { value: "09:30" } });
fireEvent.blur(startTimeInput);
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("09:30") expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("09:30")
); );
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("10:30"); await waitFor(() =>
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("10:30")
);
}); });
it("preserves 1-hour duration across midnight when changing start time from 22:30 to 23:45", async () => { it("preserves 1-hour duration across midnight when changing start time from 22:30 to 23:45", async () => {
@@ -228,6 +233,7 @@ describe("DateTimeFields", () => {
// Change start time from 22:30 to 23:45 // Change start time from 22:30 to 23:45
fireEvent.change(startTimeInput, { target: { value: "23:45" } }); fireEvent.change(startTimeInput, { target: { value: "23:45" } });
fireEvent.blur(startTimeInput);
await waitFor(() => await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("23:45") expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("23:45")
@@ -406,9 +406,10 @@ describe("CalendarApp integration", () => {
fireEvent.change(startDateInput, { fireEvent.change(startDateInput, {
target: { value: "08:00" }, target: { value: "08:00" },
}); });
fireEvent.blur(startDateInput);
}); });
// Wait for the 0.1s delay in handleStartTimeChangeWithClose to complete // Wait for blur handler to complete
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
}); });
+4 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import TextField from "@mui/material/TextField";
import { userAttendee } from "../../features/User/userDataTypes"; import { userAttendee } from "../../features/User/userDataTypes";
import { PeopleSearch, User } from "./PeopleSearch"; import { PeopleSearch, User } from "./PeopleSearch";
@@ -37,7 +38,9 @@ export default function UserSearch({
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
objectTypes={["user", "contact"]} objectTypes={["user", "contact"]}
disabled={disabled} disabled={disabled}
small={small} inputSlot={
small ? (params) => <TextField {...params} size="small" /> : undefined
}
onChange={(event: any, value: User[]) => { onChange={(event: any, value: User[]) => {
setAttendees( setAttendees(
value.map((a: User) => ({ value.map((a: User) => ({
+103 -49
View File
@@ -1,4 +1,6 @@
import Autocomplete from "@mui/material/Autocomplete"; import Autocomplete, {
AutocompleteRenderInputParams,
} from "@mui/material/Autocomplete";
import Avatar from "@mui/material/Avatar"; import Avatar from "@mui/material/Avatar";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import ListItem from "@mui/material/ListItem"; import ListItem from "@mui/material/ListItem";
@@ -22,6 +24,14 @@ export interface User {
color?: Record<string, string>; color?: Record<string, string>;
} }
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean;
helperText?: string | null;
placeholder?: string;
label?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}
export function PeopleSearch({ export function PeopleSearch({
selectedUsers, selectedUsers,
onChange, onChange,
@@ -30,7 +40,7 @@ export function PeopleSearch({
freeSolo, freeSolo,
onToggleEventPreview, onToggleEventPreview,
placeholder, placeholder,
small, inputSlot,
}: { }: {
selectedUsers: User[]; selectedUsers: User[];
onChange: Function; onChange: Function;
@@ -39,7 +49,9 @@ export function PeopleSearch({
freeSolo?: boolean; freeSolo?: boolean;
onToggleEventPreview?: () => void; onToggleEventPreview?: () => void;
placeholder?: string; placeholder?: string;
small?: boolean; inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -146,53 +158,95 @@ export function PeopleSearch({
); );
onChange(event, mapped); onChange(event, mapped);
}} }}
renderInput={(params) => ( renderInput={(params) => {
<> const inputProps = {
<label htmlFor={params.id} className="visually-hidden"> ...params.InputProps,
{t("peopleSearch.label")} startAdornment: (
</label> <>
<TextField <PeopleOutlineOutlinedIcon
{...params} sx={{ mr: 1, color: "action.active" }}
size={small ? "small" : "medium"} />
error={!!inputError} {params.InputProps.startAdornment}
helperText={inputError} </>
placeholder={searchPlaceholder} ),
label="" endAdornment: (
inputProps={{ <>
...params.inputProps, {loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
};
const enhancedParamsWithInputProps = {
...params,
InputProps: inputProps,
inputProps: {
...params.inputProps,
autoComplete: "off",
},
};
const { InputProps, ...enhancedParams } =
enhancedParamsWithInputProps;
const defaultTextFieldProps = {
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
}
},
slotProps: {
input: {
...inputProps,
autoComplete: "off", autoComplete: "off",
}} },
onKeyDown={(e) => { },
if (e.key === "Enter" && onToggleEventPreview) { };
e.preventDefault();
onToggleEventPreview(); if (inputSlot) {
} return (
}} <>
slotProps={{ <label htmlFor={params.id} className="visually-hidden">
input: { {t("peopleSearch.label")}
...params.InputProps, </label>
autoComplete: "off", {inputSlot({
startAdornment: ( ...enhancedParamsWithInputProps,
<> error: !!inputError,
<PeopleOutlineOutlinedIcon helperText: inputError,
style={{ marginRight: 8, color: "rgba(0, 0, 0, 0.54)" }} placeholder: searchPlaceholder,
/> label: "",
{params.InputProps.startAdornment} onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
</> if (e.key === "Enter" && onToggleEventPreview) {
), e.preventDefault();
endAdornment: ( onToggleEventPreview();
<> }
{loading ? ( },
<CircularProgress color="inherit" size={20} /> })}
) : null} </>
{params.InputProps.endAdornment} );
</> }
),
}, return (
}} <>
/> <label htmlFor={params.id} className="visually-hidden">
</> {t("peopleSearch.label")}
)} </label>
<TextField
{...enhancedParams}
{...defaultTextFieldProps}
InputProps={inputProps}
size="medium"
/>
</>
);
}}
renderOption={(props, option) => { renderOption={(props, option) => {
if (selectedUsers.find((u) => u.email === option.email)) return null; if (selectedUsers.find((u) => u.email === option.email)) return null;
const { key, ...otherProps } = props as any; const { key, ...otherProps } = props as any;
+2 -1
View File
@@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { getCalendars } from "../../features/Calendars/CalendarApi"; import { getCalendars } from "../../features/Calendars/CalendarApi";
import { addSharedCalendarAsync } from "../../features/Calendars/CalendarSlice"; import { addSharedCalendarAsync } from "../../features/Calendars/CalendarSlice";
import { Calendars } from "../../features/Calendars/CalendarTypes"; import { Calendars } from "../../features/Calendars/CalendarTypes";
import TextField from "@mui/material/TextField";
import { PeopleSearch, User } from "../Attendees/PeopleSearch"; import { PeopleSearch, User } from "../Attendees/PeopleSearch";
import { ResponsiveDialog } from "../Dialog"; import { ResponsiveDialog } from "../Dialog";
import { ColorPicker } from "./CalendarColorPicker"; import { ColorPicker } from "./CalendarColorPicker";
@@ -275,7 +276,7 @@ export default function CalendarSearch({
<PeopleSearch <PeopleSearch
objectTypes={["user"]} objectTypes={["user"]}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
small inputSlot={(params) => <TextField {...params} size="small" />}
onChange={async (event: any, value: User[]) => { onChange={async (event: any, value: User[]) => {
setSelectedUsers(value); setSelectedUsers(value);
@@ -8,6 +8,7 @@ import {
} from "../../features/Calendars/CalendarSlice"; } from "../../features/Calendars/CalendarSlice";
import { Calendars } from "../../features/Calendars/CalendarTypes"; import { Calendars } from "../../features/Calendars/CalendarTypes";
import { setView } from "../../features/Settings/SettingsSlice"; import { setView } from "../../features/Settings/SettingsSlice";
import TextField from "@mui/material/TextField";
import { User, PeopleSearch } from "../Attendees/PeopleSearch"; import { User, PeopleSearch } from "../Attendees/PeopleSearch";
import { getAccessiblePair } from "./utils/calendarColorsUtils"; import { getAccessiblePair } from "./utils/calendarColorsUtils";
@@ -105,7 +106,7 @@ export function TempCalendarsInput({
onChange={handleUserChange} onChange={handleUserChange}
onToggleEventPreview={handleToggleEventPreview} onToggleEventPreview={handleToggleEventPreview}
placeholder={t("peopleSearch.availabilityPlaceholder")} placeholder={t("peopleSearch.availabilityPlaceholder")}
small inputSlot={(params) => <TextField {...params} size="small" />}
/> />
); );
} }
+19 -1
View File
@@ -181,6 +181,19 @@ export default function EventFormFields({
onHasEndDateChangedChange?.(hasEndDateChanged); onHasEndDateChangedChange?.(hasEndDateChanged);
}, [hasEndDateChanged, onHasEndDateChangedChange]); }, [hasEndDateChanged, onHasEndDateChangedChange]);
// Reset hasEndDateChanged when startDate === endDate in normal mode
React.useEffect(() => {
if (
!showMore &&
hasEndDateChanged &&
startDate &&
endDate &&
startDate === endDate
) {
setHasEndDateChanged(false);
}
}, [showMore, hasEndDateChanged, startDate, endDate]);
// Use all-day toggle hook // Use all-day toggle hook
const { handleAllDayToggle } = useAllDayToggle({ const { handleAllDayToggle } = useAllDayToggle({
allday, allday,
@@ -449,7 +462,12 @@ export default function EventFormFields({
onStartTimeChange={handleStartTimeChange} onStartTimeChange={handleStartTimeChange}
onEndDateChange={handleEndDateChange} onEndDateChange={handleEndDateChange}
onEndTimeChange={handleEndTimeChange} onEndTimeChange={handleEndTimeChange}
showEndDate={showMore || allday} showEndDate={
showMore ||
allday ||
(hasEndDateChanged && startDate !== endDate) ||
(!showMore && !allday && startDate !== endDate)
}
onToggleEndDate={() => {}} onToggleEndDate={() => {}}
/> />
</FieldWithLabel> </FieldWithLabel>
+108 -135
View File
@@ -1,7 +1,10 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Box, Typography } from "@mui/material"; import { Box, Typography } from "@mui/material";
import { TextFieldProps } from "@mui/material/TextField";
import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { DatePickerFieldProps } from "@mui/x-date-pickers/DatePicker";
import { TimePicker } from "@mui/x-date-pickers/TimePicker"; import { TimePicker } from "@mui/x-date-pickers/TimePicker";
import { TimePickerFieldProps } from "@mui/x-date-pickers/TimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
@@ -16,6 +19,7 @@ import "dayjs/locale/vi";
import { PickerValue } from "@mui/x-date-pickers/internals"; import { PickerValue } from "@mui/x-date-pickers/internals";
import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers"; import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers";
import { ReadOnlyDateField } from "./ReadOnlyPickerField"; import { ReadOnlyDateField } from "./ReadOnlyPickerField";
import { EditableTimeField } from "./EditableTimeField";
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
@@ -88,15 +92,10 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
}); });
}, [startDate, endDate, startTime, endTime, getCurrentDuration]); }, [startDate, endDate, startTime, endTime, getCurrentDuration]);
const spansMultipleDays = React.useMemo(() => {
return startDate !== endDate;
}, [startDate, endDate]);
const isExpanded = showMore; const isExpanded = showMore;
const shouldShowEndDateNormal = allday || showEndDate; const shouldShowEndDateNormal = allday || showEndDate;
const shouldShowFullFieldsInNormal = isExpanded; const showSingleDateField = !isExpanded && !shouldShowEndDateNormal;
const showSingleDateField = const showFourFieldsNormal = !isExpanded && showEndDate && !allday;
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
const startDateLabel = showSingleDateField const startDateLabel = showSingleDateField
? t("dateTimeFields.date") ? t("dateTimeFields.date")
@@ -204,10 +203,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
const newEnd = toDateTime(endDate, newTimeStr); const newEnd = toDateTime(endDate, newTimeStr);
const currentStart = toDateTime(startDate, startTime); const currentStart = toDateTime(startDate, startTime);
// Update duration when user changes end (if valid) // Always update duration ref to reflect current state, even if invalid (end < start)
if (!newEnd.isBefore(currentStart)) { // This prevents stale duration from being used when user edits start time
initialDurationRef.current = newEnd.diff(currentStart, "minute"); initialDurationRef.current = newEnd.diff(currentStart, "minute");
}
onEndTimeChange(newTimeStr); onEndTimeChange(newTimeStr);
}; };
@@ -253,7 +251,17 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
testId: string, testId: string,
hasError = false, hasError = false,
testLabel?: string testLabel?: string
) => ({ ): Partial<DatePickerFieldProps | TimePickerFieldProps> &
Pick<
TextFieldProps,
| "size"
| "margin"
| "fullWidth"
| "InputLabelProps"
| "error"
| "sx"
| "inputProps"
> => ({
size: "small" as const, size: "small" as const,
margin: "dense" as const, margin: "dense" as const,
fullWidth: true, fullWidth: true,
@@ -266,33 +274,32 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
}, },
}); });
const [isStartTimeOpen, setIsStartTimeOpen] = React.useState(false); const getTimeFieldSlotProps = (
const [isEndTimeOpen, setIsEndTimeOpen] = React.useState(false); testId: string,
hasError = false,
const handleTimeInputClick = ( testLabel?: string
event: React.MouseEvent, ): Partial<TimePickerFieldProps> &
setOpen: (open: boolean) => void Pick<
) => { TextFieldProps,
// Only open if clicking on the text (span) | "size"
// In MUI X v6+, the sections are rendered as spans | "margin"
if ((event.target as HTMLElement).tagName === "SPAN") { | "fullWidth"
setOpen(true); | "InputLabelProps"
} | "error"
}; | "sx"
| "inputProps"
const handleStartTimeChangeWithClose = (value: PickerValue) => { > => ({
setIsStartTimeOpen(false); size: "small" as const,
setTimeout(() => { margin: "dense" as const,
handleStartTimeChange(value); fullWidth: true,
}, 100); InputLabelProps: { shrink: true },
}; error: hasError,
sx: { width: "100%" },
const handleEndTimeChangeWithClose = (value: PickerValue) => { inputProps: {
setIsEndTimeOpen(false); "data-testid": testId,
setTimeout(() => { ...(testLabel ? { "aria-label": testLabel } : {}),
handleEndTimeChange(value); },
}, 100); });
};
return ( return (
<LocalizationProvider <LocalizationProvider
@@ -309,7 +316,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
flexDirection="column" flexDirection="column"
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }} sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
> >
{shouldShowFullFieldsInNormal ? ( {isExpanded || showFourFieldsNormal ? (
<> <>
<Box display="flex" gap={1} flexDirection="row" alignItems="center"> <Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}> <Box sx={{ maxWidth: "300px", width: "48%" }}>
@@ -328,7 +335,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"start-date-input", "start-date-input",
false, false,
t("dateTimeFields.startDate") t("dateTimeFields.startDate")
) as any, ),
}} }}
/> />
</Box> </Box>
@@ -337,21 +344,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker <TimePicker
ampm={false} ampm={false}
value={startTimeValue} value={startTimeValue}
onChange={handleStartTimeChangeWithClose} onChange={handleStartTimeChange}
open={isStartTimeOpen}
onClose={() => setIsStartTimeOpen(false)}
thresholdToRenderTimeInASingleColumn={48} thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }} timeSteps={{ minutes: 30 }}
slots={{ actionBar: () => null }} slots={{
field: EditableTimeField,
actionBar: () => null,
}}
slotProps={{ slotProps={{
...getSlotProps(
"start-time-input",
false,
t("dateTimeFields.startTime")
),
openPickerButton: { sx: { display: "none" } }, openPickerButton: { sx: { display: "none" } },
popper: { popper: {
sx: { sx: {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
},
"& .MuiMultiSectionDigitalClockSection-item": { "& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start", justifyContent: "flex-start",
width: "100%", width: "100%",
@@ -359,20 +366,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
}, },
}, },
}, },
textField: { field: getTimeFieldSlotProps(
...getSlotProps( "start-time-input",
"start-time-input", false,
false, t("dateTimeFields.startTime")
t("dateTimeFields.startTime") ),
).textField,
onClick: (e) =>
handleTimeInputClick(e, setIsStartTimeOpen),
sx: {
"& .MuiPickersSectionList-section": {
cursor: "pointer",
},
},
},
}} }}
/> />
</Box> </Box>
@@ -395,7 +393,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"end-date-input", "end-date-input",
!!validation.errors.dateTime, !!validation.errors.dateTime,
t("dateTimeFields.endDate") t("dateTimeFields.endDate")
) as any, ),
}} }}
/> />
</Box> </Box>
@@ -404,21 +402,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker <TimePicker
ampm={false} ampm={false}
value={endTimeValue} value={endTimeValue}
onChange={handleEndTimeChangeWithClose} onChange={handleEndTimeChange}
open={isEndTimeOpen}
onClose={() => setIsEndTimeOpen(false)}
thresholdToRenderTimeInASingleColumn={48} thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }} timeSteps={{ minutes: 30 }}
slots={{ actionBar: () => null }} slots={{
field: EditableTimeField,
actionBar: () => null,
}}
slotProps={{ slotProps={{
...getSlotProps(
"end-time-input",
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
openPickerButton: { sx: { display: "none" } }, openPickerButton: { sx: { display: "none" } },
popper: { popper: {
sx: { sx: {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
},
"& .MuiMultiSectionDigitalClockSection-item": { "& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start", justifyContent: "flex-start",
width: "100%", width: "100%",
@@ -426,20 +424,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
}, },
}, },
}, },
textField: { field: getTimeFieldSlotProps(
...getSlotProps( "end-time-input",
"end-time-input", !!validation.errors.dateTime,
!!validation.errors.dateTime, t("dateTimeFields.endTime")
t("dateTimeFields.endTime") ),
).textField,
onClick: (e) =>
handleTimeInputClick(e, setIsEndTimeOpen),
sx: {
"& .MuiPickersSectionList-section": {
cursor: "pointer",
},
},
},
}} }}
/> />
</Box> </Box>
@@ -464,7 +453,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"start-date-input", "start-date-input",
false, false,
t("dateTimeFields.startDate") t("dateTimeFields.startDate")
) as any, ),
}} }}
/> />
</Box> </Box>
@@ -484,7 +473,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"end-date-input", "end-date-input",
!!validation.errors.dateTime, !!validation.errors.dateTime,
t("dateTimeFields.endDate") t("dateTimeFields.endDate")
) as any, ),
}} }}
/> />
</Box> </Box>
@@ -503,7 +492,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"start-date-input", "start-date-input",
false, false,
startDateLabel startDateLabel
) as any, ),
}} }}
/> />
</Box> </Box>
@@ -511,22 +500,22 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker <TimePicker
ampm={false} ampm={false}
value={startTimeValue} value={startTimeValue}
onChange={handleStartTimeChangeWithClose} onChange={handleStartTimeChange}
disabled={allday} disabled={allday}
open={isStartTimeOpen}
onClose={() => setIsStartTimeOpen(false)}
thresholdToRenderTimeInASingleColumn={48} thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }} timeSteps={{ minutes: 30 }}
slots={{ actionBar: () => null }} slots={{
field: EditableTimeField,
actionBar: () => null,
}}
slotProps={{ slotProps={{
...getSlotProps(
"start-time-input",
false,
t("dateTimeFields.startTime")
),
openPickerButton: { sx: { display: "none" } }, openPickerButton: { sx: { display: "none" } },
popper: { popper: {
sx: { sx: {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
},
"& .MuiMultiSectionDigitalClockSection-item": { "& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start", justifyContent: "flex-start",
width: "100%", width: "100%",
@@ -534,19 +523,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
}, },
}, },
}, },
textField: { field: getTimeFieldSlotProps(
...getSlotProps( "start-time-input",
"start-time-input", false,
false, t("dateTimeFields.startTime")
t("dateTimeFields.startTime") ),
).textField,
onClick: (e) => handleTimeInputClick(e, setIsStartTimeOpen),
sx: {
"& .MuiPickersSectionList-section": {
cursor: "pointer",
},
},
},
}} }}
/> />
</Box> </Box>
@@ -565,35 +546,22 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker <TimePicker
ampm={false} ampm={false}
value={endTimeValue} value={endTimeValue}
onChange={handleEndTimeChangeWithClose} onChange={handleEndTimeChange}
disabled={allday} disabled={allday}
open={isEndTimeOpen}
onClose={() => setIsEndTimeOpen(false)}
thresholdToRenderTimeInASingleColumn={48} thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }} timeSteps={{ minutes: 30 }}
slots={{ actionBar: () => null }} slots={{
field: EditableTimeField,
actionBar: () => null,
}}
slotProps={{ 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" } }, openPickerButton: { sx: { display: "none" } },
popper: { popper: {
sx: { sx: {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
},
"& .MuiMultiSectionDigitalClockSection-item": { "& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start", justifyContent: "flex-start",
width: "100%", width: "100%",
@@ -601,6 +569,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
}, },
}, },
}, },
field: getTimeFieldSlotProps(
"end-time-input",
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
}} }}
/> />
</Box> </Box>
@@ -0,0 +1,306 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import TextField from "@mui/material/TextField";
import {
PickerFieldAdapter,
PickerValidationScope,
useValidation,
validateTime,
} from "@mui/x-date-pickers/validation";
import {
useSplitFieldProps,
useParsedFormat,
usePickerContext,
usePickerActionsContext,
} from "@mui/x-date-pickers/hooks";
import { PickerFieldProps } from "@mui/x-date-pickers/models";
import { TimePickerFieldProps } from "@mui/x-date-pickers/TimePicker";
import dayjs, { Dayjs } from "dayjs";
import { parseTimeInput } from "../utils/dateTimeHelpers";
type FieldType = "date" | "time" | "date-time";
type GenericPickerFieldProps = PickerFieldProps<any, any, any> & {
fieldType: FieldType;
validator: (
value: any,
context: PickerValidationScope,
adapter: PickerFieldAdapter<any>
) => string | null;
};
const TIME_DISPLAY_FORMAT = "HH:mm";
/**
* Editable field for time pickers. Allows free typing with format on blur/enter.
* Click anywhere in the field to open picker.
*/
function EditableTimePickerField(props: GenericPickerFieldProps) {
const { fieldType, validator, ...fieldProps } = props;
const { internalProps, forwardedProps } = useSplitFieldProps(
fieldProps,
fieldType
);
const pickerContext = usePickerContext();
const pickerActions = usePickerActionsContext();
const parsedFormat = useParsedFormat();
const inputRef = useRef<HTMLInputElement>(null);
// Get onChange from internalProps to call directly when value changes
const onChange = internalProps.onChange as
| ((value: Dayjs | null) => void)
| undefined;
const getFormattedValue = useCallback(() => {
if (pickerContext.value == null) return "";
if (!pickerContext.value.isValid()) return "";
return pickerContext.value.format(TIME_DISPLAY_FORMAT);
}, [pickerContext.value]);
const [inputValue, setInputValue] = useState(getFormattedValue);
const [isFocused, setIsFocused] = useState(false);
const [pendingCommitValue, setPendingCommitValue] = useState<string | null>(
null
);
const prevFormattedValueRef = useRef(getFormattedValue());
const isDispatchingCloseEventRef = useRef(false);
const hasDispatchedInMicrotaskRef = useRef(false);
// Sync input value when picker value changes from dropdown selection or external
useEffect(() => {
const newFormattedValue = getFormattedValue();
const valueChanged = newFormattedValue !== prevFormattedValueRef.current;
// Sync when:
// 1. Value changed (from dropdown selection or external)
// 2. Not focused (external change)
if (valueChanged || !isFocused) {
setInputValue(newFormattedValue);
}
// Close dropdown after selection (value changed while dropdown is open)
if (valueChanged && pickerContext.open) {
pickerContext.setOpen(false);
// Clear focus after dropdown selection
setIsFocused(false);
inputRef.current?.blur();
}
prevFormattedValueRef.current = newFormattedValue;
}, [getFormattedValue, isFocused, pickerContext]);
const wasOpenRef = useRef(pickerContext.open);
// Handle dropdown open/close
useEffect(() => {
const wasOpen = wasOpenRef.current;
const isOpen = pickerContext.open;
if (isOpen && !wasOpen && inputRef.current) {
// Dropdown just opened - refocus input to allow typing
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 10);
wasOpenRef.current = isOpen;
return () => clearTimeout(timer);
}
if (!isOpen && wasOpen) {
// Dropdown just closed - check if we need to commit user input
const current = getFormattedValue();
if (isFocused && inputValue.trim() && inputValue !== current) {
// Store input value to commit later (after parseAndUpdateTime is defined)
setPendingCommitValue(inputValue);
} else {
setInputValue(current);
}
setIsFocused(false);
// Blur input to clear DOM focus (removes Mui-focused class)
inputRef.current?.blur();
}
wasOpenRef.current = isOpen;
}, [pickerContext.open, getFormattedValue, isFocused, inputValue]);
const { hasValidationError } = useValidation({
validator,
value: pickerContext.value,
timezone: pickerContext.timezone,
props: internalProps,
});
const parseAndUpdateTime = useCallback(
(value: string) => {
const trimmed = value.trim();
if (!trimmed) {
setInputValue(getFormattedValue());
return;
}
const newValue = parseTimeInput(
trimmed,
pickerContext.value as Dayjs | null
);
if (newValue) {
// Update picker internal state and trigger onChange in one call
pickerActions.setValue(newValue, { changeImportance: "accept" });
setInputValue(newValue.format(TIME_DISPLAY_FORMAT));
} else {
// Invalid input - reset to current value
setInputValue(getFormattedValue());
}
},
[pickerContext, getFormattedValue, onChange]
);
// Commit pending input value when picker closes (after parseAndUpdateTime is defined)
useEffect(() => {
if (pendingCommitValue !== null) {
parseAndUpdateTime(pendingCommitValue);
setPendingCommitValue(null);
}
}, [pendingCommitValue, parseAndUpdateTime]);
// Listen for close events from other picker fields
useEffect(() => {
const handleCloseOtherPickers = () => {
// Don't close if this field is the one dispatching the event
if (isDispatchingCloseEventRef.current) {
return;
}
// If this picker is open and another field is requesting to close others
if (pickerContext.open) {
pickerContext.setOpen(false);
}
};
window.addEventListener(
"close-other-time-pickers",
handleCloseOtherPickers
);
return () => {
window.removeEventListener(
"close-other-time-pickers",
handleCloseOtherPickers
);
};
}, [pickerContext]);
const dispatchCloseOtherPickers = useCallback(() => {
// Guard: prevent duplicate dispatch in the same microtask
if (hasDispatchedInMicrotaskRef.current) {
return;
}
// Mark that we've dispatched in this microtask
hasDispatchedInMicrotaskRef.current = true;
// Set flag to prevent this field from closing its own picker
isDispatchingCloseEventRef.current = true;
// Notify other pickers to close
window.dispatchEvent(new CustomEvent("close-other-time-pickers"));
// Reset flags in next microtask to ensure event is processed first
Promise.resolve().then(() => {
isDispatchingCloseEventRef.current = false;
hasDispatchedInMicrotaskRef.current = false;
});
}, []);
const handleClick = (e: React.MouseEvent) => {
if (forwardedProps.disabled || forwardedProps.readOnly) return;
// Stop propagation to prevent parent from toggling the picker
e.stopPropagation();
dispatchCloseOtherPickers();
// Always keep it open when clicked, or open it if closed
if (!pickerContext.open) {
pickerContext.setOpen(true);
}
};
const handleFocus = () => {
if (forwardedProps.disabled || forwardedProps.readOnly) return;
setIsFocused(true);
dispatchCloseOtherPickers();
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
// If dropdown is open, don't parse input
// MUI will handle selection and sync value via useEffect
if (pickerContext.open) {
return;
}
// Dropdown is closed - parse input and update value
setIsFocused(false);
parseAndUpdateTime(inputValue);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
parseAndUpdateTime(inputValue);
setIsFocused(false);
pickerContext.setOpen(false);
inputRef.current?.blur();
} else if (e.key === "Escape") {
setInputValue(getFormattedValue());
setIsFocused(false);
pickerContext.setOpen(false);
inputRef.current?.blur();
}
};
const mergedInputProps = {
...forwardedProps.InputProps,
ref: pickerContext.triggerRef,
sx: {
cursor: "text",
...forwardedProps.InputProps?.sx,
},
};
return (
<TextField
{...forwardedProps}
value={inputValue}
onChange={handleChange}
placeholder={parsedFormat as string}
InputProps={mergedInputProps}
inputRef={inputRef}
error={hasValidationError || forwardedProps.error}
focused={isFocused}
onClick={handleClick}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={pickerContext.rootClassName}
sx={{
...pickerContext.rootSx,
"& input": {
textAlign: "center",
},
}}
ref={pickerContext.rootRef}
name={pickerContext.name}
/>
);
}
export function EditableTimeField(props: TimePickerFieldProps) {
return (
<EditableTimePickerField
{...props}
fieldType="time"
validator={validateTime}
/>
);
}
@@ -1,5 +1,8 @@
import moment from "moment-timezone"; import moment from "moment-timezone";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
dayjs.extend(customParseFormat);
/** /**
* Helper functions for date/time string manipulation * Helper functions for date/time string manipulation
@@ -9,6 +12,8 @@ export const DATETIME_WITH_SECONDS_LENGTH = 19;
export const DATETIME_FORMAT_WITH_SECONDS = "YYYY-MM-DDTHH:mm:ss"; export const DATETIME_FORMAT_WITH_SECONDS = "YYYY-MM-DDTHH:mm:ss";
export const DATETIME_FORMAT_WITHOUT_SECONDS = "YYYY-MM-DDTHH:mm"; export const DATETIME_FORMAT_WITHOUT_SECONDS = "YYYY-MM-DDTHH:mm";
export const TIME_PARSE_FORMATS = ["HH:mm", "H:mm", "HHmm", "Hmm", "HH", "H"];
/** /**
* Detect datetime format based on string length * Detect datetime format based on string length
* @param datetime - Datetime string to analyze * @param datetime - Datetime string to analyze
@@ -83,3 +88,35 @@ export const dtDate = (d: Dayjs) => d.format("YYYY-MM-DD");
/** Extract time “HH:mm” */ /** Extract time “HH:mm” */
export const dtTime = (d: Dayjs) => d.format("HH:mm"); export const dtTime = (d: Dayjs) => d.format("HH:mm");
/**
* Parse flexible time string input into Dayjs object
* Handles formats like: "HH:mm", "HHmm", "Hmm", "HH", "H"
* Also handles 3-digit input normalization (e.g. "830" -> "08:30")
*/
export function parseTimeInput(
value: string,
currentDate: Dayjs | null
): Dayjs | null {
let trimmed = value.trim();
if (!trimmed) {
return null;
}
// Normalize 3-digit input (e.g. "830" → "0830" → 08:30, "123" → "0123" → 01:23)
if (/^\d{3}$/.test(trimmed)) {
trimmed = trimmed.padStart(4, "0");
}
const parsed = dayjs(trimmed, TIME_PARSE_FORMATS, true);
if (parsed.isValid()) {
const baseDate = currentDate || dayjs();
return baseDate
.hour(parsed.hour())
.minute(parsed.minute())
.second(0)
.millisecond(0);
}
return null;
}
+5 -25
View File
@@ -45,9 +45,12 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
let dateTimeError = ""; let dateTimeError = "";
// Determine which fields are visible based on UI mode // Determine which fields are visible based on UI mode
const showFullFields = showMore || hasEndDateChanged; const showFullFields =
showMore ||
allday ||
hasEndDateChanged ||
(!showMore && !allday && startDate !== endDate);
const showTimeOnly = !allday && !showFullFields; const showTimeOnly = !allday && !showFullFields;
const showDateOnly = allday;
// Validate start date // Validate start date
if (!startDate || startDate.trim() === "") { if (!startDate || startDate.trim() === "") {
@@ -123,29 +126,6 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
dateTimeError = "Invalid time format"; dateTimeError = "Invalid time format";
} }
} }
} else if (showDateOnly) {
// 2 fields mode: validate date only
if (!endDate || endDate.trim() === "") {
isDateTimeValid = false;
dateTimeError = "End date is required";
} else {
const toLocalDate = (ymd: string) => {
const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10));
if (!y || !m || !d) return new Date(NaN);
return new Date(y, m - 1, d);
};
const startOnly = toLocalDate(startDate);
const endOnly = toLocalDate(endDate);
if (isNaN(startOnly.getTime()) || isNaN(endOnly.getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid date";
} else if (endOnly < startOnly) {
isDateTimeValid = false;
dateTimeError = "End date must be on or after start date";
}
}
} }
const isValid = isDateTimeValid; const isValid = isDateTimeValid;
+5 -4
View File
@@ -758,16 +758,17 @@ function EventPopover({
} else { } else {
newEvent.start = convertFormDateTimeToISO(start, timezone); newEvent.start = convertFormDateTimeToISO(start, timezone);
if (end) { if (end) {
// In normal mode, only override end date when the end date field is not shown // In normal mode, only override end date when the end date field is not shown and end date is same as start date
if (!showMore && !hasEndDateChanged) { const startDateOnly = (start || "").split("T")[0];
const startDateOnly = (start || "").split("T")[0]; const endDateOnly = (end || "").split("T")[0];
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
const endTimeOnly = end.includes("T") const endTimeOnly = end.includes("T")
? end.split("T")[1]?.slice(0, 5) || "00:00" ? end.split("T")[1]?.slice(0, 5) || "00:00"
: "00:00"; : "00:00";
const endDateTime = `${startDateOnly}T${endTimeOnly}`; const endDateTime = `${startDateOnly}T${endTimeOnly}`;
newEvent.end = convertFormDateTimeToISO(endDateTime, timezone); newEvent.end = convertFormDateTimeToISO(endDateTime, timezone);
} else { } else {
// Extended mode or end date explicitly shown in normal mode: use actual end datetime // Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
newEvent.end = convertFormDateTimeToISO(end, timezone); newEvent.end = convertFormDateTimeToISO(end, timezone);
} }
} }
+5 -4
View File
@@ -562,16 +562,17 @@ function EventUpdateModal({
} else { } else {
// For timed events // For timed events
startDate = convertFormDateTimeToISO(start, timezone); startDate = convertFormDateTimeToISO(start, timezone);
// In normal mode, only override end date when the end date field is not shown // In normal mode, only override end date when the end date field is not shown and end date is same as start date
if (!showMore && !hasEndDateChanged) { const startDateOnly = (start || "").split("T")[0];
const startDateOnly = (start || "").split("T")[0]; const endDateOnly = (end || "").split("T")[0];
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
const endTimeOnly = end.includes("T") const endTimeOnly = end.includes("T")
? end.split("T")[1]?.slice(0, 5) || "00:00" ? end.split("T")[1]?.slice(0, 5) || "00:00"
: "00:00"; : "00:00";
const endDateTime = `${startDateOnly}T${endTimeOnly}`; const endDateTime = `${startDateOnly}T${endTimeOnly}`;
endDate = convertFormDateTimeToISO(endDateTime, timezone); endDate = convertFormDateTimeToISO(endDateTime, timezone);
} else { } else {
// Extended mode: use actual end datetime // Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
endDate = convertFormDateTimeToISO(end, timezone); endDate = convertFormDateTimeToISO(end, timezone);
} }
} }