Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 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';
Expand All @@ -19,31 +20,31 @@ 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;
};

// @public (undocumented)
export type TimeSelectionData = {
selectedTime: Date | null;
selectedTimeText: string | undefined;
error: TimePickerErrorType | undefined;
errorType: TimePickerErrorType | undefined;
};

// @public (undocumented)
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 All @@ -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' }),
);
},
);
Expand All @@ -122,20 +122,20 @@ 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 }),
);
});

it.each`
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,35 +145,35 @@ 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' }),
);
});

it.each`
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 type { ComponentProps } from '@fluentui/react-utilities';

export type Hour =
| 0
Expand Down Expand Up @@ -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;
Expand All @@ -73,7 +74,7 @@ export type TimeSelectionData = {
/**
* The error type for the selected option.
*/
error: TimePickerErrorType | undefined;
errorType: TimePickerErrorType | undefined;
};

export type TimeFormatOptions = {
Expand All @@ -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 @@ -160,40 +160,40 @@ 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,
});
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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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 };
Expand Down
Loading