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); } }