Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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';
Expand All @@ -19,23 +20,23 @@ export const TimePicker: ForwardRefComponent<TimePickerProps>;
export const timePickerClassNames: SlotClassNames<TimePickerSlots>;

// @public
export type TimePickerProps = Omit<ComboboxProps, 'children' | 'defaultSelectedOptions' | 'multiselect' | 'onOptionSelect' | 'selectedOptions'> & TimeFormatOptions & {
export type TimePickerProps = Omit<ComponentProps<Partial<ComboboxSlots>, 'input'>, 'children' | 'size'> & Pick<ComboboxProps, 'appearance' | 'defaultOpen' | 'defaultValue' | 'inlinePopup' | 'onOpenChange' | 'open' | 'placeholder' | 'positioning' | 'size' | 'value' | 'mountNode' | 'freeform'> & 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<Pick<TimePickerProps, 'freeform' | 'validateFreeFormTime'>> & {
export type TimePickerState = ComboboxState & Required<Pick<TimePickerProps, 'freeform' | 'formatTimeStringToDate'>> & {
submittedText: string | undefined;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ describe('TimePicker', () => {
it('shows controlled time correctly', () => {
const TestExample = () => {
const [selectedTime, setSelectedTime] = React.useState<Date | null>(dateAnchor);
const onTimeSelect: TimePickerProps['onTimeSelect'] = (_e, data) => setSelectedTime(data.selectedTime);
const onTimeChange: TimePickerProps['onTimeChange'] = (_e, data) => setSelectedTime(data.selectedTime);
return (
<TimePicker dateAnchor={dateAnchor} increment={60} selectedTime={selectedTime} onTimeSelect={onTimeSelect} />
<TimePicker dateAnchor={dateAnchor} increment={60} selectedTime={selectedTime} onTimeChange={onTimeChange} />
);
};

Expand All @@ -70,7 +70,7 @@ describe('TimePicker', () => {

const ControlledFreeFormExample = () => {
const [selectedTime, setSelectedTime] = React.useState<Date | null>(dateAnchor);
const onTimeSelect: TimePickerProps['onTimeSelect'] = (e, data) => {
const onTimeChange: TimePickerProps['onTimeChange'] = (e, data) => {
handleTimeSelect(e, data);
setSelectedTime(data.selectedTime);
};
Expand All @@ -80,13 +80,13 @@ describe('TimePicker', () => {
dateAnchor={dateAnchor}
startHour={10}
selectedTime={selectedTime}
onTimeSelect={onTimeSelect}
onTimeChange={onTimeChange}
/>
);
};

const UnControlledFreeFormExample = () => (
<TimePicker freeform dateAnchor={dateAnchor} onTimeSelect={handleTimeSelect} startHour={10} />
<TimePicker freeform dateAnchor={dateAnchor} onTimeChange={handleTimeSelect} startHour={10} />
);

beforeEach(() => {
Expand Down Expand Up @@ -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(<Component />);

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);
Expand All @@ -145,11 +145,11 @@ 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(
Expand All @@ -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(<Component />);

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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import type { ComboboxSlots, ComboboxState, ComboboxProps, SelectionEvents } from '@fluentui/react-combobox';
import { ComponentProps } from '@fluentui/react-utilities';

export type Hour =
| 0
Expand Down Expand Up @@ -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<ComponentProps<Partial<ComboboxSlots>, '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.
Expand Down Expand Up @@ -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<Pick<TimePickerProps, 'freeform' | 'validateFreeFormTime'>> & {
Required<Pick<TimePickerProps, 'freeform' | 'formatTimeStringToDate'>> & {
/**
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref<HT
dateAnchor: dateAnchorInProps,
defaultSelectedTime: defaultSelectedTimeInProps,
endHour = 24,
formatDateToTimeString,
formatDateToTimeString = defaultFormatDateToTimeString,
hourCycle = 'h23',
increment = 30,
onTimeSelect,
onTimeChange,
selectedTime: selectedTimeInProps,
showSeconds = false,
startHour = 0,
validateFreeFormTime: validateFreeFormTimeInProps,
formatTimeStringToDate: formatTimeStringToDateInProps,
...rest
} = props;
const { freeform = false } = rest;
Expand All @@ -47,21 +47,14 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref<HT
endHour,
);

const dateToText = React.useCallback(
(dateTime: Date) =>
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<Date | null>({
Expand All @@ -72,17 +65,18 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref<HT

const [submittedText, setSubmittedText] = React.useState<string | undefined>(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]);

Expand Down Expand Up @@ -116,7 +110,7 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref<HT
ref,
);

const defaultValidateTime = React.useCallback(
const defaultFormatTimeStringToDate = React.useCallback(
(time: string | undefined) =>
getDateFromTimeString(time, dateStartAnchor, dateEndAnchor, { hourCycle, showSeconds }),
[dateEndAnchor, dateStartAnchor, hourCycle, showSeconds],
Expand All @@ -125,7 +119,7 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref<HT
const state: TimePickerState = {
...baseState,
freeform,
validateFreeFormTime: validateFreeFormTimeInProps ?? defaultValidateTime,
formatTimeStringToDate: formatTimeStringToDateInProps ?? defaultFormatTimeStringToDate,
submittedText,
};

Expand All @@ -141,20 +135,18 @@ export const useTimePicker_unstable = (props: TimePickerProps, ref: React.Ref<HT
const useStableDateAnchor = (providedDate: Date | undefined, startHour: Hour, endHour: Hour) => {
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]);
};

/**
Expand All @@ -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.
Expand All @@ -186,14 +178,14 @@ const useSelectTimeFromValue = (state: TimePickerState, callback: TimePickerProp
return;
}

const { date: selectedTime, error } = validateFreeFormTime(value);
const { date: selectedTime, error } = formatTimeStringToDate(value);

// Only triggers callback when the text in input has changed.
if (submittedText !== value) {
callback?.(e, { selectedTime, selectedTimeText: value, error });
}
},
[callback, freeform, submittedText, validateFreeFormTime, value],
[callback, freeform, submittedText, formatTimeStringToDate, value],
);

const handleKeyDown: ComboboxProps['onKeyDown'] = React.useCallback(
Expand Down