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
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: Add error handling to DatePicker.",
"packageName": "@fluentui/react-datepicker-compat",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ export const DatePicker: ForwardRefComponent<DatePickerProps>;
// @public (undocumented)
export const datePickerClassNames: SlotClassNames<DatePickerSlots>;

// @public
export type DatePickerErrorData = {
error: 'invalid-input' | 'out-of-bounds' | 'required-input';
};

// @public (undocumented)
export type DatePickerProps = Omit<ComponentProps<Partial<DatePickerSlots>>, 'defaultValue' | 'value'> & {
componentRef?: React_2.RefObject<IDatePicker>;
Expand All @@ -167,6 +172,7 @@ export type DatePickerProps = Omit<ComponentProps<Partial<DatePickerSlots>>, 'de
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onValidationError?: (data: DatePickerErrorData) => void;
inlinePopup?: boolean;
positioning?: PositioningProps;
placeholder?: string;
Expand Down Expand Up @@ -224,7 +230,10 @@ export enum DayOfWeek {
export const DAYS_IN_WEEK = 7;

// @public (undocumented)
export const defaultCalendarStrings: CalendarStrings;
export const defaultDatePickerErrorStrings: Record<DatePickerErrorData['error'], string>;

// @public (undocumented)
export const defaultDatePickerStrings: CalendarStrings;

// @public
export enum FirstWeekOfYear {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"dependencies": {
"@fluentui/keyboard-keys": "^9.0.2",
"@fluentui/react-field": "^9.1.0",
"@fluentui/react-icons": "^2.0.196",
"@fluentui/react-input": "^9.4.10",
"@fluentui/react-jsx-runtime": "9.0.0-alpha.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ export type DatePickerProps = Omit<ComponentProps<Partial<DatePickerSlots>>, 'de
*/
onOpenChange?: (open: boolean) => void;

/**
* Callback to run when the DatePicker encounters an error when validating the input
*/
onValidationError?: (data: DatePickerErrorData) => void;

/**
* Whether the DatePicker should render the popup as inline or in a portal
*
Expand Down Expand Up @@ -224,32 +229,18 @@ export type DatePickerProps = Omit<ComponentProps<Partial<DatePickerSlots>>, 'de
showCloseButton?: boolean;
};

/**
* State used in rendering DatePicker.
*/
export type DatePickerState = ComponentState<DatePickerSlots> & {
disabled: boolean;
inlinePopup: boolean;
};

// TODO: remove this once we add error handling hook
export interface DatePickerStrings extends CalendarStrings {
/**
* Error message to render for Input if isRequired validation fails.
*/
isRequiredErrorMessage?: string;

/**
* Error message to render for Input if input date string parsing fails.
*/
invalidInputErrorMessage?: string;

/**
* Error message to render for Input if date boundary (minDate, maxDate) validation fails.
*/
isOutOfBoundsErrorMessage?: string;

/**
* Status message to render for Input the input date parsing fails,
* and the typed value is cleared and reset to the previous value.
* e.g. "Invalid entry `{0}`, date reset to `{1}`"
*/
isResetStatusMessage?: string;
}
/**
* Data passed to the `onValidationError` callback.
*/
export type DatePickerErrorData = {
/** The error found when validating the input. */
error: 'invalid-input' | 'out-of-bounds' | 'required-input';
};
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { defaultCalendarStrings } from '../Calendar/defaults';
import type { DatePickerStrings } from './DatePicker.types';
import type { CalendarStrings } from '../../utils/index';
import type { DatePickerErrorData } from './DatePicker.types';

// TODO: Once we have error handling hook, this needs to be either renamed or removed.
export const defaultDatePickerStrings: DatePickerStrings = {
export const defaultDatePickerStrings: CalendarStrings = {
...defaultCalendarStrings,
prevMonthAriaLabel: 'Go to previous month',
nextMonthAriaLabel: 'Go to next month',
prevYearAriaLabel: 'Go to previous year',
nextYearAriaLabel: 'Go to next year',
closeButtonAriaLabel: 'Close date picker',
isRequiredErrorMessage: 'Field is required',
invalidInputErrorMessage: 'Invalid date format',
isResetStatusMessage: 'Invalid entry "{0}", date reset to "{1}"',
};

export const defaultDatePickerErrorStrings: Record<DatePickerErrorData['error'], string> = {
'invalid-input': 'Invalid date format',
'out-of-bounds': 'Date is out of bounds',
'required-input': 'Field is required',
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import * as React from 'react';
import { ArrowDown, Enter, Escape } from '@fluentui/keyboard-keys';
import { Calendar } from '../Calendar/Calendar';
import { CalendarMonthRegular } from '@fluentui/react-icons';
import { compareDatePart, DayOfWeek, FirstWeekOfYear } from '../../utils';
import { defaultDatePickerStrings } from './defaults';
import { Input } from '@fluentui/react-input';
import { useFocusFinders, useModalAttributes } from '@fluentui/react-tabster';
import {
mergeCallbacks,
resolveShorthand,
Expand All @@ -13,14 +15,13 @@ import {
useOnClickOutside,
useOnScrollOutside,
} from '@fluentui/react-utilities';
import { compareDatePart, DayOfWeek, FirstWeekOfYear } from '../../utils';
import { Calendar } from '../Calendar/Calendar';
import { usePopupPositioning } from '../../utils/usePopupPositioning';
import { useFieldContext_unstable as useFieldContext } from '@fluentui/react-field';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
import type { InputProps, InputOnChangeData } from '@fluentui/react-input';
import { useFocusFinders, useModalAttributes } from '@fluentui/react-tabster';
import { usePopupPositioning } from '../../utils/usePopupPositioning';
import type { CalendarProps, ICalendar } from '../Calendar/Calendar.types';
import type { DatePickerProps, DatePickerState } from './DatePicker.types';
import { defaultCalendarStrings } from '../Calendar/defaults';
import type { InputProps, InputOnChangeData } from '@fluentui/react-input';

function isDateOutOfBounds(date: Date, minDate?: Date, maxDate?: Date): boolean {
return (!!minDate && compareDatePart(minDate!, date) > 0) || (!!maxDate && compareDatePart(maxDate!, date) < 0);
Expand Down Expand Up @@ -128,12 +129,13 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref<HT
onOpenChange,
onSelectDate: onUserSelectDate,
openOnClick = true,
onValidationError,
parseDateFromString = defaultParseDateFromString,
showCloseButton = false,
showGoToToday = true,
showMonthPickerAsOverlay = false,
showWeekNumbers = false,
strings = defaultCalendarStrings,
strings = defaultDatePickerStrings,
today,
underlined = false,
value,
Expand All @@ -147,6 +149,8 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref<HT
value,
});
const [open, setOpenState] = usePopupVisibility(props);
const fieldContext = useFieldContext();
const required = fieldContext?.required ?? props.required;
const popupSurfaceId = useId('datePicker-popoverSurface');

const validateTextInput = React.useCallback(
Expand All @@ -161,16 +165,27 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref<HT
}
date = date || parseDateFromString!(formattedDate);

// Check if date is null or date is and invalid date
// Check if date is null or date is an invalid date
if (!date || isNaN(date.getTime())) {
// Reset input if formatting is available
setSelectedDate(selectedDate);
} else if (!isDateOutOfBounds(date, minDate, maxDate)) {
setSelectedDate(date);
onValidationError?.({ error: 'invalid-input' });
} else {
if (isDateOutOfBounds(date, minDate, maxDate)) {
onValidationError?.({ error: 'out-of-bounds' });
} else {
setSelectedDate(date);
}
}
} else {
if (required) {
onValidationError?.({ error: 'required-input' });
}
} else if (onUserSelectDate) {
onUserSelectDate(date);

onUserSelectDate?.(date);
}
} else if (required && !formattedDate) {
onValidationError?.({ error: 'required-input' });
}
},
[
Expand All @@ -180,7 +195,9 @@ export const useDatePicker_unstable = (props: DatePickerProps, ref: React.Ref<HT
maxDate,
minDate,
onUserSelectDate,
onValidationError,
parseDateFromString,
required,
selectedDate,
setSelectedDate,
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const usePopupSurfaceClassName = makeResetStyles({
borderColor: tokens.colorTransparentStroke,
display: 'inline-flex',
color: tokens.colorNeutralForeground1,
padding: '16px',
...typographyStyles.body1,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { AnimationDirection, defaultCalendarStrings } from './Calendar';
export { AnimationDirection } from './Calendar';
export type { CalendarProps, ICalendar } from './Calendar';

export type { CalendarDayProps, ICalendarDay } from './CalendarDay';
Expand All @@ -8,11 +8,13 @@ export type { CalendarMonthProps, ICalendarMonth } from './CalendarMonth';
export {
DatePicker,
datePickerClassNames,
defaultDatePickerErrorStrings,
defaultDatePickerStrings,
renderDatePicker_unstable,
useDatePicker_unstable,
useDatePickerStyles_unstable,
} from './DatePicker';
export type { DatePickerProps, IDatePicker } from './DatePicker';
export type { DatePickerErrorData, DatePickerProps, IDatePicker } from './DatePicker';

export {
DAYS_IN_WEEK,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as React from 'react';
import { addMonths, addYears, DatePicker, defaultDatePickerErrorStrings } from '@fluentui/react-datepicker-compat';
import { Field, makeStyles } from '@fluentui/react-components';
import type { DatePickerErrorData } from '../../src/DatePicker';

const useStyles = makeStyles({
control: {
maxWidth: '300px',
},
});

const today = new Date(Date.now());
const minDate = addMonths(today, -1);
const maxDate = addYears(today, 1);

export const ErrorHandling = () => {
const styles = useStyles();
const [error, setError] = React.useState<DatePickerErrorData['error'] | undefined>(undefined);

return (
<Field
required
label={
`Select a date out of bounds (minDate: ${minDate}, maxDate: ${maxDate}),` +
` type an invalid input, or leave the input empty and close the DatePicker.`
}
validationMessage={error && defaultDatePickerErrorStrings[error]}
>
<DatePicker
minDate={minDate}
maxDate={maxDate}
placeholder="Select a date..."
allowTextInput
onValidationError={data => setError(data.error)}
className={styles.control}
/>
</Field>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { FirstDayOfTheWeek } from './DatePickerFirstDayOfTheWeek.stories';
export { WeekNumbers } from './DatePickerWeekNumbers.stories';
export { DateBoundaries } from './DatePickerDateBoundaries.stories';
export { CustomDateFormatting } from './DatePickerCustomDateFormatting.stories';
export { ErrorHandling } from './DatePickerErrorHandling.stories';
export { Controlled } from './DatePickerControlled.stories';
export { Required } from './DatePickerRequired.stories';
export { Disabled } from './DatePickerDisabled.stories';
Expand Down