From 2d93a25e46b76bb473f1deaea3180df5ef3c53ec Mon Sep 17 00:00:00 2001 From: James Wu Date: Mon, 24 Apr 2023 22:03:45 -0400 Subject: [PATCH] #25730: TimePicker Default Value Fix, Controllable Usage, Example Updates (#26482) * Update prop names, make renamed currentDate prop controlled * Added DatePicker and TimePicker combination example * Rephrase example text * Combine ComboBox imports * Fixed ComboBox import and updated DateTimePicker example * Moved examples into standalone files * Refactors and use new dateAnchor prop * Updated all examples * This should resolve the rest * This should resolve the outstanding issues... * Updated example strings * Updated examples * More changes to the basic example * Removed useComboBoxAsMenuWidth prop * Update formatTimeString function to return 00 if hours is 24 * Addressed comments * Renamed baseDate parameter to dateStartAnchor * Added prop onGetErrorMessage to get validation result * Set selectedTime to invalid or undefined * Clamp value to updated dateAnchor * Make docs look nice and readable * Removed unneeded imports * Updated clampedStartAnchor initialization value * Use internalDateAnchor * Use fallbackDateAnchor and update DateTimePicker example * Revert to ITimePickerProps to extend original omitted IComboBoxProps * Addressed comments * API snapshot update * Updated TimePicker tests and added test for new controlled component variant * Added test for handling changed base date anchor * Verify selected time changes on dateAnchor change * Added yarn change files * Resolve linting errors * Resolved more linting errors * Resolved linting import error by in-lining stack and styles * Addressed comments * Revert onChange prop types to avoid breaking changes and pass React.FormEvent to setSelectedTime callback * Added explicit undefined pass to setSelectedTime in cases the dateAnchor changes * Updated examples and call onChange outside of useControllableValue to pass in proper event type * Control snapping of TimePicker values on DatePicker anchor change * Added tests for using defaultValue or value as date anchors * Took snapping logic out, fixed invalid key bug, and shared getDateAnchor function * Pass in placeholder since placeholder prop no longer has default value * Reflect optional timeOutOfBoundsErrorMessage string * Updated tests and snapshot * Addressed comments * Removed unnecessary string state variables * Followed comment suggestion and replaced onGetErrorMessage with onValidationError prop * Added onValidationError example * Added onValidationError test case * Export TimePickerErrorData * Renamed onValidationError and TimePickerErrorData to onValidationResult and TimePickerValidationData * Renamed example to reflect new callback prop * Use new example and fix casing * Use onValidationResult and only call when stored error message differs from latest error message * Added test for verifying onValidateResult only gets called on error message changes * Split big test into two smaller tests --- ...-d63c5a5c-4853-4fc8-b329-739b7176136a.json | 7 + ...-956c059f-a412-4dce-8d66-05ce162223fc.json | 7 + .../etc/date-time-utilities.api.md | 2 +- .../src/timeFormatting/index.ts | 11 +- .../src/timeMath/timeMath.ts | 15 +- .../TimePicker/TimePicker.Basic.Example.tsx | 77 ++++ .../TimePicker.Controlled.Example.tsx | 41 ++ .../TimePicker.CustomTimeStrings.Example.tsx | 50 +++ .../TimePicker.DateTimePicker.Example.tsx | 62 +++ .../react/TimePicker/TimePicker.Example.tsx | 60 --- .../TimePicker.ValidationResult.Example.tsx | 73 ++++ .../src/react/TimePicker/TimePicker.doc.tsx | 41 +- packages/react/etc/react.api.md | 9 + .../components/TimePicker/TimePicker.test.tsx | 230 +++++++++- .../src/components/TimePicker/TimePicker.tsx | 151 +++++-- .../components/TimePicker/TimePicker.types.ts | 44 +- .../__snapshots__/TimePicker.test.tsx.snap | 402 ++++++++++++++++++ packages/react/src/index.ts | 2 +- 18 files changed, 1148 insertions(+), 136 deletions(-) create mode 100644 change/@fluentui-date-time-utilities-d63c5a5c-4853-4fc8-b329-739b7176136a.json create mode 100644 change/@fluentui-react-956c059f-a412-4dce-8d66-05ce162223fc.json create mode 100644 packages/react-examples/src/react/TimePicker/TimePicker.Basic.Example.tsx create mode 100644 packages/react-examples/src/react/TimePicker/TimePicker.Controlled.Example.tsx create mode 100644 packages/react-examples/src/react/TimePicker/TimePicker.CustomTimeStrings.Example.tsx create mode 100644 packages/react-examples/src/react/TimePicker/TimePicker.DateTimePicker.Example.tsx delete mode 100644 packages/react-examples/src/react/TimePicker/TimePicker.Example.tsx create mode 100644 packages/react-examples/src/react/TimePicker/TimePicker.ValidationResult.Example.tsx create mode 100644 packages/react/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap diff --git a/change/@fluentui-date-time-utilities-d63c5a5c-4853-4fc8-b329-739b7176136a.json b/change/@fluentui-date-time-utilities-d63c5a5c-4853-4fc8-b329-739b7176136a.json new file mode 100644 index 0000000000000..27b3ee9a58068 --- /dev/null +++ b/change/@fluentui-date-time-utilities-d63c5a5c-4853-4fc8-b329-739b7176136a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Refactored getDateFromTimeSelection variable names.", + "packageName": "@fluentui/date-time-utilities", + "email": "jamwu@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-956c059f-a412-4dce-8d66-05ce162223fc.json b/change/@fluentui-react-956c059f-a412-4dce-8d66-05ce162223fc.json new file mode 100644 index 0000000000000..efa49f9d55663 --- /dev/null +++ b/change/@fluentui-react-956c059f-a412-4dce-8d66-05ce162223fc.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat(TimePicker): Updated TimePicker controlled and uncontrolled props to work correctly.", + "packageName": "@fluentui/react", + "email": "jamwu@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/date-time-utilities/etc/date-time-utilities.api.md b/packages/date-time-utilities/etc/date-time-utilities.api.md index e747d16316d40..0da93885a90d4 100644 --- a/packages/date-time-utilities/etc/date-time-utilities.api.md +++ b/packages/date-time-utilities/etc/date-time-utilities.api.md @@ -105,7 +105,7 @@ export const formatYear: (date: Date) => string; export const getBoundedDateRange: (dateRange: Date[], minDate?: Date | undefined, maxDate?: Date | undefined) => Date[]; // @public -export const getDateFromTimeSelection: (useHour12: boolean, baseDate: Date, selectedTime: string) => Date; +export const getDateFromTimeSelection: (useHour12: boolean, dateStartAnchor: Date, selectedTime: string) => Date; // @public export function getDatePartHashValue(date: Date): number; diff --git a/packages/date-time-utilities/src/timeFormatting/index.ts b/packages/date-time-utilities/src/timeFormatting/index.ts index cb5ba9a67b011..b2725d2f21254 100644 --- a/packages/date-time-utilities/src/timeFormatting/index.ts +++ b/packages/date-time-utilities/src/timeFormatting/index.ts @@ -4,10 +4,17 @@ * @param showSeconds - Whether to show seconds in the formatted string * @param useHour12 - Whether to use 12-hour time */ -export const formatTimeString = (date: Date, showSeconds?: boolean, useHour12?: boolean): string => - date.toLocaleTimeString([], { +export const formatTimeString = (date: Date, showSeconds?: boolean, useHour12?: boolean): string => { + let localeTimeString = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: showSeconds ? '2-digit' : undefined, hour12: useHour12, }); + + if (!useHour12 && localeTimeString.slice(0, 2) === '24') { + localeTimeString = '00' + localeTimeString.slice(2); + } + + return localeTimeString; +}; diff --git a/packages/date-time-utilities/src/timeMath/timeMath.ts b/packages/date-time-utilities/src/timeMath/timeMath.ts index 73ff189d0bee3..8c7add20fea43 100644 --- a/packages/date-time-utilities/src/timeMath/timeMath.ts +++ b/packages/date-time-utilities/src/timeMath/timeMath.ts @@ -40,11 +40,11 @@ export const ceilMinuteToIncrement = (date: Date, increments: number) => { /** * Returns a date object from the selected time. * @param useHour12 - If the time picker uses 12 or 24 hour formatting - * @param baseDate - The baseline date to calculate the offset of the selected time + * @param dateStartAnchor - The baseline date to calculate the offset of the selected time * @param selectedTime - A string representing the user selected time * @returns A new date object offset from the baseDate using the selected time. */ -export const getDateFromTimeSelection = (useHour12: boolean, baseDate: Date, selectedTime: string): Date => { +export const getDateFromTimeSelection = (useHour12: boolean, dateStartAnchor: Date, selectedTime: string): Date => { const [, selectedHours, selectedMinutes, selectedSeconds, selectedAp] = TimeConstants.TimeFormatRegex.exec(selectedTime) || []; @@ -61,17 +61,20 @@ export const getDateFromTimeSelection = (useHour12: boolean, baseDate: Date, sel } let hoursOffset; - if (baseDate.getHours() > hours || (baseDate.getHours() === hours && baseDate.getMinutes() > minutes)) { - hoursOffset = TimeConstants.HoursInOneDay - baseDate.getHours() + hours; + if ( + dateStartAnchor.getHours() > hours || + (dateStartAnchor.getHours() === hours && dateStartAnchor.getMinutes() > minutes) + ) { + hoursOffset = TimeConstants.HoursInOneDay - dateStartAnchor.getHours() + hours; } else { - hoursOffset = Math.abs(baseDate.getHours() - hours); + hoursOffset = Math.abs(dateStartAnchor.getHours() - hours); } const offset = TimeConstants.MillisecondsIn1Sec * TimeConstants.MinutesInOneHour * hoursOffset * TimeConstants.SecondsInOneMinute + seconds * TimeConstants.MillisecondsIn1Sec; - const date = new Date(baseDate.getTime() + offset); + const date = new Date(dateStartAnchor.getTime() + offset); date.setMinutes(minutes); date.setSeconds(seconds); diff --git a/packages/react-examples/src/react/TimePicker/TimePicker.Basic.Example.tsx b/packages/react-examples/src/react/TimePicker/TimePicker.Basic.Example.tsx new file mode 100644 index 0000000000000..77e60dae88f0c --- /dev/null +++ b/packages/react-examples/src/react/TimePicker/TimePicker.Basic.Example.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { + TimePicker, + ITimeRange, + Text, + IStackTokens, + Stack, + IStackStyles, + IComboBoxStyles, + IComboBox, +} from '@fluentui/react'; + +const stackStyles: Partial = { root: { width: 500 } }; +const stackTokens: IStackTokens = { childrenGap: 20 }; + +const timePickerStyles: Partial = { + optionsContainerWrapper: { + height: '500px', + }, + root: { + width: '500px', + }, +}; + +export const TimePickerBasicExample: React.FC = () => { + const [basicExampleTimeString, setBasicExampleTimeString] = React.useState(''); + const [nonDefaultOptionsExampleTimeString, setNonDefaultOptionsExampleTimeString] = React.useState(''); + const basicDateAnchor = new Date('November 25, 2021 09:00:00'); + const nonDefaultOptionsDateAnchor = new Date('February 27, 2023 08:00:00'); + + const onBasicExampleChange = React.useCallback((_ev: React.FormEvent, basicExampleTime: Date) => { + setBasicExampleTimeString(basicExampleTime.toString()); + }, []); + + const onNonDefaultOptionsExampleChange = React.useCallback((_, nonDefaultOptionsExampleTime: Date) => { + setNonDefaultOptionsExampleTimeString(nonDefaultOptionsExampleTime?.toString()); + }, []); + + const timeRange: ITimeRange = { + start: 8, + end: 14, + }; + + return ( + + + {`⚓ Date anchor: ${basicDateAnchor.toString()}`} + {`⌚ Selected time: ${basicExampleTimeString ? basicExampleTimeString : ''}`} + + + {`⚓ Date anchor: ${nonDefaultOptionsDateAnchor.toString()}`} + {`⌚ Selected time: ${ + nonDefaultOptionsExampleTimeString ? nonDefaultOptionsExampleTimeString : '' + }`} + + ); +}; diff --git a/packages/react-examples/src/react/TimePicker/TimePicker.Controlled.Example.tsx b/packages/react-examples/src/react/TimePicker/TimePicker.Controlled.Example.tsx new file mode 100644 index 0000000000000..5bd608cee1cfa --- /dev/null +++ b/packages/react-examples/src/react/TimePicker/TimePicker.Controlled.Example.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { TimePicker, Text, IStackTokens, Stack, IStackStyles, IComboBoxStyles } from '@fluentui/react'; + +const stackStyles: Partial = { root: { width: 500 } }; +const stackTokens: IStackTokens = { childrenGap: 20 }; + +const timePickerStyles: Partial = { + optionsContainerWrapper: { + height: '500px', + }, + root: { + width: '500px', + }, +}; + +export const TimePickerControlledExample: React.FC = () => { + const dateAnchor = new Date('February 27, 2023 08:00:00'); + const [time, setTime] = React.useState(new Date('February 27, 2023 10:00:00')); + + const onControlledExampleChange = React.useCallback((_, newTime: Date) => { + setTime(newTime); + }, []); + + return ( + + + {`⚓ Date anchor: ${dateAnchor.toString()}`} + {`⌚ Selected time: ${time ? time.toString() : ''}`} + + ); +}; diff --git a/packages/react-examples/src/react/TimePicker/TimePicker.CustomTimeStrings.Example.tsx b/packages/react-examples/src/react/TimePicker/TimePicker.CustomTimeStrings.Example.tsx new file mode 100644 index 0000000000000..7bbd110a9360a --- /dev/null +++ b/packages/react-examples/src/react/TimePicker/TimePicker.CustomTimeStrings.Example.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { TimePicker, Text, IStackTokens, Stack, IStackStyles, IComboBoxStyles } from '@fluentui/react'; + +const stackStyles: Partial = { root: { width: 500 } }; +const stackTokens: IStackTokens = { childrenGap: 20 }; + +const timePickerStyles: Partial = { + optionsContainerWrapper: { + height: '500px', + }, + root: { + width: '500px', + }, +}; + +export const TimePickerCustomTimeStringsExample: React.FC = () => { + const [customTimeString, setCustomTimeString] = React.useState(''); + const dateAnchor = new Date('February 27, 2023 08:00:00'); + const onFormatDate = React.useCallback((date: Date) => `Custom prefix + ${date.toLocaleTimeString()}`, []); + const onValidateUserInput = React.useCallback((userInput: string) => { + if (!userInput.includes('Custom prefix +')) { + return 'Your input is missing "Custom prefix +"'; + } + return ''; + }, []); + + const onChange = React.useCallback((_, time: Date) => { + console.log('Selected time: ', time); + setCustomTimeString(time.toString()); + }, []); + + return ( + + + {`⚓ Date anchor: ${dateAnchor.toString()}`} + {`⌚ Selected time: ${customTimeString ? customTimeString : ''}`} + + ); +}; diff --git a/packages/react-examples/src/react/TimePicker/TimePicker.DateTimePicker.Example.tsx b/packages/react-examples/src/react/TimePicker/TimePicker.DateTimePicker.Example.tsx new file mode 100644 index 0000000000000..b16e29f58d693 --- /dev/null +++ b/packages/react-examples/src/react/TimePicker/TimePicker.DateTimePicker.Example.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { TimePicker, DatePicker, Label, Text, IStackTokens, Stack, IStackStyles, IComboBox } from '@fluentui/react'; + +const stackStyles: Partial = { root: { width: 500 } }; +const stackTokens: IStackTokens = { childrenGap: 20 }; + +const snapTimeToUpdatedDateAnchor = (datePickerDate: Date, currentTime: Date) => { + let snappedTime = new Date(currentTime); + + if (currentTime && !isNaN(currentTime.valueOf())) { + const startAnchor = new Date(datePickerDate); + const endAnchor = new Date(startAnchor); + endAnchor.setDate(startAnchor.getDate() + 1); + if (currentTime < startAnchor || currentTime > endAnchor) { + snappedTime = new Date(startAnchor); + snappedTime.setHours(currentTime.getHours()); + snappedTime.setMinutes(currentTime.getMinutes()); + snappedTime.setSeconds(currentTime.getSeconds()); + snappedTime.setMilliseconds(currentTime.getMilliseconds()); + } + } + + return snappedTime; +}; + +export const TimePickerDateTimePickerExample: React.FC = () => { + const currentDate = new Date('2023-02-01 05:00:00'); + const [datePickerDate, setDatePickerDate] = React.useState(currentDate); + const [currentTime, setCurrentTime] = React.useState(); + + const onSelectDate = React.useCallback( + (selectedDate: Date) => { + setDatePickerDate(selectedDate); + if (currentTime) { + const snappedTime = snapTimeToUpdatedDateAnchor(selectedDate, currentTime); + setCurrentTime(snappedTime); + } + }, + [currentTime], + ); + + const onTimePickerChange = React.useCallback((_ev: React.FormEvent, date: Date) => { + setCurrentTime(date); + }, []); + + return ( + + +
+ + +
+ {`⚓ Date anchor: ${datePickerDate.toString()}`} + {`⌚ Selected time: ${currentTime ? currentTime.toString() : ''}`} +
+ ); +}; diff --git a/packages/react-examples/src/react/TimePicker/TimePicker.Example.tsx b/packages/react-examples/src/react/TimePicker/TimePicker.Example.tsx deleted file mode 100644 index 7a005524f058b..0000000000000 --- a/packages/react-examples/src/react/TimePicker/TimePicker.Example.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react'; -import { ITimeRange, TimePicker } from '@fluentui/react/lib/TimePicker'; -import { IStackTokens, Stack, IStackStyles, IComboBoxStyles, IComboBox } from '@fluentui/react'; - -const stackStyles: Partial = { root: { maxWidth: 300 } }; -const stackTokens: IStackTokens = { childrenGap: 20 }; - -const timePickerStyles: Partial = { - optionsContainerWrapper: { - height: '500px', - }, - root: { - width: '50%', - }, -}; - -const onFormatDate = (date: Date) => `Custom prefix + ${date.toLocaleTimeString()}`; -const onChange = (_: React.FormEvent, date: Date) => console.log('SELECTED DATE: ', date); - -export const TimePickerBasicExample: React.FC = () => { - const timeRange: ITimeRange = { - start: 8, - end: 14, - }; - - return ( - - - - - - ); -}; diff --git a/packages/react-examples/src/react/TimePicker/TimePicker.ValidationResult.Example.tsx b/packages/react-examples/src/react/TimePicker/TimePicker.ValidationResult.Example.tsx new file mode 100644 index 0000000000000..2d6e78b81b6c1 --- /dev/null +++ b/packages/react-examples/src/react/TimePicker/TimePicker.ValidationResult.Example.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { + TimePicker, + TimePickerValidationResultData, + ITimeRange, + Text, + IStackTokens, + Stack, + IStackStyles, + IComboBoxStyles, + PrimaryButton, + Label, +} from '@fluentui/react'; + +const stackStyles: Partial = { root: { width: 500 } }; +const stackTokens: IStackTokens = { childrenGap: 20 }; + +const timePickerStyles: Partial = { + optionsContainerWrapper: { + height: '500px', + }, + root: { + width: '500px', + }, +}; + +const timeRange: ITimeRange = { + start: 8, + end: 17, +}; + +export const TimePickerValidationResultExample: React.FC = () => { + const dateAnchor = new Date('February 27, 2023 08:00:00'); + const [time, setTime] = React.useState(new Date('January 1, 2023 08:00:00')); + const [disableButton, setDisableButton] = React.useState(false); + + const onControlledExampleChange = React.useCallback((_, newTime: Date) => { + setTime(newTime); + }, []); + + const onValidationResult = React.useCallback((_, timePickerValidationResultData: TimePickerValidationResultData) => { + if (timePickerValidationResultData.errorMessage !== undefined) { + console.log('Validation error message received: ', timePickerValidationResultData.errorMessage); + setDisableButton(timePickerValidationResultData.errorMessage.length > 0); + } + }, []); + + return ( + + + {`⚓ Date anchor: ${dateAnchor.toString()}`} + {`⌚ Selected time: ${time ? time.toString() : ''}`} + + + + + ); +}; diff --git a/packages/react-examples/src/react/TimePicker/TimePicker.doc.tsx b/packages/react-examples/src/react/TimePicker/TimePicker.doc.tsx index 03996cd57b21e..d479fa84c15f7 100644 --- a/packages/react-examples/src/react/TimePicker/TimePicker.doc.tsx +++ b/packages/react-examples/src/react/TimePicker/TimePicker.doc.tsx @@ -2,9 +2,22 @@ import * as React from 'react'; import { IDocPageProps } from '@fluentui/react/lib/common/DocPage.types'; -import { TimePickerBasicExample } from './TimePicker.Example'; -const TimePickerExampleCode = - require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/TimePicker/TimePicker.Example.tsx') as string; +import { TimePickerBasicExample } from './TimePicker.Basic.Example'; +import { TimePickerControlledExample } from './TimePicker.Controlled.Example'; +import { TimePickerCustomTimeStringsExample } from './TimePicker.CustomTimeStrings.Example'; +import { TimePickerValidationResultExample } from './TimePicker.ValidationResult.Example'; +import { TimePickerDateTimePickerExample } from './TimePicker.DateTimePicker.Example'; + +const TimePickerBasicExampleCode = + require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/TimePicker/TimePicker.Basic.Example.tsx') as string; +const TimePickerControlledExampleCode = + require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/TimePicker/TimePicker.Controlled.Example.tsx') as string; +const TimePickerCustomTimeStringsExampleCode = + require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/TimePicker/TimePicker.CustomTimeStrings.Example.tsx') as string; +const TimePickerValidationResultExampleCode = + require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/TimePicker/TimePicker.ValidationResult.Example.tsx') as string; +const TimePickerDateTimePickerExampleCode = + require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/TimePicker/TimePicker.DateTimePicker.Example.tsx') as string; export const TimePickerPageProps: IDocPageProps = { title: 'TimePicker', @@ -13,9 +26,29 @@ export const TimePickerPageProps: IDocPageProps = { examples: [ { title: 'TimePicker basic', - code: TimePickerExampleCode, + code: TimePickerBasicExampleCode, view: , }, + { + title: 'TimePicker controlled', + code: TimePickerControlledExampleCode, + view: , + }, + { + title: 'TimePicker with custom time strings', + code: TimePickerCustomTimeStringsExampleCode, + view: , + }, + { + title: 'TimePicker using onValidationResult callback', + code: TimePickerValidationResultExampleCode, + view: , + }, + { + title: 'TimePicker with DatePicker', + code: TimePickerDateTimePickerExampleCode, + view: , + }, ], overview: require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/TimePicker/docs/TimePickerOverview.md'), bestPractices: require('!raw-loader?esModule=false!@fluentui/react-examples/src/react/TimePicker/docs/TimePickerBestPractices.md'), diff --git a/packages/react/etc/react.api.md b/packages/react/etc/react.api.md index 8e12a477b06bd..c50acb6ab993d 100644 --- a/packages/react/etc/react.api.md +++ b/packages/react/etc/react.api.md @@ -9379,21 +9379,25 @@ export interface IThemeSlotRule { // @public (undocumented) export interface ITimePickerProps extends Omit { allowFreeform?: boolean; + dateAnchor?: Date; defaultValue?: Date; increments?: number; label?: string; onChange?: (event: React_2.FormEvent, time: Date) => void; onFormatDate?: (date: Date) => string; onValidateUserInput?: (userInput: string) => string; + onValidationResult?: (event: React_2.FormEvent, data: TimePickerValidationResultData) => void; showSeconds?: boolean; strings?: ITimePickerStrings; timeRange?: ITimeRange; useHour12?: boolean; + value?: Date; } // @public export interface ITimePickerStrings { invalidInputErrorMessage: string; + timeOutOfBoundsErrorMessage?: string; } // @public @@ -11251,6 +11255,11 @@ export { TimeConstants } // @public (undocumented) export const TimePicker: React_2.FunctionComponent; +// @public +export type TimePickerValidationResultData = { + errorMessage?: string; +}; + // @public (undocumented) export const Toggle: React_2.FunctionComponent; diff --git a/packages/react/src/components/TimePicker/TimePicker.test.tsx b/packages/react/src/components/TimePicker/TimePicker.test.tsx index 8ea8849fe54d6..c335bfea34912 100644 --- a/packages/react/src/components/TimePicker/TimePicker.test.tsx +++ b/packages/react/src/components/TimePicker/TimePicker.test.tsx @@ -1,33 +1,237 @@ import * as React from 'react'; import { TimePicker } from './TimePicker'; -// import { ITimeRange } from './TimePicker.types'; -// import { create } from '@fluentui/test-utilities'; +import { ITimeRange, TimePickerValidationResultData } from './TimePicker.types'; +import { create } from '@fluentui/test-utilities'; import { mount } from 'enzyme'; import type { IComboBox } from '../ComboBox/ComboBox.types'; import { KeyCodes } from '../../Utilities'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; describe('TimePicker', () => { - // TODO: times in this snapshot test changed and failed builds - // it('renders correctly', () => { - // const timeRange: ITimeRange = { - // start: 0, - // end: 5, - // }; - // const component = create(); - // const tree = component.toJSON(); - // expect(tree).toMatchSnapshot(); - // }); + it('renders correctly', () => { + const timeRange: ITimeRange = { + start: 0, + end: 5, + }; + const component = create( + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); it('generates the formatted option', () => { const onFormatDate = (date: Date) => { return 'custom date option'; }; const timePicker = React.createRef(); + const dateAnchor = new Date('November 25, 2021 09:00:00'); + + mount( + , + ); - mount(); expect(timePicker!.current!.selectedOptions[0].text).toBe('custom date option'); }); + it('shows controlled time correctly', () => { + let _selectedTime = new Date('February 27, 2023 10:00:00'); + const onChange = (_ev: React.FormEvent, time: Date): void => { + if (time) { + _selectedTime = time; + } + }; + const dateAnchor = new Date('February 27, 2023 08:00:00'); + + const { getByRole, getAllByRole } = render( + , + ); + + const timePickerComboBox = getByRole('combobox') as HTMLInputElement; + expect(timePickerComboBox.value).toEqual('10:00:00'); + + userEvent.click(timePickerComboBox); + const timePickerOptions = getAllByRole('option') as HTMLButtonElement[]; + userEvent.click(timePickerOptions[2], undefined, { skipPointerEventsCheck: true }); + + const formattedSelectedTime = _selectedTime.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const expectedTime = '08:30:00'; + expect(formattedSelectedTime).toEqual(expectedTime); + }); + + it('correctly renders options using value as date anchor', () => { + let _selectedTime = new Date('March 12, 2023 17:00:00'); + const onChange = (_ev: React.FormEvent, time: Date): void => { + if (time) { + _selectedTime = time; + } + }; + + const { getByRole, getAllByRole } = render( + , + ); + + const timePickerComboBox = getByRole('combobox') as HTMLInputElement; + expect(timePickerComboBox.value).toEqual('5:00:00 PM'); + + userEvent.click(timePickerComboBox); + const timePickerOptions = getAllByRole('option') as HTMLButtonElement[]; + userEvent.click(timePickerOptions[2], undefined, { skipPointerEventsCheck: true }); + + const formattedSelectedTime = _selectedTime.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); + + const expectedTime = '6:00:00 PM'; + expect(formattedSelectedTime).toEqual(expectedTime); + }); + + it('correctly renders options using default value as date anchor', () => { + const defaultValue = new Date('April 1, 2023 13:00:00'); + + const { getByRole, getAllByRole } = render( + , + ); + + const timePickerComboBox = getByRole('combobox') as HTMLInputElement; + expect(timePickerComboBox.value).toEqual('13:00'); + + userEvent.click(timePickerComboBox); + const timePickerOptions = getAllByRole('option') as HTMLButtonElement[]; + userEvent.click(timePickerOptions[2], undefined, { skipPointerEventsCheck: true }); + + expect(timePickerComboBox.value).toEqual('14:00'); + }); + + it('shows the error message under the ComboBox on input validation error', () => { + const onValidationResult = jest.fn(); + + const dateAnchor = new Date('March 15, 2023 10:00:00'); + const timeRange: ITimeRange = { + start: 10, + end: 17, + }; + + const { getByRole } = render( + , + ); + + const timePickerComboBox = getByRole('combobox') as HTMLInputElement; + + userEvent.click(timePickerComboBox); + userEvent.type(timePickerComboBox, '10:45:00{enter}'); + expect(onValidationResult).toHaveBeenCalledTimes(0); + + userEvent.clear(timePickerComboBox); + userEvent.click(timePickerComboBox); + userEvent.type(timePickerComboBox, '11111 AM{enter}'); + + expect(onValidationResult).toHaveBeenCalledTimes(1); + const errorMessageElement = getByRole('alert') as HTMLDivElement; + expect(errorMessageElement).not.toBe(null); + }); + + it('calls onValidationResult only when the error message changes', () => { + let _errorMessage: string = ''; + const onValidationResult = jest.fn( + (_ev: React.FormEvent, timePickerErrorData: TimePickerValidationResultData): void => { + if (timePickerErrorData.errorMessage !== undefined) { + _errorMessage = timePickerErrorData.errorMessage; + } + }, + ); + const dateAnchor = new Date('February 27, 2023 08:00:00'); + const timeRange: ITimeRange = { + start: 8, + end: 20, + }; + + const { getByRole } = render( + , + ); + + const timePickerComboBox = getByRole('combobox') as HTMLInputElement; + expect(_errorMessage).toEqual(''); + + userEvent.click(timePickerComboBox); + userEvent.type(timePickerComboBox, '11111 AM{enter}'); + const firstExpectedErrorMessage = 'Enter a valid time in the 24-hour format: hh:mm:ss'; + expect(_errorMessage).toEqual(firstExpectedErrorMessage); + expect(onValidationResult).toHaveBeenCalled(); + onValidationResult.mockClear(); + + // verify that onValidationResult is not called twice for the same error message + userEvent.clear(timePickerComboBox); + userEvent.click(timePickerComboBox); + userEvent.type(timePickerComboBox, '88888 AM{enter}'); + expect(_errorMessage).toEqual(firstExpectedErrorMessage); + expect(onValidationResult).not.toHaveBeenCalled(); + + // verify that onValidationResult is finally called again for a new error message + userEvent.click(timePickerComboBox); + userEvent.type(timePickerComboBox, '03:00:00{enter}'); + expect(onValidationResult).toHaveBeenCalled(); + + const secondExpectedErrorMessage = 'Please enter a time within the range'; + expect(_errorMessage).toContain(secondExpectedErrorMessage); + }); + describe('validates entered text when', () => { it('receives an invalid hour input for 24-hour-no-seconds format', () => { const wrapper = mount(); diff --git a/packages/react/src/components/TimePicker/TimePicker.tsx b/packages/react/src/components/TimePicker/TimePicker.tsx index d24fbd783b261..2442d38b065ca 100644 --- a/packages/react/src/components/TimePicker/TimePicker.tsx +++ b/packages/react/src/components/TimePicker/TimePicker.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { useConst } from '@fluentui/react-hooks'; import { KeyCodes } from '../../Utilities'; import { TimeConstants, @@ -9,8 +8,10 @@ import { getDateFromTimeSelection, } from '@fluentui/date-time-utilities'; import { ComboBox } from '../../ComboBox'; +import { format } from '../../Utilities'; import type { IComboBox, IComboBoxOption } from '../../ComboBox'; import type { ITimePickerProps, ITimeRange, ITimePickerStrings } from './TimePicker.types'; +import { useControllableValue, useConst } from '@fluentui/react-hooks'; const REGEX_SHOW_SECONDS_HOUR_12 = /^((1[0-2]|0?[1-9]):([0-5][0-9]):([0-5][0-9])\s([AaPp][Mm]))$/; const REGEX_HIDE_SECONDS_HOUR_12 = /^((1[0-2]|0?[1-9]):[0-5][0-9]\s([AaPp][Mm]))$/; @@ -23,10 +24,12 @@ const TIME_UPPER_BOUND = 23; const getDefaultStrings = (useHour12: boolean, showSeconds: boolean): ITimePickerStrings => { const hourUnits = useHour12 ? '12-hour' : '24-hour'; const timeFormat = `hh:mm${showSeconds ? ':ss' : ''}${useHour12 ? ' AP' : ''}`; - const errorMessageToDisplay = `Enter a valid time in the ${hourUnits} format: ${timeFormat}`; + const invalidInputErrorMessage = `Enter a valid time in the ${hourUnits} format: ${timeFormat}`; + const timeOutOfBoundsErrorMessage = `Please enter a time within the range of {0} and {1}`; return { - invalidInputErrorMessage: errorMessageToDisplay, + invalidInputErrorMessage, + timeOutOfBoundsErrorMessage, }; }; @@ -42,20 +45,34 @@ export const TimePicker: React.FunctionComponent = ({ timeRange, strings = getDefaultStrings(useHour12, showSeconds), defaultValue, + value, + dateAnchor, onChange, onFormatDate, onValidateUserInput, + onValidationResult, ...rest }: ITimePickerProps) => { - const [userText, setUserText] = React.useState(''); + const [comboBoxText, setComboBoxText] = React.useState(''); + const [selectedKey, setSelectedKey] = React.useState(); const [errorMessage, setErrorMessage] = React.useState(''); + const fallbackDateAnchor = useConst(new Date()); + + const [selectedTime, setSelectedTime] = useControllableValue(value, defaultValue); + const optionsCount = getDropdownOptionsCount(increments, timeRange); - const initialValue = useConst(defaultValue || new Date()); - const baseDate: Date = React.useMemo( - () => generateBaseDate(increments, timeRange, initialValue), - [increments, timeRange, initialValue], + const internalDateAnchor = dateAnchor || value || defaultValue || fallbackDateAnchor; + + const dateStartAnchor = React.useMemo( + () => getDateAnchor(internalDateAnchor, 'start', increments, timeRange), + [internalDateAnchor, increments, timeRange], + ); + + const dateEndAnchor = React.useMemo( + () => getDateAnchor(internalDateAnchor, 'end', increments, timeRange), + [internalDateAnchor, increments, timeRange], ); const timePickerOptions: IComboBoxOption[] = React.useMemo(() => { @@ -65,20 +82,30 @@ export const TimePicker: React.FunctionComponent = ({ } return optionsList.map((_, index) => { - const option = addMinutes(baseDate, increments * index); + const option: Date = addMinutes(dateStartAnchor, increments * index); option.setSeconds(0); - const optionText = onFormatDate ? onFormatDate(option) : formatTimeString(option, showSeconds, useHour12); + const formattedTimeString = formatTimeString(option, showSeconds, useHour12); + const optionText = onFormatDate ? onFormatDate(option) : formattedTimeString; return { - key: optionText, + key: formattedTimeString, text: optionText, }; }); - }, [baseDate, increments, optionsCount, showSeconds, onFormatDate, useHour12]); + }, [dateStartAnchor, increments, optionsCount, showSeconds, onFormatDate, useHour12]); - const [selectedKey, setSelectedKey] = React.useState(timePickerOptions[0].key); + React.useEffect(() => { + if (selectedTime && !isNaN(selectedTime.valueOf())) { + const formattedTimeString = formatTimeString(selectedTime, showSeconds, useHour12); + const comboboxOption = timePickerOptions.find((option: IComboBoxOption) => option.key === formattedTimeString); + setSelectedKey(comboboxOption?.key); + setComboBoxText(comboboxOption ? comboboxOption.text : formattedTimeString); + } else { + setSelectedKey(null); + } + }, [selectedTime, timePickerOptions, onFormatDate, showSeconds, useHour12]); const onInputChange = React.useCallback( - (event: React.FormEvent, option?: IComboBoxOption, index?: number, value?: string): void => { + (ev: React.FormEvent, option?: IComboBoxOption, _index?: number, input?: string): void => { const validateUserInput = (userInput: string): string => { let errorMessageToDisplay = ''; let regex: RegExp; @@ -89,49 +116,70 @@ export const TimePicker: React.FunctionComponent = ({ } if (!regex.test(userInput)) { errorMessageToDisplay = strings.invalidInputErrorMessage; + } else if (timeRange && strings.timeOutOfBoundsErrorMessage) { + const optionDate: Date = getDateFromTimeSelection(useHour12, dateStartAnchor, userInput); + if (optionDate < dateStartAnchor || optionDate > dateEndAnchor) { + errorMessageToDisplay = format( + strings.timeOutOfBoundsErrorMessage, + dateStartAnchor.toString(), + dateEndAnchor.toString(), + ); + } } return errorMessageToDisplay; }; - const key = option?.key; - let updatedUserText = ''; let errorMessageToDisplay = ''; - if (value) { + if (input) { if (allowFreeform && !option) { if (!onFormatDate) { // Validate only if user did not add onFormatDate - errorMessageToDisplay = validateUserInput(value); + errorMessageToDisplay = validateUserInput(input); } else { // Use user provided validation if onFormatDate is provided if (onValidateUserInput) { - errorMessageToDisplay = onValidateUserInput(value); + errorMessageToDisplay = onValidateUserInput(input); } } } - updatedUserText = value; - } else if (option) { - updatedUserText = option.text; } - if (onChange && !errorMessageToDisplay) { - const selectedTime = value || option?.text || ''; - const date = getDateFromTimeSelection(useHour12, baseDate, selectedTime); - onChange(event, date); + if (onValidationResult && errorMessage !== errorMessageToDisplay) { + // only call onValidationResult if stored errorMessage state value is different from latest error message + onValidationResult(ev, { errorMessage: errorMessageToDisplay }); } + let changedTime: Date; + if (errorMessageToDisplay || (input !== undefined && !input.length)) { + const timeSelection = input || option?.text || ''; + setComboBoxText(timeSelection); + setSelectedTime(errorMessageToDisplay ? new Date('invalid') : undefined); + changedTime = new Date('invalid'); + } else { + const timeSelection = (option?.key as string) || input || ''; + const updatedTime = getDateFromTimeSelection(useHour12, dateStartAnchor, timeSelection); + setSelectedTime(updatedTime); + changedTime = updatedTime; + } + + onChange?.(ev, changedTime); setErrorMessage(errorMessageToDisplay); - setUserText(updatedUserText); - setSelectedKey(key); }, [ - baseDate, + timeRange, + dateStartAnchor, + dateEndAnchor, allowFreeform, - onChange, onFormatDate, onValidateUserInput, showSeconds, useHour12, strings.invalidInputErrorMessage, + strings.timeOutOfBoundsErrorMessage, + setSelectedTime, + onValidationResult, + onChange, + errorMessage, ], ); @@ -162,14 +210,38 @@ export const TimePicker: React.FunctionComponent = ({ errorMessage={errorMessage} options={timePickerOptions} onChange={onInputChange} - text={userText} + text={comboBoxText} //eslint-disable-next-line onKeyPress={evaluatePressedKey} + useComboBoxAsMenuWidth /> ); }; TimePicker.displayName = 'TimePicker'; +const getDateAnchor = ( + internalDateAnchor: Date, + startEnd: 'start' | 'end', + increments: number, + timeRange?: ITimeRange, +) => { + const clampedDateAnchor = new Date(internalDateAnchor.getTime()); + if (timeRange) { + const clampedTimeRange = clampTimeRange(timeRange); + const timeRangeHours = startEnd === 'start' ? clampedTimeRange.start : clampedTimeRange.end; + if (clampedDateAnchor.getHours() !== timeRangeHours) { + clampedDateAnchor.setHours(timeRangeHours); + } + } else if (startEnd === 'end') { + clampedDateAnchor.setDate(clampedDateAnchor.getDate() + 1); + } + clampedDateAnchor.setMinutes(0); + clampedDateAnchor.setSeconds(0); + clampedDateAnchor.setMilliseconds(0); + + return ceilMinuteToIncrement(clampedDateAnchor, increments); +}; + const clampTimeRange = (timeRange: ITimeRange): ITimeRange => { return { start: Math.min(Math.max(timeRange.start, TIME_LOWER_BOUND), TIME_UPPER_BOUND), @@ -177,16 +249,7 @@ const clampTimeRange = (timeRange: ITimeRange): ITimeRange => { }; }; -const generateBaseDate = (increments: number, timeRange: ITimeRange | undefined, baseDate: Date) => { - if (timeRange) { - const clampedTimeRange = clampTimeRange(timeRange); - baseDate.setHours(clampedTimeRange.start); - } - - return ceilMinuteToIncrement(baseDate, increments); -}; - -const getDropdownOptionsCount = (increments: number, timeRange: ITimeRange | undefined) => { +const getHoursInRange = (timeRange: ITimeRange | undefined) => { let hoursInRange = TimeConstants.HoursInOneDay; if (timeRange) { const clampedTimeRange = clampTimeRange(timeRange); @@ -196,5 +259,11 @@ const getDropdownOptionsCount = (increments: number, timeRange: ITimeRange | und hoursInRange = timeRange.end - timeRange.start; } } + + return hoursInRange; +}; + +const getDropdownOptionsCount = (increments: number, timeRange: ITimeRange | undefined) => { + const hoursInRange = getHoursInRange(timeRange); return Math.floor((TimeConstants.MinutesInOneHour * hoursInRange) / increments); }; diff --git a/packages/react/src/components/TimePicker/TimePicker.types.ts b/packages/react/src/components/TimePicker/TimePicker.types.ts index f769decb882b8..80f153c5c034f 100644 --- a/packages/react/src/components/TimePicker/TimePicker.types.ts +++ b/packages/react/src/components/TimePicker/TimePicker.types.ts @@ -20,8 +20,18 @@ export interface ITimeRange { export interface ITimePickerStrings { /** Error message to render below the field if input parsing fails. */ invalidInputErrorMessage: string; + /** Error message to render if the user input date is out of bounds. */ + timeOutOfBoundsErrorMessage?: string; } +/** + * {@docCategory TimePicker} + * A type used to represent the TimePicker validation result. + */ +export type TimePickerValidationResultData = { + errorMessage?: string; +}; + /** * {@docCategory TimePicker} */ @@ -31,12 +41,12 @@ export interface ITimePickerProps 'options' | 'selectedKey' | 'defaultSelectedKey' | 'multiSelect' | 'text' | 'defaultValue' | 'onChange' > { /** - * Label of the component + * Label of the component. */ label?: string; /** - * Time increments, in minutes, of the options in the dropdown + * Time increments, in minutes, of the options in the dropdown. */ increments?: number; @@ -60,32 +70,50 @@ export interface ITimePickerProps allowFreeform?: boolean; /** - * Custom time range to for time options + * Custom time range to for time options. */ timeRange?: ITimeRange; /** - * Localized strings to use in the TimePicker + * Localized strings to use in the TimePicker. */ strings?: ITimePickerStrings; /** - * Default value of the TimePicker, if any + * The uncontrolled default selected time. + * Mutually exclusive with `value`. */ defaultValue?: Date; /** - * Callback issued when the time is changed + * A Date representing the selected time. If you provide this, you must maintain selection + * state by observing onChange events and passing a new value in when changed. + * Mutually exclusive with `defaultValue`. + */ + value?: Date; + + /** + * The date in which all dropdown options are based off of. + */ + dateAnchor?: Date; + + /** + * A callback for receiving a notification when the time has been changed. */ onChange?: (event: React.FormEvent, time: Date) => void; /** - * Callback to localize the date strings displayed for dropdown options + * Callback to localize the date strings displayed for dropdown options. */ onFormatDate?: (date: Date) => string; /** - * Callback to use custom user-input validation + * Callback to use custom user-input validation. */ onValidateUserInput?: (userInput: string) => string; + + /** + * Callback to get validation result. + */ + onValidationResult?: (event: React.FormEvent, data: TimePickerValidationResultData) => void; } diff --git a/packages/react/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap b/packages/react/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap new file mode 100644 index 0000000000000..75e9c9945226d --- /dev/null +++ b/packages/react/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap @@ -0,0 +1,402 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimePicker renders correctly 1`] = ` +
+ +
+ + +
+
+`; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d6798ee62343c..5f3d49abe0686 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1198,7 +1198,7 @@ export { } from './ThemeGenerator'; export type { IThemeRules, IThemeSlotRule } from './ThemeGenerator'; export { TimePicker } from './TimePicker'; -export type { ITimePickerProps, ITimePickerStrings, ITimeRange } from './TimePicker'; +export type { ITimePickerProps, ITimePickerStrings, ITimeRange, TimePickerValidationResultData } from './TimePicker'; export { Toggle, ToggleBase } from './Toggle'; export type { IToggle, IToggleProps, IToggleStyleProps, IToggleStyles } from './Toggle'; export { Tooltip, TooltipBase, TooltipDelay, TooltipHost, TooltipHostBase, TooltipOverflowMode } from './Tooltip';