mui / mui-x

MUI X: Build complex and data-rich applications using a growing list of advanced React components, like the Data Grid, Date and Time Pickers, Charts, and more!
https://mui.com/x/
4.57k stars 1.34k forks source link

[pickers] Do not force users to listen to the intermediate values of the pickers #6161

Open flaviendelangle opened 2 years ago

flaviendelangle commented 2 years ago

Duplicates

Latest version

Summary 💡

With the current pickers, users have to control the value (i.e to pass a value and onChange props).

The onChange prop is then called every time the selected date changes to a valid value.

We also expose an onAccept prop that is called when the user finishes his date selection. Which can happen when:

If a developer wants to only care about the date selected at the end of the date selection, he can't easily do it.

On the new fields / pickers, developers will be able to only provide a defaultValue instead of controlling. But it does not solve the whole issue. Right now, the old input (and new field) new triggered onAccept. And with onChange, there is no way to easily now if it was an acceptance call in the view. I am unclear about the actual solution, adding it to the "To Explore" board.

Related issues

Somehow related to #5774

Examples 🌈

No response

Motivation 🔦

No response

Order ID 💳 (optional)

No response

githorse commented 2 years ago

Related to https://github.com/mui/mui-x/issues/6736. I've spent 8+ hours now trying to figure out a solution that correctly covers onAccept, onBlur, and on onChange, works in both the desktop and mobile pickers with and without the accept button, doesn't break input validation, etc.. I want the users of my date picker to be listen for real, "committed" updates to the date without worrying about whether that came from the keyboard input or calendar picker, and without firing a bunch of intermediate dates (or firing with the same date twice) while editing or selecting in the modal view before pressing "accept." It's turned out to be quite tricky to make that work.

After mucking around a bit, I think the date picker should keep its own internal state about the currently selected date, and fire onAccept when:

  1. The user clicks on a date in a calendar picker with no Accept/OK button (i.e., the default desktop picker).
  2. The user presses the Accept/OK button in a calendar picker with a new date not equal to the current value.
  3. The user types a valid date in the field which is different than the current value and then clicks out of the field (onBlur).
  4. The user types a valid date in the field which is different than the current value and presses the enter key.

After much trial-and-error, here's my current attempt (which also solves the AdapterDateFns input leniency problem):

import React, {KeyboardEvent, useEffect, useState} from 'react'
import TextField from '@mui/material/TextField'
import {DatePicker} from '@mui/x-date-pickers/DatePicker'
import {AdapterDateFns} from '@mui/x-date-pickers/AdapterDateFns'
import {LocalizationProvider} from '@mui/x-date-pickers/LocalizationProvider'

export type DatePickerProps = {
    value:    Date | null
    onUpdate: ((date: Date | null) => void)
} 

const PARSER = {
    format: "yyyy-MM-dd",
    mask:   "____-__-__",
    regex:  /^(\d{4})-(\d{2})-(\d{2})$/
}

function isValidDate(date: any): boolean {
    return date instanceof Date && !isNaN(date.valueOf())
}

function isValidDateString(input: string): boolean {
   return PARSER.regex.test(input) && isValidDate(new Date(input))
}

export default function MyFunnyDatePicker({
    onUpdate,
    value,
    ...pickerProps
}: DatePickerProps) {
    const [date, setDate] = useState<Date | null>(value)
    useEffect(() => setDate(value), [value])

    const shouldUpdateDate =
        date.toLocaleDateString() !== value?.toLocaleDateString()

    const handleChange =
        (newDate: Date | null, rawInput?: string) => {
            if (!rawInput && isValidDate(date)) {
                setDate(newDate)
            } else if (rawInput && rawInput?.length && isValidDateString(rawInput)) {
                setDate(newDate)
            }
        }

    const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
        if (event.key === 'Enter' && shouldUpdateDate) {
            onUpdate(date)
        }
    }

    const onAccept = () => {
        if (shouldUpdateDate) {
            onUpdate(date)
        }
    }

    return (
        <LocalizationProvider dateAdapter={AdapterDateFns}>
            <DatePicker
                inputFormat={PARSER.format}
                mask={PARSER.mask}
                value={date}
                InputAdornmentProps={{position: 'end'}}
                onAccept={onAccept}
                onChange={handleChange}
                {...pickerProps}
                renderInput={(params) =>
                    <TextField
                        {...params}
                        size='small'
                        onKeyDown={onKeyDown}
                        onBlur={onAccept}
                    />
                }
            />
        </LocalizationProvider>
    )
}

I've also tried debouncing the text input so that updates will fire after some delay, but that makes implementation trickier and it's unclear that is a better user experience than waiting for the user to trigger an update with onBlur or enter.

This solution still doesn't handle the accept button correctly in desktop view (when I manually add it) ... apparently that works differently than in mobile view?

croraf commented 1 year ago

onAccept and onChange semantics are wrong in the DateTimePicker. onChange should be the callback that is called when the selection is confirmed (for example the user clicks the OK button, or the popup is closed in some other way that assumes that the user has confirmed the selection).

Internal changes in the popup should trigger some other callback prop.

pzi commented 5 months ago

Agree with @croraf, sadly still an issue.