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:
committed by
Benoit TELLIER
parent
12776d36a4
commit
627e9bf44b
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user