Skip to content

Commit

Permalink
[pickers] Clean the internals around the custom fields
Browse files Browse the repository at this point in the history
  • Loading branch information
flaviendelangle committed Oct 30, 2024
1 parent f613377 commit f5366cf
Show file tree
Hide file tree
Showing 34 changed files with 407 additions and 380 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function AutocompleteField(props: AutocompleteFieldProps) {
...other
} = forwardedProps;

const { hasValidationError } = useValidation({
const { hasValidationError, getValidationErrorForNewValue } = useValidation({
validator: validateDate,
value,
timezone,
Expand Down Expand Up @@ -96,7 +96,9 @@ function AutocompleteField(props: AutocompleteFieldProps) {
}}
value={value}
onChange={(_, newValue) => {
onChange?.(newValue, { validationError: null });
onChange(newValue, {
validationError: getValidationErrorForNewValue(newValue),
});
}}
isOptionEqualToValue={(option, valueToCheck) =>
option.toISOString() === valueToCheck.toISOString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as React from 'react';
import dayjs, { Dayjs } from 'dayjs';
import { useRifm } from 'rifm';
import TextField from '@mui/material/TextField';
import useControlled from '@mui/utils/useControlled';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import {
Expand Down Expand Up @@ -32,21 +31,7 @@ function MaskedField(props: DatePickerFieldProps<Dayjs>) {

const { forwardedProps, internalProps } = useSplitFieldProps(other, 'date');

const {
format,
value: valueProp,
defaultValue,
onChange,
timezone,
onError,
} = internalProps;

const [value, setValue] = useControlled({
controlled: valueProp,
default: defaultValue ?? null,
name: 'MaskedField',
state: 'value',
});
const { format, value, onChange, timezone } = internalProps;

// Control the input text
const [inputValue, setInputValue] = React.useState<string>(() =>
Expand All @@ -65,7 +50,6 @@ function MaskedField(props: DatePickerFieldProps<Dayjs>) {
const { hasValidationError, getValidationErrorForNewValue } = useValidation({
value,
timezone,
onError,
props: internalProps,
validator: validateDate,
});
Expand All @@ -74,13 +58,9 @@ function MaskedField(props: DatePickerFieldProps<Dayjs>) {
setInputValue(newValueStr);

const newValue = dayjs(newValueStr, format);
setValue(newValue);

if (onChange) {
onChange(newValue, {
validationError: getValidationErrorForNewValue(newValue),
});
}
onChange(newValue, {
validationError: getValidationErrorForNewValue(newValue),
});
};

const rifmFormat = React.useMemo(() => {
Expand Down
89 changes: 68 additions & 21 deletions docs/data/date-pickers/custom-field/custom-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,30 +157,77 @@ The same logic can be applied to any Range Picker:

## How to build a custom field

The main challenge when building a custom field, is to make sure that all the relevant props passed by the pickers are correctly handled.
:::success
The sections below show how to build a field for your picker.
Unlike the field components exposed by `@mui/x-date-pickers` and `@mui/x-date-pickers-pro`, those fields are not suitable for a standalone usage.
:::

On the examples below, you can see that the typing of the props received by a custom field always have the same shape:
### Typing

Each picker component exposes an interface describing the props it passes to its field:

```tsx
interface JoyDateFieldProps
extends UseDateFieldProps<Dayjs, true>, // The headless field props
BaseSingleInputFieldProps<
Dayjs | null,
Dayjs,
FieldSection,
true, // `false` for `enableAccessibleFieldDOMStructure={false}`
DateValidationError
> {} // The DOM field props

interface JoyDateTimeFieldProps
extends UseDateTimeFieldProps<Dayjs, true>, // The headless field props
BaseSingleInputFieldProps<
Dayjs | null,
Dayjs,
FieldSection,
true, // `false` for `enableAccessibleFieldDOMStructure={false}`
DateTimeValidationError
> {} // The DOM field props
import { DatePickerFieldProps } from '@mui/x-date-pickers/DatePicker';

function CustomDateField(props: DatePickerFieldProps<Dayjs>) {
// Your custom field
}

function DatePickerWithCustomField() {
return (
<DatePicker slots={{ field: CustomDateField }}>
)
}
```

| Component | Field props interface |
| --------------------- | ------------------------------- |
| `DatePicker` | `DatePickerFieldProps` |
| `TimePicker` | `TimePickerFieldProps` |
| `DateTimePicker` | `DateTimePickerFieldProps` |
| `DateRangePicker` | `DateRangePickerFieldProps` |
| `DateTimeRangePicker` | `DateTimeRangePickerFieldProps` |

### Validation

You can use the `useValidation` hook to check if the current value passed to your field is valid or not:

```ts
const {
// The error associated to the current value
// (i.e.: `minDate` if `props.value < props.minDate`)
validationError,
// `true` if the value is invalid
// (on range pickers it is true if the start date or the end date is invalid)
hasValidationError,
// Imperatively get the error of a value.
// Can be useful to generate the context to pass to `onChange`
getValidationErrorForNewValue,
} = useValidation();
```

```tsx
import { useValidation, validateDate } from '@mui/x-date-pickers/validation';

function CustomDateField(props: DatePickerFieldProps<Dayjs>) {
const { validationError, hasValidationError, getValidationErrorForNewValue } =
useValidation();

const handleValueStrChange = (newValueStr: string) => {
setInputValue(newValueStr);

const newValue = dayjs(newValueStr, format);
setValue(newValue);

if (onChange) {
onChange(newValue, {
validationError: getValidationErrorForNewValue(newValue),
});
}
};

return <input value />;
}
```

### The headless field props
Expand Down
68 changes: 34 additions & 34 deletions docs/src/modules/components/overview/mainDemo/PickerButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,67 +3,67 @@ import dayjs, { Dayjs } from 'dayjs';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CalendarTodayRoundedIcon from '@mui/icons-material/CalendarTodayRounded';
import { UseDateFieldProps } from '@mui/x-date-pickers/DateField';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import {
BaseSingleInputFieldProps,
DateValidationError,
FieldSection,
} from '@mui/x-date-pickers/models';
import { DatePicker, DatePickerFieldProps } from '@mui/x-date-pickers/DatePicker';
import { useParsedFormat, usePickersContext, useSplitFieldProps } from '@mui/x-date-pickers/hooks';
import { useValidation, validateDate } from '@mui/x-date-pickers/validation';

interface ButtonFieldProps
extends UseDateFieldProps<Dayjs, true>,
BaseSingleInputFieldProps<Dayjs | null, Dayjs, FieldSection, true, DateValidationError> {
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
function ButtonDateField(props: DatePickerFieldProps<Dayjs>) {
const { internalProps, forwardedProps } = useSplitFieldProps(props, 'date');
const { value, timezone, format } = internalProps;
const { InputProps, slotProps, slots, ownerState, label, focused, name, ...other } =
forwardedProps;

const pickersContext = usePickersContext();

const parsedFormat = useParsedFormat(internalProps);
const { hasValidationError } = useValidation({
validator: validateDate,
value,
timezone,
props: internalProps,
});

const handleTogglePicker = (event: React.UIEvent) => {
if (pickersContext.open) {
pickersContext.onClose(event);
} else {
pickersContext.onOpen(event);
}
};

function ButtonField(props: ButtonFieldProps) {
const {
setOpen,
label,
id,
disabled,
InputProps: { ref } = {},
inputProps: { 'aria-label': ariaLabel } = {},
} = props;
const valueStr = value == null ? parsedFormat : value.format(format);

return (
<Button
{...other}
variant="outlined"
size="small"
id={id}
disabled={disabled}
ref={ref}
aria-label={ariaLabel}
onClick={() => setOpen?.((prev) => !prev)}
startIcon={<CalendarTodayRoundedIcon fontSize="small" />}
sx={{ minWidth: 'fit-content' }}
fullWidth
color={hasValidationError ? 'error' : 'primary'}
ref={InputProps?.ref}
onClick={handleTogglePicker}
>
{label ? `${label}` : 'Pick a date'}
{label ? `${label}: ${valueStr}` : valueStr}
</Button>
);
}

export default function PickerButton() {
const [value, setValue] = React.useState<Dayjs | null>(dayjs('2023-04-17'));
const [open, setOpen] = React.useState(false);

return (
<Card variant="outlined" sx={{ padding: 1 }}>
<DatePicker
value={value}
label={value == null ? null : value.format('MMM DD, YYYY')}
format="MMM DD, YYYY"
onChange={(newValue) => setValue(newValue)}
slots={{ field: ButtonField }}
slots={{ field: ButtonDateField }}
slotProps={{
field: { setOpen } as any,
nextIconButton: { size: 'small' },
previousIconButton: { size: 'small' },
}}
open={open}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
views={['day', 'month', 'year']}
/>
</Card>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ import {
DayCalendarProps,
ExportedUseViewsOptions,
} from '@mui/x-date-pickers/internals';
import { DayRangeValidationProps } from '../internals/models/dateRange';
import { DateRange, RangePosition } from '../models';
import { DateRangeCalendarClasses } from './dateRangeCalendarClasses';
import { DateRangePickerDay, DateRangePickerDayProps } from '../DateRangePickerDay';
import { UseRangePositionProps } from '../internals/hooks/useRangePosition';
import { PickersRangeCalendarHeaderProps } from '../PickersRangeCalendarHeader';
import { ExportedValidateDateRangeProps } from '../validation/validateDateRange';

export interface DateRangeCalendarSlots<TDate extends PickerValidDate>
extends PickersArrowSwitcherSlots,
Expand Down Expand Up @@ -62,8 +62,7 @@ export interface DateRangeCalendarSlotProps<TDate extends PickerValidDate>

export interface ExportedDateRangeCalendarProps<TDate extends PickerValidDate>
extends ExportedDayCalendarProps<TDate>,
BaseDateValidationProps<TDate>,
DayRangeValidationProps<TDate>,
ExportedValidateDateRangeProps<TDate>,
TimezoneProps {
/**
* If `true`, after selecting `start` date calendar will not automatically switch to the month of `end` date.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
RangeFieldSection,
UseDateRangeFieldProps,
} from '../models';
import { ValidateDateRangeProps } from '../validation';

export interface DateRangePickerSlots<TDate extends PickerValidDate>
extends DesktopDateRangePickerSlots<TDate>,
Expand Down Expand Up @@ -56,14 +57,11 @@ export interface DateRangePickerProps<
export type DateRangePickerFieldProps<
TDate extends PickerValidDate,
TEnableAccessibleFieldDOMStructure extends boolean = true,
> = MakeRequired<
UseDateRangeFieldProps<TDate, TEnableAccessibleFieldDOMStructure>,
'format' | 'timezone' | 'value' | keyof BaseDateValidationProps<TDate>
> &
> = ValidateDateRangeProps<TDate> &
BaseSingleInputFieldProps<
DateRange<TDate>,
TDate,
RangeFieldSection,
false,
TEnableAccessibleFieldDOMStructure,
DateRangeValidationError
>;
3 changes: 1 addition & 2 deletions packages/x-date-pickers-pro/src/DateRangePicker/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ export interface BaseDateRangePickerProps<TDate extends PickerValidDate>
BasePickerInputProps<DateRange<TDate>, TDate, 'day', DateRangeValidationError>,
'view' | 'views' | 'openTo' | 'onViewChange' | 'orientation'
>,
ExportedDateRangeCalendarProps<TDate>,
BaseDateValidationProps<TDate> {
ExportedDateRangeCalendarProps<TDate> {
/**
* Overridable component slots.
* @default {}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import {
BaseDateValidationProps,
BaseTimeValidationProps,
MakeRequired,
} from '@mui/x-date-pickers/internals';
import { BaseSingleInputFieldProps, PickerValidDate } from '@mui/x-date-pickers/models';
import {
DesktopDateTimeRangePickerProps,
Expand All @@ -14,8 +9,8 @@ import {
MobileDateTimeRangePickerSlots,
MobileDateTimeRangePickerSlotProps,
} from '../MobileDateTimeRangePicker';
import { UseDateTimeRangeFieldProps } from '../internals/models';
import { DateRange, DateTimeRangeValidationError, RangeFieldSection } from '../models';
import type { ValidateDateTimeRangeProps } from '../validation';

export interface DateTimeRangePickerSlots<TDate extends PickerValidDate>
extends DesktopDateTimeRangePickerSlots<TDate>,
Expand Down Expand Up @@ -56,19 +51,11 @@ export interface DateTimeRangePickerProps<
export type DateTimeRangePickerFieldProps<
TDate extends PickerValidDate,
TEnableAccessibleFieldDOMStructure extends boolean = true,
> = MakeRequired<
UseDateTimeRangeFieldProps<TDate, TEnableAccessibleFieldDOMStructure>,
| 'format'
| 'timezone'
| 'value'
| 'ampm'
| keyof BaseDateValidationProps<TDate>
| keyof BaseTimeValidationProps
> &
> = ValidateDateTimeRangeProps<TDate> &
BaseSingleInputFieldProps<
DateRange<TDate>,
TDate,
RangeFieldSection,
false,
TEnableAccessibleFieldDOMStructure,
DateTimeRangeValidationError
>;
Loading

0 comments on commit f5366cf

Please sign in to comment.