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 4a683cc
Show file tree
Hide file tree
Showing 36 changed files with 420 additions and 416 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function AutocompleteField(props) {
...other
} = forwardedProps;

const { hasValidationError } = useValidation({
const { hasValidationError, getValidationErrorForNewValue } = useValidation({
validator: validateDate,
value,
timezone,
Expand Down Expand Up @@ -85,7 +85,9 @@ function AutocompleteField(props) {
}}
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 @@ -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 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 { DatePicker } from '@mui/x-date-pickers/DatePicker';
Expand All @@ -28,21 +27,7 @@ function MaskedField(props) {

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(() =>
Expand All @@ -61,7 +46,6 @@ function MaskedField(props) {
const { hasValidationError, getValidationErrorForNewValue } = useValidation({
value,
timezone,
onError,
props: internalProps,
validator: validateDate,
});
Expand All @@ -70,13 +54,9 @@ function MaskedField(props) {
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
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
Loading

0 comments on commit 4a683cc

Please sign in to comment.