diff --git a/__test__/components/DateTimeField.test.tsx b/__test__/components/DateTimeField.test.tsx
index c41c36b..acb09b9 100644
--- a/__test__/components/DateTimeField.test.tsx
+++ b/__test__/components/DateTimeField.test.tsx
@@ -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")
diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx
index 2ea714f..f859616 100644
--- a/__test__/components/EventModifications.test.tsx
+++ b/__test__/components/EventModifications.test.tsx
@@ -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));
});
diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx
index db8fd95..f7ca65c 100644
--- a/src/components/Attendees/AttendeeSearch.tsx
+++ b/src/components/Attendees/AttendeeSearch.tsx
@@ -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) => : undefined
+ }
onChange={(event: any, value: User[]) => {
setAttendees(
value.map((a: User) => ({
diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx
index b463d36..148fe5b 100644
--- a/src/components/Attendees/PeopleSearch.tsx
+++ b/src/components/Attendees/PeopleSearch.tsx
@@ -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;
}
+export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
+ error?: boolean;
+ helperText?: string | null;
+ placeholder?: string;
+ label?: string;
+ onKeyDown?: (e: React.KeyboardEvent) => 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) => (
- <>
-
- {
+ const inputProps = {
+ ...params.InputProps,
+ startAdornment: (
+ <>
+
+ {params.InputProps.startAdornment}
+ >
+ ),
+ endAdornment: (
+ <>
+ {loading ? (
+
+ ) : 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) => {
+ 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: (
- <>
-
- {params.InputProps.startAdornment}
- >
- ),
- endAdornment: (
- <>
- {loading ? (
-
- ) : null}
- {params.InputProps.endAdornment}
- >
- ),
- },
- }}
- />
- >
- )}
+ },
+ },
+ };
+
+ if (inputSlot) {
+ return (
+ <>
+
+ {inputSlot({
+ ...enhancedParamsWithInputProps,
+ error: !!inputError,
+ helperText: inputError,
+ placeholder: searchPlaceholder,
+ label: "",
+ onKeyDown: (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && onToggleEventPreview) {
+ e.preventDefault();
+ onToggleEventPreview();
+ }
+ },
+ })}
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+ }}
renderOption={(props, option) => {
if (selectedUsers.find((u) => u.email === option.email)) return null;
const { key, ...otherProps } = props as any;
diff --git a/src/components/Calendar/CalendarSearch.tsx b/src/components/Calendar/CalendarSearch.tsx
index 8c729ae..2c76217 100644
--- a/src/components/Calendar/CalendarSearch.tsx
+++ b/src/components/Calendar/CalendarSearch.tsx
@@ -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({
}
onChange={async (event: any, value: User[]) => {
setSelectedUsers(value);
diff --git a/src/components/Calendar/TempCalendarsInput.tsx b/src/components/Calendar/TempCalendarsInput.tsx
index 935ca9c..224c8c6 100644
--- a/src/components/Calendar/TempCalendarsInput.tsx
+++ b/src/components/Calendar/TempCalendarsInput.tsx
@@ -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) => }
/>
);
}
diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx
index 77731c3..84d3e89 100644
--- a/src/components/Event/EventFormFields.tsx
+++ b/src/components/Event/EventFormFields.tsx
@@ -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={() => {}}
/>
diff --git a/src/components/Event/components/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields.tsx
index 1e6414b..74e8e79 100644
--- a/src/components/Event/components/DateTimeFields.tsx
+++ b/src/components/Event/components/DateTimeFields.tsx
@@ -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 = ({
});
}, [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 = ({
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 = ({
testId: string,
hasError = false,
testLabel?: string
- ) => ({
+ ): Partial &
+ 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 = ({
},
});
- 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 &
+ 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 (
= ({
flexDirection="column"
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
>
- {shouldShowFullFieldsInNormal ? (
+ {isExpanded || showFourFieldsNormal ? (
<>
@@ -328,7 +335,7 @@ export const DateTimeFields: React.FC = ({
"start-date-input",
false,
t("dateTimeFields.startDate")
- ) as any,
+ ),
}}
/>
@@ -337,21 +344,21 @@ export const DateTimeFields: React.FC = ({
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 = ({
},
},
},
- 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")
+ ),
}}
/>
@@ -395,7 +393,7 @@ export const DateTimeFields: React.FC = ({
"end-date-input",
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
- ) as any,
+ ),
}}
/>
@@ -404,21 +402,21 @@ export const DateTimeFields: React.FC = ({
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 = ({
},
},
},
- 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")
+ ),
}}
/>
@@ -464,7 +453,7 @@ export const DateTimeFields: React.FC = ({
"start-date-input",
false,
t("dateTimeFields.startDate")
- ) as any,
+ ),
}}
/>
@@ -484,7 +473,7 @@ export const DateTimeFields: React.FC = ({
"end-date-input",
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
- ) as any,
+ ),
}}
/>
@@ -503,7 +492,7 @@ export const DateTimeFields: React.FC = ({
"start-date-input",
false,
startDateLabel
- ) as any,
+ ),
}}
/>
@@ -511,22 +500,22 @@ export const DateTimeFields: React.FC = ({
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 = ({
},
},
},
- 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")
+ ),
}}
/>
@@ -565,35 +546,22 @@ export const DateTimeFields: React.FC = ({
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 = ({
},
},
},
+ field: getTimeFieldSlotProps(
+ "end-time-input",
+ !!validation.errors.dateTime,
+ t("dateTimeFields.endTime")
+ ),
}}
/>
diff --git a/src/components/Event/components/EditableTimeField.tsx b/src/components/Event/components/EditableTimeField.tsx
new file mode 100644
index 0000000..f2f1a88
--- /dev/null
+++ b/src/components/Event/components/EditableTimeField.tsx
@@ -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 & {
+ fieldType: FieldType;
+ validator: (
+ value: any,
+ context: PickerValidationScope,
+ adapter: PickerFieldAdapter
+ ) => 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(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(
+ 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) => {
+ // 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) => {
+ setInputValue(e.target.value);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ 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 (
+
+ );
+}
+
+export function EditableTimeField(props: TimePickerFieldProps) {
+ return (
+
+ );
+}
diff --git a/src/components/Event/utils/dateTimeHelpers.ts b/src/components/Event/utils/dateTimeHelpers.ts
index 48cb1e9..6d9a878 100644
--- a/src/components/Event/utils/dateTimeHelpers.ts
+++ b/src/components/Event/utils/dateTimeHelpers.ts
@@ -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;
+}
diff --git a/src/components/Event/utils/formValidation.ts b/src/components/Event/utils/formValidation.ts
index 1f3f9cf..19d9ea5 100644
--- a/src/components/Event/utils/formValidation.ts
+++ b/src/components/Event/utils/formValidation.ts
@@ -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;
diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx
index c32a18a..e877fbf 100644
--- a/src/features/Events/EventModal.tsx
+++ b/src/features/Events/EventModal.tsx
@@ -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);
}
}
diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx
index 667c748..cfafdb4 100644
--- a/src/features/Events/EventUpdateModal.tsx
+++ b/src/features/Events/EventUpdateModal.tsx
@@ -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);
}
}