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");
fireEvent.change(startTimeInput, { target: { value: "12:00" } });
fireEvent.blur(startTimeInput);
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("12:00")
@@ -109,6 +110,7 @@ describe("DateTimeFields", () => {
const endTimeInput = screen.getByTestId("end-time-input");
fireEvent.change(endTimeInput, { target: { value: "08:00" } });
fireEvent.blur(endTimeInput);
await waitFor(() =>
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("08:00")
@@ -208,11 +210,14 @@ describe("DateTimeFields", () => {
const startTimeInput = screen.getByTestId("start-time-input");
fireEvent.change(startTimeInput, { target: { value: "09:30" } });
fireEvent.blur(startTimeInput);
await waitFor(() =>
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 () => {
@@ -228,6 +233,7 @@ describe("DateTimeFields", () => {
// Change start time from 22:30 to 23:45
fireEvent.change(startTimeInput, { target: { value: "23:45" } });
fireEvent.blur(startTimeInput);
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("23:45")
@@ -406,9 +406,10 @@ describe("CalendarApp integration", () => {
fireEvent.change(startDateInput, {
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 new Promise((resolve) => setTimeout(resolve, 150));
});
+4 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import TextField from "@mui/material/TextField";
import { userAttendee } from "../../features/User/userDataTypes";
import { PeopleSearch, User } from "./PeopleSearch";
@@ -37,7 +38,9 @@ export default function UserSearch({
selectedUsers={selectedUsers}
objectTypes={["user", "contact"]}
disabled={disabled}
small={small}
inputSlot={
small ? (params) => <TextField {...params} size="small" /> : undefined
}
onChange={(event: any, value: User[]) => {
setAttendees(
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 CircularProgress from "@mui/material/CircularProgress";
import ListItem from "@mui/material/ListItem";
@@ -22,6 +24,14 @@ export interface User {
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({
selectedUsers,
onChange,
@@ -30,7 +40,7 @@ export function PeopleSearch({
freeSolo,
onToggleEventPreview,
placeholder,
small,
inputSlot,
}: {
selectedUsers: User[];
onChange: Function;
@@ -39,7 +49,9 @@ export function PeopleSearch({
freeSolo?: boolean;
onToggleEventPreview?: () => void;
placeholder?: string;
small?: boolean;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
@@ -146,53 +158,95 @@ export function PeopleSearch({
);
onChange(event, mapped);
}}
renderInput={(params) => (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
</label>
<TextField
{...params}
size={small ? "small" : "medium"}
error={!!inputError}
helperText={inputError}
placeholder={searchPlaceholder}
label=""
inputProps={{
...params.inputProps,
renderInput={(params) => {
const inputProps = {
...params.InputProps,
startAdornment: (
<>
<PeopleOutlineOutlinedIcon
sx={{ mr: 1, color: "action.active" }}
/>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<>
{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",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
}
}}
slotProps={{
input: {
...params.InputProps,
autoComplete: "off",
startAdornment: (
<>
<PeopleOutlineOutlinedIcon
style={{ marginRight: 8, color: "rgba(0, 0, 0, 0.54)" }}
/>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
</>
)}
},
},
};
if (inputSlot) {
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
</label>
{inputSlot({
...enhancedParamsWithInputProps,
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
}
},
})}
</>
);
}
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
</label>
<TextField
{...enhancedParams}
{...defaultTextFieldProps}
InputProps={inputProps}
size="medium"
/>
</>
);
}}
renderOption={(props, option) => {
if (selectedUsers.find((u) => u.email === option.email)) return null;
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 { addSharedCalendarAsync } from "../../features/Calendars/CalendarSlice";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import TextField from "@mui/material/TextField";
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
import { ResponsiveDialog } from "../Dialog";
import { ColorPicker } from "./CalendarColorPicker";
@@ -275,7 +276,7 @@ export default function CalendarSearch({
<PeopleSearch
objectTypes={["user"]}
selectedUsers={selectedUsers}
small
inputSlot={(params) => <TextField {...params} size="small" />}
onChange={async (event: any, value: User[]) => {
setSelectedUsers(value);
@@ -8,6 +8,7 @@ import {
} from "../../features/Calendars/CalendarSlice";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import { setView } from "../../features/Settings/SettingsSlice";
import TextField from "@mui/material/TextField";
import { User, PeopleSearch } from "../Attendees/PeopleSearch";
import { getAccessiblePair } from "./utils/calendarColorsUtils";
@@ -105,7 +106,7 @@ export function TempCalendarsInput({
onChange={handleUserChange}
onToggleEventPreview={handleToggleEventPreview}
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);
}, [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
const { handleAllDayToggle } = useAllDayToggle({
allday,
@@ -449,7 +462,12 @@ export default function EventFormFields({
onStartTimeChange={handleStartTimeChange}
onEndDateChange={handleEndDateChange}
onEndTimeChange={handleEndTimeChange}
showEndDate={showMore || allday}
showEndDate={
showMore ||
allday ||
(hasEndDateChanged && startDate !== endDate) ||
(!showMore && !allday && startDate !== endDate)
}
onToggleEndDate={() => {}}
/>
</FieldWithLabel>
+108 -135
View File
@@ -1,7 +1,10 @@
import React, { useMemo } from "react";
import { Box, Typography } from "@mui/material";
import { TextFieldProps } from "@mui/material/TextField";
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 { TimePickerFieldProps } 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";
@@ -16,6 +19,7 @@ import "dayjs/locale/vi";
import { PickerValue } from "@mui/x-date-pickers/internals";
import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers";
import { ReadOnlyDateField } from "./ReadOnlyPickerField";
import { EditableTimeField } from "./EditableTimeField";
dayjs.extend(customParseFormat);
@@ -88,15 +92,10 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
});
}, [startDate, endDate, startTime, endTime, getCurrentDuration]);
const spansMultipleDays = React.useMemo(() => {
return startDate !== endDate;
}, [startDate, endDate]);
const isExpanded = showMore;
const shouldShowEndDateNormal = allday || showEndDate;
const shouldShowFullFieldsInNormal = isExpanded;
const showSingleDateField =
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
const showSingleDateField = !isExpanded && !shouldShowEndDateNormal;
const showFourFieldsNormal = !isExpanded && showEndDate && !allday;
const startDateLabel = showSingleDateField
? t("dateTimeFields.date")
@@ -204,10 +203,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
const newEnd = toDateTime(endDate, newTimeStr);
const currentStart = toDateTime(startDate, startTime);
// Update duration when user changes end (if valid)
if (!newEnd.isBefore(currentStart)) {
initialDurationRef.current = newEnd.diff(currentStart, "minute");
}
// Always update duration ref to reflect current state, even if invalid (end < start)
// This prevents stale duration from being used when user edits start time
initialDurationRef.current = newEnd.diff(currentStart, "minute");
onEndTimeChange(newTimeStr);
};
@@ -253,7 +251,17 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
testId: string,
hasError = false,
testLabel?: string
) => ({
): Partial<DatePickerFieldProps | TimePickerFieldProps> &
Pick<
TextFieldProps,
| "size"
| "margin"
| "fullWidth"
| "InputLabelProps"
| "error"
| "sx"
| "inputProps"
> => ({
size: "small" as const,
margin: "dense" as const,
fullWidth: true,
@@ -266,33 +274,32 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
},
});
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);
};
const getTimeFieldSlotProps = (
testId: string,
hasError = false,
testLabel?: string
): Partial<TimePickerFieldProps> &
Pick<
TextFieldProps,
| "size"
| "margin"
| "fullWidth"
| "InputLabelProps"
| "error"
| "sx"
| "inputProps"
> => ({
size: "small" as const,
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: "100%" },
inputProps: {
"data-testid": testId,
...(testLabel ? { "aria-label": testLabel } : {}),
},
});
return (
<LocalizationProvider
@@ -309,7 +316,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
flexDirection="column"
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
>
{shouldShowFullFieldsInNormal ? (
{isExpanded || showFourFieldsNormal ? (
<>
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
@@ -328,7 +335,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"start-date-input",
false,
t("dateTimeFields.startDate")
) as any,
),
}}
/>
</Box>
@@ -337,21 +344,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker
ampm={false}
value={startTimeValue}
onChange={handleStartTimeChangeWithClose}
open={isStartTimeOpen}
onClose={() => setIsStartTimeOpen(false)}
onChange={handleStartTimeChange}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{ actionBar: () => null }}
slots={{
field: EditableTimeField,
actionBar: () => null,
}}
slotProps={{
...getSlotProps(
"start-time-input",
false,
t("dateTimeFields.startTime")
),
openPickerButton: { sx: { display: "none" } },
popper: {
sx: {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
},
"& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start",
width: "100%",
@@ -359,20 +366,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
},
},
},
textField: {
...getSlotProps(
"start-time-input",
false,
t("dateTimeFields.startTime")
).textField,
onClick: (e) =>
handleTimeInputClick(e, setIsStartTimeOpen),
sx: {
"& .MuiPickersSectionList-section": {
cursor: "pointer",
},
},
},
field: getTimeFieldSlotProps(
"start-time-input",
false,
t("dateTimeFields.startTime")
),
}}
/>
</Box>
@@ -395,7 +393,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"end-date-input",
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
) as any,
),
}}
/>
</Box>
@@ -404,21 +402,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker
ampm={false}
value={endTimeValue}
onChange={handleEndTimeChangeWithClose}
open={isEndTimeOpen}
onClose={() => setIsEndTimeOpen(false)}
onChange={handleEndTimeChange}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{ actionBar: () => null }}
slots={{
field: EditableTimeField,
actionBar: () => null,
}}
slotProps={{
...getSlotProps(
"end-time-input",
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
openPickerButton: { sx: { display: "none" } },
popper: {
sx: {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
},
"& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start",
width: "100%",
@@ -426,20 +424,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
},
},
},
textField: {
...getSlotProps(
"end-time-input",
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
).textField,
onClick: (e) =>
handleTimeInputClick(e, setIsEndTimeOpen),
sx: {
"& .MuiPickersSectionList-section": {
cursor: "pointer",
},
},
},
field: getTimeFieldSlotProps(
"end-time-input",
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
}}
/>
</Box>
@@ -464,7 +453,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"start-date-input",
false,
t("dateTimeFields.startDate")
) as any,
),
}}
/>
</Box>
@@ -484,7 +473,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"end-date-input",
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
) as any,
),
}}
/>
</Box>
@@ -503,7 +492,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
"start-date-input",
false,
startDateLabel
) as any,
),
}}
/>
</Box>
@@ -511,22 +500,22 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker
ampm={false}
value={startTimeValue}
onChange={handleStartTimeChangeWithClose}
onChange={handleStartTimeChange}
disabled={allday}
open={isStartTimeOpen}
onClose={() => setIsStartTimeOpen(false)}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{ actionBar: () => null }}
slots={{
field: EditableTimeField,
actionBar: () => null,
}}
slotProps={{
...getSlotProps(
"start-time-input",
false,
t("dateTimeFields.startTime")
),
openPickerButton: { sx: { display: "none" } },
popper: {
sx: {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
},
"& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start",
width: "100%",
@@ -534,19 +523,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
},
},
},
textField: {
...getSlotProps(
"start-time-input",
false,
t("dateTimeFields.startTime")
).textField,
onClick: (e) => handleTimeInputClick(e, setIsStartTimeOpen),
sx: {
"& .MuiPickersSectionList-section": {
cursor: "pointer",
},
},
},
field: getTimeFieldSlotProps(
"start-time-input",
false,
t("dateTimeFields.startTime")
),
}}
/>
</Box>
@@ -565,35 +546,22 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker
ampm={false}
value={endTimeValue}
onChange={handleEndTimeChangeWithClose}
onChange={handleEndTimeChange}
disabled={allday}
open={isEndTimeOpen}
onClose={() => setIsEndTimeOpen(false)}
thresholdToRenderTimeInASingleColumn={48}
timeSteps={{ minutes: 30 }}
slots={{ actionBar: () => null }}
slots={{
field: EditableTimeField,
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: {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
},
"& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start",
width: "100%",
@@ -601,6 +569,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
},
},
},
field: getTimeFieldSlotProps(
"end-time-input",
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
}}
/>
</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 dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
dayjs.extend(customParseFormat);
/**
* 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_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
* @param datetime - Datetime string to analyze
@@ -83,3 +88,35 @@ export const dtDate = (d: Dayjs) => d.format("YYYY-MM-DD");
/** Extract time “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 = "";
// 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 showDateOnly = allday;
// Validate start date
if (!startDate || startDate.trim() === "") {
@@ -123,29 +126,6 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
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;
+5 -4
View File
@@ -758,16 +758,17 @@ function EventPopover({
} else {
newEvent.start = convertFormDateTimeToISO(start, timezone);
if (end) {
// In normal mode, only override end date when the end date field is not shown
if (!showMore && !hasEndDateChanged) {
const startDateOnly = (start || "").split("T")[0];
// In normal mode, only override end date when the end date field is not shown and end date is same as start date
const startDateOnly = (start || "").split("T")[0];
const endDateOnly = (end || "").split("T")[0];
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
const endTimeOnly = end.includes("T")
? end.split("T")[1]?.slice(0, 5) || "00:00"
: "00:00";
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
newEvent.end = convertFormDateTimeToISO(endDateTime, timezone);
} 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);
}
}
+5 -4
View File
@@ -562,16 +562,17 @@ function EventUpdateModal({
} else {
// For timed events
startDate = convertFormDateTimeToISO(start, timezone);
// In normal mode, only override end date when the end date field is not shown
if (!showMore && !hasEndDateChanged) {
const startDateOnly = (start || "").split("T")[0];
// In normal mode, only override end date when the end date field is not shown and end date is same as start date
const startDateOnly = (start || "").split("T")[0];
const endDateOnly = (end || "").split("T")[0];
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
const endTimeOnly = end.includes("T")
? end.split("T")[1]?.slice(0, 5) || "00:00"
: "00:00";
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
endDate = convertFormDateTimeToISO(endDateTime, timezone);
} 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);
}
}