diff --git a/packages/react-components/react-timepicker-compat-preview/etc/react-timepicker-compat-preview.api.md b/packages/react-components/react-timepicker-compat-preview/etc/react-timepicker-compat-preview.api.md index ffbb0b7da6292..7ea556bbba333 100644 --- a/packages/react-components/react-timepicker-compat-preview/etc/react-timepicker-compat-preview.api.md +++ b/packages/react-components/react-timepicker-compat-preview/etc/react-timepicker-compat-preview.api.md @@ -7,6 +7,7 @@ import type { ComboboxProps } from '@fluentui/react-combobox'; import type { ComboboxSlots } from '@fluentui/react-combobox'; import type { ComboboxState } from '@fluentui/react-combobox'; +import type { ComponentProps } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import * as React_2 from 'react'; import type { SelectionEvents } from '@fluentui/react-combobox'; @@ -19,23 +20,23 @@ export const TimePicker: ForwardRefComponent; export const timePickerClassNames: SlotClassNames; // @public -export type TimePickerProps = Omit & TimeFormatOptions & { +export type TimePickerProps = Omit, 'input'>, 'children' | 'size'> & Pick & TimeFormatOptions & { startHour?: Hour; endHour?: Hour; increment?: number; dateAnchor?: Date; selectedTime?: Date | null; defaultSelectedTime?: Date; - onTimeSelect?: (event: TimeSelectionEvents, data: TimeSelectionData) => void; + onTimeChange?: (event: TimeSelectionEvents, data: TimeSelectionData) => void; formatDateToTimeString?: (date: Date) => string; - validateFreeFormTime?: (time: string | undefined) => TimeStringValidationResult; + formatTimeStringToDate?: (time: string | undefined) => TimeStringValidationResult; }; // @public (undocumented) export type TimePickerSlots = ComboboxSlots; // @public -export type TimePickerState = ComboboxState & Required> & { +export type TimePickerState = ComboboxState & Required> & { submittedText: string | undefined; }; @@ -43,7 +44,7 @@ export type TimePickerState = ComboboxState & Required { it('shows controlled time correctly', () => { const TestExample = () => { const [selectedTime, setSelectedTime] = React.useState(dateAnchor); - const onTimeSelect: TimePickerProps['onTimeSelect'] = (_e, data) => setSelectedTime(data.selectedTime); + const onTimeChange: TimePickerProps['onTimeChange'] = (_e, data) => setSelectedTime(data.selectedTime); return ( - + ); }; @@ -70,7 +70,7 @@ describe('TimePicker', () => { const ControlledFreeFormExample = () => { const [selectedTime, setSelectedTime] = React.useState(dateAnchor); - const onTimeSelect: TimePickerProps['onTimeSelect'] = (e, data) => { + const onTimeChange: TimePickerProps['onTimeChange'] = (e, data) => { handleTimeSelect(e, data); setSelectedTime(data.selectedTime); }; @@ -80,13 +80,13 @@ describe('TimePicker', () => { dateAnchor={dateAnchor} startHour={10} selectedTime={selectedTime} - onTimeSelect={onTimeSelect} + onTimeChange={onTimeChange} /> ); }; const UnControlledFreeFormExample = () => ( - + ); beforeEach(() => { @@ -106,7 +106,7 @@ describe('TimePicker', () => { expect(handleTimeSelect).toHaveBeenCalledTimes(1); expect(handleTimeSelect).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ selectedTime: null, selectedTimeText: '111', error: 'invalid-input' }), + expect.objectContaining({ selectedTime: null, selectedTimeText: '111', errorType: 'invalid-input' }), ); }, ); @@ -122,7 +122,7 @@ describe('TimePicker', () => { expect(handleTimeSelect).toHaveBeenCalledTimes(1); expect(handleTimeSelect).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ selectedTimeText: '11:00', error: undefined }), + expect.objectContaining({ selectedTimeText: '11:00', errorType: undefined }), ); }); @@ -130,12 +130,12 @@ describe('TimePicker', () => { name | Component ${'uncontrolled'} | ${UnControlledFreeFormExample} ${'controlled'} | ${ControlledFreeFormExample} - `('$name - trigger onTimeSelect only when value change', ({ Component }) => { + `('$name - trigger onTimeChange only when value change', ({ Component }) => { const { getByRole, getAllByRole } = render(); const input = getByRole('combobox'); - // Call onTimeSelect when select an option + // Call onTimeChange when select an option userEvent.click(input); userEvent.click(getAllByRole('option')[1]); expect(handleTimeSelect).toHaveBeenCalledTimes(1); @@ -145,16 +145,16 @@ describe('TimePicker', () => { ); handleTimeSelect.mockClear(); - // Do not call onTimeSelect on Enter when the value remains the same + // Do not call onTimeChange on Enter when the value remains the same fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); expect(handleTimeSelect).toHaveBeenCalledTimes(0); - // Call onTimeSelect on Enter when the value changes + // Call onTimeChange on Enter when the value changes userEvent.type(input, '111{enter}'); expect(handleTimeSelect).toHaveBeenCalledTimes(1); expect(handleTimeSelect).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ selectedTimeText: '10:30111', error: 'invalid-input' }), + expect.objectContaining({ selectedTimeText: '10:30111', errorType: 'invalid-input' }), ); }); @@ -162,18 +162,18 @@ describe('TimePicker', () => { name | Component ${'uncontrolled'} | ${UnControlledFreeFormExample} ${'controlled'} | ${ControlledFreeFormExample} - `('$name - trigger onTimeSelect on blur when value change', ({ Component }) => { + `('$name - trigger onTimeChange on blur when value change', ({ Component }) => { const { getByRole } = render(); const input = getByRole('combobox'); const expandIcon = getByRole('button'); - // Do not call onTimeSelect when clicking dropdown icon + // Do not call onTimeChange when clicking dropdown icon userEvent.type(input, '111'); userEvent.click(expandIcon); expect(handleTimeSelect).toHaveBeenCalledTimes(0); - // Call onTimeSelect on focus lose + // Call onTimeChange on focus lose userEvent.tab(); expect(handleTimeSelect).toHaveBeenCalledWith( expect.anything(), diff --git a/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/TimePicker.types.ts b/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/TimePicker.types.ts index 540efaf33f725..7510e1afafa8c 100644 --- a/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/TimePicker.types.ts +++ b/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/TimePicker.types.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import type { ComboboxSlots, ComboboxState, ComboboxProps, SelectionEvents } from '@fluentui/react-combobox'; +import type { ComponentProps } from '@fluentui/react-utilities'; export type Hour = | 0 @@ -55,7 +56,7 @@ export type TimePickerErrorType = 'invalid-input' | 'out-of-bounds' | 'required- export type TimeStringValidationResult = { date: Date | null; - error?: TimePickerErrorType; + errorType?: TimePickerErrorType; }; export type TimePickerSlots = ComboboxSlots; @@ -73,7 +74,7 @@ export type TimeSelectionData = { /** * The error type for the selected option. */ - error: TimePickerErrorType | undefined; + errorType: TimePickerErrorType | undefined; }; export type TimeFormatOptions = { @@ -94,16 +95,22 @@ export type TimeFormatOptions = { /** * TimePicker Props */ -export type TimePickerProps = Omit< - ComboboxProps, - // Omit children as TimePicker has predefined children - | 'children' - // Omit selection props as TimePicker has `selectedTime` props - | 'defaultSelectedOptions' - | 'multiselect' - | 'onOptionSelect' - | 'selectedOptions' -> & +export type TimePickerProps = Omit, 'input'>, 'children' | 'size'> & + Pick< + ComboboxProps, + | 'appearance' + | 'defaultOpen' + | 'defaultValue' + | 'inlinePopup' + | 'onOpenChange' + | 'open' + | 'placeholder' + | 'positioning' + | 'size' + | 'value' + | 'mountNode' + | 'freeform' + > & TimeFormatOptions & { /** * Start hour (inclusive) for the time range, 0-24. @@ -138,24 +145,24 @@ export type TimePickerProps = Omit< /** * Callback for when a time selection is made. */ - onTimeSelect?: (event: TimeSelectionEvents, data: TimeSelectionData) => void; + onTimeChange?: (event: TimeSelectionEvents, data: TimeSelectionData) => void; /** - * Custom the date strings displayed in dropdown options. + * Customizes the formatting of date strings displayed in dropdown options. */ formatDateToTimeString?: (date: Date) => string; /** - * Custom validation for the input time string from user in freeform TimePicker. + * In the freeform TimePicker, customizes the parsing from the input time string into a Date and provides custom validation. */ - validateFreeFormTime?: (time: string | undefined) => TimeStringValidationResult; + formatTimeStringToDate?: (time: string | undefined) => TimeStringValidationResult; }; /** * State used in rendering TimePicker */ export type TimePickerState = ComboboxState & - Required> & { + Required> & { /** * Submitted text from the input field. It is used to determine if the input value has changed when user submit a new value on Enter or blur from input. */ diff --git a/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/timeMath.test.ts b/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/timeMath.test.ts index e5af1f193ba33..83ccd84d519ae 100644 --- a/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/timeMath.test.ts +++ b/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/timeMath.test.ts @@ -160,22 +160,22 @@ describe('Time Utilities', () => { }); expect(result.date?.getHours()).toBe(14); expect(result.date?.getMinutes()).toBe(30); - expect(result.error).toBeUndefined(); + expect(result.errorType).toBeUndefined(); }); - it('returns an error when no time string is provided', () => { + it('returns an errorType when no time string is provided', () => { const result = getDateFromTimeString(undefined, dateStartAnchor, dateEndAnchor, {}); expect(result.date).toBeNull(); - expect(result.error).toBe('required-input'); + expect(result.errorType).toBe('required-input'); }); - it('returns an error for an invalid time string', () => { + it('returns an errorType for an invalid time string', () => { const result = getDateFromTimeString('25:30', dateStartAnchor, dateEndAnchor, {}); expect(result.date).toBeNull(); - expect(result.error).toBe('invalid-input'); + expect(result.errorType).toBe('invalid-input'); }); - it('returns a date in the next day and an out-of-bounds error when the time is before the dateStartAnchor', () => { + it('returns a date in the next day and an out-of-bounds errorType when the time is before the dateStartAnchor', () => { const result = getDateFromTimeString('11:30 AM', dateStartAnchor, new Date('November 25, 2023 13:00:00'), { hourCycle: 'h11', showSeconds: false, @@ -183,17 +183,17 @@ describe('Time Utilities', () => { expect(result.date?.getDate()).toBe(26); expect(result.date?.getHours()).toBe(11); expect(result.date?.getMinutes()).toBe(30); - expect(result.error).toBe('out-of-bounds'); + expect(result.errorType).toBe('out-of-bounds'); }); - it('returns an out-of-bounds error when the time is same as the dateEndAnchor', () => { + it('returns an out-of-bounds errorType when the time is same as the dateEndAnchor', () => { const result = getDateFromTimeString('1:00 PM', dateStartAnchor, new Date('November 25, 2023 13:00:00'), { hourCycle: 'h11', showSeconds: false, }); expect(result.date?.getHours()).toBe(13); expect(result.date?.getMinutes()).toBe(0); - expect(result.error).toBe('out-of-bounds'); + expect(result.errorType).toBe('out-of-bounds'); }); }); }); diff --git a/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/timeMath.ts b/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/timeMath.ts index 398c09a1d9eee..383bd80c4b31c 100644 --- a/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/timeMath.ts +++ b/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/timeMath.ts @@ -119,23 +119,23 @@ const REGEX_HIDE_SECONDS_HOUR_24 = /^([0-1]?[0-9]|2[0-4]):[0-5][0-9]$/; /** * Calculates a new date from the user-selected time string based on anchor dates. - * Returns an object containing a date if the provided time string is valid, and an optional error message indicating the type of error. + * Returns an object containing a date if the provided time string is valid, and an optional string indicating the type of error. * * @param time - The time string to be parsed (e.g., "2:30 PM", "15:45:20"). * @param dateStartAnchor - The start anchor date. * @param dateEndAnchor - The end anchor date. * @param timeFormatOptions - format options for the provided time string. - * @returns An object with either a 'date' or an 'error'. + * @returns An object with either a 'date' or an 'errorType'. * * @example * Input: time="2:30 PM", dateStartAnchor=2023-10-06T12:00:00Z, dateEndAnchor=2023-10-07T12:00:00Z, options={hourCycle: 'h12', showSeconds: false} * Output: { date: 2023-10-06T14:30:00Z } * * Input: time="25:30" - * Output: { error: 'invalid-input' } + * Output: { errorType: 'invalid-input' } * * Input: time="1:30 AM", dateStartAnchor=2023-10-06T03:00:00Z, dateEndAnchor=2023-10-07T03:00:00Z, options={hourCycle: 'h12', showSeconds: false} - * Output: { date: 2023-10-07T01:30:00Z, error: 'out-of-bounds' } + * Output: { date: 2023-10-07T01:30:00Z, errorType: 'out-of-bounds' } */ export function getDateFromTimeString( time: string | undefined, @@ -144,7 +144,7 @@ export function getDateFromTimeString( timeFormatOptions: TimeFormatOptions, ): TimeStringValidationResult { if (!time) { - return { date: null, error: 'required-input' }; + return { date: null, errorType: 'required-input' }; } const { hourCycle, showSeconds } = timeFormatOptions; @@ -160,12 +160,12 @@ export function getDateFromTimeString( : REGEX_HIDE_SECONDS_HOUR_24; if (!regex.test(time)) { - return { date: null, error: 'invalid-input' }; + return { date: null, errorType: 'invalid-input' }; } const timeParts = /^(\d\d?):(\d\d):?(\d\d)? ?([ap]m)?/i.exec(time); if (!timeParts) { - return { date: null, error: 'invalid-input' }; + return { date: null, errorType: 'invalid-input' }; } const [, selectedHours, minutes, seconds, amPm] = timeParts; @@ -189,7 +189,7 @@ export function getDateFromTimeString( } if (adjustedDate >= dateEndAnchor) { - return { date: adjustedDate, error: 'out-of-bounds' }; + return { date: adjustedDate, errorType: 'out-of-bounds' }; } return { date: adjustedDate }; diff --git a/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/useTimePicker.tsx b/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/useTimePicker.tsx index 3b02d39e68abb..84ae8a71490c3 100644 --- a/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/useTimePicker.tsx +++ b/packages/react-components/react-timepicker-compat-preview/src/components/TimePicker/useTimePicker.tsx @@ -29,14 +29,14 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref - formatDateToTimeString - ? formatDateToTimeString(dateTime) - : defaultFormatDateToTimeString(dateTime, { showSeconds, hourCycle }), - [hourCycle, formatDateToTimeString, showSeconds], - ); const options: TimePickerOption[] = React.useMemo( () => getTimesBetween(dateStartAnchor, dateEndAnchor, increment).map(time => ({ date: time, key: dateToKey(time), - text: dateToText(time), + text: formatDateToTimeString(time, { showSeconds, hourCycle }), })), - [dateStartAnchor, dateEndAnchor, increment, dateToText], + [dateEndAnchor, dateStartAnchor, formatDateToTimeString, hourCycle, increment, showSeconds], ); const [selectedTime, setSelectedTime] = useControllableState({ @@ -72,17 +65,18 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref(undefined); - const selectTime: TimePickerProps['onTimeSelect'] = React.useCallback( + const selectTime: TimePickerProps['onTimeChange'] = React.useCallback( (e, data) => { setSelectedTime(data.selectedTime); setSubmittedText(data.selectedTimeText); - onTimeSelect?.(e, data); + onTimeChange?.(e, data); }, - [onTimeSelect, setSelectedTime], + [onTimeChange, setSelectedTime], ); const selectedOptions = React.useMemo(() => { - const selectedOption = options.find(date => date.key === dateToKey(selectedTime)); + const selectedTimeKey = dateToKey(selectedTime); + const selectedOption = options.find(date => date.key === selectedTimeKey); return selectedOption ? [selectedOption.key] : []; }, [options, selectedTime]); @@ -95,7 +89,7 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref getDateFromTimeString(time, dateStartAnchor, dateEndAnchor, { hourCycle, showSeconds }), [dateEndAnchor, dateStartAnchor, hourCycle, showSeconds], @@ -125,7 +119,7 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref { const [fallbackDateAnchor] = React.useState(() => new Date()); - // Convert the Date object to a stable key representation. This ensures that the memoization remains stable when a new Date object representing the same date is passed in. - const dateAnchorKey = dateToKey(providedDate ?? null); - const dateAnchor = React.useMemo( - () => keyToDate(dateAnchorKey) ?? fallbackDateAnchor, - [dateAnchorKey, fallbackDateAnchor], - ); + const providedDateKey = dateToKey(providedDate ?? null); - const dateStartAnchor = React.useMemo(() => getDateStartAnchor(dateAnchor, startHour), [dateAnchor, startHour]); - const dateEndAnchor = React.useMemo( - () => getDateEndAnchor(dateAnchor, startHour, endHour), - [dateAnchor, endHour, startHour], - ); + return React.useMemo(() => { + const dateAnchor = providedDate ?? fallbackDateAnchor; + + const dateStartAnchor = getDateStartAnchor(dateAnchor, startHour); + const dateEndAnchor = getDateEndAnchor(dateAnchor, startHour, endHour); - return { dateStartAnchor, dateEndAnchor }; + return { dateStartAnchor, dateEndAnchor }; + // `providedDate`'s stable key representation is used as dependency instead of the Date object. This ensures that the memoization remains stable when a new Date object representing the same date is passed in. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [endHour, fallbackDateAnchor, providedDateKey, startHour]); }; /** @@ -163,8 +155,8 @@ const useStableDateAnchor = (providedDate: Date | undefined, startHour: Hour, en * - Enter/Tab key is pressed on the input. * - TimePicker loses focus, signifying a possible change. */ -const useSelectTimeFromValue = (state: TimePickerState, callback: TimePickerProps['onTimeSelect']) => { - const { activeOption, freeform, validateFreeFormTime, submittedText, setActiveOption, value } = state; +const useSelectTimeFromValue = (state: TimePickerState, callback: TimePickerProps['onTimeChange']) => { + const { activeOption, freeform, formatTimeStringToDate, submittedText, setActiveOption, value } = state; // Base Combobox has activeOption default to first option in dropdown even if it doesn't match input value, and Enter key will select it. // This effect ensures that the activeOption is cleared when the input doesn't match any option. @@ -186,14 +178,14 @@ const useSelectTimeFromValue = (state: TimePickerState, callback: TimePickerProp return; } - const { date: selectedTime, error } = validateFreeFormTime(value); + const { date: selectedTime, errorType } = formatTimeStringToDate(value); // Only triggers callback when the text in input has changed. if (submittedText !== value) { - callback?.(e, { selectedTime, selectedTimeText: value, error }); + callback?.(e, { selectedTime, selectedTimeText: value, errorType }); } }, - [callback, freeform, submittedText, validateFreeFormTime, value], + [callback, freeform, submittedText, formatTimeStringToDate, value], ); const handleKeyDown: ComboboxProps['onKeyDown'] = React.useCallback(