diff --git a/.eslintrc.js b/.eslintrc.js index 051f32dd561433..9833425ea86db7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,8 +64,7 @@ module.exports = { '!@material-ui/utils/macros', '@material-ui/utils/macros/*', '!@material-ui/utils/macros/*.macro', - // public API: https://next.material-ui-pickers.dev/getting-started/installation#peer-library - '!@material-ui/pickers/adapter/*', + '!@material-ui/lab/dateAdapter/*', ], }, ], diff --git a/.gitignore b/.gitignore index 0f2a98601f9ae1..adcfc918790f40 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .idea .vscode *.log +*.tsbuildinfo /.eslintcache /.nyc_output /benchmark/**/dist diff --git a/docs/next.config.js b/docs/next.config.js index cc54352f5913df..9e87a4a71ea879 100644 --- a/docs/next.config.js +++ b/docs/next.config.js @@ -71,9 +71,7 @@ module.exports = { config.externals = [ (context, request, callback) => { - const hasDependencyOnRepoPackages = ['notistack', '@material-ui/pickers'].includes( - request, - ); + const hasDependencyOnRepoPackages = ['notistack'].includes(request); if (hasDependencyOnRepoPackages) { return callback(null); @@ -108,7 +106,7 @@ module.exports = { // transpile 3rd party packages with dependencies in this repository { test: /\.(js|mjs|jsx)$/, - include: /node_modules(\/|\\)(notistack|@material-ui(\/|\\)pickers)/, + include: /node_modules(\/|\\)notistack/, use: { loader: 'babel-loader', options: { diff --git a/docs/package.json b/docs/package.json index 7d41fd91ffba4b..4a46abb92774db 100644 --- a/docs/package.json +++ b/docs/package.json @@ -32,7 +32,6 @@ "@material-ui/docs": "^5.0.0-alpha.1", "@material-ui/icons": "^5.0.0-alpha.1", "@material-ui/lab": "^5.0.0-alpha.1", - "@material-ui/pickers": "^4.0.0-alpha.11", "@material-ui/styled-engine": "^5.0.0-alpha.1", "@material-ui/styled-engine-sc": "^5.0.0-alpha.1", "@material-ui/styles": "^5.0.0-alpha.1", @@ -69,7 +68,7 @@ "create-emotion-server": "^10.0.27", "cross-env": "^7.0.0", "css-mediaquery": "^0.1.2", - "date-fns": "^2.15.0", + "date-fns": "^2.0.0", "docsearch.js": "^2.6.3", "doctrine": "^3.0.0", "emotion-theming": "^10.0.27", diff --git a/docs/pages/components/date-picker.js b/docs/pages/components/date-picker.js new file mode 100644 index 00000000000000..a9faa5bc5db5fb --- /dev/null +++ b/docs/pages/components/date-picker.js @@ -0,0 +1,24 @@ +import React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import { prepareMarkdown } from 'docs/src/modules/utils/parseMarkdown'; + +const pageFilename = 'components/date-picker'; +const requireDemo = require.context('docs/src/pages/components/date-picker', false, /\.(js|tsx)$/); +const requireRaw = require.context( + '!raw-loader!../../src/pages/components/date-picker', + false, + /\.(js|md|tsx)$/, +); + +// Run styled-components ref logic +// https://github.com/styled-components/styled-components/pull/2998 +requireDemo.keys().map(requireDemo); + +export default function Page({ demos, docs }) { + return ; +} + +Page.getInitialProps = () => { + const { demos, docs } = prepareMarkdown({ pageFilename, requireRaw }); + return { demos, docs }; +}; diff --git a/docs/pages/components/date-range-picker.js b/docs/pages/components/date-range-picker.js new file mode 100644 index 00000000000000..fdea211ea31c9d --- /dev/null +++ b/docs/pages/components/date-range-picker.js @@ -0,0 +1,28 @@ +import React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import { prepareMarkdown } from 'docs/src/modules/utils/parseMarkdown'; + +const pageFilename = 'components/date-range-picker'; +const requireDemo = require.context( + 'docs/src/pages/components/date-range-picker', + false, + /\.(js|tsx)$/, +); +const requireRaw = require.context( + '!raw-loader!../../src/pages/components/date-range-picker', + false, + /\.(js|md|tsx)$/, +); + +// Run styled-components ref logic +// https://github.com/styled-components/styled-components/pull/2998 +requireDemo.keys().map(requireDemo); + +export default function Page({ demos, docs }) { + return ; +} + +Page.getInitialProps = () => { + const { demos, docs } = prepareMarkdown({ pageFilename, requireRaw }); + return { demos, docs }; +}; diff --git a/docs/pages/components/date-time-picker.js b/docs/pages/components/date-time-picker.js new file mode 100644 index 00000000000000..d76dfa6060f970 --- /dev/null +++ b/docs/pages/components/date-time-picker.js @@ -0,0 +1,28 @@ +import React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import { prepareMarkdown } from 'docs/src/modules/utils/parseMarkdown'; + +const pageFilename = 'components/date-time-picker'; +const requireDemo = require.context( + 'docs/src/pages/components/date-time-picker', + false, + /\.(js|tsx)$/, +); +const requireRaw = require.context( + '!raw-loader!../../src/pages/components/date-time-picker', + false, + /\.(js|md|tsx)$/, +); + +// Run styled-components ref logic +// https://github.com/styled-components/styled-components/pull/2998 +requireDemo.keys().map(requireDemo); + +export default function Page({ demos, docs }) { + return ; +} + +Page.getInitialProps = () => { + const { demos, docs } = prepareMarkdown({ pageFilename, requireRaw }); + return { demos, docs }; +}; diff --git a/docs/pages/components/time-picker.js b/docs/pages/components/time-picker.js new file mode 100644 index 00000000000000..c474df51e50e68 --- /dev/null +++ b/docs/pages/components/time-picker.js @@ -0,0 +1,24 @@ +import React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import { prepareMarkdown } from 'docs/src/modules/utils/parseMarkdown'; + +const pageFilename = 'components/time-picker'; +const requireDemo = require.context('docs/src/pages/components/time-picker', false, /\.(js|tsx)$/); +const requireRaw = require.context( + '!raw-loader!../../src/pages/components/time-picker', + false, + /\.(js|md|tsx)$/, +); + +// Run styled-components ref logic +// https://github.com/styled-components/styled-components/pull/2998 +requireDemo.keys().map(requireDemo); + +export default function Page({ demos, docs }) { + return ; +} + +Page.getInitialProps = () => { + const { demos, docs } = prepareMarkdown({ pageFilename, requireRaw }); + return { demos, docs }; +}; diff --git a/docs/scripts/buildApi.ts b/docs/scripts/buildApi.ts index d1d57b32b128af..b54cef27522f61 100644 --- a/docs/scripts/buildApi.ts +++ b/docs/scripts/buildApi.ts @@ -281,6 +281,10 @@ async function buildDocs(options: { prettierConfigPath, theme, } = options; + if (componentObject.filename.indexOf('internal') !== -1) { + return; + } + const src = readFileSync(componentObject.filename, 'utf8'); if (src.match(/@ignore - internal component\./) || src.match(/@ignore - do not document\./)) { diff --git a/docs/src/modules/utils/helpers.js b/docs/src/modules/utils/helpers.js index ca337f20c312e3..5914d7bdd1f44c 100644 --- a/docs/src/modules/utils/helpers.js +++ b/docs/src/modules/utils/helpers.js @@ -83,7 +83,6 @@ function includePeerDependencies(deps, versions) { if ( deps['@material-ui/lab'] || - deps['@material-ui/pickers'] || deps['@material-ui/x'] || deps['@material-ui/x-grid'] || deps['@material-ui/x-pickers'] || @@ -98,10 +97,6 @@ function includePeerDependencies(deps, versions) { deps['@material-ui/icons'] = versions['@material-ui/icons']; deps['@material-ui/lab'] = versions['@material-ui/lab']; } - - if (deps['@material-ui/pickers']) { - deps['date-fns'] = 'latest'; - } } /** @@ -131,8 +126,10 @@ function getDependencies(raw, options = {}) { const deps = {}; const versions = { - 'react-dom': reactVersion, react: reactVersion, + 'react-dom': reactVersion, + '@emotion/core': 'latest', + '@emotion/styled': 'latest', '@material-ui/core': getMuiPackageVersion('core', muiCommitRef), '@material-ui/icons': getMuiPackageVersion('icons', muiCommitRef), '@material-ui/lab': getMuiPackageVersion('lab', muiCommitRef), @@ -142,9 +139,6 @@ function getDependencies(raw, options = {}) { '@material-ui/system': getMuiPackageVersion('system', muiCommitRef), '@material-ui/unstyled': getMuiPackageVersion('unstyled', muiCommitRef), '@material-ui/utils': getMuiPackageVersion('utils', muiCommitRef), - '@material-ui/pickers': 'next', - '@emotion/core': 'latest', - '@emotion/styled': 'latest', }; const re = /^import\s'([^']+)'|import\s[\s\S]*?\sfrom\s+'([^']+)/gm; @@ -164,6 +158,12 @@ function getDependencies(raw, options = {}) { if (!deps[name]) { deps[name] = versions[name] ? versions[name] : 'latest'; } + + // e.g date-fns + const dateAdapter = /^@material-ui\/lab\/dateAdapter\/(.*)/; + if (dateAdapter.test(m[2])) { + deps[dateAdapter.exec(m[2])[1]] = 'latest'; + } } includePeerDependencies(deps, versions); diff --git a/docs/src/modules/utils/helpers.test.js b/docs/src/modules/utils/helpers.test.js index 80152e4c1918f4..71cda164298c27 100644 --- a/docs/src/modules/utils/helpers.test.js +++ b/docs/src/modules/utils/helpers.test.js @@ -22,13 +22,13 @@ const styles = theme => ({ it('should handle @ dependencies', () => { expect(getDependencies(s1)).to.deep.equal({ + react: 'latest', + 'react-dom': 'latest', '@emotion/core': 'latest', '@emotion/styled': 'latest', '@foo-bar/bip': 'latest', '@material-ui/core': 'next', 'prop-types': 'latest', - 'react-dom': 'latest', - react: 'latest', }); }); @@ -48,6 +48,8 @@ const suggestions = [ `; expect(getDependencies(source)).to.deep.equal({ + react: 'latest', + 'react-dom': 'latest', '@emotion/core': 'latest', '@emotion/styled': 'latest', '@material-ui/core': 'next', @@ -55,20 +57,18 @@ const suggestions = [ 'autosuggest-highlight': 'latest', 'prop-types': 'latest', 'react-draggable': 'latest', - 'react-dom': 'latest', - react: 'latest', }); }); it('should support next dependencies', () => { expect(getDependencies(s1, { reactVersion: 'next' })).to.deep.equal({ + react: 'next', + 'react-dom': 'next', '@emotion/core': 'latest', '@emotion/styled': 'latest', '@foo-bar/bip': 'latest', '@material-ui/core': 'next', 'prop-types': 'latest', - 'react-dom': 'next', - react: 'next', }); }); @@ -78,31 +78,31 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import Grid from '@material-ui/core/Grid'; import { withStyles } from '@material-ui/core/styles'; -import DateFnsAdapter from "@material-ui/pickers/adapter/date-fns"; -import { LocalizationProvider as MuiPickersLocalizationProvider, KeyboardTimePicker, KeyboardDatePicker } from '@material-ui/pickers'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import { LocalizationProvider as MuiPickersLocalizationProvider, KeyboardTimePicker, KeyboardDatePicker } from '@material-ui/lab'; `; expect(getDependencies(source)).to.deep.equal({ - 'date-fns': 'latest', + react: 'latest', + 'react-dom': 'latest', + 'prop-types': 'latest', '@emotion/core': 'latest', '@emotion/styled': 'latest', - '@material-ui/pickers': 'next', '@material-ui/core': 'next', - 'prop-types': 'latest', - 'react-dom': 'latest', - react: 'latest', + '@material-ui/lab': 'next', + 'date-fns': 'latest', }); }); it('can collect required @types packages', () => { expect(getDependencies(s1, { codeLanguage: 'TS' })).to.deep.equal({ + react: 'latest', + 'react-dom': 'latest', + 'prop-types': 'latest', '@emotion/core': 'latest', '@emotion/styled': 'latest', '@foo-bar/bip': 'latest', '@material-ui/core': 'next', - 'prop-types': 'latest', - 'react-dom': 'latest', - react: 'latest', '@types/foo-bar__bip': 'latest', '@types/prop-types': 'latest', '@types/react-dom': 'latest', @@ -114,22 +114,22 @@ import { LocalizationProvider as MuiPickersLocalizationProvider, KeyboardTimePic it('should handle multilines', () => { const source = ` import * as React from 'react'; -import DateFnsAdapter from '@material-ui/pickers/adapter/date-fns'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; import { LocalizationProvider as MuiPickersLocalizationProvider, KeyboardTimePicker, KeyboardDatePicker, -} from '@material-ui/pickers'; +} from '@material-ui/lab'; `; expect(getDependencies(source)).to.deep.equal({ - 'date-fns': 'latest', + react: 'latest', + 'react-dom': 'latest', '@emotion/core': 'latest', '@emotion/styled': 'latest', '@material-ui/core': 'next', - '@material-ui/pickers': 'next', - react: 'latest', - 'react-dom': 'latest', + '@material-ui/lab': 'next', + 'date-fns': 'latest', }); }); @@ -139,12 +139,12 @@ import lab from '@material-ui/lab'; `; expect(getDependencies(source)).to.deep.equal({ + react: 'latest', + 'react-dom': 'latest', '@emotion/core': 'latest', '@emotion/styled': 'latest', '@material-ui/core': 'next', '@material-ui/lab': 'next', - react: 'latest', - 'react-dom': 'latest', }); }); @@ -156,6 +156,8 @@ import { useDemoData } from '@material-ui/x-grid-data-generator'; `; expect(getDependencies(source, { codeLanguage: 'TS' })).to.deep.equal({ + react: 'latest', + 'react-dom': 'latest', '@emotion/core': 'latest', '@emotion/styled': 'latest', '@material-ui/core': 'next', @@ -165,8 +167,6 @@ import { useDemoData } from '@material-ui/x-grid-data-generator'; '@material-ui/x-grid-data-generator': 'latest', '@types/react': 'latest', '@types/react-dom': 'latest', - react: 'latest', - 'react-dom': 'latest', typescript: 'latest', }); }); diff --git a/docs/src/pages.js b/docs/src/pages.js index 0abcbefb7047d5..dd1b74621197f9 100644 --- a/docs/src/pages.js +++ b/docs/src/pages.js @@ -38,7 +38,6 @@ const pages = [ { pathname: '/components/button-group' }, { pathname: '/components/checkboxes', title: 'Checkbox' }, { pathname: '/components/floating-action-button' }, - { pathname: '/components/pickers', title: 'Date / Time' }, { pathname: '/components/radio-buttons', title: 'Radio button' }, { pathname: '/components/rating' }, { pathname: '/components/selects', title: 'Select' }, @@ -145,6 +144,18 @@ const pages = [ subheader: '/components/lab', children: [ { pathname: '/components/about-the-lab', title: 'About the lab ๐Ÿงช' }, + { + pathname: '/components', + subheader: '/components/lab-pickers', + title: 'Date / Time', + children: [ + { pathname: '/components/pickers', title: 'Introduction' }, + { pathname: '/components/date-picker' }, + { pathname: '/components/date-range-picker' }, + { pathname: '/components/date-time-picker' }, + { pathname: '/components/time-picker' }, + ], + }, { pathname: '/components/slider-styled' }, { pathname: '/components/timeline' }, { pathname: '/components/trap-focus' }, diff --git a/docs/src/pages/components/date-picker/BasicDatePicker.js b/docs/src/pages/components/date-picker/BasicDatePicker.js new file mode 100644 index 00000000000000..264282a26d1890 --- /dev/null +++ b/docs/src/pages/components/date-picker/BasicDatePicker.js @@ -0,0 +1,22 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DatePicker from '@material-ui/lab/DatePicker'; + +export default function BasicDatePicker() { + const [value, setValue] = React.useState(null); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/BasicDatePicker.tsx b/docs/src/pages/components/date-picker/BasicDatePicker.tsx new file mode 100644 index 00000000000000..40ad4418a997c4 --- /dev/null +++ b/docs/src/pages/components/date-picker/BasicDatePicker.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DatePicker from '@material-ui/lab/DatePicker'; + +export default function BasicDatePicker() { + const [value, setValue] = React.useState(null); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/CustomDay.js b/docs/src/pages/components/date-picker/CustomDay.js new file mode 100644 index 00000000000000..a4007731f04b27 --- /dev/null +++ b/docs/src/pages/components/date-picker/CustomDay.js @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { makeStyles } from '@material-ui/core'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import DatePicker from '@material-ui/lab/DatePicker'; +import PickersDay from '@material-ui/lab/PickersDay'; +import clsx from 'clsx'; +import endOfWeek from 'date-fns/endOfWeek'; +import isSameDay from 'date-fns/isSameDay'; +import isWithinInterval from 'date-fns/isWithinInterval'; +import startOfWeek from 'date-fns/startOfWeek'; + +const useStyles = makeStyles((theme) => ({ + highlight: { + borderRadius: 0, + backgroundColor: theme.palette.primary.main, + color: theme.palette.common.white, + '&:hover, &:focus': { + backgroundColor: theme.palette.primary.dark, + }, + }, + firstHighlight: { + borderTopLeftRadius: '50%', + borderBottomLeftRadius: '50%', + }, + endHighlight: { + borderTopRightRadius: '50%', + borderBottomRightRadius: '50%', + }, +})); + +export default function CustomDay() { + const classes = useStyles(); + const [selectedDate, handleDateChange] = React.useState(new Date()); + + const renderWeekPickerDay = ( + date, + _selectedDates, + PickersDayComponentProps, + ) => { + if (!selectedDate) { + return ; + } + + const start = startOfWeek(selectedDate); + const end = endOfWeek(selectedDate); + + const dayIsBetween = isWithinInterval(date, { start, end }); + const isFirstDay = isSameDay(date, start); + const isLastDay = isSameDay(date, end); + + return ( + + ); + }; + + return ( + + } + inputFormat="'Week of' MMM d" + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/CustomDay.tsx b/docs/src/pages/components/date-picker/CustomDay.tsx new file mode 100644 index 00000000000000..7717868d0e8ec2 --- /dev/null +++ b/docs/src/pages/components/date-picker/CustomDay.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { makeStyles } from '@material-ui/core'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import DatePicker from '@material-ui/lab/DatePicker'; +import PickersDay, { PickersDayProps } from '@material-ui/lab/PickersDay'; +import clsx from 'clsx'; +import endOfWeek from 'date-fns/endOfWeek'; +import isSameDay from 'date-fns/isSameDay'; +import isWithinInterval from 'date-fns/isWithinInterval'; +import startOfWeek from 'date-fns/startOfWeek'; + +const useStyles = makeStyles((theme) => ({ + highlight: { + borderRadius: 0, + backgroundColor: theme.palette.primary.main, + color: theme.palette.common.white, + '&:hover, &:focus': { + backgroundColor: theme.palette.primary.dark, + }, + }, + firstHighlight: { + borderTopLeftRadius: '50%', + borderBottomLeftRadius: '50%', + }, + endHighlight: { + borderTopRightRadius: '50%', + borderBottomRightRadius: '50%', + }, +})); + +export default function CustomDay() { + const classes = useStyles(); + const [selectedDate, handleDateChange] = React.useState( + new Date(), + ); + + const renderWeekPickerDay = ( + date: Date, + _selectedDates: Date[], + PickersDayComponentProps: PickersDayProps, + ) => { + if (!selectedDate) { + return ; + } + + const start = startOfWeek(selectedDate); + const end = endOfWeek(selectedDate); + + const dayIsBetween = isWithinInterval(date, { start, end }); + const isFirstDay = isSameDay(date, start); + const isLastDay = isSameDay(date, end); + + return ( + + ); + }; + + return ( + + } + inputFormat="'Week of' MMM d" + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/CustomInput.js b/docs/src/pages/components/date-picker/CustomInput.js new file mode 100644 index 00000000000000..62f6ec8116f184 --- /dev/null +++ b/docs/src/pages/components/date-picker/CustomInput.js @@ -0,0 +1,27 @@ +import * as React from 'react'; +import Box from '@material-ui/core/Box'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import DesktopDatePicker from '@material-ui/lab/DatePicker'; + +export default function CustomInput() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={({ inputRef, inputProps, InputProps }) => ( + + + {InputProps?.endAdornment} + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/CustomInput.tsx b/docs/src/pages/components/date-picker/CustomInput.tsx new file mode 100644 index 00000000000000..4450f2abe79c54 --- /dev/null +++ b/docs/src/pages/components/date-picker/CustomInput.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import Box from '@material-ui/core/Box'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import DesktopDatePicker from '@material-ui/lab/DatePicker'; + +export default function CustomInput() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={({ inputRef, inputProps, InputProps }) => ( + + + {InputProps?.endAdornment} + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/InternalPickers.js b/docs/src/pages/components/date-picker/InternalPickers.js new file mode 100644 index 00000000000000..dc630d6404d03e --- /dev/null +++ b/docs/src/pages/components/date-picker/InternalPickers.js @@ -0,0 +1,18 @@ +import * as React from 'react'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import DayPicker from '@material-ui/lab/DayPicker'; + +export default function InternalPickers() { + const [date, setDate] = React.useState(new Date()); + + return ( + + setDate(newValue)} + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/InternalPickers.tsx b/docs/src/pages/components/date-picker/InternalPickers.tsx new file mode 100644 index 00000000000000..b2a7f16c48cd0b --- /dev/null +++ b/docs/src/pages/components/date-picker/InternalPickers.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import DayPicker from '@material-ui/lab/DayPicker'; + +export default function InternalPickers() { + const [date, setDate] = React.useState(new Date()); + + return ( + + setDate(newValue)} + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/LocalizedDatePicker.js b/docs/src/pages/components/date-picker/LocalizedDatePicker.js new file mode 100644 index 00000000000000..47c9b5eba21a41 --- /dev/null +++ b/docs/src/pages/components/date-picker/LocalizedDatePicker.js @@ -0,0 +1,61 @@ +import * as React from 'react'; +import frLocale from 'date-fns/locale/fr'; +import ruLocale from 'date-fns/locale/ru'; +import deLocale from 'date-fns/locale/de'; +import enLocale from 'date-fns/locale/en-US'; +import ToggleButton from '@material-ui/core/ToggleButton'; +import ToggleButtonGroup from '@material-ui/core/ToggleButtonGroup'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import DatePicker from '@material-ui/lab/DatePicker'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; + +const localeMap = { + en: enLocale, + fr: frLocale, + ru: ruLocale, + de: deLocale, +}; + +const maskMap = { + fr: '__/__/____', + en: '__/__/____', + ru: '__.__.____', + de: '__.__.____', +}; + +export default function LocalizedDatePicker() { + const [locale, setLocale] = React.useState('ru'); + const [selectedDate, handleDateChange] = React.useState(new Date()); + + const selectLocale = (newLocale) => { + setLocale(newLocale); + }; + + return ( + +
+ handleDateChange(date)} + renderInput={(params) => } + /> + + {Object.keys(localeMap).map((localeItem) => ( + selectLocale(localeItem)} + > + {localeItem} + + ))} + +
+
+ ); +} diff --git a/docs/src/pages/components/date-picker/LocalizedDatePicker.tsx b/docs/src/pages/components/date-picker/LocalizedDatePicker.tsx new file mode 100644 index 00000000000000..d077d0660b1329 --- /dev/null +++ b/docs/src/pages/components/date-picker/LocalizedDatePicker.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import frLocale from 'date-fns/locale/fr'; +import ruLocale from 'date-fns/locale/ru'; +import deLocale from 'date-fns/locale/de'; +import enLocale from 'date-fns/locale/en-US'; +import ToggleButton from '@material-ui/core/ToggleButton'; +import ToggleButtonGroup from '@material-ui/core/ToggleButtonGroup'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import DatePicker from '@material-ui/lab/DatePicker'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; + +const localeMap = { + en: enLocale, + fr: frLocale, + ru: ruLocale, + de: deLocale, +}; + +const maskMap = { + fr: '__/__/____', + en: '__/__/____', + ru: '__.__.____', + de: '__.__.____', +}; + +export default function LocalizedDatePicker() { + const [locale, setLocale] = React.useState('ru'); + const [selectedDate, handleDateChange] = React.useState( + new Date(), + ); + + const selectLocale = (newLocale: any) => { + setLocale(newLocale); + }; + + return ( + +
+ handleDateChange(date)} + renderInput={(params) => } + /> + + {Object.keys(localeMap).map((localeItem) => ( + selectLocale(localeItem)} + > + {localeItem} + + ))} + +
+
+ ); +} diff --git a/docs/src/pages/components/date-picker/ResponsiveDatePickers.js b/docs/src/pages/components/date-picker/ResponsiveDatePickers.js new file mode 100644 index 00000000000000..30aa27aac43a08 --- /dev/null +++ b/docs/src/pages/components/date-picker/ResponsiveDatePickers.js @@ -0,0 +1,46 @@ +import React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DatePicker from '@material-ui/lab/DatePicker'; +import MobileDatePicker from '@material-ui/lab/MobileDatePicker'; +import DesktopDatePicker from '@material-ui/lab/DesktopDatePicker'; + +export default function ResponsiveDatePickers() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-picker/ResponsiveDatePickers.tsx b/docs/src/pages/components/date-picker/ResponsiveDatePickers.tsx new file mode 100644 index 00000000000000..09c17ad9e74d0b --- /dev/null +++ b/docs/src/pages/components/date-picker/ResponsiveDatePickers.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DatePicker from '@material-ui/lab/DatePicker'; +import MobileDatePicker from '@material-ui/lab/MobileDatePicker'; +import DesktopDatePicker from '@material-ui/lab/DesktopDatePicker'; + +export default function ResponsiveDatePickers() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-picker/ServerRequestDatePicker.js b/docs/src/pages/components/date-picker/ServerRequestDatePicker.js new file mode 100644 index 00000000000000..477e6635c62010 --- /dev/null +++ b/docs/src/pages/components/date-picker/ServerRequestDatePicker.js @@ -0,0 +1,106 @@ +import * as React from 'react'; +import Badge from '@material-ui/core/Badge'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import PickersDay from '@material-ui/lab/PickersDay'; +import DatePicker from '@material-ui/lab/DatePicker'; +import PickersCalendarSkeleton from '@material-ui/lab/PickersCalendarSkeleton'; +import getDaysInMonth from 'date-fns/getDaysInMonth'; + +function getRandomNumber(min, max) { + return Math.round(Math.random() * (max - min) + min); +} + +/** + * Mimic fetch with abort controller https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort + * โš ๏ธ No IE11 support + */ +function fakeFetch(date, { signal }) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const daysInMonth = getDaysInMonth(date); + const daysToHighlight = [1, 2, 3].map(() => + getRandomNumber(1, daysInMonth), + ); + + resolve({ daysToHighlight }); + }, 500); + + signal.onabort = () => { + clearTimeout(timeout); + reject(new Error('aborted')); + }; + }); +} + +const initialValue = new Date(); + +export default function ServerRequestDatePicker() { + const requestAbortController = React.useRef(null); + const [isLoading, setIsLoading] = React.useState(false); + const [highlightedDays, setHighlightedDays] = React.useState([1, 2, 15]); + const [value, setValue] = React.useState(initialValue); + + const fetchHighlightedDays = (date) => { + const controller = new AbortController(); + fakeFetch(date, { + signal: controller.signal, + }) + .then(({ daysToHighlight }) => { + setHighlightedDays(daysToHighlight); + setIsLoading(false); + }) + .catch(() => console.log('Wow, you are switching months too quickly ๐Ÿ•')); + + requestAbortController.current = controller; + }; + + React.useEffect(() => { + fetchHighlightedDays(initialValue); + // abort request on unmount + return () => requestAbortController.current?.abort(); + }, []); + + const handleMonthChange = (date) => { + if (requestAbortController.current) { + // make sure that you are aborting useless requests + // because it is possible to switch between months pretty quickly + requestAbortController.current.abort(); + } + + setIsLoading(true); + setHighlightedDays([]); + fetchHighlightedDays(date); + }; + + return ( + + { + setValue(newValue); + }} + onMonthChange={handleMonthChange} + renderInput={(params) => } + renderLoading={() => } + renderDay={(day, _value, DayComponentProps) => { + const isSelected = + !DayComponentProps.outsideCurrentMonth && + highlightedDays.indexOf(day.getDate()) > 0; + + return ( + + + + ); + }} + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/ServerRequestDatePicker.tsx b/docs/src/pages/components/date-picker/ServerRequestDatePicker.tsx new file mode 100644 index 00000000000000..295f98c649cd1b --- /dev/null +++ b/docs/src/pages/components/date-picker/ServerRequestDatePicker.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import Badge from '@material-ui/core/Badge'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import PickersDay from '@material-ui/lab/PickersDay'; +import DatePicker from '@material-ui/lab/DatePicker'; +import PickersCalendarSkeleton from '@material-ui/lab/PickersCalendarSkeleton'; +import getDaysInMonth from 'date-fns/getDaysInMonth'; + +function getRandomNumber(min: number, max: number) { + return Math.round(Math.random() * (max - min) + min); +} + +/** + * Mimic fetch with abort controller https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort + * โš ๏ธ No IE11 support + */ +function fakeFetch(date: Date, { signal }: { signal: AbortSignal }) { + return new Promise<{ daysToHighlight: number[] }>((resolve, reject) => { + const timeout = setTimeout(() => { + const daysInMonth = getDaysInMonth(date); + const daysToHighlight = [1, 2, 3].map(() => + getRandomNumber(1, daysInMonth), + ); + + resolve({ daysToHighlight }); + }, 500); + + signal.onabort = () => { + clearTimeout(timeout); + reject(new Error('aborted')); + }; + }); +} + +const initialValue = new Date(); + +export default function ServerRequestDatePicker() { + const requestAbortController = React.useRef(null); + const [isLoading, setIsLoading] = React.useState(false); + const [highlightedDays, setHighlightedDays] = React.useState([1, 2, 15]); + const [value, setValue] = React.useState(initialValue); + + const fetchHighlightedDays = (date: Date) => { + const controller = new AbortController(); + fakeFetch(date, { + signal: controller.signal, + }) + .then(({ daysToHighlight }) => { + setHighlightedDays(daysToHighlight); + setIsLoading(false); + }) + .catch(() => console.log('Wow, you are switching months too quickly ๐Ÿ•')); + + requestAbortController.current = controller; + }; + + React.useEffect(() => { + fetchHighlightedDays(initialValue); + // abort request on unmount + return () => requestAbortController.current?.abort(); + }, []); + + const handleMonthChange = (date: Date) => { + if (requestAbortController.current) { + // make sure that you are aborting useless requests + // because it is possible to switch between months pretty quickly + requestAbortController.current.abort(); + } + + setIsLoading(true); + setHighlightedDays([]); + fetchHighlightedDays(date); + }; + + return ( + + { + setValue(newValue); + }} + onMonthChange={handleMonthChange} + renderInput={(params) => } + renderLoading={() => } + renderDay={(day, _value, DayComponentProps) => { + const isSelected = + !DayComponentProps.outsideCurrentMonth && + highlightedDays.indexOf(day.getDate()) > 0; + + return ( + + + + ); + }} + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/StaticDatePickerDemo.js b/docs/src/pages/components/date-picker/StaticDatePickerDemo.js new file mode 100644 index 00000000000000..8672cf992393fc --- /dev/null +++ b/docs/src/pages/components/date-picker/StaticDatePickerDemo.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import StaticDatePicker from '@material-ui/lab/StaticDatePicker'; + +export default function StaticDatePickerDemo() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/StaticDatePickerDemo.tsx b/docs/src/pages/components/date-picker/StaticDatePickerDemo.tsx new file mode 100644 index 00000000000000..0afc3390aee734 --- /dev/null +++ b/docs/src/pages/components/date-picker/StaticDatePickerDemo.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import StaticDatePicker from '@material-ui/lab/StaticDatePicker'; + +export default function StaticDatePickerDemo() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/StaticDatePickerLandscape.js b/docs/src/pages/components/date-picker/StaticDatePickerLandscape.js new file mode 100644 index 00000000000000..16022e1cf500b1 --- /dev/null +++ b/docs/src/pages/components/date-picker/StaticDatePickerLandscape.js @@ -0,0 +1,25 @@ +import * as React from 'react'; +import isWeekend from 'date-fns/isWeekend'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import StaticDatePicker from '@material-ui/lab/StaticDatePicker'; + +export default function StaticDatePickerLandscape() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/StaticDatePickerLandscape.tsx b/docs/src/pages/components/date-picker/StaticDatePickerLandscape.tsx new file mode 100644 index 00000000000000..b7fd17a98a51c3 --- /dev/null +++ b/docs/src/pages/components/date-picker/StaticDatePickerLandscape.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import isWeekend from 'date-fns/isWeekend'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import StaticDatePicker from '@material-ui/lab/StaticDatePicker'; + +export default function StaticDatePickerLandscape() { + const [value, setValue] = React.useState(new Date()); + + return ( + + + orientation="landscape" + openTo="date" + value={value} + shouldDisableDate={isWeekend} + onChange={(newValue) => { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/date-picker/ViewsDatePicker.js b/docs/src/pages/components/date-picker/ViewsDatePicker.js new file mode 100644 index 00000000000000..0297d95edb10f0 --- /dev/null +++ b/docs/src/pages/components/date-picker/ViewsDatePicker.js @@ -0,0 +1,74 @@ +import React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import DatePicker from '@material-ui/lab/DatePicker'; + +export default function ViewsDatePicker() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-picker/ViewsDatePicker.tsx b/docs/src/pages/components/date-picker/ViewsDatePicker.tsx new file mode 100644 index 00000000000000..bc53ca7dfdfbbd --- /dev/null +++ b/docs/src/pages/components/date-picker/ViewsDatePicker.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizaitonProvider from '@material-ui/lab/LocalizationProvider'; +import DatePicker from '@material-ui/lab/DatePicker'; + +export default function ViewsDatePicker() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + renderInput={(params) => ( + + )} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-picker/date-picker.md b/docs/src/pages/components/date-picker/date-picker.md new file mode 100644 index 00000000000000..58f1b2347a0355 --- /dev/null +++ b/docs/src/pages/components/date-picker/date-picker.md @@ -0,0 +1,105 @@ +--- +title: React Date Picker component +components: DatePicker, PickersDay +githubLabel: 'component: DatePicker' +packageName: '@material-ui/lab' +materialDesign: https://material.io/components/date-pickers +--- + +# Date Picker + +

Date pickers let the user select a date.

+ +Date pickers let the user select a date. Date pickers are displayed with: + +- Dialogs on mobile +- Text field dropdowns on desktop + +{{"component": "modules/components/ComponentLinkHeader.js"}} + +## Requirements + +This component relies on the date management library of your choice. It supports [date-fns](https://date-fns.org/), [luxon](https://moment.github.io/luxon/), [dayjs](https://github.com/iamkun/dayjs), [moment](https://momentjs.com/) and any other library via a public `dateAdapter` interface. + +Please install any of these libraries and set up the right date engine by wrapping your root (or the highest level you wish the pickers to be available) with `LocalizationProvider`: + +```jsx +// or @material-ui/lab/dateAdapter/{dayjs,luxon,moment} or any valid date-io adapter +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; + +function App() { + return ( + + ... + + ); +} +``` + +## Basic usage + +The date picker will be rendered as a modal dialog on mobile, and a textfield with a popover on desktop. + +{{"demo": "pages/components/date-picker/BasicDatePicker.js"}} + +## Responsiveness + +The date picker component is designed and optimized for the device it runs on. + +- The "Mobile" version works best for touch devices and small screens. +- The "Desktop" version works best for mouse devices and large screens. + +By default, the `DatePicker` component uses a `@media (pointer: fine)` media query to determine which version to use. +This can be customized with the `desktopModeMediaQuery` prop. + +{{"demo": "pages/components/date-picker/ResponsiveDatePickers.js"}} + +## Localization + +Use `LocalizationProvider` to change the date-engine locale that is used to render the date picker. Here is an example of changing the locale for the `date-fns` adapter: + +{{"demo": "pages/components/date-picker/LocalizedDatePicker.js"}} + +## Views playground + +It's possible to combine `year`, `month`, and `date` selection views. Views will appear in the order they're included in the `views` array. + +{{"demo": "pages/components/date-picker/ViewsDatePicker.js"}} + +## Static mode + +It's possible to render any picker without the modal/popover and text field. This can be helpful when building custom popover/modal containers. + +{{"demo": "pages/components/date-picker/StaticDatePickerDemo.js", "bg": true}} + +## Landscape orientation + +For ease of use the date picker will automatically change the layout between portrait and landscape by subscription to the `window.orientation` change. You can force a specific layout using the `orientation` prop. + +{{"demo": "pages/components/date-picker/StaticDatePickerLandscape.js", "bg": true}} + +## Sub-components + +Some lower level sub-components (`DayPicker`, `MonthPicker` and `YearPicker`) are also exported. These are rendered without a wrapper or outer logic (masked input, date values parsing and validation, etc.). + +{{"demo": "pages/components/date-picker/InternalPickers.js"}} + +## Custom input component + +You can customize rendering of the input with the `renderInput` prop. Make sure to spread `ref` and `inputProps` correctly to the custom input component. + +{{"demo": "pages/components/date-picker/CustomInput.js"}} + +## Customized day rendering + +The displayed days are customizable with the `renderDay` function prop. +You can take advantage of the internal [PickersDay](/api/pickers-day) component. + +{{"demo": "pages/components/date-picker/CustomDay.js"}} + +## Dynamic data + +Sometimes it may be necessary to display additional info right in the calendar. Here's an example of prefetching and displaying server-side data using the `onMonthChange`, `loading`, and `renderDay` props. + +{{"demo": "pages/components/date-picker/ServerRequestDatePicker.js"}} diff --git a/docs/src/pages/components/date-range-picker/BasicDateRangePicker.js b/docs/src/pages/components/date-range-picker/BasicDateRangePicker.js new file mode 100644 index 00000000000000..820556c9d393e7 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/BasicDateRangePicker.js @@ -0,0 +1,30 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateRangePicker from '@material-ui/lab/DateRangePicker'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; + +export default function BasicDateRangePicker() { + const [value, setValue] = React.useState([null, null]); + + return ( + + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/BasicDateRangePicker.tsx b/docs/src/pages/components/date-range-picker/BasicDateRangePicker.tsx new file mode 100644 index 00000000000000..a41da0dbdf94ab --- /dev/null +++ b/docs/src/pages/components/date-range-picker/BasicDateRangePicker.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateRangePicker, { DateRange } from '@material-ui/lab/DateRangePicker'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; + +export default function BasicDateRangePicker() { + const [value, setValue] = React.useState>([null, null]); + + return ( + + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/CalendarsDateRangePicker.js b/docs/src/pages/components/date-range-picker/CalendarsDateRangePicker.js new file mode 100644 index 00000000000000..598cf7a15888b3 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/CalendarsDateRangePicker.js @@ -0,0 +1,64 @@ +import * as React from 'react'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangePicker from '@material-ui/lab/DateRangePicker'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; + +export default function CalendarsDateRangePicker() { + const [value, setValue] = React.useState([null, null]); + + return ( + + + 1 calendar + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + 2 calendars + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + 3 calendars + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + + ); +} diff --git a/docs/src/pages/components/date-range-picker/CalendarsDateRangePicker.tsx b/docs/src/pages/components/date-range-picker/CalendarsDateRangePicker.tsx new file mode 100644 index 00000000000000..ccbb195d05b594 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/CalendarsDateRangePicker.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangePicker, { DateRange } from '@material-ui/lab/DateRangePicker'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; + +export default function CalendarsDateRangePicker() { + const [value, setValue] = React.useState>([null, null]); + + return ( + + + 1 calendar + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + 2 calendars + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + 3 calendars + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + + ); +} diff --git a/docs/src/pages/components/date-range-picker/CustomDateRangeInputs.js b/docs/src/pages/components/date-range-picker/CustomDateRangeInputs.js new file mode 100644 index 00000000000000..96973e426ecba0 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/CustomDateRangeInputs.js @@ -0,0 +1,24 @@ +import * as React from 'react'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangePicker from '@material-ui/lab/DateRangePicker'; + +export default function CustomDateRangeInputs() { + const [selectedDate, handleDateChange] = React.useState([null, null]); + + return ( + + handleDateChange(date)} + renderInput={(startProps, endProps) => ( + + + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/CustomDateRangeInputs.tsx b/docs/src/pages/components/date-range-picker/CustomDateRangeInputs.tsx new file mode 100644 index 00000000000000..8c595e73e35b8c --- /dev/null +++ b/docs/src/pages/components/date-range-picker/CustomDateRangeInputs.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangePicker, { DateRange } from '@material-ui/lab/DateRangePicker'; + +export default function CustomDateRangeInputs() { + const [selectedDate, handleDateChange] = React.useState>([ + null, + null, + ]); + + return ( + + handleDateChange(date)} + renderInput={(startProps, endProps) => ( + + } + {...startProps.inputProps} + /> + } + {...endProps.inputProps} + /> + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/MinMaxDateRangePicker.js b/docs/src/pages/components/date-range-picker/MinMaxDateRangePicker.js new file mode 100644 index 00000000000000..75bb9caf8879db --- /dev/null +++ b/docs/src/pages/components/date-range-picker/MinMaxDateRangePicker.js @@ -0,0 +1,35 @@ +import * as React from 'react'; +import addWeeks from 'date-fns/addWeeks'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangePicker from '@material-ui/lab/DateRangePicker'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; + +function getWeeksAfter(date, amount) { + return date ? addWeeks(date, amount) : undefined; +} + +export default function MinMaxDateRangePicker() { + const [value, setValue] = React.useState([null, null]); + + return ( + + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/MinMaxDateRangePicker.tsx b/docs/src/pages/components/date-range-picker/MinMaxDateRangePicker.tsx new file mode 100644 index 00000000000000..a3389354d33a15 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/MinMaxDateRangePicker.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import addWeeks from 'date-fns/addWeeks'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangePicker, { DateRange } from '@material-ui/lab/DateRangePicker'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; + +function getWeeksAfter(date: Date | null, amount: number) { + return date ? addWeeks(date, amount) : undefined; +} + +export default function MinMaxDateRangePicker() { + const [value, setValue] = React.useState>([null, null]); + + return ( + + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/ResponsiveDateRangePicker.js b/docs/src/pages/components/date-range-picker/ResponsiveDateRangePicker.js new file mode 100644 index 00000000000000..7b19699ad4dc28 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/ResponsiveDateRangePicker.js @@ -0,0 +1,44 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; +import MobileDateRangePicker from '@material-ui/lab/MobileDateRangePicker'; +import DesktopDateRangePicker from '@material-ui/lab/DesktopDateRangePicker'; + +export default function ResponsiveDateRangePicker() { + const [value, setValue] = React.useState([null, null]); + + return ( + + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/ResponsiveDateRangePicker.tsx b/docs/src/pages/components/date-range-picker/ResponsiveDateRangePicker.tsx new file mode 100644 index 00000000000000..872aa6b735bce4 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/ResponsiveDateRangePicker.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; +import MobileDateRangePicker, { + DateRange, +} from '@material-ui/lab/MobileDateRangePicker'; +import DesktopDateRangePicker from '@material-ui/lab/DesktopDateRangePicker'; + +export default function ResponsiveDateRangePicker() { + const [value, setValue] = React.useState>([null, null]); + + return ( + + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/StaticDateRangePicker.js b/docs/src/pages/components/date-range-picker/StaticDateRangePicker.js new file mode 100644 index 00000000000000..2d6e76e6f78ecc --- /dev/null +++ b/docs/src/pages/components/date-range-picker/StaticDateRangePicker.js @@ -0,0 +1,29 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import StaticDateRangePicker from '@material-ui/lab/StaticDateRangePicker'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; + +export default function StaticDateRangePickerExample() { + const [value, setValue] = React.useState([null, null]); + + return ( + + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/StaticDateRangePicker.tsx b/docs/src/pages/components/date-range-picker/StaticDateRangePicker.tsx new file mode 100644 index 00000000000000..c8173872d206a4 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/StaticDateRangePicker.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import StaticDateRangePicker, { + DateRange, +} from '@material-ui/lab/StaticDateRangePicker'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateRangeDelimiter from '@material-ui/lab/DateRangeDelimiter'; + +export default function StaticDateRangePickerExample() { + const [value, setValue] = React.useState>([null, null]); + + return ( + + { + setValue(newValue); + }} + renderInput={(startProps, endProps) => ( + + + to + + + )} + /> + + ); +} diff --git a/docs/src/pages/components/date-range-picker/date-range-picker.md b/docs/src/pages/components/date-range-picker/date-range-picker.md new file mode 100644 index 00000000000000..340da42b935179 --- /dev/null +++ b/docs/src/pages/components/date-range-picker/date-range-picker.md @@ -0,0 +1,83 @@ +--- +title: React Date Range Picker component +components: DateRangePicker +githubLabel: 'component: DateRangePicker' +packageName: '@material-ui/lab' +materialDesign: https://material.io/components/date-pickers +--- + +# Date Range Picker [โšก๏ธ](https://material-ui.com/store/items/material-ui-x/) + +

Date pickers let the user select a range of dates.

+ +> โš ๏ธโš ๏ธ The date range picker is unstable, and **not suitable** for use in production. โš ๏ธโš ๏ธ +>

+> The date range picker will be made available in the coming months for production use as part of a paid extension (commercial license) to the community edition (MIT license) of Material-UI. +> This paid extension will include advanced components (rich data grid, date range picker, tree view drag & drop, etc.). [Early access](https://material-ui.com/store/items/material-ui-x/) starts at an affordable price. + +The date range pickers let the user select a range of dates. + +{{"component": "modules/components/ComponentLinkHeader.js"}} + +## Requirements + +This component relies on the date management library of your choice. It supports [date-fns](https://date-fns.org/), [luxon](https://moment.github.io/luxon/), [dayjs](https://github.com/iamkun/dayjs), [moment](https://momentjs.com/) and any other library via a public `dateAdapter` interface. + +Please install any of these libraries and set up the right date engine by wrapping your root (or the highest level you wish the pickers to be available) with `LocalizationProvider`: + +```jsx +// or @material-ui/lab/dateAdapter/{dayjs,luxon,moment} or any valid date-io adapter +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; + +function App() { + return ( + + ... + + ); +} +``` + +## Basic usage + +Note that you can pass almost any prop from [DatePicker]('/api/date-picker/'). + +{{"demo": "pages/components/date-range-picker/BasicDateRangePicker.js"}} + +## Responsiveness + +The date range picker component is designed to be optimized for the device it runs on. + +- The "Mobile" version works best for touch devices and small screens. +- The "Desktop" version works best for mouse devices and large screens. + +By default, the `DateRangePicker` component uses a `@media (pointer: fine)` media query to determine which version to use. +This can be customized with the `desktopModeMediaQuery` prop. + +{{"demo": "pages/components/date-range-picker/ResponsiveDateRangePicker.js"}} + +## Different number of months + +Note that the `calendars` prop only works in desktop mode. + +{{"demo": "pages/components/date-range-picker/CalendarsDateRangePicker.js"}} + +## Disabling dates + +Disabling dates behaves the same as the simple `DatePicker`. + +{{"demo": "pages/components/date-range-picker/MinMaxDateRangePicker.js"}} + +## Custom input component + +You can customize the rendered input with the `renderInput` prop. For `DateRangePicker` it takes **2** parameters โ€“ for start and end input respectively. +If you need to render custom inputs make sure to spread `ref` and `inputProps` correctly to the input components. + +{{"demo": "pages/components/date-range-picker/CustomDateRangeInputs.js"}} + +## Static mode + +It is possible to render any picker without a modal or popper. For this use `StaticDateRangePicker`. + +{{"demo": "pages/components/date-range-picker/StaticDateRangePicker.js"}} diff --git a/docs/src/pages/components/date-time-picker/BasicDateTimePicker.js b/docs/src/pages/components/date-time-picker/BasicDateTimePicker.js new file mode 100644 index 00000000000000..b9c629297a01c8 --- /dev/null +++ b/docs/src/pages/components/date-time-picker/BasicDateTimePicker.js @@ -0,0 +1,20 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateTimePicker from '@material-ui/lab/DateTimePicker'; + +export default function BasicDateTimePicker() { + const [selectedDate, handleDateChange] = React.useState(new Date()); + + return ( + + } + label="DateTimePicker" + value={selectedDate} + onChange={handleDateChange} + /> + + ); +} diff --git a/docs/src/pages/components/date-time-picker/BasicDateTimePicker.tsx b/docs/src/pages/components/date-time-picker/BasicDateTimePicker.tsx new file mode 100644 index 00000000000000..870355e74b7576 --- /dev/null +++ b/docs/src/pages/components/date-time-picker/BasicDateTimePicker.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateTimePicker from '@material-ui/lab/DateTimePicker'; + +export default function BasicDateTimePicker() { + const [selectedDate, handleDateChange] = React.useState( + new Date(), + ); + + return ( + + } + label="DateTimePicker" + value={selectedDate} + onChange={handleDateChange} + /> + + ); +} diff --git a/docs/src/pages/components/date-time-picker/CustomDateTimePicker.js b/docs/src/pages/components/date-time-picker/CustomDateTimePicker.js new file mode 100644 index 00000000000000..117b4f9acb394f --- /dev/null +++ b/docs/src/pages/components/date-time-picker/CustomDateTimePicker.js @@ -0,0 +1,74 @@ +import * as React from 'react'; +import AlarmIcon from '@material-ui/icons/Alarm'; +import SnoozeIcon from '@material-ui/icons/Snooze'; +import TextField from '@material-ui/core/TextField'; +import ClockIcon from '@material-ui/icons/AccessTime'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateTimePicker from '@material-ui/lab/DateTimePicker'; +import MobileDateTimePicker from '@material-ui/lab/MobileDateTimePicker'; + +export default function CustomDateTimePicker() { + const [clearedDate, setClearedDate] = React.useState(null); + const [value, setValue] = React.useState(new Date('2019-01-01T18:54')); + + return ( + +
+ { + setValue(newValue); + }} + minDate={new Date('2018-01-01')} + leftArrowIcon={} + rightArrowIcon={} + leftArrowButtonText="Open previous month" + rightArrowButtonText="Open next month" + openPickerIcon={} + minTime={new Date(0, 0, 0, 9)} + maxTime={new Date(0, 0, 0, 20)} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + label="With error handler" + onError={console.log} + minDate={new Date('2018-01-01T00:00')} + inputFormat="yyyy/MM/dd hh:mm a" + mask="___/__/__ __:__ _M" + renderInput={(params) => ( + + )} + /> + setClearedDate(newValue)} + renderInput={(params) => ( + + )} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-time-picker/CustomDateTimePicker.tsx b/docs/src/pages/components/date-time-picker/CustomDateTimePicker.tsx new file mode 100644 index 00000000000000..1e62a25889135d --- /dev/null +++ b/docs/src/pages/components/date-time-picker/CustomDateTimePicker.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import AlarmIcon from '@material-ui/icons/Alarm'; +import SnoozeIcon from '@material-ui/icons/Snooze'; +import TextField from '@material-ui/core/TextField'; +import ClockIcon from '@material-ui/icons/AccessTime'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateTimePicker from '@material-ui/lab/DateTimePicker'; +import MobileDateTimePicker from '@material-ui/lab/MobileDateTimePicker'; + +export default function CustomDateTimePicker() { + const [clearedDate, setClearedDate] = React.useState(null); + const [value, setValue] = React.useState( + new Date('2019-01-01T18:54'), + ); + + return ( + +
+ { + setValue(newValue); + }} + minDate={new Date('2018-01-01')} + leftArrowIcon={} + rightArrowIcon={} + leftArrowButtonText="Open previous month" + rightArrowButtonText="Open next month" + openPickerIcon={} + minTime={new Date(0, 0, 0, 9)} + maxTime={new Date(0, 0, 0, 20)} + renderInput={(params) => ( + + )} + /> + { + setValue(newValue); + }} + label="With error handler" + onError={console.log} + minDate={new Date('2018-01-01T00:00')} + inputFormat="yyyy/MM/dd hh:mm a" + mask="___/__/__ __:__ _M" + renderInput={(params) => ( + + )} + /> + setClearedDate(newValue)} + renderInput={(params) => ( + + )} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-time-picker/DateTimeValidation.js b/docs/src/pages/components/date-time-picker/DateTimeValidation.js new file mode 100644 index 00000000000000..0f8fd008adfbf9 --- /dev/null +++ b/docs/src/pages/components/date-time-picker/DateTimeValidation.js @@ -0,0 +1,36 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateTimePicker from '@material-ui/lab/DateTimePicker'; + +export default function DateTimeValidation() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ } + label="Ignore date and time" + value={value} + onChange={(newValue) => { + setValue(newValue); + }} + minDateTime={new Date()} + /> + } + label="Ignore time in each day" + value={value} + onChange={(newValue) => { + setValue(newValue); + }} + minDate={new Date('2020-02-14')} + minTime={new Date(0, 0, 0, 8)} + maxTime={new Date(0, 0, 0, 18, 45)} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-time-picker/DateTimeValidation.tsx b/docs/src/pages/components/date-time-picker/DateTimeValidation.tsx new file mode 100644 index 00000000000000..18936a6cd6d60c --- /dev/null +++ b/docs/src/pages/components/date-time-picker/DateTimeValidation.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateTimePicker from '@material-ui/lab/DateTimePicker'; + +export default function DateTimeValidation() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ } + label="Ignore date and time" + value={value} + onChange={(newValue) => { + setValue(newValue); + }} + minDateTime={new Date()} + /> + } + label="Ignore time in each day" + value={value} + onChange={(newValue) => { + setValue(newValue); + }} + minDate={new Date('2020-02-14')} + minTime={new Date(0, 0, 0, 8)} + maxTime={new Date(0, 0, 0, 18, 45)} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-time-picker/ResponsiveDateTimePickers.js b/docs/src/pages/components/date-time-picker/ResponsiveDateTimePickers.js new file mode 100644 index 00000000000000..1e0c9d1f511161 --- /dev/null +++ b/docs/src/pages/components/date-time-picker/ResponsiveDateTimePickers.js @@ -0,0 +1,41 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateTimePicker from '@material-ui/lab/DateTimePicker'; +import MobileDateTimePicker from '@material-ui/lab/MobileDateTimePicker'; +import DesktopDateTimePicker from '@material-ui/lab/DesktopDateTimePicker'; + +export default function ResponsiveDateTimePickers() { + const [value, setValue] = React.useState( + new Date('2018-01-01T00:00:00.000Z'), + ); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> + } + value={value} + onChange={(newValue) => { + setValue(newValue); + }} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-time-picker/ResponsiveDateTimePickers.tsx b/docs/src/pages/components/date-time-picker/ResponsiveDateTimePickers.tsx new file mode 100644 index 00000000000000..b730f644cba691 --- /dev/null +++ b/docs/src/pages/components/date-time-picker/ResponsiveDateTimePickers.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateTimePicker from '@material-ui/lab/DateTimePicker'; +import MobileDateTimePicker from '@material-ui/lab/MobileDateTimePicker'; +import DesktopDateTimePicker from '@material-ui/lab/DesktopDateTimePicker'; + +export default function ResponsiveDateTimePickers() { + const [value, setValue] = React.useState( + new Date('2018-01-01T00:00:00.000Z'), + ); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> + } + value={value} + onChange={(newValue) => { + setValue(newValue); + }} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/date-time-picker/date-time-picker.md b/docs/src/pages/components/date-time-picker/date-time-picker.md new file mode 100644 index 00000000000000..d192666a7c59d3 --- /dev/null +++ b/docs/src/pages/components/date-time-picker/date-time-picker.md @@ -0,0 +1,71 @@ +--- +title: React Date Time Picker component +components: DateTimePicker +githubLabel: 'component: DateTimePicker' +packageName: '@material-ui/lab' +materialDesign: https://material.io/components/date-pickers +--- + +# Date Time Picker + +

Combined date & time picker.

+ +This component combines the date & time pickers. It allows the user to select both date and time with the same control. + +Note that this component is the [DatePicker](/components/date-picker/) and [TimePicker](/components/time-picker/) +component combined, so any of these components' props can be passed to the DateTimePicker. + +{{"component": "modules/components/ComponentLinkHeader.js"}} + +## Requirements + +This component relies on the date management library of your choice. It supports [date-fns](https://date-fns.org/), [luxon](https://moment.github.io/luxon/), [dayjs](https://github.com/iamkun/dayjs), [moment](https://momentjs.com/) and any other library via a public `dateAdapter` interface. + +Please install any of these libraries and set up the right date engine by wrapping your root (or the highest level you wish the pickers to be available) with `LocalizationProvider`: + +```jsx +// or @material-ui/lab/dateAdapter/{dayjs,luxon,moment} or any valid date-io adapter +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; + +function App() { + return ( + + ... + + ); +} +``` + +## Basic usage + +Allows choosing date then time. There are 4 steps available (year, date, hour and minute), so tabs are required to visually distinguish date/time steps. + +{{"demo": "pages/components/date-time-picker/BasicDateTimePicker.js"}} + +## Responsiveness + +The `DateTimePicker` component is designed and optimized for the device it runs on. + +- The "Mobile" version works best for touch devices and small screens. +- The "Desktop" version works best for mouse devices and large screens. + +By default, the `DateTimePicker` component uses a `@media (pointer: fine)` media query to determine which version to use. +This can be customized with the `desktopModeMediaQuery` prop. + +{{"demo": "pages/components/date-time-picker/ResponsiveDateTimePickers.js"}} + +## Date and time validation + +It is possible to restrict date and time selection in two ways: + +- by using `minDateTime`/`maxDateTime` its possible to restrict time selection to before or after a particular moment in time +- using `minTime`/`maxTime`, you can disable selecting times before or after a certain time each day respectively + +{{"demo": "pages/components/date-time-picker/DateTimeValidation.js"}} + +## Customization + +Here are some examples of heavily customized date & time pickers: + +{{"demo": "pages/components/date-time-picker/CustomDateTimePicker.js"}} diff --git a/docs/src/pages/components/pickers/MaterialUIPickers.js b/docs/src/pages/components/pickers/MaterialUIPickers.js index 9a5c4a9898f5cb..065bad26371316 100644 --- a/docs/src/pages/components/pickers/MaterialUIPickers.js +++ b/docs/src/pages/components/pickers/MaterialUIPickers.js @@ -1,16 +1,13 @@ import * as React from 'react'; import Grid from '@material-ui/core/Grid'; import TextField from '@material-ui/core/TextField'; -import DateFnsAdapter from '@material-ui/pickers/adapter/date-fns'; -import { - LocalizationProvider as MuiPickersLocalizationProvider, - TimePicker, - DesktopDatePicker, - MobileDatePicker, -} from '@material-ui/pickers'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; +import DesktopDatePicker from '@material-ui/lab/DesktopDatePicker'; +import MobileDatePicker from '@material-ui/lab/MobileDatePicker'; export default function MaterialUIPickers() { - // The first commit of Material-UI const [selectedDate, setSelectedDate] = React.useState( new Date('2014-08-18T21:11:54'), ); @@ -20,15 +17,15 @@ export default function MaterialUIPickers() { }; return ( - + ( - + renderInput={(params) => ( + )} OpenPickerButtonProps={{ 'aria-label': 'change date', @@ -39,8 +36,8 @@ export default function MaterialUIPickers() { inputFormat="MM/dd/yyyy" value={selectedDate} onChange={handleDateChange} - renderInput={(props) => ( - + renderInput={(params) => ( + )} OpenPickerButtonProps={{ 'aria-label': 'change date', @@ -50,12 +47,12 @@ export default function MaterialUIPickers() { label="Time picker" value={selectedDate} onChange={handleDateChange} - renderInput={(props) => } + renderInput={(params) => } OpenPickerButtonProps={{ 'aria-label': 'change time', }} /> - + ); } diff --git a/docs/src/pages/components/pickers/MaterialUIPickers.tsx b/docs/src/pages/components/pickers/MaterialUIPickers.tsx index 7dbf199d57a204..512877d487b144 100644 --- a/docs/src/pages/components/pickers/MaterialUIPickers.tsx +++ b/docs/src/pages/components/pickers/MaterialUIPickers.tsx @@ -1,16 +1,13 @@ import * as React from 'react'; import Grid from '@material-ui/core/Grid'; import TextField from '@material-ui/core/TextField'; -import DateFnsAdapter from '@material-ui/pickers/adapter/date-fns'; -import { - LocalizationProvider as MuiPickersLocalizationProvider, - TimePicker, - DesktopDatePicker, - MobileDatePicker, -} from '@material-ui/pickers'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; +import DesktopDatePicker from '@material-ui/lab/DesktopDatePicker'; +import MobileDatePicker from '@material-ui/lab/MobileDatePicker'; export default function MaterialUIPickers() { - // The first commit of Material-UI const [selectedDate, setSelectedDate] = React.useState( new Date('2014-08-18T21:11:54'), ); @@ -20,15 +17,15 @@ export default function MaterialUIPickers() { }; return ( - + ( - + renderInput={(params) => ( + )} OpenPickerButtonProps={{ 'aria-label': 'change date', @@ -39,8 +36,8 @@ export default function MaterialUIPickers() { inputFormat="MM/dd/yyyy" value={selectedDate} onChange={handleDateChange} - renderInput={(props) => ( - + renderInput={(params) => ( + )} OpenPickerButtonProps={{ 'aria-label': 'change date', @@ -50,12 +47,12 @@ export default function MaterialUIPickers() { label="Time picker" value={selectedDate} onChange={handleDateChange} - renderInput={(props) => } + renderInput={(params) => } OpenPickerButtonProps={{ 'aria-label': 'change time', }} /> - + ); } diff --git a/docs/src/pages/components/pickers/pickers.md b/docs/src/pages/components/pickers/pickers.md index cfec750aee9fd4..af4aa0c17a8e5b 100644 --- a/docs/src/pages/components/pickers/pickers.md +++ b/docs/src/pages/components/pickers/pickers.md @@ -16,19 +16,13 @@ packageName: '@material-ui/lab' {{"component": "modules/components/ComponentLinkHeader.js"}} -## @material-ui/pickers - -![stars](https://img.shields.io/github/stars/mui-org/material-ui-pickers.svg?style=social&label=Stars) -![npm downloads](https://img.shields.io/npm/dm/@material-ui/pickers.svg) - -[@material-ui/pickers](https://material-ui-pickers.dev/) provides date picker and time picker controls. +## React components {{"demo": "pages/components/pickers/MaterialUIPickers.js"}} ## Native pickers โš ๏ธ Native input controls support by browsers [isn't perfect](https://caniuse.com/#feat=input-datetime). -Have a look at [@material-ui/pickers](https://material-ui-pickers.dev/) for a richer solution. ### Datepickers diff --git a/docs/src/pages/components/progress/DelayingAppearance.js b/docs/src/pages/components/progress/DelayingAppearance.js index ff03dd29a768ea..826a99045294dc 100644 --- a/docs/src/pages/components/progress/DelayingAppearance.js +++ b/docs/src/pages/components/progress/DelayingAppearance.js @@ -37,7 +37,9 @@ export default function DelayingAppearance() { }; const handleClickQuery = () => { - clearTimeout(timerRef.current); + if (timerRef.current) { + clearTimeout(timerRef.current); + } if (query !== 'idle') { setQuery('idle'); diff --git a/docs/src/pages/components/progress/DelayingAppearance.tsx b/docs/src/pages/components/progress/DelayingAppearance.tsx index 03e15e1899df59..5ba7af3f0a5c9e 100644 --- a/docs/src/pages/components/progress/DelayingAppearance.tsx +++ b/docs/src/pages/components/progress/DelayingAppearance.tsx @@ -39,7 +39,9 @@ export default function DelayingAppearance() { }; const handleClickQuery = () => { - clearTimeout(timerRef.current); + if (timerRef.current) { + clearTimeout(timerRef.current); + } if (query !== 'idle') { setQuery('idle'); diff --git a/docs/src/pages/components/time-picker/BasicTimePicker.js b/docs/src/pages/components/time-picker/BasicTimePicker.js new file mode 100644 index 00000000000000..e8706e1df15c00 --- /dev/null +++ b/docs/src/pages/components/time-picker/BasicTimePicker.js @@ -0,0 +1,33 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; + +export default function BasicTimePicker() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/BasicTimePicker.tsx b/docs/src/pages/components/time-picker/BasicTimePicker.tsx new file mode 100644 index 00000000000000..9298df571b262c --- /dev/null +++ b/docs/src/pages/components/time-picker/BasicTimePicker.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; + +export default function BasicTimePicker() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/LocalizedTimePicker.js b/docs/src/pages/components/time-picker/LocalizedTimePicker.js new file mode 100644 index 00000000000000..cf38f994d9b9e2 --- /dev/null +++ b/docs/src/pages/components/time-picker/LocalizedTimePicker.js @@ -0,0 +1,56 @@ +import * as React from 'react'; +import frLocale from 'date-fns/locale/fr'; +import ruLocale from 'date-fns/locale/ru'; +import arSaLocale from 'date-fns/locale/ar-SA'; +import enLocale from 'date-fns/locale/en-US'; +import ToggleButton from '@material-ui/core/ToggleButton'; +import ToggleButtonGroup from '@material-ui/core/ToggleButtonGroup'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; + +import TimePicker from '@material-ui/lab/TimePicker'; + +const localeMap = { + en: enLocale, + fr: frLocale, + ru: ruLocale, + ar: arSaLocale, +}; + +export default function LocalizedTimePicker() { + const [locale, setLocale] = React.useState('ru'); + const [selectedDate, handleDateChange] = React.useState(new Date()); + + const selectLocale = (newLocale) => { + setLocale(newLocale); + }; + + return ( + +
+ + handleDateChange(date)} + renderInput={(params) => } + /> + + {Object.keys(localeMap).map((localeItem) => ( + selectLocale(localeItem)} + > + {localeItem} + + ))} + + +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/LocalizedTimePicker.tsx b/docs/src/pages/components/time-picker/LocalizedTimePicker.tsx new file mode 100644 index 00000000000000..74c74c4604b347 --- /dev/null +++ b/docs/src/pages/components/time-picker/LocalizedTimePicker.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import frLocale from 'date-fns/locale/fr'; +import ruLocale from 'date-fns/locale/ru'; +import arSaLocale from 'date-fns/locale/ar-SA'; +import enLocale from 'date-fns/locale/en-US'; +import ToggleButton from '@material-ui/core/ToggleButton'; +import ToggleButtonGroup from '@material-ui/core/ToggleButtonGroup'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; + +import TimePicker from '@material-ui/lab/TimePicker'; + +const localeMap = { + en: enLocale, + fr: frLocale, + ru: ruLocale, + ar: arSaLocale, +}; + +export default function LocalizedTimePicker() { + const [locale, setLocale] = React.useState('ru'); + const [selectedDate, handleDateChange] = React.useState( + new Date(), + ); + + const selectLocale = (newLocale: any) => { + setLocale(newLocale); + }; + + return ( + +
+ + handleDateChange(date)} + renderInput={(params) => } + /> + + {Object.keys(localeMap).map((localeItem) => ( + selectLocale(localeItem)} + > + {localeItem} + + ))} + + +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/ResponsiveTimePickers.js b/docs/src/pages/components/time-picker/ResponsiveTimePickers.js new file mode 100644 index 00000000000000..dc77f244a5760c --- /dev/null +++ b/docs/src/pages/components/time-picker/ResponsiveTimePickers.js @@ -0,0 +1,41 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; +import MobileTimePicker from '@material-ui/lab/MobileTimePicker'; +import DesktopTimePicker from '@material-ui/lab/DesktopTimePicker'; + +export default function ResponsiveTimePickers() { + const [value, setValue] = React.useState( + new Date('2018-01-01T00:00:00.000Z'), + ); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> + } + /> +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/ResponsiveTimePickers.tsx b/docs/src/pages/components/time-picker/ResponsiveTimePickers.tsx new file mode 100644 index 00000000000000..0552187c375a61 --- /dev/null +++ b/docs/src/pages/components/time-picker/ResponsiveTimePickers.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; +import MobileTimePicker from '@material-ui/lab/MobileTimePicker'; +import DesktopTimePicker from '@material-ui/lab/DesktopTimePicker'; + +export default function ResponsiveTimePickers() { + const [value, setValue] = React.useState( + new Date('2018-01-01T00:00:00.000Z'), + ); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> + } + /> +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/SecondsTimePicker.js b/docs/src/pages/components/time-picker/SecondsTimePicker.js new file mode 100644 index 00000000000000..91a5752892c293 --- /dev/null +++ b/docs/src/pages/components/time-picker/SecondsTimePicker.js @@ -0,0 +1,41 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; + +export default function SecondsTimePicker() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/SecondsTimePicker.tsx b/docs/src/pages/components/time-picker/SecondsTimePicker.tsx new file mode 100644 index 00000000000000..c64959595806c8 --- /dev/null +++ b/docs/src/pages/components/time-picker/SecondsTimePicker.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; + +export default function SecondsTimePicker() { + const [value, setValue] = React.useState(new Date()); + + return ( + +
+ { + setValue(newValue); + }} + renderInput={(params) => } + /> + { + setValue(newValue); + }} + renderInput={(params) => } + /> +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/StaticTimePickerDemo.js b/docs/src/pages/components/time-picker/StaticTimePickerDemo.js new file mode 100644 index 00000000000000..61a317584eadc3 --- /dev/null +++ b/docs/src/pages/components/time-picker/StaticTimePickerDemo.js @@ -0,0 +1,22 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import StaticTimePicker from '@material-ui/lab/StaticTimePicker'; + +export default function StaticTimePickerDemo() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/time-picker/StaticTimePickerDemo.tsx b/docs/src/pages/components/time-picker/StaticTimePickerDemo.tsx new file mode 100644 index 00000000000000..e0289e25b26f98 --- /dev/null +++ b/docs/src/pages/components/time-picker/StaticTimePickerDemo.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import StaticTimePicker from '@material-ui/lab/StaticTimePicker'; + +export default function StaticTimePickerDemo() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/time-picker/StaticTimePickerLandscape.js b/docs/src/pages/components/time-picker/StaticTimePickerLandscape.js new file mode 100644 index 00000000000000..1109b159aa3df6 --- /dev/null +++ b/docs/src/pages/components/time-picker/StaticTimePickerLandscape.js @@ -0,0 +1,24 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import StaticTimePicker from '@material-ui/lab/StaticTimePicker'; + +export default function StaticTimePickerLandscape() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/time-picker/StaticTimePickerLandscape.tsx b/docs/src/pages/components/time-picker/StaticTimePickerLandscape.tsx new file mode 100644 index 00000000000000..31d4043833e313 --- /dev/null +++ b/docs/src/pages/components/time-picker/StaticTimePickerLandscape.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import StaticTimePicker from '@material-ui/lab/StaticTimePicker'; + +export default function StaticTimePickerLandscape() { + const [value, setValue] = React.useState(new Date()); + + return ( + + { + setValue(newValue); + }} + renderInput={(params) => } + /> + + ); +} diff --git a/docs/src/pages/components/time-picker/TimeValidationTimePicker.js b/docs/src/pages/components/time-picker/TimeValidationTimePicker.js new file mode 100644 index 00000000000000..fc35465cd28105 --- /dev/null +++ b/docs/src/pages/components/time-picker/TimeValidationTimePicker.js @@ -0,0 +1,41 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; + +export default function TimeValidationTimePicker() { + const [value, setValue] = React.useState(new Date('2020-01-01 12:00')); + + return ( + +
+ } + value={value} + label="min/max time" + onChange={(newValue) => { + setValue(newValue); + }} + minTime={new Date(0, 0, 0, 8)} + maxTime={new Date(0, 0, 0, 18, 45)} + /> + } + label="Disable odd hours" + value={value} + onChange={(newValue) => { + setValue(newValue); + }} + shouldDisableTime={(timeValue, clockType) => { + if (clockType === 'hours' && timeValue % 2) { + return true; + } + + return false; + }} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/TimeValidationTimePicker.tsx b/docs/src/pages/components/time-picker/TimeValidationTimePicker.tsx new file mode 100644 index 00000000000000..c096073a6af2ed --- /dev/null +++ b/docs/src/pages/components/time-picker/TimeValidationTimePicker.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import TimePicker from '@material-ui/lab/TimePicker'; + +export default function TimeValidationTimePicker() { + const [value, setValue] = React.useState( + new Date('2020-01-01 12:00'), + ); + + return ( + +
+ } + value={value} + label="min/max time" + onChange={(newValue) => { + setValue(newValue); + }} + minTime={new Date(0, 0, 0, 8)} + maxTime={new Date(0, 0, 0, 18, 45)} + /> + } + label="Disable odd hours" + value={value} + onChange={(newValue) => { + setValue(newValue); + }} + shouldDisableTime={(timeValue, clockType) => { + if (clockType === 'hours' && timeValue % 2) { + return true; + } + + return false; + }} + /> +
+
+ ); +} diff --git a/docs/src/pages/components/time-picker/time-picker.md b/docs/src/pages/components/time-picker/time-picker.md new file mode 100644 index 00000000000000..bea749062b1f65 --- /dev/null +++ b/docs/src/pages/components/time-picker/time-picker.md @@ -0,0 +1,80 @@ +--- +title: React Time Picker component +components: TimePicker +githubLabel: 'component: TimePicker' +packageName: '@material-ui/lab' +materialDesign: https://material.io/components/time-pickers +--- + +# Time Picker + +

Time pickers allow the user to select a single time.

+ +Time pickers allow the user to select a single time (in the hours:minutes format). +The selected time is indicated by the filled circle at the end of the clock hand. + +{{"component": "modules/components/ComponentLinkHeader.js"}} + +## Requirements + +This component relies on the date management library of your choice. It supports [date-fns](https://date-fns.org/), [luxon](https://moment.github.io/luxon/), [dayjs](https://github.com/iamkun/dayjs), [moment](https://momentjs.com/) and any other library via a public `dateAdapter` interface. + +Please install any of these libraries and set up the right date engine by wrapping your root (or the highest level you wish the pickers to be available) with `LocalizationProvider`: + +```jsx +// or @material-ui/lab/dateAdapter/{dayjs,luxon,moment} or any valid date-io adapter +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; + +function App() { + return ( + + ... + + ); +} +``` + +## Basic usage + +The time picker will automatically adjust to the locale's time setting, i.e. the 12-hour or 24-hour format. This can be controlled with `ampm` prop. + +{{"demo": "pages/components/time-picker/BasicTimePicker.js"}} + +## Localization + +Use `LocalizationProvider` to change the date-engine locale that is used to render the time picker. Note that `am/pm` setting is switched automatically: + +{{"demo": "pages/components/time-picker/LocalizedTimePicker.js"}} + +## Responsiveness + +The time picker component is designed and optimized for the device it runs on. + +- The "Mobile" version works best for touch devices and small screens. +- The "Desktop" version works best for mouse devices and large screens. + +By default, the `TimePicker` component uses a `@media (pointer: fine)` media query to determine which version to use. +This can be customized with the `desktopModeMediaQuery` prop. + +{{"demo": "pages/components/time-picker/ResponsiveTimePickers.js"}} + +## Time validation + +{{"demo": "pages/components/time-picker/TimeValidationTimePicker.js"}} + +## Static mode + +It's possible to render any picker inline. This will enable building custom popover/modal containers. + +{{"demo": "pages/components/time-picker/StaticTimePickerDemo.js", "bg": true}} + +## Landscape + +{{"demo": "pages/components/time-picker/StaticTimePickerLandscape.js", "bg": true}} + +## Seconds + +The seconds input can be used for selection of a precise time point. + +{{"demo": "pages/components/time-picker/SecondsTimePicker.js"}} diff --git a/docs/translations/translations.json b/docs/translations/translations.json index 2e9b713d681202..ea991e666e3ee4 100644 --- a/docs/translations/translations.json +++ b/docs/translations/translations.json @@ -134,7 +134,6 @@ "/components/button-group": "Button Group", "/components/checkboxes": "Checkbox", "/components/floating-action-button": "Floating Action Button", - "/components/pickers": "Date / Time", "/components/radio-buttons": "Radio button", "/components/rating": "Rating", "/components/selects": "Select", @@ -202,6 +201,12 @@ "/components/use-media-query": "useMediaQuery", "/components/lab": "Lab", "/components/about-the-lab": "About the lab ๐Ÿงช", + "/components/lab-pickers": "Date / Time", + "/components/pickers": "Introduction", + "/components/date-picker": "Date Picker", + "/components/date-range-picker": "Date Range Picker", + "/components/date-time-picker": "Date Time Picker", + "/components/time-picker": "Time Picker", "/components/slider-styled": "Slider Styled", "/components/timeline": "Timeline", "/components/trap-focus": "Trap Focus", diff --git a/package.json b/package.json index b0a6f7fba43bdd..ae5142d8dd1a7f 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,13 @@ "docs:mdicons:synonyms": "babel-node --config-file ./babel.config.js ./docs/scripts/updateIconSynonyms", "extract-error-codes": "lerna run --parallel extract-error-codes", "framer:build": "yarn workspace framer build", - "jsonlint": "node scripts/jsonlint.js", + "jsonlint": "node ./scripts/jsonlint.js", "lint": "eslint . --cache --report-unused-disable-directives --ext .js,.ts,.tsx", "lint:ci": "eslint . --report-unused-disable-directives --ext .js,.ts,.tsx", "stylelint": "stylelint 'docs/**/*.js' 'docs/**/*.ts' 'docs/**/*.tsx'", "prettier": "node ./scripts/prettier.js", "prettier:all": "node ./scripts/prettier.js write", - "size:snapshot": "node scripts/sizeSnapshot/create", + "size:snapshot": "node --max-old-space-size=2048 ./scripts/sizeSnapshot/create", "size:why": "node scripts/sizeSnapshot/why", "start": "yarn && yarn docs:dev", "t": "node test/cli.js", @@ -181,6 +181,7 @@ "nyc": { "include": [ "packages/material-ui/src/**/*.js", + "packages/material-ui/lab/**/*.{ts,tsx}", "packages/material-ui-utils/src/**/*.js", "packages/material-ui-styles/src/**/*.js" ], diff --git a/packages/eslint-plugin-material-ui/README.md b/packages/eslint-plugin-material-ui/README.md index a9f2ed702e6460..b5e5fd45548798 100644 --- a/packages/eslint-plugin-material-ui/README.md +++ b/packages/eslint-plugin-material-ui/README.md @@ -7,6 +7,7 @@ Custom eslint rules for Material-UI. - `disallow-active-element-as-key-event-target` - `docgen-ignore-before-comment` - `no-hardcoded-labels` +- `lower-case-test-name` - ~~`restricted-path-imports`~~ ### disallow-active-element-as-key-event-target diff --git a/packages/material-ui-codemod/src/v1.0.0/import-path.test/actual.js b/packages/material-ui-codemod/src/v1.0.0/import-path.test/actual.js index 4dc0b3f4bf1705..d7ad017d5e7ae8 100644 --- a/packages/material-ui-codemod/src/v1.0.0/import-path.test/actual.js +++ b/packages/material-ui-codemod/src/v1.0.0/import-path.test/actual.js @@ -14,11 +14,7 @@ import List, { ListItemSecondaryAction, } from '@material-ui/core/List'; import Dialog, { DialogTitle } from '@material-ui/core/Dialog'; -import { - DialogActions, - DialogContent, - DialogContentText, -} from '@material-ui/core/Dialog'; +import { DialogActions, DialogContent, DialogContentText } from '@material-ui/core/Dialog'; import Slide from '@material-ui/core/transitions/Slide'; import Radio, { RadioGroup } from '@material-ui/core/Radio'; import { FormControlLabel } from '@material-ui/core/Form'; diff --git a/packages/material-ui-lab/package.json b/packages/material-ui-lab/package.json index debdb2e41bf2ae..73cb6d49fff793 100644 --- a/packages/material-ui-lab/package.json +++ b/packages/material-ui-lab/package.json @@ -38,12 +38,28 @@ "peerDependencies": { "@material-ui/core": "^5.0.0-alpha.11", "@types/react": "^16.8.6 || ^17.0.0", + "date-fns": "^2.0.0", + "dayjs": "^1.8.17", + "luxon": "^1.21.3", + "moment": "^2.24.0", "react": "^16.8.0 || ^17.0.0", "react-dom": "^16.8.0 || ^17.0.0" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true } }, "dependencies": { @@ -53,10 +69,21 @@ "@material-ui/utils": "^5.0.0-alpha.15", "clsx": "^1.0.4", "prop-types": "^15.7.2", - "react-is": "^16.8.0 || ^17.0.0" + "react-is": "^16.8.0 || ^17.0.0", + "@date-io/date-fns": "^2.8.0", + "@date-io/dayjs": "^2.8.0", + "@date-io/luxon": "^2.8.0", + "@date-io/moment": "^2.8.0", + "react-transition-group": "^4.4.1", + "rifm": "^0.12.0" }, "devDependencies": { - "@material-ui/types": "^5.1.0" + "@material-ui/types": "^5.1.0", + "@types/luxon": "^0.5.2", + "date-fns": "^2.0.0", + "dayjs": "^1.8.17", + "luxon": "^1.21.3", + "moment": "^2.24.0" }, "sideEffects": false, "publishConfig": { diff --git a/packages/material-ui-lab/src/ClockPicker/Clock.tsx b/packages/material-ui-lab/src/ClockPicker/Clock.tsx new file mode 100644 index 00000000000000..a7a5f783dbf6fb --- /dev/null +++ b/packages/material-ui-lab/src/ClockPicker/Clock.tsx @@ -0,0 +1,269 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import IconButton from '@material-ui/core/IconButton'; +import Typography from '@material-ui/core/Typography'; +import { createStyles, WithStyles, Theme, withStyles } from '@material-ui/core/styles'; +import ClockPointer from './ClockPointer'; +import { useUtils, MuiPickersAdapter } from '../internal/pickers/hooks/useUtils'; +import { VIEW_HEIGHT } from '../internal/pickers/constants/dimensions'; +import { ClockViewType } from '../internal/pickers/constants/ClockType'; +import { getHours, getMinutes } from '../internal/pickers/time-utils'; +import { useGlobalKeyDown, keycode } from '../internal/pickers/hooks/useKeyDown'; +import { + WrapperVariantContext, + IsStaticVariantContext, +} from '../internal/pickers/wrappers/WrapperVariantContext'; +import { PickerSelectionState } from '../internal/pickers/hooks/usePickerState'; +import { useMeridiemMode } from '../internal/pickers/hooks/date-helpers-hooks'; + +export interface ClockProps extends ReturnType { + date: TDate | null; + type: ClockViewType; + value: number; + isTimeDisabled: (timeValue: number, type: ClockViewType) => boolean; + children: React.ReactNode[]; + onChange: (value: number, isFinish?: PickerSelectionState) => void; + ampm?: boolean; + minutesStep?: number; + ampmInClock?: boolean; + allowKeyboardControl?: boolean; + getClockLabelText: ( + view: 'hours' | 'minutes' | 'seconds', + time: TDate, + adapter: MuiPickersAdapter, + ) => string; +} + +export const styles = (theme: Theme) => + createStyles({ + root: { + display: 'flex', + justifyContent: 'center', + position: 'relative', + minHeight: VIEW_HEIGHT, + alignItems: 'center', + }, + clock: { + backgroundColor: 'rgba(0,0,0,.07)', + borderRadius: '50%', + height: 260, + width: 260, + position: 'relative', + pointerEvents: 'none', + }, + squareMask: { + width: '100%', + height: '100%', + position: 'absolute', + pointerEvents: 'auto', + outline: 'none', + touchActions: 'none', + userSelect: 'none', + '&:active': { + cursor: 'move', + }, + }, + pin: { + width: 6, + height: 6, + borderRadius: '50%', + backgroundColor: theme.palette.primary.main, + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }, + amButton: { + zIndex: 1, + left: 8, + position: 'absolute', + bottom: 8, + }, + pmButton: { + zIndex: 1, + position: 'absolute', + bottom: 8, + right: 8, + }, + meridiemButtonSelected: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + '&:hover': { + backgroundColor: theme.palette.primary.light, + }, + }, + }); + +export type ClockClassKey = keyof WithStyles['classes']; + +/** + * @ignore - internal component. + */ +function Clock(props: ClockProps & WithStyles) { + const { + allowKeyboardControl, + ampm, + ampmInClock = false, + children: numbersElementsArray, + classes, + date, + handleMeridiemChange, + isTimeDisabled, + meridiemMode, + minutesStep = 1, + onChange, + type, + value, + getClockLabelText, + } = props; + + const utils = useUtils(); + const isStatic = React.useContext(IsStaticVariantContext); + const wrapperVariant = React.useContext(WrapperVariantContext); + const isMoving = React.useRef(false); + + const isSelectedTimeDisabled = isTimeDisabled(value, type); + const isPointerInner = !ampm && type === 'hours' && (value < 1 || value > 12); + + const handleValueChange = (newValue: number, isFinish: PickerSelectionState) => { + if (isTimeDisabled(newValue, type)) { + return; + } + + onChange(newValue, isFinish); + }; + + const setTime = (e: any, isFinish: PickerSelectionState) => { + let { offsetX, offsetY } = e; + + if (typeof offsetX === 'undefined') { + const rect = e.target.getBoundingClientRect(); + + offsetX = e.changedTouches[0].clientX - rect.left; + offsetY = e.changedTouches[0].clientY - rect.top; + } + + const newSelectedValue = + type === 'seconds' || type === 'minutes' + ? getMinutes(offsetX, offsetY, minutesStep) + : getHours(offsetX, offsetY, Boolean(ampm)); + + handleValueChange(newSelectedValue, isFinish); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + isMoving.current = true; + setTime(e, 'shallow'); + }; + + const handleTouchEnd = (e: React.TouchEvent) => { + if (isMoving.current) { + setTime(e, 'finish'); + isMoving.current = false; + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // MouseEvent.which is deprecated, but MouseEvent.buttons is not supported in Safari + const isButtonPressed = + // tslint:disable-next-line deprecation + typeof e.buttons === 'undefined' ? e.nativeEvent.which === 1 : e.buttons === 1; + + if (isButtonPressed) { + setTime(e.nativeEvent, 'shallow'); + } + }; + + const handleMouseUp = (e: React.MouseEvent) => { + if (isMoving.current) { + isMoving.current = false; + } + + setTime(e.nativeEvent, 'finish'); + }; + + const hasSelected = React.useMemo(() => { + if (type === 'hours') { + return true; + } + + return value % 5 === 0; + }, [type, value]); + + const keyboardControlStep = type === 'minutes' ? minutesStep : 1; + useGlobalKeyDown(Boolean(allowKeyboardControl ?? !isStatic) && !isMoving.current, { + [keycode.Home]: () => handleValueChange(0, 'partial'), // annulate both hours and minutes + [keycode.End]: () => handleValueChange(type === 'minutes' ? 59 : 23, 'partial'), + [keycode.ArrowUp]: () => handleValueChange(value + keyboardControlStep, 'partial'), + [keycode.ArrowDown]: () => handleValueChange(value - keyboardControlStep, 'partial'), + }); + + return ( +
+
+
+ {!isSelectedTimeDisabled && ( + +
+ {date && ( + + )} + + )} + {numbersElementsArray} +
+ {ampm && (wrapperVariant === 'desktop' || ampmInClock) && ( + + handleMeridiemChange('am')} + disabled={meridiemMode === null} + className={clsx(classes.amButton, { + [classes.meridiemButtonSelected]: meridiemMode === 'am', + })} + > + AM + + handleMeridiemChange('pm')} + className={clsx(classes.pmButton, { + [classes.meridiemButtonSelected]: meridiemMode === 'pm', + })} + > + PM + + + )} +
+ ); +} + +Clock.propTypes = { + ampm: PropTypes.bool, + minutesStep: PropTypes.number, +} as any; + +export default withStyles(styles, { + name: 'MuiClock', +})(Clock) as (props: ClockProps) => JSX.Element; diff --git a/packages/material-ui-lab/src/ClockPicker/ClockNumber.tsx b/packages/material-ui-lab/src/ClockPicker/ClockNumber.tsx new file mode 100644 index 00000000000000..8322211d94f7be --- /dev/null +++ b/packages/material-ui-lab/src/ClockPicker/ClockNumber.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Typography from '@material-ui/core/Typography'; +import ButtonBase from '@material-ui/core/ButtonBase'; +import { createStyles, WithStyles, withStyles, Theme, alpha } from '@material-ui/core/styles'; +import { onSpaceOrEnter } from '../internal/pickers/utils'; +import { useCanAutoFocus } from '../internal/pickers/hooks/useCanAutoFocus'; +import { PickerSelectionState } from '../internal/pickers/hooks/usePickerState'; + +const positions: Record = { + 0: [0, 40], + 1: [55, 19.6], + 2: [94.4, 59.5], + 3: [109, 114], + 4: [94.4, 168.5], + 5: [54.5, 208.4], + 6: [0, 223], + 7: [-54.5, 208.4], + 8: [-94.4, 168.5], + 9: [-109, 114], + 10: [-94.4, 59.5], + 11: [-54.5, 19.6], + 12: [0, 5], + 13: [36.9, 49.9], + 14: [64, 77], + 15: [74, 114], + 16: [64, 151], + 17: [37, 178], + 18: [0, 188], + 19: [-37, 178], + 20: [-64, 151], + 21: [-74, 114], + 22: [-64, 77], + 23: [-37, 50], +}; + +export interface ClockNumberProps { + disabled: boolean; + getClockNumberText: (currentItemText: string) => string; + index: number; + isInner?: boolean; + label: string; + onSelect: (isFinish: PickerSelectionState) => void; + selected: boolean; +} + +export const styles = (theme: Theme) => { + const size = 32; + const clockNumberColor = + theme.palette.mode === 'light' ? theme.palette.text.primary : theme.palette.text.secondary; + + return createStyles({ + root: { + outline: 0, + width: size, + height: size, + userSelect: 'none', + position: 'absolute', + left: `calc((100% - ${size}px) / 2)`, + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '50%', + color: clockNumberColor, + '&:focused': { + backgroundColor: theme.palette.background.paper, + }, + }, + clockNumberSelected: { + color: theme.palette.primary.contrastText, + }, + clockNumberDisabled: { + pointerEvents: 'none', + color: alpha(clockNumberColor, 0.2), + }, + }); +}; + +export type ClockNumberClassKey = keyof WithStyles['classes']; + +/** + * @ignore - internal component. + */ +const ClockNumber: React.FC> = (props) => { + const { + disabled, + getClockNumberText, + index, + isInner, + label, + onSelect, + selected, + classes, + } = props; + + const canAutoFocus = useCanAutoFocus(); + const ref = React.useRef(null); + const className = clsx(classes.root, { + [classes.clockNumberSelected]: selected, + [classes.clockNumberDisabled]: disabled, + }); + + const transformStyle = React.useMemo(() => { + const position = positions[index]; + + return { + transform: `translate(${position[0]}px, ${position[1]}px`, + }; + }, [index]); + + React.useEffect(() => { + if (canAutoFocus && selected && ref.current) { + ref.current.focus(); + } + }, [canAutoFocus, selected]); + + return ( + onSelect('finish'))} + > + {label} + + ); +}; + +export default withStyles(styles, { name: 'MuiClockNumber' })(ClockNumber); diff --git a/packages/material-ui-lab/src/ClockPicker/ClockNumbers.tsx b/packages/material-ui-lab/src/ClockPicker/ClockNumbers.tsx new file mode 100644 index 00000000000000..43617104061651 --- /dev/null +++ b/packages/material-ui-lab/src/ClockPicker/ClockNumbers.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import ClockNumber from './ClockNumber'; +import { MuiPickersAdapter } from '../internal/pickers/hooks/useUtils'; +import { PickerSelectionState } from '../internal/pickers/hooks/usePickerState'; + +interface GetHourNumbersOptions { + ampm: boolean; + date: unknown; + getClockNumberText: (hour: string) => string; + isDisabled: (value: number) => boolean; + onChange: (value: number, isFinish?: PickerSelectionState) => void; + utils: MuiPickersAdapter; +} + +/** + * @ignore - internal component. + */ +export const getHourNumbers = ({ + ampm, + date, + getClockNumberText, + isDisabled, + onChange, + utils, +}: GetHourNumbersOptions) => { + const currentHours = date ? utils.getHours(date) : null; + + const hourNumbers: JSX.Element[] = []; + const startHour = ampm ? 1 : 0; + const endHour = ampm ? 12 : 23; + + const isSelected = (hour: number) => { + if (currentHours === null) { + return false; + } + + if (ampm) { + if (hour === 12) { + return currentHours === 12 || currentHours === 0; + } + + return currentHours === hour || currentHours - 12 === hour; + } + + return currentHours === hour; + }; + + for (let hour = startHour; hour <= endHour; hour += 1) { + let label = hour.toString(); + + if (hour === 0) { + label = '00'; + } + + const isInner = !ampm && (hour === 0 || hour > 12); + hourNumbers.push( + onChange(hour, 'finish')} + getClockNumberText={getClockNumberText} + />, + ); + } + + return hourNumbers; +}; + +export const getMinutesNumbers = ({ + utils, + value, + onChange, + isDisabled, + getClockNumberText, +}: Omit & { value: number }) => { + const f = utils.formatNumber; + + return ([ + [5, f('05')], + [10, f('10')], + [15, f('15')], + [20, f('20')], + [25, f('25')], + [30, f('30')], + [35, f('35')], + [40, f('40')], + [45, f('45')], + [50, f('50')], + [55, f('55')], + [0, f('00')], + ] as const).map(([numberValue, label], index) => ( + onChange(numberValue, isFinish)} + getClockNumberText={getClockNumberText} + /> + )); +}; diff --git a/packages/material-ui-lab/src/ClockPicker/ClockPicker.tsx b/packages/material-ui-lab/src/ClockPicker/ClockPicker.tsx new file mode 100644 index 00000000000000..8566dfe8b09b28 --- /dev/null +++ b/packages/material-ui-lab/src/ClockPicker/ClockPicker.tsx @@ -0,0 +1,440 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; +import Clock from './Clock'; +import { pipe } from '../internal/pickers/utils'; +import { useUtils, useNow, MuiPickersAdapter } from '../internal/pickers/hooks/useUtils'; +import { getHourNumbers, getMinutesNumbers } from './ClockNumbers'; +import ArrowSwitcher, { + ExportedArrowSwitcherProps, +} from '../internal/pickers/PickersArrowSwitcher'; +import { + convertValueToMeridiem, + createIsAfterIgnoreDatePart, + TimeValidationProps, +} from '../internal/pickers/time-utils'; +import { PickerOnChangeFn } from '../internal/pickers/hooks/useViews'; +import { PickerSelectionState } from '../internal/pickers/hooks/usePickerState'; +import { useMeridiemMode } from '../internal/pickers/hooks/date-helpers-hooks'; + +export interface ExportedClockPickerProps extends TimeValidationProps { + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm?: boolean; + /** + * Step over minutes. + * @default 1 + */ + minutesStep?: number; + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock?: boolean; + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl?: boolean; + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText?: ( + view: 'hours' | 'minutes' | 'seconds', + time: TDate, + adapter: MuiPickersAdapter, + ) => string; +} + +export interface ClockPickerProps + extends ExportedClockPickerProps, + ExportedArrowSwitcherProps { + /** + * Selected date @DateIOType. + */ + date: TDate | null; + /** + * On change callback @DateIOType. + */ + onChange: PickerOnChangeFn; + /** + * Get clock number aria-text for hours. + */ + getHoursClockNumberText?: (hoursText: string) => string; + /** + * Get clock number aria-text for minutes. + */ + getMinutesClockNumberText?: (minutesText: string) => string; + /** + * Get clock number aria-text for seconds. + */ + getSecondsClockNumberText?: (secondsText: string) => string; + openNextView: () => void; + openPreviousView: () => void; + view: 'hours' | 'minutes' | 'seconds'; + nextViewAvailable: boolean; + previousViewAvailable: boolean; + showViewSwitcher?: boolean; +} + +export const styles = createStyles({ + arrowSwitcher: { + position: 'absolute', + right: 12, + top: 15, + }, +}); + +const getDefaultClockLabelText = ( + view: 'hours' | 'minutes' | 'seconds', + time: TDate, + adapter: MuiPickersAdapter, +) => `Select ${view}. Selected time is ${adapter.format(time, 'fullTime')}`; + +const getMinutesAriaText = (minute: string) => `${minute} minutes`; + +const getHoursAriaText = (hour: string) => `${hour} hours`; + +const getSecondsAriaText = (seconds: string) => `${seconds} seconds`; + +/** + * @ignore - do not document. + */ +function ClockPicker(props: ClockPickerProps & WithStyles) { + const { + allowKeyboardControl, + ampm, + ampmInClock, + classes, + date, + disableIgnoringDatePartForTimeValidation, + getClockLabelText = getDefaultClockLabelText, + getHoursClockNumberText = getHoursAriaText, + getMinutesClockNumberText = getMinutesAriaText, + getSecondsClockNumberText = getSecondsAriaText, + leftArrowButtonProps, + leftArrowButtonText = 'open previous view', + leftArrowIcon, + maxTime, + minTime, + minutesStep = 1, + nextViewAvailable, + onChange, + openNextView, + openPreviousView, + previousViewAvailable, + rightArrowButtonProps, + rightArrowButtonText = 'open next view', + rightArrowIcon, + shouldDisableTime, + showViewSwitcher, + view, + } = props; + + const now = useNow(); + const utils = useUtils(); + const dateOrNow = date || now; + + const { meridiemMode, handleMeridiemChange } = useMeridiemMode(dateOrNow, ampm, onChange); + + const isTimeDisabled = React.useCallback( + (rawValue: number, viewType: 'hours' | 'minutes' | 'seconds') => { + if (date === null) { + return false; + } + + const validateTimeValue = (getRequestedTimePoint: (when: 'start' | 'end') => TDate) => { + const isAfterComparingFn = createIsAfterIgnoreDatePart( + Boolean(disableIgnoringDatePartForTimeValidation), + utils, + ); + + return Boolean( + (minTime && isAfterComparingFn(minTime, getRequestedTimePoint('end'))) || + (maxTime && isAfterComparingFn(getRequestedTimePoint('start'), maxTime)) || + (shouldDisableTime && shouldDisableTime(rawValue, viewType)), + ); + }; + + switch (viewType) { + case 'hours': { + const hoursWithMeridiem = convertValueToMeridiem(rawValue, meridiemMode, Boolean(ampm)); + return validateTimeValue((when: 'start' | 'end') => + pipe( + (currentDate) => utils.setHours(currentDate, hoursWithMeridiem), + (dateWithHours) => utils.setMinutes(dateWithHours, when === 'start' ? 0 : 59), + (dateWithMinutes) => utils.setSeconds(dateWithMinutes, when === 'start' ? 0 : 59), + )(date), + ); + } + + case 'minutes': + return validateTimeValue((when: 'start' | 'end') => + pipe( + (currentDate) => utils.setMinutes(currentDate, rawValue), + (dateWithMinutes) => utils.setSeconds(dateWithMinutes, when === 'start' ? 0 : 59), + )(date), + ); + + case 'seconds': + return validateTimeValue(() => utils.setSeconds(date, rawValue)); + + default: + throw new Error('not supported'); + } + }, + [ + ampm, + date, + disableIgnoringDatePartForTimeValidation, + maxTime, + meridiemMode, + minTime, + shouldDisableTime, + utils, + ], + ); + + const viewProps = React.useMemo(() => { + switch (view) { + case 'hours': { + const handleHoursChange = (value: number, isFinish?: PickerSelectionState) => { + const valueWithMeridiem = convertValueToMeridiem(value, meridiemMode, Boolean(ampm)); + onChange(utils.setHours(dateOrNow, valueWithMeridiem), isFinish); + }; + + return { + onChange: handleHoursChange, + value: utils.getHours(dateOrNow), + children: getHourNumbers({ + date, + utils, + ampm: Boolean(ampm), + onChange: handleHoursChange, + getClockNumberText: getHoursClockNumberText, + isDisabled: (value) => isTimeDisabled(value, 'hours'), + }), + }; + } + + case 'minutes': { + const minutesValue = utils.getMinutes(dateOrNow); + const handleMinutesChange = (value: number, isFinish?: PickerSelectionState) => { + onChange(utils.setMinutes(dateOrNow, value), isFinish); + }; + + return { + value: minutesValue, + onChange: handleMinutesChange, + children: getMinutesNumbers({ + utils, + value: minutesValue, + onChange: handleMinutesChange, + getClockNumberText: getMinutesClockNumberText, + isDisabled: (value) => isTimeDisabled(value, 'minutes'), + }), + }; + } + + case 'seconds': { + const secondsValue = utils.getSeconds(dateOrNow); + const handleSecondsChange = (value: number, isFinish?: PickerSelectionState) => { + onChange(utils.setSeconds(dateOrNow, value), isFinish); + }; + + return { + value: secondsValue, + onChange: handleSecondsChange, + children: getMinutesNumbers({ + utils, + value: secondsValue, + onChange: handleSecondsChange, + getClockNumberText: getSecondsClockNumberText, + isDisabled: (value) => isTimeDisabled(value, 'seconds'), + }), + }; + } + + default: + throw new Error('You must provide the type for ClockView'); + } + }, [ + view, + utils, + date, + ampm, + getHoursClockNumberText, + getMinutesClockNumberText, + getSecondsClockNumberText, + meridiemMode, + onChange, + dateOrNow, + isTimeDisabled, + ]); + + return ( + + {showViewSwitcher && ( + + )} + + + date={date} + ampmInClock={ampmInClock} + type={view} + ampm={ampm} + // @ts-expect-error TODO figure out this weird error + getClockLabelText={getClockLabelText} + minutesStep={minutesStep} + allowKeyboardControl={allowKeyboardControl} + isTimeDisabled={isTimeDisabled} + meridiemMode={meridiemMode} + handleMeridiemChange={handleMeridiemChange} + {...viewProps} + /> + + ); +} + +(ClockPicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * @ignore + */ + classes: PropTypes.object.isRequired, + /** + * Selected date @DateIOType. + */ + date: PropTypes.any, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get clock number aria-text for hours. + */ + getHoursClockNumberText: PropTypes.func, + /** + * Get clock number aria-text for minutes. + */ + getMinutesClockNumberText: PropTypes.func, + /** + * Get clock number aria-text for seconds. + */ + getSecondsClockNumberText: PropTypes.func, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * Max time acceptable time. + * For input validation date part of passed object will be ignored if `disableIgnoringDatePartForTimeValidation` not specified. + */ + maxTime: PropTypes.any, + /** + * Min time acceptable time. + * For input validation date part of passed object will be ignored if `disableIgnoringDatePartForTimeValidation` not specified. + */ + minTime: PropTypes.any, + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * @ignore + */ + nextViewAvailable: PropTypes.bool.isRequired, + /** + * On change callback @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * @ignore + */ + openNextView: PropTypes.func.isRequired, + /** + * @ignore + */ + openPreviousView: PropTypes.func.isRequired, + /** + * @ignore + */ + previousViewAvailable: PropTypes.bool.isRequired, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * @ignore + */ + showViewSwitcher: PropTypes.bool, + /** + * @ignore + */ + view: PropTypes.oneOf(['hours', 'minutes', 'seconds']).isRequired, +}; + +export default withStyles(styles, { name: 'MuiPickersClockView' })(ClockPicker) as ( + props: ClockPickerProps, +) => JSX.Element; diff --git a/packages/material-ui-lab/src/ClockPicker/ClockPickerStandalone.test.tsx b/packages/material-ui-lab/src/ClockPicker/ClockPickerStandalone.test.tsx new file mode 100644 index 00000000000000..c47f8b3c75b292 --- /dev/null +++ b/packages/material-ui-lab/src/ClockPicker/ClockPickerStandalone.test.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { createMount, describeConformance } from 'test/utils'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import ClockPicker from '@material-ui/lab/ClockPicker'; + +describe('', () => { + const mount = createMount(); + + const localizedMount = (node: React.ReactNode) => { + return mount({node}); + }; + + describeConformance( {}} />, () => ({ + classes: {}, + inheritComponent: 'div', + mount: localizedMount, + refInstanceof: window.HTMLDivElement, + // cannot test reactTestRenderer because of required context + skip: ['componentProp', 'propsSpread', 'reactTestRenderer'], + })); +}); diff --git a/packages/material-ui-lab/src/ClockPicker/ClockPickerStandalone.tsx b/packages/material-ui-lab/src/ClockPicker/ClockPickerStandalone.tsx new file mode 100644 index 00000000000000..e08b6f3f961700 --- /dev/null +++ b/packages/material-ui-lab/src/ClockPicker/ClockPickerStandalone.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import ClockPicker, { ClockPickerProps } from './ClockPicker'; +import { TimePickerView } from '../internal/pickers/typings/Views'; +import PickerView from '../internal/pickers/Picker/PickerView'; +import { useViews } from '../internal/pickers/hooks/useViews'; + +export interface ClockPickerStandaloneProps + extends Omit< + ClockPickerProps, + 'view' | 'openNextView' | 'openPreviousView' | 'nextViewAvailable' | 'previousViewAvailable' + > { + /** Controlled clock view. */ + view?: TimePickerView; + /** Available views for clock. */ + views?: TimePickerView[]; + /** Callback fired on view change. */ + onViewChange?: (view: TimePickerView) => void; + /** Initially opened view. */ + openTo?: TimePickerView; + className?: string; +} + +/** + * Wrapping public API for better standalone usage of './ClockPicker' + * @ignore - internal component. + */ +export default React.forwardRef(function ClockPickerStandalone( + props: ClockPickerStandaloneProps, + ref: React.Ref, +) { + const { + view, + openTo, + className, + onViewChange, + views = ['hours', 'minutes'] as TimePickerView[], + ...other + } = props; + + const { openView, setOpenView, nextView, previousView } = useViews({ + view, + views, + openTo, + onViewChange, + onChange: other.onChange, + }); + + return ( + + setOpenView(nextView)} + openPreviousView={() => setOpenView(previousView)} + {...other} + /> + + ); +}) as ( + props: ClockPickerStandaloneProps & React.RefAttributes, +) => JSX.Element; diff --git a/packages/material-ui-lab/src/ClockPicker/ClockPointer.tsx b/packages/material-ui-lab/src/ClockPicker/ClockPointer.tsx new file mode 100644 index 00000000000000..9a39a6e35de36d --- /dev/null +++ b/packages/material-ui-lab/src/ClockPicker/ClockPointer.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { withStyles, createStyles, Theme, WithStyles } from '@material-ui/core/styles'; +import { ClockViewType } from '../internal/pickers/constants/ClockType'; + +export const styles = (theme: Theme) => + createStyles({ + pointer: { + width: 2, + backgroundColor: theme.palette.primary.main, + position: 'absolute', + left: 'calc(50% - 1px)', + bottom: '50%', + transformOrigin: 'center bottom 0px', + }, + animateTransform: { + transition: theme.transitions.create(['transform', 'height']), + }, + thumb: { + width: 4, + height: 4, + backgroundColor: theme.palette.primary.contrastText, + borderRadius: '100%', + position: 'absolute', + top: -21, + left: -15, + border: `14px solid ${theme.palette.primary.main}`, + boxSizing: 'content-box', + }, + noPoint: { + backgroundColor: theme.palette.primary.main, + }, + }); + +export type ClockPointerClassKey = keyof WithStyles['classes']; + +export interface ClockPointerProps + extends React.HTMLProps, + WithStyles { + value: number; + hasSelected: boolean; + isInner: boolean; + type: ClockViewType; +} + +/** + * @ignore - internal component. + */ +class ClockPointer extends React.Component { + static getDerivedStateFromProps = ( + nextProps: ClockPointerProps, + state: ClockPointer['state'], + ) => { + if (nextProps.type !== state.previousType) { + return { + toAnimateTransform: true, + previousType: nextProps.type, + }; + } + + return { + toAnimateTransform: false, + previousType: nextProps.type, + }; + }; + + state = { + toAnimateTransform: false, + // eslint-disable-next-line react/no-unused-state + previousType: undefined, + }; + + getAngleStyle = () => { + const { value, isInner, type } = this.props; + + const max = type === 'hours' ? 12 : 60; + let angle = (360 / max) * value; + + if (type === 'hours' && value > 12) { + angle -= 360; // round up angle to max 360 degrees + } + + return { + height: isInner ? '26%' : '40%', + transform: `rotateZ(${angle}deg)`, + }; + }; + + render() { + const { classes, hasSelected, isInner, type, value, ...other } = this.props; + + return ( +
+
+
+ ); + } +} + +export default withStyles(styles, { + name: 'MuiClockPointer', +})(ClockPointer); diff --git a/packages/material-ui-lab/src/ClockPicker/index.ts b/packages/material-ui-lab/src/ClockPicker/index.ts new file mode 100644 index 00000000000000..aa84e211e250fe --- /dev/null +++ b/packages/material-ui-lab/src/ClockPicker/index.ts @@ -0,0 +1,5 @@ +export { default } from './ClockPickerStandalone'; + +export type ClockPickerProps = import('./ClockPickerStandalone').ClockPickerStandaloneProps< + TDate +>; diff --git a/packages/material-ui-lab/src/DatePicker/DatePicker.spec.tsx b/packages/material-ui-lab/src/DatePicker/DatePicker.spec.tsx new file mode 100644 index 00000000000000..1178f50e368e8d --- /dev/null +++ b/packages/material-ui-lab/src/DatePicker/DatePicker.spec.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import moment, { Moment } from 'moment'; +import { DatePicker, StaticDatePicker, DayPicker, PickersDay } from '@material-ui/lab'; +import DateFnsAdapter from '../dateAdapter/date-fns'; +import MomentAdapter from '../dateAdapter/moment'; + +// Allows to set date type right with generic JSX syntax + + value={new Date()} + onChange={(date) => date?.getDate()} + renderInput={() => } +/>; + +// Throws error if passed value is invalid + + // @ts-expect-error Value is invalid + value={moment()} + onChange={(date) => date?.getDate()} + renderInput={() => } +/>; + +// Inference from the state +const InferTest = () => { + const [date, setDate] = React.useState(moment()); + + return ( + setDate(date)} renderInput={() => } /> + ); +}; + +// Infer value type from the dateAdapter + console.log(date)} + renderInput={() => } + dateAdapter={new MomentAdapter()} +/>; + +// Conflict between value type and date adapter causes error + console.log(date)} + renderInput={() => } + // @ts-expect-error + dateAdapter={new DateFnsAdapter()} +/>; + +// Conflict between explicit generic type and date adapter causes error + + value={moment()} + onChange={(date) => console.log(date)} + renderInput={() => } + // @ts-expect-error + dateAdapter={new LuxonAdapter()} +/>; + +// Allows inferring for side props + {day.format('D')} } + onChange={(date) => date?.set({ second: 0 })} + renderInput={() => } +/>; + +// External components are generic as well + + view="date" + views={['date']} + date={moment()} + minDate={moment()} + maxDate={moment()} + onChange={(date) => date?.format()} +/>; + + + day={new Date()} + allowSameDateSelection + outsideCurrentMonth + onDaySelect={(date) => date?.getDay()} +/>; + +// Edge case and known issue. When the passed `value` is not a date type +// We cannot infer the type properly without explicit generic type or `dateAdapter` prop +// So in this case it is expected that type will be the type of `value` as for now + + // getDate is never + // @ts-expect-error + date?.getDate() + } + renderInput={() => } +/>; + +{ + // Allows to pass the wrapper-specific props only to the proper wrapper + date?.getDate()} + renderInput={() => } + displayStaticWrapperAs="desktop" + />; + + date?.getDate()} + renderInput={() => } + // @ts-expect-error + displayStaticWrapperAs="desktop" + />; +} diff --git a/packages/material-ui-lab/src/DatePicker/DatePicker.test.tsx b/packages/material-ui-lab/src/DatePicker/DatePicker.test.tsx new file mode 100644 index 00000000000000..6248539d0055ce --- /dev/null +++ b/packages/material-ui-lab/src/DatePicker/DatePicker.test.tsx @@ -0,0 +1,521 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import TextField from '@material-ui/core/TextField'; +import { fireEvent, screen, waitFor } from 'test/utils'; +import PickersDay from '@material-ui/lab/PickersDay'; +import CalendarSkeleton from '@material-ui/lab/PickersCalendarSkeleton'; +import DatePicker from '@material-ui/lab/DatePicker'; +import MobileDatePicker from '@material-ui/lab/MobileDatePicker'; +import DesktopDatePicker from '@material-ui/lab/DesktopDatePicker'; +import StaticDatePicker from '@material-ui/lab/StaticDatePicker'; +import { + createPickerRender, + FakeTransitionComponent, + adapterToUse, + getByMuiTest, + getAllByMuiTest, + queryAllByMuiTest, + openDesktopPicker, + openMobilePicker, +} from '../internal/pickers/test-utils'; + +describe('', () => { + const render = createPickerRender({ strict: false }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('render proper month', () => { + render( + {}} + renderInput={(params) => } + />, + ); + + expect(screen.getByText('January')).toBeVisible(); + expect(screen.getByText('2019')).toBeVisible(); + expect(getAllByMuiTest('day')).to.have.length(31); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('desktop Mode โ€“ Accepts date on day button click', () => { + const onChangeMock = spy(); + + render( + } + />, + ); + + openDesktopPicker(); + + fireEvent.click(screen.getByLabelText('Jan 2, 2019')); + expect(onChangeMock.callCount).to.equal(1); + + expect(screen.queryByRole('dialog')).to.equal(null); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('mobile mode โ€“ Accepts date on `OK` button click', () => { + const onChangeMock = spy(); + render( + } + />, + ); + + openMobilePicker(); + + fireEvent.click(screen.getByLabelText('Jan 2, 2019')); + expect(onChangeMock.callCount).to.equal(1); + expect(screen.queryByRole('dialog')).not.to.equal(null); + + fireEvent.click(screen.getByText(/ok/i)); + // TODO revisit calling onChange twice. Now it is expected for mobile mode. + expect(onChangeMock.callCount).to.equal(2); + expect(screen.queryByRole('dialog')).to.equal(null); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('switches between months', () => { + render( + {}} + renderInput={(params) => } + />, + ); + + expect(getByMuiTest('calendar-month-text')).to.have.text('January'); + + fireEvent.click(screen.getByLabelText('next month')); + fireEvent.click(screen.getByLabelText('next month')); + + fireEvent.click(screen.getByLabelText('previous month')); + fireEvent.click(screen.getByLabelText('previous month')); + fireEvent.click(screen.getByLabelText('previous month')); + + expect(getByMuiTest('calendar-month-text')).to.have.text('December'); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('selects the closest enabled date if selected date is disabled', () => { + const onChangeMock = spy(); + + render( + } + maxDate={adapterToUse.date('2018-01-01T00:00:00.000')} + />, + ); + + expect(getByMuiTest('calendar-year-text')).to.have.text('2018'); + expect(getByMuiTest('calendar-month-text')).to.have.text('January'); + + // onChange must be dispatched with newly selected date + expect(onChangeMock.calledWith(adapterToUse.date('2018-01-01T00:00:00.000'))).to.be.equal(true); + }); + + it('allows to change only year', () => { + const onChangeMock = spy(); + render( + } + />, + ); + + fireEvent.click(screen.getByLabelText(/switch to year view/i)); + fireEvent.click(screen.getByText('2010', { selector: 'button' })); + + expect(getByMuiTest('calendar-year-text')).to.have.text('2010'); + expect(onChangeMock.callCount).to.equal(1); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('allows to select edge years from list', () => { + render( + {}} + openTo="year" + minDate={new Date('2000-01-01T00:00:00.000')} + maxDate={new Date('2010-01-01T00:00:00.000')} + renderInput={(params) => } + />, + ); + + fireEvent.click(screen.getByText('2010', { selector: 'button' })); + expect(getByMuiTest('datepicker-toolbar-date')).to.have.text('Fri, Jan 1'); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip("doesn't close picker on selection in Mobile mode", () => { + render( + {}} + renderInput={(params) => } + />, + ); + + fireEvent.click(screen.getByRole('textbox')); + fireEvent.click(screen.getByLabelText('Jan 2, 2018')); + + expect(screen.queryByRole('dialog')).toBeVisible(); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('closes picker on selection in Desktop mode', async () => { + render( + {}} + renderInput={(params) => } + />, + ); + + fireEvent.click(screen.getByLabelText('Choose date, selected date is Jan 1, 2018')); + + await waitFor(() => screen.getByRole('dialog')); + fireEvent.click(screen.getByLabelText('Jan 2, 2018')); + + expect(screen.queryByRole('dialog')).to.equal(null); + }); + + it('prop `clearable` - renders clear button in Mobile mode', () => { + const onChangeMock = spy(); + render( + } + />, + ); + + openMobilePicker(); + fireEvent.click(screen.getByText('Clear')); + + expect(onChangeMock.calledWith(null)).to.be.equal(true); + expect(screen.queryByRole('dialog')).to.equal(null); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip("prop `disableCloseOnSelect` โ€“ if `true` doesn't close picker", () => { + render( + {}} + renderInput={(params) => } + />, + ); + + openDesktopPicker(); + fireEvent.click(screen.getByLabelText('Jan 2, 2018')); + + expect(screen.queryByRole('dialog')).toBeVisible(); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('does not call onChange if same date selected', async () => { + const onChangeMock = spy(); + + render( + } + />, + ); + + fireEvent.click(screen.getByLabelText('Choose date, selected date is Jan 1, 2018')); + await waitFor(() => screen.getByRole('dialog')); + + fireEvent.click(screen.getByLabelText('Jan 1, 2018')); + expect(onChangeMock.callCount).to.equal(0); + }); + + it('allows to change selected date from the input according to `format`', () => { + const onChangeMock = spy(); + render( + } + label="Masked input" + inputFormat="dd/MM/yyyy" + value={new Date('2018-01-01T00:00:00.000Z')} + onChange={onChangeMock} + InputAdornmentProps={{ + disableTypography: true, + }} + />, + ); + + fireEvent.change(screen.getByRole('textbox'), { + target: { + value: '10/11/2018', + }, + }); + + expect(screen.getByRole('textbox')).to.have.value('10/11/2018'); + expect(onChangeMock.callCount).to.equal(1); + }); + + it('prop `showToolbar` โ€“ renders toolbar in desktop mode', () => { + render( + {}} + TransitionComponent={FakeTransitionComponent} + value={adapterToUse.date('2018-01-01T00:00:00.000')} + renderInput={(params) => } + />, + ); + + expect(getByMuiTest('picker-toolbar')).toBeVisible(); + }); + + it('prop `toolbarTitle` โ€“ should render title from the prop', () => { + render( + } + open + toolbarTitle="test" + label="something" + onChange={() => {}} + value={adapterToUse.date('2018-01-01T00:00:00.000')} + />, + ); + + expect(getByMuiTest('picker-toolbar-title').textContent).to.equal('test'); + }); + + it('prop `toolbarTitle` โ€“ should use label if no toolbar title', () => { + render( + {}} + renderInput={(params) => } + value={adapterToUse.date('2018-01-01T00:00:00.000')} + />, + ); + + expect(getByMuiTest('picker-toolbar-title').textContent).to.equal('Default label'); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('prop `toolbarFormat` โ€“ should format toolbar according to passed format', () => { + render( + } + open + onChange={() => {}} + toolbarFormat="MMMM" + value={adapterToUse.date('2018-01-01T00:00:00.000')} + />, + ); + + expect(getByMuiTest('datepicker-toolbar-date').textContent).to.equal('January'); + }); + + it('prop `showTodayButton` โ€“ accept current date when "today" button is clicked', () => { + const onCloseMock = spy(); + const onChangeMock = spy(); + render( + } + showTodayButton + cancelText="stream" + onClose={onCloseMock} + onChange={onChangeMock} + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + DialogProps={{ TransitionComponent: FakeTransitionComponent }} + />, + ); + + fireEvent.click(screen.getByRole('textbox')); + fireEvent.click(screen.getByText(/today/i)); + + expect(onCloseMock.callCount).to.equal(1); + expect(onChangeMock.callCount).to.equal(1); + }); + + it('ref - should forwardRef to text field', () => { + const Component = () => { + const ref = React.useRef(null); + const focusPicker = () => { + if (ref.current) { + ref.current.focus(); + expect(ref.current.id).to.equal('test-focusing-picker'); + } else { + throw new Error('Ref must be available'); + } + }; + + return ( + + {}} + renderInput={(params) => } + /> + + + ); + }; + + render(); + fireEvent.click(screen.getByText('test')); + }); + + it('prop `shouldDisableYear` โ€“ disables years dynamically', () => { + render( + } + openTo="year" + onChange={() => {}} + // getByRole() with name attribute is too slow, so restrict the number of rendered years + minDate={new Date('2025-01-01T00:00:00.000')} + maxDate={new Date('2035-01-01T00:00:00.000')} + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + shouldDisableYear={(year) => adapterToUse.getYear(year) === 2030} + />, + ); + + const getYearButton = (year: number) => + screen.getByText(year.toString(), { selector: 'button' }); + + expect(getYearButton(2029)).not.to.have.attribute('disabled'); + expect(getYearButton(2030)).to.have.attribute('disabled'); + expect(getYearButton(2031)).not.to.have.attribute('disabled'); + }); + + it('prop `onMonthChange` โ€“ dispatches callback when months switching', () => { + const onMonthChangeMock = spy(); + render( + } + onChange={() => {}} + onMonthChange={onMonthChangeMock} + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + />, + ); + + fireEvent.click(screen.getByLabelText('next month')); + expect(onMonthChangeMock.callCount).to.equal(1); + }); + + it('prop `loading` โ€“ displays default loading indicator', () => { + render( + } + onChange={() => {}} + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + />, + ); + + expect(queryAllByMuiTest(document.body, 'day')).to.have.length(0); + expect(getByMuiTest('loading-progress')).toBeVisible(); + }); + + it('prop `renderLoading` โ€“ displays custom loading indicator', () => { + render( + } + open + onChange={() => {}} + renderInput={(params) => } + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + />, + ); + + expect(screen.queryByTestId('loading-progress')).to.equal(null); + expect(screen.getByTestId('custom-loading')).toBeVisible(); + }); + + it('prop `ToolbarComponent` โ€“ render custom toolbar component', () => { + render( + } + open + value={new Date()} + onChange={() => {}} + ToolbarComponent={() =>
} + />, + ); + + expect(screen.getByTestId('custom-toolbar')).toBeVisible(); + }); + + it('prop `renderDay` โ€“ renders custom day', () => { + render( + } + open + value={adapterToUse.date('2018-01-01T00:00:00.000')} + onChange={() => {}} + renderDay={(day, _selected, DayComponentProps) => ( + + )} + />, + ); + + expect(screen.getAllByTestId('test-day')).to.have.length(31); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('prop `defaultCalendarMonth` โ€“ opens on provided month if date is `null`', () => { + render( + } + open + value={null} + onChange={() => {}} + defaultCalendarMonth={new Date('2018-07-01T00:00:00.000')} + />, + ); + + expect(screen.getByText('July')).toBeVisible(); + }); +}); diff --git a/packages/material-ui-lab/src/DatePicker/DatePicker.tsx b/packages/material-ui-lab/src/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000000..655c7aa73b0bb7 --- /dev/null +++ b/packages/material-ui-lab/src/DatePicker/DatePicker.tsx @@ -0,0 +1,298 @@ +import PropTypes from 'prop-types'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import DatePickerToolbar from './DatePickerToolbar'; +import type { WithViewsProps } from '../internal/pickers/Picker/SharedPickerProps'; +import { ResponsiveWrapper } from '../internal/pickers/wrappers/ResponsiveWrapper'; +import { + useParsedDate, + OverrideParsableDateProps, +} from '../internal/pickers/hooks/date-helpers-hooks'; +import type { ExportedDayPickerProps } from '../DayPicker/DayPicker'; +import { MobileWrapper, SomeWrapper } from '../internal/pickers/wrappers/Wrapper'; +import { makeValidationHook, ValidationProps } from '../internal/pickers/hooks/useValidation'; +import { + ParsableDate, + defaultMinDate, + defaultMaxDate, +} from '../internal/pickers/constants/prop-types'; +import { + makePickerWithStateAndWrapper, + AllPickerProps, + SharedPickerProps, +} from '../internal/pickers/Picker/makePickerWithState'; +import { + getFormatAndMaskByViews, + DateValidationError, + validateDate, +} from '../internal/pickers/date-utils'; + +export type DatePickerView = 'year' | 'date' | 'month'; + +export interface BaseDatePickerProps + extends WithViewsProps<'year' | 'date' | 'month'>, + ValidationProps, + OverrideParsableDateProps, 'minDate' | 'maxDate'> {} + +export const datePickerConfig = { + useValidation: makeValidationHook< + DateValidationError, + ParsableDate, + BaseDatePickerProps + >(validateDate), + DefaultToolbarComponent: DatePickerToolbar, + useInterceptProps: ({ + openTo = 'date', + views = ['year', 'date'], + minDate: __minDate = defaultMinDate, + maxDate: __maxDate = defaultMaxDate, + ...other + }: AllPickerProps>) => { + const utils = useUtils(); + const minDate = useParsedDate(__minDate); + const maxDate = useParsedDate(__maxDate); + + return { + views, + openTo, + minDate, + maxDate, + ...getFormatAndMaskByViews(views, utils), + ...other, + }; + }, +}; + +export type DatePickerGenericComponent = ( + props: BaseDatePickerProps & SharedPickerProps, +) => JSX.Element; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const DatePicker = makePickerWithStateAndWrapper>(ResponsiveWrapper, { + name: 'MuiDatePicker', + ...datePickerConfig, +}) as DatePickerGenericComponent; + +(DatePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText: PropTypes.node, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable: PropTypes.bool, + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText: PropTypes.node, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * "OK" button text. + * @default "OK" + */ + okText: PropTypes.node, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), +}; + +export type DatePickerProps = React.ComponentProps; + +export default DatePicker; diff --git a/packages/material-ui-lab/src/DatePicker/DatePickerKeyboard.test.tsx b/packages/material-ui-lab/src/DatePicker/DatePickerKeyboard.test.tsx new file mode 100644 index 00000000000000..1a0c71273e0b78 --- /dev/null +++ b/packages/material-ui-lab/src/DatePicker/DatePickerKeyboard.test.tsx @@ -0,0 +1,275 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { isWeekend } from 'date-fns'; +import TextField from '@material-ui/core/TextField'; +import { fireEvent, screen, act } from 'test/utils'; +import DesktopDatePicker, { DesktopDatePickerProps } from '@material-ui/lab/DesktopDatePicker'; +import StaticDatePicker from '@material-ui/lab/StaticDatePicker'; +import { createPickerRender } from '../internal/pickers/test-utils'; +import { MakeOptional } from '../internal/pickers/typings/helpers'; + +function TestKeyboardDatePicker( + PickerProps: MakeOptional, +) { + const [value, setValue] = React.useState( + PickerProps.value ?? new Date('2019-01-01T00:00:00.000'), + ); + + return ( + setValue(newDate)} + renderInput={(props) => } + {...PickerProps} + /> + ); +} + +describe(' keyboard interactions', () => { + const render = createPickerRender({ strict: false }); + + context('input', () => { + it('allows to change selected date from the input according to `format`', () => { + const onChangeMock = spy(); + render(); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '10/11/2018' }, + }); + + expect(screen.getByRole('textbox')).to.have.value('10/11/2018'); + expect(onChangeMock.callCount).to.equal(1); + }); + + it("doesn't accept invalid date format", () => { + render(); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '01' }, + }); + expect(screen.getByRole('textbox')).to.have.attr('aria-invalid', 'true'); + }); + + it('removes invalid state when chars are cleared from invalid input', () => { + render(); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '01' }, + }); + expect(screen.getByRole('textbox')).to.have.attr('aria-invalid', 'true'); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '' }, + }); + expect(screen.getByRole('textbox')).to.have.attr('aria-invalid', 'false'); + }); + + it('renders correct format helper text and placeholder', () => { + render( + } + />, + ); + + const helperText = document.getElementById('test-helper-text'); + expect(helperText).to.have.text('yyyy'); + + expect(screen.getByRole('textbox')).to.have.attr('placeholder', 'yyyy'); + }); + + it('correctly input dates according to the input mask', () => { + render(); + const input = screen.getByRole('textbox'); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '011' }, + }); + expect(input).to.have.value('01/1'); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '01102019' }, + }); + expect(input).to.have.value('01/10/2019'); + }); + + it('prop `disableMaskedInput` โ€“ disables mask and allows to input anything to the field', () => { + render(); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'any text' }, + }); + + const input = screen.getByRole('textbox'); + expect(input).to.have.value('any text'); + expect(input).to.have.attr('aria-invalid', 'true'); + }); + + it('prop `disableMaskedInput` โ€“ correctly parses date string when mask is disabled', () => { + const onChangeMock = spy(); + render(); + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '01/10/2019' }, + }); + + const input = screen.getByRole('textbox'); + expect(input).to.have.value('01/10/2019'); + expect(input).to.have.attribute('aria-invalid', 'false'); + expect(onChangeMock.callCount).to.equal(1); + }); + }); + + context('Calendar keyboard navigation', () => { + beforeEach(() => { + // Important: Use here in order to avoid async waiting for focus because of packages/material-ui-lab/src/internal/pickers/hooks/useCanAutoFocus.tsx logic + render( + {}} + renderInput={(params) => } + />, + ); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('autofocus selected day on mount', () => { + expect(screen.getByLabelText('Aug 13, 2020')).toHaveFocus(); + }); + + [ + { keyCode: 35, key: 'End', expectFocusedDay: 'Aug 15, 2020' }, + { keyCode: 36, key: 'Home', expectFocusedDay: 'Aug 9, 2020' }, + { keyCode: 37, key: 'ArrowLeft', expectFocusedDay: 'Aug 12, 2020' }, + { keyCode: 38, key: 'ArrowUp', expectFocusedDay: 'Aug 6, 2020' }, + { keyCode: 39, key: 'ArrowRight', expectFocusedDay: 'Aug 14, 2020' }, + { keyCode: 40, key: 'ArrowDown', expectFocusedDay: 'Aug 20, 2020' }, + ].forEach(({ key, keyCode, expectFocusedDay }) => { + it(key, () => { + fireEvent.keyDown(document.body, { force: true, keyCode, key }); + + expect(document.activeElement).toHaveAccessibleName(expectFocusedDay); + }); + }); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip("doesn't allow to select disabled date from keyboard", async () => { + render( + {}} + renderInput={(params) => } + />, + ); + + expect(document.activeElement).to.have.attr('aria-label', 'Aug 13, 2020'); + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < 3; i++) { + fireEvent.keyDown(document.body, { force: true, keyCode: 37, key: 'ArrowLeft' }); + } + + // leaves focus on the same date + expect(document.activeElement).to.have.attr('aria-label', 'Aug 13, 2020'); + }); + + context('YearPicker keyboard navigation', () => { + [ + { keyCode: 37, key: 'ArrowLeft', expectFocusedYear: '2019' }, + { keyCode: 38, key: 'ArrowUp', expectFocusedYear: '2016' }, + { keyCode: 39, key: 'ArrowRight', expectFocusedYear: '2021' }, + { keyCode: 40, key: 'ArrowDown', expectFocusedYear: '2024' }, + ].forEach(({ key, keyCode, expectFocusedYear }) => { + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip(key, () => { + render( + {}} + renderInput={(params) => } + />, + ); + + fireEvent.keyDown(document.body, { force: true, keyCode, key }); + + expect(document.activeElement).to.have.text(expectFocusedYear); + }); + }); + }); + + context('input validaiton', () => { + [ + { expectedError: 'invalidDate', props: {}, input: 'invalidText' }, + { expectedError: 'disablePast', props: { disablePast: true }, input: '01/01/1900' }, + { expectedError: 'disableFuture', props: { disableFuture: true }, input: '01/01/2050' }, + { expectedError: 'minDate', props: { minDate: new Date('01/01/2000') }, input: '01/01/1990' }, + { expectedError: 'maxDate', props: { maxDate: new Date('01/01/2000') }, input: '01/01/2010' }, + { + expectedError: 'shouldDisableDate', + props: { shouldDisableDate: isWeekend }, + input: '04/25/2020', + }, + ].forEach(({ props, input, expectedError }) => { + it(`dispatches ${expectedError} error`, () => { + const onErrorMock = spy(); + // we are running validation on value change + function DatePickerInput() { + const [date, setDate] = React.useState(null); + + return ( + + value={date} + onError={onErrorMock} + onChange={(newDate) => setDate(newDate)} + renderInput={(inputProps) => } + {...props} + /> + ); + } + + render(); + + fireEvent.change(screen.getByRole('textbox'), { + target: { + value: input, + }, + }); + + expect(onErrorMock.calledWith(expectedError)).to.be.equal(true); + }); + }); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('Opens calendar by keydown on the open button', () => { + render(); + const openButton = screen.getByLabelText(/choose date/i); + + act(() => { + openButton.focus(); + }); + + fireEvent.keyDown(openButton, { + key: 'Enter', + keyCode: 13, + }); + + expect(screen.queryByRole('dialog')).toBeVisible(); + }); +}); diff --git a/packages/material-ui-lab/src/DatePicker/DatePickerLocalization.test.tsx b/packages/material-ui-lab/src/DatePicker/DatePickerLocalization.test.tsx new file mode 100644 index 00000000000000..d25b9ee719d347 --- /dev/null +++ b/packages/material-ui-lab/src/DatePicker/DatePickerLocalization.test.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import fr from 'date-fns/locale/fr'; +import deLocale from 'date-fns/locale/de'; +import enLocale from 'date-fns/locale/en-US'; +import TextField from '@material-ui/core/TextField'; +import MobileDatePicker from '@material-ui/lab/MobileDatePicker'; +import DesktopDatePicker, { DesktopDatePickerProps } from '@material-ui/lab/DesktopDatePicker'; +import { fireEvent, screen } from 'test/utils'; +import { adapterToUse, getByMuiTest, createPickerRender } from '../internal/pickers/test-utils'; + +describe(' localization', () => { + const render = createPickerRender({ strict: false, locale: fr }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('datePicker localized format for year view', () => { + render( + } + value={adapterToUse.date('2018-01-01T00:00:00.000')} + onChange={() => {}} + views={['year']} + />, + ); + + expect(screen.getByRole('textbox')).to.have.value('2018'); + + fireEvent.click(screen.getByLabelText(/Choose date/)); + expect(getByMuiTest('datepicker-toolbar-date').textContent).to.equal('2018'); + }); + + it('datePicker localized format for year+month view', () => { + render( + } + value={adapterToUse.date('2018-01-01T00:00:00.000')} + onChange={() => {}} + views={['year', 'month']} + />, + ); + + expect(screen.getByRole('textbox')).to.have.value('janvier 2018'); + + fireEvent.click(screen.getByLabelText(/Choose date/)); + expect(getByMuiTest('datepicker-toolbar-date').textContent).to.equal('janvier'); + }); + + it('datePicker localized format for year+month+date view', () => { + render( + {}} + renderInput={(params) => } + value={adapterToUse.date('2018-01-01T00:00:00.000')} + views={['year', 'month', 'date']} + />, + ); + + expect(screen.getByRole('textbox')).to.have.value('01/01/2018'); + + fireEvent.click(screen.getByLabelText(/Choose date/)); + expect(getByMuiTest('datepicker-toolbar-date').textContent).to.equal('1 janvier'); + }); + + describe('input validation', () => { + interface FormProps { + Picker: React.ElementType; + PickerProps: Partial; + } + + const Form = (props: FormProps) => { + const { Picker, PickerProps } = props; + const [value, setValue] = React.useState(new Date('01/01/2020')); + + return ( + } + value={value} + {...PickerProps} + /> + ); + }; + + const tests = [ + { + locale: 'en-US', + valid: 'January 2020', + invalid: 'Januar 2020', + dateFnsLocale: enLocale, + }, + { + locale: 'de', + valid: 'Januar 2020', + invalid: 'Janua 2020', + dateFnsLocale: deLocale, + }, + ]; + + tests.forEach(({ valid, invalid, locale, dateFnsLocale }) => { + const localizedRender = createPickerRender({ strict: false, locale: dateFnsLocale }); + + it(`${locale}: should set invalid`, () => { + localizedRender( +
, + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: invalid } }); + + expect(input).to.have.attribute('aria-invalid', 'true'); + }); + + it(`${locale}: should set to valid when was invalid`, () => { + localizedRender( + , + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: invalid } }); + fireEvent.change(input, { target: { value: valid } }); + + expect(input).to.have.attribute('aria-invalid', 'false'); + }); + }); + }); +}); diff --git a/packages/material-ui-lab/src/DatePicker/DatePickerToolbar.tsx b/packages/material-ui-lab/src/DatePicker/DatePickerToolbar.tsx new file mode 100644 index 00000000000000..24e1e107f9a960 --- /dev/null +++ b/packages/material-ui-lab/src/DatePicker/DatePickerToolbar.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Typography from '@material-ui/core/Typography'; +import { createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; +import PickerToolbar from '../internal/pickers/PickersToolbar'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import { isYearAndMonthViews, isYearOnlyView } from '../internal/pickers/date-utils'; +import { DatePickerView } from '../internal/pickers/typings/Views'; +import { ToolbarComponentProps } from '../internal/pickers/typings/BasePicker'; + +export const styles = createStyles({ + root: {}, + dateTitleLandscape: { + margin: 'auto 16px auto auto', + }, + penIcon: { + position: 'relative', + top: 4, + }, +}); +export type DatePickerToolbarClassKey = keyof WithStyles['classes']; + +/** + * @ignore - internal component. + */ +const DatePickerToolbar: React.FC> = ({ + classes, + date, + isLandscape, + isMobileKeyboardViewOpen, + onChange, + toggleMobileKeyboardView, + toolbarFormat, + toolbarPlaceholder = 'โ€“โ€“', + toolbarTitle = 'SELECT DATE', + views, + ...other +}) => { + const utils = useUtils(); + + const dateText = React.useMemo(() => { + if (!date) { + return toolbarPlaceholder; + } + + if (toolbarFormat) { + return utils.formatByString(date, toolbarFormat); + } + + if (isYearOnlyView(views as DatePickerView[])) { + return utils.format(date, 'year'); + } + + if (isYearAndMonthViews(views as DatePickerView[])) { + return utils.format(date, 'month'); + } + + // Little localization hack (Google is doing the same for android native pickers): + // For english localization it is convenient to include weekday into the date "Mon, Jun 1" + // For other locales using strings like "June 1", without weekday + return /en/.test(utils.getCurrentLocaleCode()) + ? utils.format(date, 'normalDateWithWeekday') + : utils.format(date, 'normalDate'); + }, [date, toolbarFormat, toolbarPlaceholder, utils, views]); + + return ( + + + {dateText} + + + ); +}; + +export default withStyles(styles, { name: 'MuiDatePickerToolbar' })(DatePickerToolbar); diff --git a/packages/material-ui-lab/src/DatePicker/index.ts b/packages/material-ui-lab/src/DatePicker/index.ts new file mode 100644 index 00000000000000..a4c99812fd6629 --- /dev/null +++ b/packages/material-ui-lab/src/DatePicker/index.ts @@ -0,0 +1,2 @@ +export { default } from './DatePicker'; +export * from './DatePicker'; diff --git a/packages/material-ui-lab/src/DateRangeDelimiter/DateRangeDelimiter.tsx b/packages/material-ui-lab/src/DateRangeDelimiter/DateRangeDelimiter.tsx new file mode 100644 index 00000000000000..cd3956b497b55c --- /dev/null +++ b/packages/material-ui-lab/src/DateRangeDelimiter/DateRangeDelimiter.tsx @@ -0,0 +1,17 @@ +import Typography from '@material-ui/core/Typography'; +import { styled } from '@material-ui/core/styles'; + +const DateRangeDelimiter = styled(Typography)( + { + margin: '0 16px', + }, + { name: 'MuiPickersDateRangeDelimiter' }, +); + +export type DateRangeDelimiterProps = React.ComponentProps; + +/** + * TODO use Box + * @ignore - internal component. + */ +export default DateRangeDelimiter; diff --git a/packages/material-ui-lab/src/DateRangeDelimiter/index.ts b/packages/material-ui-lab/src/DateRangeDelimiter/index.ts new file mode 100644 index 00000000000000..0b8fd34b98def9 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangeDelimiter/index.ts @@ -0,0 +1,2 @@ +export * from './DateRangeDelimiter'; +export { default } from './DateRangeDelimiter'; diff --git a/packages/material-ui-lab/src/DateRangePicker/DateRangePicker.test.tsx b/packages/material-ui-lab/src/DateRangePicker/DateRangePicker.test.tsx new file mode 100644 index 00000000000000..3ed98b98f266b7 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/DateRangePicker.test.tsx @@ -0,0 +1,289 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { isWeekend } from 'date-fns'; +import { screen, fireEvent } from 'test/utils'; +import TextField, { TextFieldProps } from '@material-ui/core/TextField'; +import DesktopDateRangePicker, { DateRange } from '@material-ui/lab/DesktopDateRangePicker'; +import StaticDateRangePicker from '@material-ui/lab/StaticDateRangePicker'; +import { + createPickerRender, + FakeTransitionComponent, + adapterToUse, + getAllByMuiTest, + queryByMuiTest, +} from '../internal/pickers/test-utils'; + +const defaultRangeRenderInput = (startProps: TextFieldProps, endProps: TextFieldProps) => ( + + + + +); + +describe('', () => { + const render = createPickerRender({ strict: false }); + + before(function beforeHook() { + if (!/jsdom/.test(window.navigator.userAgent)) { + // FIXME This test suite is extremely flaky in test:karma + this.skip(); + } + }); + + it('allows to select date range end-to-end', () => { + function RangePickerTest() { + const [range, changeRange] = React.useState>([ + new Date('2019-01-01T00:00:00.000'), + new Date('2019-01-01T00:00:00.000'), + ]); + + return ( + changeRange(date)} + value={range} + TransitionComponent={FakeTransitionComponent} + /> + ); + } + + render(); + + fireEvent.click(screen.getByLabelText('Jan 1, 2019')); + fireEvent.click(screen.getByLabelText('Jan 24, 2019')); + + expect(getAllByMuiTest('DateRangeHighlight')).to.have.length(24); + }); + + it('highlights the selected range of dates', () => { + render( + {}} + value={[ + adapterToUse.date(new Date('2018-01-01T00:00:00.000Z')), + adapterToUse.date(new Date('2018-01-31T00:00:00.000Z')), + ]} + />, + ); + + expect(getAllByMuiTest('DateRangeHighlight')).to.have.length(31); + }); + + it('selects the range from the next month', () => { + const onChangeMock = spy(); + render( + , + ); + + fireEvent.click(screen.getByLabelText('Jan 1, 2019')); + fireEvent.click( + screen.getByLabelText('next month', { selector: ':not([aria-hidden="true"])' }), + ); + fireEvent.click(screen.getByLabelText('Mar 19, 2019')); + + expect(onChangeMock.callCount).to.equal(2); + const [changedRange] = onChangeMock.lastCall.args; + expect(changedRange[0]).to.toEqualDateTime(new Date('2019-01-01T00:00:00.000')); + expect(changedRange[1]).to.toEqualDateTime(new Date('2019-03-19T00:00:00.000')); + }); + + it('continues start selection if selected "end" date is before start', () => { + const onChangeMock = spy(); + render( + , + ); + + fireEvent.click(screen.getByLabelText('Jan 30, 2019')); + fireEvent.click(screen.getByLabelText('Jan 19, 2019')); + + expect(queryByMuiTest(document.body, 'DateRangeHighlight')).to.equal(null); + + fireEvent.click(screen.getByLabelText('Jan 30, 2019')); + + expect(onChangeMock.callCount).to.equal(3); + const [changedRange] = onChangeMock.lastCall.args; + expect(changedRange[0]).to.toEqualDateTime(new Date('2019-01-19T00:00:00.000')); + expect(changedRange[1]).to.toEqualDateTime(new Date('2019-01-30T00:00:00.000')); + }); + + it('starts selection from end if end text field was focused', function test() { + const onChangeMock = spy(); + render( + , + ); + + fireEvent.focus(screen.getAllByRole('textbox')[1]); + + fireEvent.click(screen.getByLabelText('Jan 30, 2019')); + fireEvent.click(screen.getByLabelText('Jan 19, 2019')); + + expect(getAllByMuiTest('DateRangeHighlight')).to.have.length(12); + expect(onChangeMock.callCount).to.equal(2); + const [changedRange] = onChangeMock.lastCall.args; + expect(changedRange[0]).toEqualDateTime(new Date('2019-01-19T00:00:00.000')); + expect(changedRange[1]).toEqualDateTime(new Date('2019-01-30T00:00:00.000')); + }); + + it('closes on focus out of fields', () => { + render( + + {}} + TransitionComponent={FakeTransitionComponent} + /> + + , + ); + + fireEvent.focus(screen.getAllByRole('textbox')[0]); + expect(screen.getByRole('tooltip')).toBeVisible(); + + fireEvent.focus(screen.getByText('focus me')); + expect(screen.getByRole('tooltip')).not.toBeVisible(); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('allows pure keyboard selection of range', () => { + const onChangeMock = spy(); + render( + , + ); + + fireEvent.focus(screen.getAllByRole('textbox')[0]); + fireEvent.change(screen.getAllByRole('textbox')[0], { + target: { + value: '06/06/2019', + }, + }); + + fireEvent.change(screen.getAllByRole('textbox')[1], { + target: { + value: '08/08/2019', + }, + }); + + expect( + onChangeMock.calledWith([ + new Date('2019-06-06T00:00:00.000'), + new Date('2019-06-06T00:00:00.000'), + ]), + ).to.equal(true); + }); + + it('scrolls current month to the active selection on focusing appropriate field', () => { + render( + {}} + TransitionComponent={FakeTransitionComponent} + />, + ); + + fireEvent.focus(screen.getAllByRole('textbox')[0]); + expect(screen.getByText('May 2019')).toBeVisible(); + + fireEvent.focus(screen.getAllByRole('textbox')[1]); + expect(screen.getByText('October 2019')).toBeVisible(); + + // scroll back + fireEvent.focus(screen.getAllByRole('textbox')[0]); + expect(screen.getByText('May 2019')).toBeVisible(); + }); + + it('allows disabling dates', () => { + render( + {}} + value={[ + adapterToUse.date('2018-01-01T00:00:00.000'), + adapterToUse.date('2018-01-31T00:00:00.000'), + ]} + />, + ); + + expect( + getAllByMuiTest('DateRangeDay').filter((day) => day.getAttribute('disabled') !== undefined), + ).to.have.length(31); + }); + + it(`doesn't crash if opening picker with invalid date input`, async () => { + render( + {}} + TransitionComponent={FakeTransitionComponent} + value={[adapterToUse.date(new Date(NaN)), adapterToUse.date('2018-01-31T00:00:00.000')]} + />, + ); + + fireEvent.focus(screen.getAllByRole('textbox')[0]); + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + + it('prop โ€“ `renderDay` should be called and render days', async () => { + render( + {}} + renderDay={(day) =>
} + value={[null, null]} + />, + ); + + expect(getAllByMuiTest('renderDayCalled')).not.to.have.length(0); + }); + + it('prop โ€“ `calendars` renders provided amount of calendars', () => { + render( + {}} + value={[ + adapterToUse.date('2018-01-01T00:00:00.000'), + adapterToUse.date('2018-01-31T00:00:00.000'), + ]} + />, + ); + + expect(getAllByMuiTest('pickers-calendar')).to.have.length(3); + }); +}); diff --git a/packages/material-ui-lab/src/DateRangePicker/DateRangePicker.tsx b/packages/material-ui-lab/src/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 00000000000000..394bcf506b9550 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/DateRangePicker.tsx @@ -0,0 +1,372 @@ +import PropTypes from 'prop-types'; +import { ResponsiveTooltipWrapper } from '../internal/pickers/wrappers/ResponsiveWrapper'; +import { makeDateRangePicker } from './makeDateRangePicker'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const DateRangePicker = makeDateRangePicker('MuiPickersDateRangePicker', ResponsiveTooltipWrapper); + +(DateRangePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * The number of calendars that render on **desktop**. + * @default 2 + */ + calendars: PropTypes.oneOf([1, 2, 3]), + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText: PropTypes.node, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable: PropTypes.bool, + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText: PropTypes.node, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * CSS media query when `Mobile` mode will be changed to `Desktop`. + * @default "@media (pointer: fine)" + * @example "@media (min-width: 720px)" or theme.breakpoints.up("sm") + */ + desktopModeMediaQuery: PropTypes.string, + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps: PropTypes.object, + /** + * if `true` after selecting `start` date calendar will not automatically switch to the month of `end` date + * @default false + */ + disableAutoMonthSwitching: PropTypes.bool, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * Text for end input label and toolbar placeholder. + * @default "end" + */ + endText: PropTypes.node, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * Max selectable date. @DateIOType + * @default Date(2099-31-12) + */ + maxDate: PropTypes.any, + /** + * Min selectable date. @DateIOType + * @default Date(1900-01-01) + */ + minDate: PropTypes.any, + /** + * "OK" button text. + * @default "OK" + */ + okText: PropTypes.node, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Popper props passed down to [Popper](https://material-ui.com/api/popper/) component. + */ + PopperProps: PropTypes.object, + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Custom renderer for `` days. @DateIOType + * @example (date, DateRangeDayProps) => + */ + renderDay: PropTypes.func, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `startProps` and `endProps` arguments of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api), + * that you need to forward to the range start/end inputs respectively. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example + * ```jsx + * ( + * + * + * to + * + * ; + * )} + * /> + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Text for start input label and toolbar placeholder. + * @default "Start" + */ + startText: PropTypes.node, + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * Custom component for popper [Transition](https://material-ui.com/components/transitions/#transitioncomponent-prop). + */ + TransitionComponent: PropTypes.elementType, + /** + * The value of the picker. + */ + value: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + ).isRequired, +}; + +export type DateRangePickerProps = React.ComponentProps; + +export type DateRange = import('./RangeTypes').DateRange; + +export default DateRangePicker; diff --git a/packages/material-ui-lab/src/DateRangePicker/DateRangePickerInput.tsx b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerInput.tsx new file mode 100644 index 00000000000000..ee8b2469c9ab3b --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerInput.tsx @@ -0,0 +1,186 @@ +import * as React from 'react'; +import { withStyles, WithStyles, createStyles, Theme } from '@material-ui/core/styles'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import { RangeInput, DateRange, CurrentlySelectingRangeEndProps } from './RangeTypes'; +import { useMaskedInput } from '../internal/pickers/hooks/useMaskedInput'; +import { DateRangeValidationError } from '../internal/pickers/date-utils'; +import { WrapperVariantContext } from '../internal/pickers/wrappers/WrapperVariantContext'; +import { mergeRefs, executeInTheNextEventLoopTick } from '../internal/pickers/utils'; +import { DateInputProps, MuiTextFieldProps } from '../internal/pickers/PureDateInput'; + +export const styles = (theme: Theme) => + createStyles({ + root: { + display: 'flex', + alignItems: 'baseline', + [theme.breakpoints.down('xs')]: { + flexDirection: 'column', + alignItems: 'center', + }, + }, + toLabelDelimiter: { + margin: '8px 0', + [theme.breakpoints.up('sm')]: { + margin: '0 16px', + }, + }, + }); + +export interface ExportedDateRangePickerInputProps { + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `startProps` and `endProps` arguments of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api), + * that you need to forward to the range start/end inputs respectively. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example + * ```jsx + * ( + * + * + * to + * + * ; + * )} + * /> + * ```` + */ + renderInput: (startProps: MuiTextFieldProps, endProps: MuiTextFieldProps) => React.ReactElement; +} + +export interface DateRangeInputProps + extends ExportedDateRangePickerInputProps, + CurrentlySelectingRangeEndProps, + Omit< + DateInputProps, DateRange>, + 'validationError' | 'renderInput' | 'forwardedRef' + > { + startText: React.ReactNode; + endText: React.ReactNode; + forwardedRef?: React.Ref; + containerRef?: React.Ref; + validationError: DateRangeValidationError; +} + +/** + * @ignore - internal component. + */ +const DateRangePickerInput: React.FC> = ({ + classes, + containerRef, + currentlySelectingRangeEnd, + disableOpenPicker, + endText, + forwardedRef, + onBlur, + onChange, + open, + openPicker, + rawValue, + rawValue: [start, end], + readOnly, + renderInput, + setCurrentlySelectingRangeEnd, + startText, + TextFieldProps, + validationError: [startValidationError, endValidationError], + ...other +}) => { + const utils = useUtils(); + const startRef = React.useRef(null); + const endRef = React.useRef(null); + const wrapperVariant = React.useContext(WrapperVariantContext); + + React.useEffect(() => { + if (!open) { + return; + } + + if (currentlySelectingRangeEnd === 'start') { + startRef.current?.focus(); + } else if (currentlySelectingRangeEnd === 'end') { + endRef.current?.focus(); + } + }, [currentlySelectingRangeEnd, open]); + + // TODO: rethink this approach. We do not need to wait for calendar to be updated to rerender input (looks like freezing) + // TODO: so simply break 1 react's commit phase in 2 (first for input and second for calendars) by executing onChange in the next tick + const lazyHandleChangeCallback = React.useCallback( + (...args: Parameters) => + executeInTheNextEventLoopTick(() => onChange(...args)), + [onChange], + ); + + const handleStartChange = (date: unknown, inputString?: string) => { + lazyHandleChangeCallback([date, utils.date(end)], inputString); + }; + + const handleEndChange = (date: unknown, inputString?: string) => { + lazyHandleChangeCallback([utils.date(start), date], inputString); + }; + + const openRangeStartSelection = () => { + if (setCurrentlySelectingRangeEnd) { + setCurrentlySelectingRangeEnd('start'); + } + if (!disableOpenPicker) { + openPicker(); + } + }; + + const openRangeEndSelection = () => { + if (setCurrentlySelectingRangeEnd) { + setCurrentlySelectingRangeEnd('end'); + } + if (!disableOpenPicker) { + openPicker(); + } + }; + + const openOnFocus = wrapperVariant === 'desktop'; + const startInputProps = useMaskedInput({ + ...other, + readOnly, + rawValue: start, + onChange: handleStartChange, + label: startText, + validationError: startValidationError !== null, + TextFieldProps: { + ...TextFieldProps, + ref: startRef, + variant: 'outlined', + focused: open && currentlySelectingRangeEnd === 'start', + }, + inputProps: { + onClick: !openOnFocus ? openRangeStartSelection : undefined, + onFocus: openOnFocus ? openRangeStartSelection : undefined, + }, + }); + + const endInputProps = useMaskedInput({ + ...other, + readOnly, + label: endText, + rawValue: end, + onChange: handleEndChange, + validationError: endValidationError !== null, + TextFieldProps: { + ...TextFieldProps, + ref: endRef, + variant: 'outlined', + focused: open && currentlySelectingRangeEnd === 'end', + }, + inputProps: { + onClick: !openOnFocus ? openRangeEndSelection : undefined, + onFocus: openOnFocus ? openRangeEndSelection : undefined, + }, + }); + + return ( +
+ {renderInput(startInputProps, endInputProps)} +
+ ); +}; + +export default withStyles(styles, { name: 'MuiPickersDateRangePickerInput' })(DateRangePickerInput); diff --git a/packages/material-ui-lab/src/DateRangePicker/DateRangePickerToolbar.tsx b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerToolbar.tsx new file mode 100644 index 00000000000000..d6a818dba61378 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerToolbar.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import Typography from '@material-ui/core/Typography'; +import { withStyles, createStyles, WithStyles } from '@material-ui/core/styles'; +import PickersToolbar from '../internal/pickers/PickersToolbar'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import PickersToolbarButton from '../internal/pickers/PickersToolbarButton'; +import { ToolbarComponentProps } from '../internal/pickers/typings/BasePicker'; +import { DateRange, CurrentlySelectingRangeEndProps } from './RangeTypes'; + +export const styles = createStyles({ + root: {}, + penIcon: { + position: 'relative', + top: 4, + }, + dateTextContainer: { + display: 'flex', + }, +}); + +interface DateRangePickerToolbarProps + extends CurrentlySelectingRangeEndProps, + Pick< + ToolbarComponentProps, + 'isMobileKeyboardViewOpen' | 'toggleMobileKeyboardView' | 'toolbarTitle' | 'toolbarFormat' + > { + date: DateRange; + startText: React.ReactNode; + endText: React.ReactNode; + currentlySelectingRangeEnd: 'start' | 'end'; + setCurrentlySelectingRangeEnd: (newSelectingEnd: 'start' | 'end') => void; +} + +/** + * @ignore - internal component. + */ +const DateRangePickerToolbar: React.FC> = ({ + classes, + currentlySelectingRangeEnd, + date: [start, end], + endText, + isMobileKeyboardViewOpen, + setCurrentlySelectingRangeEnd, + startText, + toggleMobileKeyboardView, + toolbarFormat, + toolbarTitle = 'SELECT DATE RANGE', +}) => { + const utils = useUtils(); + + const startDateValue = start + ? utils.formatByString(start, toolbarFormat || utils.formats.shortDate) + : startText; + + const endDateValue = end + ? utils.formatByString(end, toolbarFormat || utils.formats.shortDate) + : endText; + + return ( + +
+ setCurrentlySelectingRangeEnd('start')} + /> +  {'โ€“'}  + setCurrentlySelectingRangeEnd('end')} + /> +
+
+ ); +}; + +export default withStyles(styles, { name: 'MuiPickersDateRangePickerToolbarProps' })( + DateRangePickerToolbar, +); diff --git a/packages/material-ui-lab/src/DateRangePicker/DateRangePickerView.tsx b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerView.tsx new file mode 100644 index 00000000000000..2a73bedbee0c3a --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerView.tsx @@ -0,0 +1,231 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { isRangeValid } from '../internal/pickers/date-utils'; +import { BasePickerProps } from '../internal/pickers/typings/BasePicker'; +import { calculateRangeChange } from './date-range-manager'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import { SharedPickerProps } from '../internal/pickers/Picker/SharedPickerProps'; +import DateRangePickerToolbar from './DateRangePickerToolbar'; +import { useCalendarState } from '../DayPicker/useCalendarState'; +import { DateRangePickerViewMobile } from './DateRangePickerViewMobile'; +import { WrapperVariantContext } from '../internal/pickers/wrappers/WrapperVariantContext'; +import { MobileKeyboardInputView } from '../internal/pickers/Picker/Picker'; +import DateRangePickerInput, { DateRangeInputProps } from './DateRangePickerInput'; +import { RangeInput, DateRange, CurrentlySelectingRangeEndProps } from './RangeTypes'; +import { ExportedDayPickerProps, defaultReduceAnimations } from '../DayPicker/DayPicker'; +import DateRangePickerViewDesktop, { + ExportedDesktopDateRangeCalendarProps, +} from './DateRangePickerViewDesktop'; + +type BaseCalendarPropsToReuse = Omit< + ExportedDayPickerProps, + 'onYearChange' | 'renderDay' +>; + +export interface ExportedDateRangePickerViewProps + extends BaseCalendarPropsToReuse, + ExportedDesktopDateRangeCalendarProps, + Omit { + /** + * if `true` after selecting `start` date calendar will not automatically switch to the month of `end` date + * @default false + */ + disableAutoMonthSwitching?: boolean; +} + +interface DateRangePickerViewProps + extends CurrentlySelectingRangeEndProps, + ExportedDateRangePickerViewProps, + SharedPickerProps, DateRange, DateRangeInputProps> { + open: boolean; + startText: React.ReactNode; + endText: React.ReactNode; +} + +/** + * @ignore - internal component. + */ +export function DateRangePickerView(props: DateRangePickerViewProps) { + const { + calendars = 2, + className, + currentlySelectingRangeEnd, + date, + DateInputProps, + defaultCalendarMonth, + disableAutoMonthSwitching = false, + disableFuture, + disableHighlightToday, + disablePast, + endText, + isMobileKeyboardViewOpen, + maxDate, + minDate, + onDateChange, + onMonthChange, + open, + reduceAnimations = defaultReduceAnimations, + setCurrentlySelectingRangeEnd, + shouldDisableDate, + showToolbar, + startText, + toggleMobileKeyboardView, + toolbarFormat, + toolbarTitle, + ...other + } = props; + + const utils = useUtils(); + const wrapperVariant = React.useContext(WrapperVariantContext); + + const [start, end] = date; + const { + changeMonth, + calendarState, + isDateDisabled, + onMonthSwitchingAnimationEnd, + changeFocusedDay, + } = useCalendarState({ + date: start || end, + defaultCalendarMonth, + disableFuture, + disablePast, + disableSwitchToMonthOnDayFocus: true, + maxDate, + minDate, + onMonthChange, + reduceAnimations, + shouldDisableDate, + }); + + const toShowToolbar = showToolbar ?? wrapperVariant !== 'desktop'; + + const scrollToDayIfNeeded = (day: TDate | null) => { + if (!day || !utils.isValid(day) || isDateDisabled(day)) { + return; + } + + const currentlySelectedDate = currentlySelectingRangeEnd === 'start' ? start : end; + if (currentlySelectedDate === null) { + // do not scroll if one of ages is not selected + return; + } + + const displayingMonthRange = wrapperVariant === 'mobile' ? 0 : calendars - 1; + const currentMonthNumber = utils.getMonth(calendarState.currentMonth); + const requestedMonthNumber = utils.getMonth(day); + + if ( + !utils.isSameYear(calendarState.currentMonth, day) || + requestedMonthNumber < currentMonthNumber || + requestedMonthNumber > currentMonthNumber + displayingMonthRange + ) { + const newMonth = + currentlySelectingRangeEnd === 'start' + ? currentlySelectedDate + : // If need to focus end, scroll to the state when "end" is displaying in the last calendar + utils.addMonths(currentlySelectedDate, -displayingMonthRange); + + changeMonth(newMonth); + } + }; + + React.useEffect(() => { + if (disableAutoMonthSwitching || !open) { + return; + } + + scrollToDayIfNeeded(currentlySelectingRangeEnd === 'start' ? start : end); + }, [currentlySelectingRangeEnd, date]); // eslint-disable-line + + const handleChange = React.useCallback( + (newDate: TDate | null) => { + const { nextSelection, newRange } = calculateRangeChange({ + newDate, + utils, + range: date, + currentlySelectingRangeEnd, + }); + + setCurrentlySelectingRangeEnd(nextSelection); + + const isFullRangeSelected = + currentlySelectingRangeEnd === 'end' && isRangeValid(utils, newRange); + + onDateChange( + newRange as DateRange, + wrapperVariant, + isFullRangeSelected ? 'finish' : 'partial', + ); + }, + [ + currentlySelectingRangeEnd, + date, + onDateChange, + setCurrentlySelectingRangeEnd, + utils, + wrapperVariant, + ], + ); + + const renderView = () => { + const sharedCalendarProps = { + date, + isDateDisabled, + changeFocusedDay, + onChange: handleChange, + reduceAnimations, + disableHighlightToday, + onMonthSwitchingAnimationEnd, + changeMonth, + currentlySelectingRangeEnd, + disableFuture, + disablePast, + minDate, + maxDate, + ...calendarState, + ...other, + }; + + switch (wrapperVariant) { + case 'desktop': { + return ; + } + + default: { + return ; + } + } + }; + + return ( +
+ {toShowToolbar && ( + + )} + + {isMobileKeyboardViewOpen ? ( + + + + ) : ( + renderView() + )} +
+ ); +} + +DateRangePickerView.propTypes = { + calendars: PropTypes.oneOf([1, 2, 3]), + disableAutoMonthSwitching: PropTypes.bool, +}; diff --git a/packages/material-ui-lab/src/DateRangePicker/DateRangePickerViewDesktop.tsx b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerViewDesktop.tsx new file mode 100644 index 00000000000000..62d31fcd5887ac --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerViewDesktop.tsx @@ -0,0 +1,215 @@ +import * as React from 'react'; +import { withStyles, WithStyles, createStyles, Theme } from '@material-ui/core/styles'; +import { DateRange } from './RangeTypes'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import { calculateRangePreview } from './date-range-manager'; +import PickersCalendar, { PickersCalendarProps } from '../DayPicker/PickersCalendar'; +import DateRangeDay, { DateRangePickerDayProps } from '../DateRangePickerDay'; +import { defaultMinDate, defaultMaxDate } from '../internal/pickers/constants/prop-types'; +import ArrowSwitcher, { + ExportedArrowSwitcherProps, +} from '../internal/pickers/PickersArrowSwitcher'; +import { + usePreviousMonthDisabled, + useNextMonthDisabled, +} from '../internal/pickers/hooks/date-helpers-hooks'; +import { + isWithinRange, + isStartOfRange, + isEndOfRange, + DateValidationProps, +} from '../internal/pickers/date-utils'; +import { doNothing } from '../internal/pickers/utils'; + +export interface ExportedDesktopDateRangeCalendarProps { + /** + * The number of calendars that render on **desktop**. + * @default 2 + */ + calendars?: 1 | 2 | 3; + /** + * Custom renderer for `` days. @DateIOType + * @example (date, DateRangeDayProps) => + */ + renderDay?: (date: TDate, DateRangeDayProps: DateRangePickerDayProps) => JSX.Element; +} + +interface DesktopDateRangeCalendarProps + extends ExportedDesktopDateRangeCalendarProps, + Omit, 'renderDay' | 'onFocusedDayChange'>, + DateValidationProps, + ExportedArrowSwitcherProps { + date: DateRange; + changeMonth: (date: TDate) => void; + currentlySelectingRangeEnd: 'start' | 'end'; +} + +export const styles = (theme: Theme) => + createStyles({ + root: { + display: 'flex', + flexDirection: 'row', + }, + rangeCalendarContainer: { + '&:not(:last-child)': { + borderRight: `2px solid ${theme.palette.divider}`, + }, + }, + calendar: { + minWidth: 312, + minHeight: 288, + }, + arrowSwitcher: { + padding: '16px 16px 8px 16px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + }); + +function getCalendarsArray(calendars: ExportedDesktopDateRangeCalendarProps['calendars']) { + switch (calendars) { + case 1: + return [0]; + case 2: + return [0, 0]; + case 3: + return [0, 0, 0]; + // this will not work in IE11, but allows to support any amount of calendars + default: + return new Array(calendars).fill(0); + } +} + +/** + * @ignore - internal component. + */ +function DateRangePickerViewDesktop( + props: DesktopDateRangeCalendarProps & WithStyles, +) { + const { + date, + classes, + calendars = 2, + changeMonth, + leftArrowButtonProps, + leftArrowButtonText = 'previous month', + leftArrowIcon, + rightArrowButtonProps, + rightArrowButtonText = 'next month', + rightArrowIcon, + onChange, + disableFuture, + disablePast, + // eslint-disable-next-line @typescript-eslint/naming-convention + minDate: __minDate, + // eslint-disable-next-line @typescript-eslint/naming-convention + maxDate: __maxDate, + currentlySelectingRangeEnd, + currentMonth, + renderDay = (_, dateRangeProps) => , + ...other + } = props; + + const utils = useUtils(); + const minDate = __minDate || utils.date(defaultMinDate); + const maxDate = __maxDate || utils.date(defaultMaxDate); + + const [rangePreviewDay, setRangePreviewDay] = React.useState(null); + + const isNextMonthDisabled = useNextMonthDisabled(currentMonth, { disableFuture, maxDate }); + const isPreviousMonthDisabled = usePreviousMonthDisabled(currentMonth, { disablePast, minDate }); + + const previewingRange = calculateRangePreview({ + utils, + range: date, + newDate: rangePreviewDay, + currentlySelectingRangeEnd, + }); + + const handleDayChange = React.useCallback( + (day: TDate | null) => { + setRangePreviewDay(null); + onChange(day); + }, + [onChange], + ); + + const handlePreviewDayChange = (newPreviewRequest: TDate) => { + if (!isWithinRange(utils, newPreviewRequest, date)) { + setRangePreviewDay(newPreviewRequest); + } else { + setRangePreviewDay(null); + } + }; + + const CalendarTransitionProps = React.useMemo( + () => ({ + onMouseLeave: () => setRangePreviewDay(null), + }), + [], + ); + + const selectNextMonth = React.useCallback(() => { + changeMonth(utils.getNextMonth(currentMonth)); + }, [changeMonth, currentMonth, utils]); + + const selectPreviousMonth = React.useCallback(() => { + changeMonth(utils.getPreviousMonth(currentMonth)); + }, [changeMonth, currentMonth, utils]); + + return ( +
+ {getCalendarsArray(calendars).map((_, index) => { + const monthOnIteration = utils.setMonth(currentMonth, utils.getMonth(currentMonth) + index); + + return ( +
+ + + {...other} + key={index} + date={date} + onFocusedDayChange={doNothing} + className={classes.calendar} + onChange={handleDayChange} + currentMonth={monthOnIteration} + TransitionProps={CalendarTransitionProps} + renderDay={(day, __, DayProps) => + renderDay(day, { + isPreviewing: isWithinRange(utils, day, previewingRange), + isStartOfPreviewing: isStartOfRange(utils, day, previewingRange), + isEndOfPreviewing: isEndOfRange(utils, day, previewingRange), + isHighlighting: isWithinRange(utils, day, date), + isStartOfHighlighting: isStartOfRange(utils, day, date), + isEndOfHighlighting: isEndOfRange(utils, day, date), + onMouseEnter: () => handlePreviewDayChange(day), + ...DayProps, + }) + } + /> +
+ ); + })} +
+ ); +} + +export default withStyles(styles, { name: 'MuiPickersDesktopDateRangeCalendar' })( + DateRangePickerViewDesktop, +) as (props: DesktopDateRangeCalendarProps) => JSX.Element; diff --git a/packages/material-ui-lab/src/DateRangePicker/DateRangePickerViewMobile.tsx b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerViewMobile.tsx new file mode 100644 index 00000000000000..7badee5233275a --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/DateRangePickerViewMobile.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import PickersCalendarHeader, { + ExportedCalendarHeaderProps, +} from '../DayPicker/PickersCalendarHeader'; +import { DateRange } from './RangeTypes'; +import DateRangeDay from '../DateRangePickerDay/DateRangePickerDay'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import PickersCalendar, { PickersCalendarProps } from '../DayPicker/PickersCalendar'; +import { defaultMinDate, defaultMaxDate } from '../internal/pickers/constants/prop-types'; +import { ExportedDesktopDateRangeCalendarProps } from './DateRangePickerViewDesktop'; +import { + isWithinRange, + isStartOfRange, + isEndOfRange, + DateValidationProps, +} from '../internal/pickers/date-utils'; +import { doNothing } from '../internal/pickers/utils'; + +export interface ExportedMobileDateRangeCalendarProps + extends Pick, 'renderDay'> {} + +interface DesktopDateRangeCalendarProps + extends ExportedMobileDateRangeCalendarProps, + Omit, 'date' | 'renderDay' | 'onFocusedDayChange'>, + DateValidationProps, + ExportedCalendarHeaderProps { + date: DateRange; + changeMonth: (date: TDate) => void; +} + +const onlyDateView = ['date'] as ['date']; + +/** + * @ignore - internal component. + */ +export function DateRangePickerViewMobile(props: DesktopDateRangeCalendarProps) { + const { + changeMonth, + date, + leftArrowButtonProps, + leftArrowButtonText, + leftArrowIcon, + // eslint-disable-next-line @typescript-eslint/naming-convention + minDate: __minDate, + // eslint-disable-next-line @typescript-eslint/naming-convention + maxDate: __maxDate, + onChange, + rightArrowButtonProps, + rightArrowButtonText, + rightArrowIcon, + renderDay = (_, dayProps) => {...dayProps} />, + ...other + } = props; + + const utils = useUtils(); + const minDate = __minDate || utils.date(defaultMinDate); + const maxDate = __maxDate || utils.date(defaultMaxDate); + + return ( + + + + {...other} + date={date} + onChange={onChange} + onFocusedDayChange={doNothing} + renderDay={(day, _, DayProps) => + renderDay(day, { + isPreviewing: false, + isStartOfPreviewing: false, + isEndOfPreviewing: false, + isHighlighting: isWithinRange(utils, day, date), + isStartOfHighlighting: isStartOfRange(utils, day, date), + isEndOfHighlighting: isEndOfRange(utils, day, date), + ...DayProps, + }) + } + /> + + ); +} diff --git a/packages/material-ui-lab/src/DateRangePicker/RangeTypes.ts b/packages/material-ui-lab/src/DateRangePicker/RangeTypes.ts new file mode 100644 index 00000000000000..f2fdcefe4029a7 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/RangeTypes.ts @@ -0,0 +1,17 @@ +import { ParsableDate } from '../internal/pickers/constants/prop-types'; +import { AllSharedPickerProps } from '../internal/pickers/Picker/SharedPickerProps'; + +export type RangeInput = [ParsableDate, ParsableDate]; +export type DateRange = [TDate | null, TDate | null]; +export type NonEmptyDateRange = [TDate, TDate]; + +export type AllSharedDateRangePickerProps = Omit< + AllSharedPickerProps, DateRange>, + 'renderInput' | 'orientation' +> & + import('./DateRangePickerInput').ExportedDateRangePickerInputProps; + +export interface CurrentlySelectingRangeEndProps { + currentlySelectingRangeEnd: 'start' | 'end'; + setCurrentlySelectingRangeEnd: (newSelectingEnd: 'start' | 'end') => void; +} diff --git a/packages/material-ui-lab/src/DateRangePicker/date-range-manager.test.ts b/packages/material-ui-lab/src/DateRangePicker/date-range-manager.test.ts new file mode 100644 index 00000000000000..03381be898c7af --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/date-range-manager.test.ts @@ -0,0 +1,152 @@ +import { expect } from 'chai'; +import { calculateRangeChange, calculateRangePreview } from './date-range-manager'; +import { adapterToUse } from '../internal/pickers/test-utils'; +import { DateRange } from './RangeTypes'; + +const start2018 = new Date('2018-01-01T00:00:00.000Z'); +const mid2018 = new Date('2018-06-01T00:00:00.000Z'); +const end2019 = new Date('2019-01-01T00:00:00.000Z'); + +describe('date-range-manager', () => { + [ + { + range: [null, null], + selectingEnd: 'start' as const, + newDate: start2018, + expectedRange: [start2018, null], + expectedNextSelection: 'end' as const, + }, + { + range: [start2018, null], + selectingEnd: 'start' as const, + newDate: end2019, + expectedRange: [end2019, null], + expectedNextSelection: 'end' as const, + }, + { + range: [null, end2019], + selectingEnd: 'start' as const, + newDate: mid2018, + expectedRange: [mid2018, end2019], + expectedNextSelection: 'end' as const, + }, + { + range: [null, end2019], + selectingEnd: 'end' as const, + newDate: mid2018, + expectedRange: [null, mid2018], + expectedNextSelection: 'start' as const, + }, + { + range: [mid2018, null], + selectingEnd: 'start' as const, + newDate: start2018, + expectedRange: [start2018, null], + expectedNextSelection: 'end' as const, + }, + { + range: [start2018, end2019], + selectingEnd: 'start' as const, + newDate: mid2018, + expectedRange: [mid2018, end2019], + expectedNextSelection: 'end' as const, + }, + { + range: [start2018, end2019], + selectingEnd: 'end' as const, + newDate: mid2018, + expectedRange: [start2018, mid2018], + expectedNextSelection: 'start' as const, + }, + { + range: [mid2018, end2019], + selectingEnd: 'start' as const, + newDate: start2018, + expectedRange: [start2018, end2019], + expectedNextSelection: 'end' as const, + }, + { + range: [start2018, mid2018], + selectingEnd: 'end' as const, + newDate: mid2018, + expectedRange: [start2018, mid2018], + expectedNextSelection: 'start' as const, + }, + ].forEach(({ range, selectingEnd, newDate, expectedRange, expectedNextSelection }) => { + it(`calculateRangeChange should return ${expectedRange} when selecting ${selectingEnd} of ${range} with user input ${newDate}`, () => { + expect( + calculateRangeChange({ + utils: adapterToUse, + range: range as DateRange, + newDate, + currentlySelectingRangeEnd: selectingEnd, + }), + ).to.deep.equal({ + nextSelection: expectedNextSelection, + newRange: expectedRange, + }); + }); + }); + + [ + { + range: [start2018, end2019], + selectingEnd: 'start' as const, + newDate: null, + expectedRangePreview: [null, null], + }, + { + range: [null, null], + selectingEnd: 'start' as const, + newDate: start2018, + expectedRangePreview: [start2018, null], + }, + { + range: [start2018, null], + selectingEnd: 'start' as const, + newDate: end2019, + expectedRangePreview: [end2019, null], + }, + { + range: [null, end2019], + selectingEnd: 'start' as const, + newDate: mid2018, + expectedRangePreview: [mid2018, end2019], + }, + { + range: [null, end2019], + selectingEnd: 'end' as const, + newDate: mid2018, + expectedRangePreview: [null, mid2018], + }, + { + range: [mid2018, null], + selectingEnd: 'start' as const, + newDate: start2018, + expectedRangePreview: [start2018, null], + }, + { + range: [mid2018, end2019], + selectingEnd: 'start' as const, + newDate: start2018, + expectedRangePreview: [start2018, mid2018], + }, + { + range: [start2018, mid2018], + selectingEnd: 'end' as const, + newDate: end2019, + expectedRangePreview: [mid2018, end2019], + }, + ].forEach(({ range, selectingEnd, newDate, expectedRangePreview }) => { + it(`calculateRangePreview should return ${expectedRangePreview} when selecting ${selectingEnd} of $range when user hover ${newDate}`, () => { + expect( + calculateRangePreview({ + utils: adapterToUse, + range: range as DateRange, + newDate, + currentlySelectingRangeEnd: selectingEnd, + }), + ).to.deep.equal(expectedRangePreview); + }); + }); +}); diff --git a/packages/material-ui-lab/src/DateRangePicker/date-range-manager.ts b/packages/material-ui-lab/src/DateRangePicker/date-range-manager.ts new file mode 100644 index 00000000000000..408deb9d2b1e67 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/date-range-manager.ts @@ -0,0 +1,49 @@ +import { DateRange } from './RangeTypes'; +import { MuiPickersAdapter } from '../internal/pickers/hooks/useUtils'; + +interface CalculateRangeChangeOptions { + utils: MuiPickersAdapter; + range: DateRange; + newDate: TDate; + currentlySelectingRangeEnd: 'start' | 'end'; +} + +export function calculateRangeChange({ + utils, + range, + newDate: selectedDate, + currentlySelectingRangeEnd, +}: CalculateRangeChangeOptions): { + nextSelection: 'start' | 'end'; + newRange: DateRange; +} { + const [start, end] = range; + + if (currentlySelectingRangeEnd === 'start') { + return Boolean(end) && utils.isAfter(selectedDate, end!) + ? { nextSelection: 'end', newRange: [selectedDate, null] } + : { nextSelection: 'end', newRange: [selectedDate, end] }; + } + + return Boolean(start) && utils.isBefore(selectedDate, start!) + ? { nextSelection: 'end', newRange: [selectedDate, null] } + : { nextSelection: 'start', newRange: [start, selectedDate] }; +} + +export function calculateRangePreview( + options: CalculateRangeChangeOptions, +): DateRange { + if (!options.newDate) { + return [null, null]; + } + + const [start, end] = options.range; + const { newRange } = calculateRangeChange(options); + + if (!start || !end) { + return newRange; + } + + const [previewStart, previewEnd] = newRange; + return options.currentlySelectingRangeEnd === 'end' ? [end, previewEnd] : [previewStart, start]; +} diff --git a/packages/material-ui-lab/src/DateRangePicker/index.ts b/packages/material-ui-lab/src/DateRangePicker/index.ts new file mode 100644 index 00000000000000..f779bc448d6658 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/index.ts @@ -0,0 +1,2 @@ +export * from './DateRangePicker'; +export { default } from './DateRangePicker'; diff --git a/packages/material-ui-lab/src/DateRangePicker/makeDateRangePicker.tsx b/packages/material-ui-lab/src/DateRangePicker/makeDateRangePicker.tsx new file mode 100644 index 00000000000000..d66994dfd54242 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePicker/makeDateRangePicker.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import { withDefaultProps } from '../internal/pickers/withDefaultProps'; +import { useParsedDate } from '../internal/pickers/hooks/date-helpers-hooks'; +import { withDateAdapterProp } from '../internal/pickers/withDateAdapterProp'; +import { makeWrapperComponent } from '../internal/pickers/wrappers/makeWrapperComponent'; +import { defaultMinDate, defaultMaxDate } from '../internal/pickers/constants/prop-types'; +import { SomeWrapper, ExtendWrapper } from '../internal/pickers/wrappers/Wrapper'; +import { RangeInput, AllSharedDateRangePickerProps, DateRange } from './RangeTypes'; +import { makeValidationHook, ValidationProps } from '../internal/pickers/hooks/useValidation'; +import { usePickerState, PickerStateValueManager } from '../internal/pickers/hooks/usePickerState'; +import { DateRangePickerView, ExportedDateRangePickerViewProps } from './DateRangePickerView'; +import DateRangePickerInput, { ExportedDateRangePickerInputProps } from './DateRangePickerInput'; +import { + parseRangeInputValue, + validateDateRange, + DateRangeValidationError, +} from '../internal/pickers/date-utils'; +import { DateInputPropsLike } from '../internal/pickers/wrappers/WrapperProps'; + +export interface BaseDateRangePickerProps + extends ExportedDateRangePickerViewProps, + ValidationProps>, + ExportedDateRangePickerInputProps { + /** + * Text for start input label and toolbar placeholder. + * @default "Start" + */ + startText?: React.ReactNode; + /** + * Text for end input label and toolbar placeholder. + * @default "end" + */ + endText?: React.ReactNode; +} + +export type DateRangePickerComponent = ( + props: BaseDateRangePickerProps & + ExtendWrapper & + AllSharedDateRangePickerProps & + React.RefAttributes, +) => JSX.Element; + +export const useDateRangeValidation = makeValidationHook< + DateRangeValidationError, + RangeInput, + BaseDateRangePickerProps +>(validateDateRange, { + defaultValidationError: [null, null], + isSameError: (a, b) => a[1] === b[1] && a[0] === b[0], +}); + +export function makeDateRangePicker( + name: string, + Wrapper: TWrapper, +): DateRangePickerComponent { + const WrapperComponent = makeWrapperComponent(Wrapper, { + KeyboardDateInputComponent: DateRangePickerInput as React.FC, + PureDateInputComponent: DateRangePickerInput as React.FC, + }); + + const rangePickerValueManager: PickerStateValueManager = { + emptyValue: [null, null], + parseInput: parseRangeInputValue, + areValuesEqual: (utils, a, b) => utils.isEqual(a[0], b[0]) && utils.isEqual(a[1], b[1]), + }; + + function RangePickerWithStateAndWrapper({ + calendars, + value, + onChange, + mask = '__/__/____', + startText = 'Start', + endText = 'End', + inputFormat: passedInputFormat, + minDate: __minDate = defaultMinDate as TDate, + maxDate: __maxDate = defaultMaxDate as TDate, + ...other + }: BaseDateRangePickerProps & + AllSharedDateRangePickerProps & + ExtendWrapper) { + const utils = useUtils(); + const minDate = useParsedDate(__minDate); + const maxDate = useParsedDate(__maxDate); + const [currentlySelectingRangeEnd, setCurrentlySelectingRangeEnd] = React.useState< + 'start' | 'end' + >('start'); + + const pickerStateProps = { + ...other, + value, + onChange, + inputFormat: passedInputFormat || utils.formats.keyboardDate, + }; + + const restProps = { + ...other, + minDate, + maxDate, + }; + + const { pickerProps, inputProps, wrapperProps } = usePickerState< + RangeInput, + DateRange + >(pickerStateProps, rangePickerValueManager); + + const validationError = useDateRangeValidation(value, restProps); + + const DateInputProps = { + ...inputProps, + ...restProps, + currentlySelectingRangeEnd, + setCurrentlySelectingRangeEnd, + startText, + endText, + mask, + validationError, + }; + + return ( + + + open={wrapperProps.open} + DateInputProps={DateInputProps} + calendars={calendars} + currentlySelectingRangeEnd={currentlySelectingRangeEnd} + setCurrentlySelectingRangeEnd={setCurrentlySelectingRangeEnd} + startText={startText} + endText={endText} + {...pickerProps} + {...restProps} + /> + + ); + } + + const FinalPickerComponent = withDefaultProps( + { name }, + withDateAdapterProp(RangePickerWithStateAndWrapper), + ); + + // @ts-expect-error Impossible to save component generics when wrapping with HOC + return React.forwardRef< + HTMLDivElement, + React.ComponentProps + >((props, ref) => ); +} diff --git a/packages/material-ui-lab/src/DateRangePickerDay/DateRangePickerDay.test.tsx b/packages/material-ui-lab/src/DateRangePickerDay/DateRangePickerDay.test.tsx new file mode 100644 index 00000000000000..1e18167eb23575 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePickerDay/DateRangePickerDay.test.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { getClasses, createMount, describeConformance } from 'test/utils'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import DateRangePickerDay from '@material-ui/lab/DateRangePickerDay'; + +describe('', () => { + const mount = createMount(); + let classes: Record; + + const localizedMount = (node: React.ReactNode) => { + return mount({node}); + }; + + before(() => { + classes = getClasses( + {}} + isHighlighting + isPreviewing + isStartOfPreviewing + isEndOfPreviewing + isStartOfHighlighting + isEndOfHighlighting + />, + ); + }); + + describeConformance( + {}} + isHighlighting + isPreviewing + isStartOfPreviewing + isEndOfPreviewing + isStartOfHighlighting + isEndOfHighlighting + />, + () => ({ + classes, + inheritComponent: 'button', + mount: localizedMount, + refInstanceof: window.HTMLButtonElement, + // cannot test reactTestRenderer because of required context + skip: ['componentProp', 'reactTestRenderer', 'propsSpread', 'refForwarding'], + }), + ); +}); diff --git a/packages/material-ui-lab/src/DateRangePickerDay/DateRangePickerDay.tsx b/packages/material-ui-lab/src/DateRangePickerDay/DateRangePickerDay.tsx new file mode 100644 index 00000000000000..19351c3970efae --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePickerDay/DateRangePickerDay.tsx @@ -0,0 +1,239 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { withStyles, WithStyles, alpha, createStyles, Theme } from '@material-ui/core/styles'; +import { DAY_MARGIN } from '../internal/pickers/constants/dimensions'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import PickersDay, { PickersDayProps, areDayPropsEqual } from '../PickersDay/PickersDay'; + +export interface DateRangePickerDayProps extends PickersDayProps { + isHighlighting: boolean; + isEndOfHighlighting: boolean; + isStartOfHighlighting: boolean; + isPreviewing: boolean; + isEndOfPreviewing: boolean; + isStartOfPreviewing: boolean; +} + +const endBorderStyle = { + borderTopRightRadius: '50%', + borderBottomRightRadius: '50%', +}; + +const startBorderStyle = { + borderTopLeftRadius: '50%', + borderBottomLeftRadius: '50%', +}; + +const styles = (theme: Theme) => + createStyles({ + root: { + '&:first-child $rangeIntervalDayPreview': { + ...startBorderStyle, + borderLeftColor: theme.palette.divider, + }, + '&:last-child $rangeIntervalDayPreview': { + ...endBorderStyle, + borderRightColor: theme.palette.divider, + }, + }, + rangeIntervalDayHighlight: { + borderRadius: 0, + color: theme.palette.primary.contrastText, + backgroundColor: alpha(theme.palette.primary.light, 0.6), + '&:first-child': startBorderStyle, + '&:last-child': endBorderStyle, + }, + rangeIntervalDayHighlightStart: { + ...startBorderStyle, + paddingLeft: 0, + marginLeft: DAY_MARGIN / 2, + }, + rangeIntervalDayHighlightEnd: { + ...endBorderStyle, + paddingRight: 0, + marginRight: DAY_MARGIN / 2, + }, + day: { + // Required to overlap preview border + transform: 'scale(1.1)', + '& > *': { + transform: 'scale(0.9)', + }, + }, + dayOutsideRangeInterval: { + '&:hover': { + border: `1px solid ${theme.palette.grey[500]}`, + }, + }, + dayInsideRangeInterval: { + color: theme.palette.getContrastText(alpha(theme.palette.primary.light, 0.6)), + }, + notSelectedDate: { + backgroundColor: 'transparent', + }, + rangeIntervalPreview: { + // replace default day component margin with transparent border to avoid jumping on preview + border: '2px solid transparent', + }, + rangeIntervalDayPreview: { + borderRadius: 0, + border: `2px dashed ${theme.palette.divider}`, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + '&$rangeIntervalDayPreviewStart': { + borderLeftColor: theme.palette.divider, + ...startBorderStyle, + }, + '&$rangeIntervalDayPreviewEnd': { + borderRightColor: theme.palette.divider, + ...endBorderStyle, + }, + }, + rangeIntervalDayPreviewStart: {}, + rangeIntervalDayPreviewEnd: {}, + }); + +/** + * @ignore - do not document. + */ +const DateRangePickerDay = React.forwardRef(function DateRangePickerDay( + props: DateRangePickerDayProps & WithStyles, + ref: React.Ref, +) { + const { + classes, + className, + day, + outsideCurrentMonth, + isEndOfHighlighting, + isEndOfPreviewing, + isHighlighting, + isPreviewing, + isStartOfHighlighting, + isStartOfPreviewing, + selected, + ...other + } = props; + const utils = useUtils(); + + const isEndOfMonth = utils.isSameDay(day, utils.endOfMonth(day)); + const isStartOfMonth = utils.isSameDay(day, utils.startOfMonth(day)); + + const shouldRenderHighlight = isHighlighting && !outsideCurrentMonth; + const shouldRenderPreview = isPreviewing && !outsideCurrentMonth; + + return ( +
+
+ + {...other} + ref={ref} + disableMargin + allowSameDateSelection + allowKeyboardControl={false} + day={day} + selected={selected} + outsideCurrentMonth={outsideCurrentMonth} + data-mui-test="DateRangeDay" + className={clsx(classes.day, { + [classes.notSelectedDate]: !selected, + [classes.dayOutsideRangeInterval]: !isHighlighting, + [classes.dayInsideRangeInterval]: !selected && isHighlighting, + })} + /> +
+
+ ); +}); + +(DateRangePickerDay as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * @ignore + */ + classes: PropTypes.object.isRequired, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The date to show. + */ + day: PropTypes.any.isRequired, + /** + * @ignore + */ + isEndOfHighlighting: PropTypes.bool.isRequired, + /** + * @ignore + */ + isEndOfPreviewing: PropTypes.bool.isRequired, + /** + * @ignore + */ + isHighlighting: PropTypes.bool.isRequired, + /** + * @ignore + */ + isPreviewing: PropTypes.bool.isRequired, + /** + * @ignore + */ + isStartOfHighlighting: PropTypes.bool.isRequired, + /** + * @ignore + */ + isStartOfPreviewing: PropTypes.bool.isRequired, + /** + * If `true`, day is outside of month and will be hidden. + */ + outsideCurrentMonth: PropTypes.bool.isRequired, + /** + * If `true`, renders as selected. + */ + selected: PropTypes.bool, +}; + +/** + * + * API: + * + * - [DateRangePickerDay API](https://material-ui.com/api/date-range-picker-day/) + */ +export default withStyles(styles, { name: 'MuiDateRangePickerDay' })( + React.memo(DateRangePickerDay, (prevProps, nextProps) => { + return ( + prevProps.isHighlighting === nextProps.isHighlighting && + prevProps.isEndOfHighlighting === nextProps.isEndOfHighlighting && + prevProps.isStartOfHighlighting === nextProps.isStartOfHighlighting && + prevProps.isPreviewing === nextProps.isPreviewing && + prevProps.isEndOfPreviewing === nextProps.isEndOfPreviewing && + prevProps.isStartOfPreviewing === nextProps.isStartOfPreviewing && + areDayPropsEqual(prevProps, nextProps) + ); + }), +) as ( + props: DateRangePickerDayProps & React.RefAttributes, +) => JSX.Element; diff --git a/packages/material-ui-lab/src/DateRangePickerDay/index.ts b/packages/material-ui-lab/src/DateRangePickerDay/index.ts new file mode 100644 index 00000000000000..52cf5ab8d18210 --- /dev/null +++ b/packages/material-ui-lab/src/DateRangePickerDay/index.ts @@ -0,0 +1,2 @@ +export * from './DateRangePickerDay'; +export { default } from './DateRangePickerDay'; diff --git a/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.spec.tsx b/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.spec.tsx new file mode 100644 index 00000000000000..3cd3d75643d575 --- /dev/null +++ b/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.spec.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import moment from 'moment'; +import { DateTimePicker } from '@material-ui/lab'; + + date?.set({ second: 0 })} + renderInput={() => } +/>; diff --git a/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.test.tsx b/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.test.tsx new file mode 100644 index 00000000000000..fae85c6221b90e --- /dev/null +++ b/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.test.tsx @@ -0,0 +1,262 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import { expect } from 'chai'; +import { spy, useFakeTimers, SinonSpy, SinonFakeTimers } from 'sinon'; +import { fireEvent, fireTouchChangedEvent, screen } from 'test/utils'; +import 'dayjs/locale/ru'; +import dayjs from 'dayjs'; +import MobileDateTimePicker from '@material-ui/lab/MobileDateTimePicker'; +import DesktopDateTimePicker from '@material-ui/lab/DesktopDateTimePicker'; +import StaticDateTimePicker from '@material-ui/lab/StaticDateTimePicker'; +import DayJsAdapter from '../dateAdapter/dayjs'; +import { adapterToUse, getByMuiTest, createPickerRender } from '../internal/pickers/test-utils'; + +describe('', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(new Date('2018-01-01T00:00:00.000').getTime()); + }); + + afterEach(() => { + clock.restore(); + }); + + const render = createPickerRender({ strict: false }); + + it('opens dialog on textField click for Mobile mode', () => { + render( + {}} + renderInput={(params) => } + />, + ); + + fireEvent.click(screen.getByRole('textbox')); + expect(screen.getByRole('dialog')).toBeVisible(); + }); + + it('opens dialog on calendar button click for Mobile mode', () => { + render( + {}} + renderInput={(params) => } + />, + ); + + fireEvent.click(screen.getByLabelText(/choose date/i)); + expect(screen.getByRole('dialog')).toBeVisible(); + }); + + it('allows to select full date end-to-end', function test() { + if (typeof window.Touch === 'undefined' || typeof window.TouchEvent === 'undefined') { + this.skip(); + } + + let onChangeMock: SinonSpy = spy(); + const clockTouchEvent = { + changedTouches: [ + { + clientX: 20, + clientY: 15, + }, + ], + }; + + function DateTimePickerWithState() { + const [date, setDate] = React.useState(null); + onChangeMock = spy(setDate); + + return ( + onChangeMock(newDate)} + renderInput={(params) => } + /> + ); + } + + render(); + fireEvent.click(screen.getByLabelText(/choose date/i)); + + expect(getByMuiTest('datetimepicker-toolbar-date')).to.have.text('Enter Date'); + expect(getByMuiTest('hours')).to.have.text('--'); + expect(getByMuiTest('minutes')).to.have.text('--'); + + // 1. Year view + fireEvent.click(screen.getByLabelText(/switch to year view/)); + fireEvent.click(screen.getByText('2010', { selector: 'button' })); + + expect(getByMuiTest('datetimepicker-toolbar-year')).to.have.text('2010'); + + // 2. Date + fireEvent.click(screen.getByLabelText('Jan 15, 2010')); + + expect(getByMuiTest('datetimepicker-toolbar-date')).to.have.text('Jan 15'); + + // 3. Hours + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockTouchEvent); + fireTouchChangedEvent(getByMuiTest('clock'), 'touchend', clockTouchEvent); + + expect(getByMuiTest('hours')).to.have.text('11'); + + // 4. Minutes + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockTouchEvent); + fireTouchChangedEvent(getByMuiTest('clock'), 'touchend', clockTouchEvent); + + expect(getByMuiTest('minutes')).to.have.text('53'); + + fireEvent.click(screen.getByText(/ok/i)); + expect(onChangeMock.calledWith(new Date('2010-01-15T11:53:00.000'))).to.be.equal(true); + }); + + it('prop: open โ€“ overrides open state', () => { + render( + } + open + onChange={() => {}} + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + />, + ); + + expect(screen.getByRole('dialog')).toBeVisible(); + }); + + it('prop: onCloseMock โ€“ dispatches on close request', () => { + const onCloseMock = spy(); + render( + } + open + onClose={onCloseMock} + onChange={() => {}} + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + />, + ); + + fireEvent.click(screen.getByText('Cancel')); + expect(onCloseMock.callCount).to.equal(1); + }); + + it('prop: dateAdapter โ€“ allows to override date adapter with prop', () => { + render( + } + onChange={() => {}} + dateAdapter={new DayJsAdapter({ locale: 'ru' })} + disableMaskedInput + value={dayjs('2018-01-15T00:00:00.000Z')} + />, + ); + + expect(screen.getByText('ัะฝะฒะฐั€ัŒ')).toBeVisible(); + }); + + it('prop: mask โ€“ should take the mask prop into account', () => { + render( + } + ampm={false} + inputFormat="mm.dd.yyyy hh:mm" + mask="__.__.____ __:__" + onChange={() => {}} + value={null} + />, + ); + + const textbox = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.change(textbox, { + target: { + value: '12', + }, + }); + + expect(textbox.value).to.equal('12.'); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('prop: maxDateTime โ€“ minutes is disabled by date part', () => { + render( + {}} + renderInput={(params) => } + value={adapterToUse.date('2018-01-01T12:00:00.000Z')} + minDateTime={adapterToUse.date('2018-01-01T12:30:00.000Z')} + />, + ); + + expect(screen.getByLabelText('25 minutes')).to.have.attribute('aria-disabled', 'true'); + expect(screen.getByLabelText('35 minutes')).to.have.attribute('aria-disabled', 'false'); + }); + + it('prop: minDateTime โ€“ hours is disabled by date part', () => { + render( + {}} + ampm={false} + renderInput={(params) => } + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + minDateTime={adapterToUse.date('2018-01-01T12:30:00.000Z')} + />, + ); + + expect(screen.getByLabelText('11 hours')).to.have.attribute('aria-disabled', 'true'); + }); + + it('shows ArrowSwitcher on ClockView disabled and not allows to return back to the date', () => { + render( + {}} + renderInput={(params) => } + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + />, + ); + + expect(screen.getByLabelText('open previous view')).to.have.attribute('disabled'); + }); + + it('allows to switch using ArrowSwitcher on ClockView', () => { + render( + {}} + renderInput={(params) => } + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + />, + ); + + fireEvent.click(screen.getByLabelText('open next view')); + expect(screen.getByLabelText('open next view')).to.have.attribute('disabled'); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('allows to select the same day and move to the next view', () => { + const onChangeMock = spy(); + render( + } + value={adapterToUse.date('2018-01-01T00:00:00.000Z')} + />, + ); + + fireEvent.click(screen.getByLabelText('Jan 1, 2018')); + expect(onChangeMock.callCount).to.equal(1); + + expect(screen.getByLabelText(/Selected time/)).toBeVisible(); + }); +}); diff --git a/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.tsx b/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.tsx new file mode 100644 index 00000000000000..9fe1e9dda05cb3 --- /dev/null +++ b/packages/material-ui-lab/src/DateTimePicker/DateTimePicker.tsx @@ -0,0 +1,570 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import DateTimePickerToolbar from './DateTimePickerToolbar'; +import { ExportedClockPickerProps } from '../ClockPicker/ClockPicker'; +import { ResponsiveWrapper } from '../internal/pickers/wrappers/ResponsiveWrapper'; +import { pick12hOr24hFormat } from '../internal/pickers/text-field-helper'; +import { + useParsedDate, + OverrideParsableDateProps, +} from '../internal/pickers/hooks/date-helpers-hooks'; +import { ExportedDayPickerProps } from '../DayPicker/DayPicker'; +import { + makePickerWithStateAndWrapper, + SharedPickerProps, +} from '../internal/pickers/Picker/makePickerWithState'; +import { SomeWrapper } from '../internal/pickers/wrappers/Wrapper'; +import { WithViewsProps, AllSharedPickerProps } from '../internal/pickers/Picker/SharedPickerProps'; +import { DateAndTimeValidationError, validateDateAndTime } from './date-time-utils'; +import { makeValidationHook, ValidationProps } from '../internal/pickers/hooks/useValidation'; +import { + ParsableDate, + defaultMinDate, + defaultMaxDate, +} from '../internal/pickers/constants/prop-types'; + +type DateTimePickerViewsProps = OverrideParsableDateProps< + TDate, + ExportedClockPickerProps & ExportedDayPickerProps, + 'minDate' | 'maxDate' | 'minTime' | 'maxTime' +>; + +export interface BaseDateTimePickerProps + extends WithViewsProps<'year' | 'date' | 'month' | 'hours' | 'minutes'>, + ValidationProps, + DateTimePickerViewsProps { + /** + * To show tabs. + */ + hideTabs?: boolean; + /** + * Date tab icon. + */ + dateRangeIcon?: React.ReactNode; + /** + * Time tab icon. + */ + timeIcon?: React.ReactNode; + /** + * Minimal selectable moment of time with binding to date, to set min time in each day use `minTime`. + */ + minDateTime?: ParsableDate; + /** + * Minimal selectable moment of time with binding to date, to set max time in each day use `maxTime`. + */ + maxDateTime?: ParsableDate; + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat?: string; +} + +function useInterceptProps({ + ampm, + inputFormat, + maxDate: __maxDate = defaultMaxDate, + maxDateTime: __maxDateTime, + maxTime: __maxTime, + minDate: __minDate = defaultMinDate, + minDateTime: __minDateTime, + minTime: __minTime, + openTo = 'date', + orientation = 'portrait', + views = ['year', 'date', 'hours', 'minutes'], + ...other +}: BaseDateTimePickerProps & AllSharedPickerProps) { + const utils = useUtils(); + const minTime = useParsedDate(__minTime); + const maxTime = useParsedDate(__maxTime); + const minDate = useParsedDate(__minDate); + const maxDate = useParsedDate(__maxDate); + const minDateTime = useParsedDate(__minDateTime); + const maxDateTime = useParsedDate(__maxDateTime); + const willUseAmPm = ampm ?? utils.is12HourCycleInCurrentLocale(); + + if (orientation !== 'portrait') { + throw new Error('We are not supporting custom orientation for DateTimePicker yet :('); + } + + return { + openTo, + views, + ampm: willUseAmPm, + ampmInClock: true, + orientation, + showToolbar: true, + showTabs: true, + allowSameDateSelection: true, + minDate: minDateTime || minDate, + minTime: minDateTime || minTime, + maxDate: maxDateTime || maxDate, + maxTime: maxDateTime || maxTime, + disableIgnoringDatePartForTimeValidation: Boolean(minDateTime || maxDateTime), + acceptRegex: willUseAmPm ? /[\dap]/gi : /\d/gi, + mask: '__/__/____ __:__', + disableMaskedInput: willUseAmPm, + inputFormat: pick12hOr24hFormat(inputFormat, willUseAmPm, { + localized: utils.formats.keyboardDateTime, + '12h': utils.formats.keyboardDateTime12h, + '24h': utils.formats.keyboardDateTime24h, + }), + ...other, + }; +} + +const useValidation = makeValidationHook< + DateAndTimeValidationError, + ParsableDate, + BaseDateTimePickerProps +>(validateDateAndTime); + +export const dateTimePickerConfig = { + useInterceptProps, + useValidation, + DefaultToolbarComponent: DateTimePickerToolbar, +}; + +export type DateTimePickerGenericComponent = ( + props: BaseDateTimePickerProps & SharedPickerProps, +) => JSX.Element; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const DateTimePicker = makePickerWithStateAndWrapper>( + ResponsiveWrapper, + { + name: 'MuiDateTimePicker', + ...dateTimePickerConfig, + }, +) as DateTimePickerGenericComponent; + +(DateTimePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText: PropTypes.node, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable: PropTypes.bool, + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText: PropTypes.node, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Date tab icon. + */ + dateRangeIcon: PropTypes.node, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * CSS media query when `Mobile` mode will be changed to `Desktop`. + * @default "@media (pointer: fine)" + * @example "@media (min-width: 720px)" or theme.breakpoints.up("sm") + */ + desktopModeMediaQuery: PropTypes.string, + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText: PropTypes.func, + /** + * To show tabs. + */ + hideTabs: PropTypes.bool, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Minimal selectable moment of time with binding to date, to set max time in each day use `maxTime`. + */ + maxDateTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + maxTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Minimal selectable moment of time with binding to date, to set min time in each day use `minTime`. + */ + minDateTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * "OK" button text. + * @default "OK" + */ + okText: PropTypes.node, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Callback firing on year change @DateIOType. + */ + onYearChange: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * First view to show. + */ + openTo: PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'seconds', 'year']), + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Popper props passed down to [Popper](https://material-ui.com/api/popper/) component. + */ + PopperProps: PropTypes.object, + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Custom renderer for day. Check [DayComponentProps api](https://material-ui-pickers.dev/api/Day) @DateIOType. + */ + renderDay: PropTypes.func, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Time tab icon. + */ + timeIcon: PropTypes.node, + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * Custom component for popper [Transition](https://material-ui.com/components/transitions/#transitioncomponent-prop). + */ + TransitionComponent: PropTypes.elementType, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Array of views to show. + */ + views: PropTypes.arrayOf( + PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'year']).isRequired, + ), +}; + +export type DateTimePickerProps = React.ComponentProps; + +export default DateTimePicker; diff --git a/packages/material-ui-lab/src/DateTimePicker/DateTimePickerTabs.tsx b/packages/material-ui-lab/src/DateTimePicker/DateTimePickerTabs.tsx new file mode 100644 index 00000000000000..6e72f22dfc87a2 --- /dev/null +++ b/packages/material-ui-lab/src/DateTimePicker/DateTimePickerTabs.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Tab from '@material-ui/core/Tab'; +import Tabs from '@material-ui/core/Tabs'; +import Paper from '@material-ui/core/Paper'; +import { createStyles, WithStyles, withStyles, Theme, useTheme } from '@material-ui/core/styles'; +import TimeIcon from '../internal/svg-icons/Time'; +import DateRangeIcon from '../internal/svg-icons/DateRange'; +import { WrapperVariantContext } from '../internal/pickers/wrappers/WrapperVariantContext'; +import { DateTimePickerView } from '../internal/pickers/typings/Views'; + +const viewToTabIndex = (openView: DateTimePickerView) => { + if (openView === 'date' || openView === 'year') { + return 'date'; + } + + return 'time'; +}; + +const tabIndexToView = (tab: DateTimePickerView) => { + if (tab === 'date') { + return 'date'; + } + + return 'hours'; +}; + +export interface DateTimePickerTabsProps { + dateRangeIcon?: React.ReactNode; + onChange: (view: DateTimePickerView) => void; + timeIcon?: React.ReactNode; + view: DateTimePickerView; +} + +export const styles = (theme: Theme) => { + const tabsBackground = + theme.palette.mode === 'light' ? theme.palette.primary.main : theme.palette.background.default; + + return createStyles({ + root: {}, + modeDesktop: { + order: 1, + }, + tabs: { + color: theme.palette.getContrastText(tabsBackground), + backgroundColor: tabsBackground, + }, + }); +}; + +export type DateTimePickerTabsClassKey = keyof WithStyles['classes']; + +/** + * @ignore - internal component. + */ +const DateTimePickerTabs: React.FC> = ( + props, +) => { + const { + classes, + dateRangeIcon = , + onChange, + timeIcon = , + view, + } = props; + + const theme = useTheme(); + const wrapperVariant = React.useContext(WrapperVariantContext); + const indicatorColor = theme.palette.mode === 'light' ? 'secondary' : 'primary'; + + const handleChange = (e: React.ChangeEvent<{}>, value: DateTimePickerView) => { + if (value !== viewToTabIndex(view)) { + onChange(tabIndexToView(value)); + } + }; + + return ( + + + {dateRangeIcon}} + /> + {timeIcon}} + /> + + + ); +}; + +export default withStyles(styles, { name: 'MuiDateTimePickerTabs' })(DateTimePickerTabs); diff --git a/packages/material-ui-lab/src/DateTimePicker/DateTimePickerToolbar.tsx b/packages/material-ui-lab/src/DateTimePicker/DateTimePickerToolbar.tsx new file mode 100644 index 00000000000000..94da6a71b6ae01 --- /dev/null +++ b/packages/material-ui-lab/src/DateTimePicker/DateTimePickerToolbar.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import { createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; +import ToolbarText from '../internal/pickers/PickersToolbarText'; +import PickerToolbar from '../internal/pickers/PickersToolbar'; +import ToolbarButton from '../internal/pickers/PickersToolbarButton'; +import DateTimePickerTabs from './DateTimePickerTabs'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import { WrapperVariantContext } from '../internal/pickers/wrappers/WrapperVariantContext'; +import { ToolbarComponentProps } from '../internal/pickers/typings/BasePicker'; +import { DateTimePickerView } from '../internal/pickers/typings/Views'; + +export const styles = createStyles({ + root: { + paddingLeft: 16, + paddingRight: 16, + justifyContent: 'space-around', + }, + separator: { + margin: '0 4px 0 2px', + cursor: 'default', + }, + timeContainer: { + display: 'flex', + }, + dateContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + }, + timeTypography: {}, + penIcon: { + position: 'absolute', + top: 8, + right: 8, + }, +}); + +export type DateTimePickerToolbarClassKey = keyof WithStyles['classes']; + +/** + * @ignore - internal component. + */ +const DateTimePickerToolbar: React.FC> = ( + props, +) => { + const { + ampm, + date, + dateRangeIcon, + classes, + hideTabs, + isMobileKeyboardViewOpen, + onChange, + openView, + setOpenView, + timeIcon, + toggleMobileKeyboardView, + toolbarFormat, + toolbarPlaceholder = 'โ€“โ€“', + toolbarTitle = 'SELECT DATE & TIME', + ...other + } = props; + const utils = useUtils(); + const wrapperVariant = React.useContext(WrapperVariantContext); + const showTabs = + wrapperVariant === 'desktop' + ? true + : !hideTabs && typeof window !== 'undefined' && window.innerHeight > 667; + + const formatHours = (time: unknown) => + ampm ? utils.format(time, 'hours12h') : utils.format(time, 'hours24h'); + + const dateText = React.useMemo(() => { + if (!date) { + return toolbarPlaceholder; + } + + if (toolbarFormat) { + return utils.formatByString(date, toolbarFormat); + } + + return utils.format(date, 'shortDate'); + }, [date, toolbarFormat, toolbarPlaceholder, utils]); + + return ( + + {wrapperVariant !== 'desktop' && ( + +
+ setOpenView('year')} + selected={openView === 'year'} + value={date ? utils.format(date, 'year') : 'โ€“'} + /> + setOpenView('date')} + selected={openView === 'date'} + value={dateText} + /> +
+
+ setOpenView('hours')} + selected={openView === 'hours'} + value={date ? formatHours(date) : '--'} + typographyClassName={classes.timeTypography} + /> + + setOpenView('minutes')} + selected={openView === 'minutes'} + value={date ? utils.format(date, 'minutes') : '--'} + typographyClassName={classes.timeTypography} + /> +
+
+ )} + {showTabs && ( + + )} +
+ ); +}; + +export default withStyles(styles, { name: 'MuiDateTimePickerToolbar' })(DateTimePickerToolbar); diff --git a/packages/material-ui-lab/src/DateTimePicker/date-time-utils.ts b/packages/material-ui-lab/src/DateTimePicker/date-time-utils.ts new file mode 100644 index 00000000000000..cf44776c6cc370 --- /dev/null +++ b/packages/material-ui-lab/src/DateTimePicker/date-time-utils.ts @@ -0,0 +1,33 @@ +import { ParsableDate } from '../internal/pickers/constants/prop-types'; +import { MuiPickersAdapter } from '../internal/pickers/hooks/useUtils'; +import { DateValidationProps, validateDate } from '../internal/pickers/date-utils'; +import { TimeValidationProps, validateTime } from '../internal/pickers/time-utils'; + +export function validateDateAndTime( + utils: MuiPickersAdapter, + value: ParsableDate, + { + minDate, + maxDate, + disableFuture, + shouldDisableDate, + disablePast, + ...timeValidationProps + }: DateValidationProps & TimeValidationProps, +) { + const dateValidationResult = validateDate(utils, value, { + minDate, + maxDate, + disableFuture, + shouldDisableDate, + disablePast, + }); + + if (dateValidationResult !== null) { + return dateValidationResult; + } + + return validateTime(utils, value, timeValidationProps); +} + +export type DateAndTimeValidationError = ReturnType; diff --git a/packages/material-ui-lab/src/DateTimePicker/index.ts b/packages/material-ui-lab/src/DateTimePicker/index.ts new file mode 100644 index 00000000000000..2d763f4b586cdb --- /dev/null +++ b/packages/material-ui-lab/src/DateTimePicker/index.ts @@ -0,0 +1,2 @@ +export * from './DateTimePicker'; +export { default } from './DateTimePicker'; diff --git a/packages/material-ui-lab/src/DayPicker/DayPicker.test.tsx b/packages/material-ui-lab/src/DayPicker/DayPicker.test.tsx new file mode 100644 index 00000000000000..451f5234602f24 --- /dev/null +++ b/packages/material-ui-lab/src/DayPicker/DayPicker.test.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { getClasses, createMount, fireEvent, screen, describeConformance } from 'test/utils'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import DayPicker from '@material-ui/lab/DayPicker'; +import { createPickerRender, getAllByMuiTest } from '../internal/pickers/test-utils'; + +describe('', () => { + const mount = createMount(); + const render = createPickerRender({ strict: false }); + let classes: Record; + + const localizedMount = (node: React.ReactNode) => { + return mount({node}); + }; + + before(() => { + classes = getClasses( {}} />); + }); + + describeConformance( {}} />, () => ({ + classes, + inheritComponent: 'div', + mount: localizedMount, + refInstanceof: window.HTMLDivElement, + // cannot test reactTestRenderer because of required context + skip: ['componentProp', 'propsSpread', 'reactTestRenderer'], + })); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('renders calendar standalone', () => { + render( {}} />); + + expect(screen.getByText('January')).toBeVisible(); + expect(screen.getByText('2019')).toBeVisible(); + expect(getAllByMuiTest('day')).to.have.length(31); + }); + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('renders year selection standalone', () => { + render( + {}} />, + ); + + expect(getAllByMuiTest('year')).to.have.length(200); + }); + + it('switches between views uncontrolled', () => { + render( {}} />); + + fireEvent.click(screen.getByLabelText(/switch to year view/i)); + + expect(screen.queryByLabelText(/switch to year view/i)).to.equal(null); + expect(screen.getByLabelText('year view is open, switch to calendar view')).toBeVisible(); + }); +}); diff --git a/packages/material-ui-lab/src/DayPicker/DayPicker.tsx b/packages/material-ui-lab/src/DayPicker/DayPicker.tsx new file mode 100644 index 00000000000000..f4e98bb5bf5971 --- /dev/null +++ b/packages/material-ui-lab/src/DayPicker/DayPicker.tsx @@ -0,0 +1,347 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { createStyles, withStyles, WithStyles } from '@material-ui/core/styles'; +import clsx from 'clsx'; +import MonthPicker from '../MonthPicker/MonthPicker'; +import { useCalendarState } from './useCalendarState'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import FadeTransitionGroup from './PickersFadeTransitionGroup'; +import Calendar, { ExportedCalendarProps } from './PickersCalendar'; +import { PickerOnChangeFn, useViews } from '../internal/pickers/hooks/useViews'; +import { DAY_SIZE, DAY_MARGIN } from '../internal/pickers/constants/dimensions'; +import CalendarHeader, { ExportedCalendarHeaderProps } from './PickersCalendarHeader'; +import YearPicker, { ExportedYearPickerProps } from '../YearPicker/YearPicker'; +import { defaultMinDate, defaultMaxDate } from '../internal/pickers/constants/prop-types'; +import { IsStaticVariantContext } from '../internal/pickers/wrappers/WrapperVariantContext'; +import { DateValidationProps, findClosestEnabledDate } from '../internal/pickers/date-utils'; +import { DatePickerView } from '../internal/pickers/typings/Views'; +import PickerView from '../internal/pickers/Picker/PickerView'; + +export interface DayPickerProps + extends DateValidationProps, + ExportedCalendarProps, + ExportedYearPickerProps, + ExportedCalendarHeaderProps { + date: TDate | null; + /** Views for day picker. */ + views?: TView[]; + /** Controlled open view. */ + view?: TView; + /** Initially open view. */ + openTo?: TView; + /** Callback fired on view change. */ + onViewChange?: (view: TView) => void; + /** Callback fired on date change */ + onChange: PickerOnChangeFn; + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations?: boolean; + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange?: (date: TDate) => void; + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth?: TDate; + className?: string; +} + +export type ExportedDayPickerProps = Omit< + DayPickerProps, + | 'date' + | 'view' + | 'views' + | 'openTo' + | 'onChange' + | 'changeView' + | 'slideDirection' + | 'currentMonth' + | 'className' +>; + +export const styles = createStyles({ + root: { + display: 'flex', + flexDirection: 'column', + }, + viewTransitionContainer: { + overflowY: 'auto', + }, + fullHeightContainer: { + flex: 1, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minHeight: (DAY_SIZE + DAY_MARGIN * 4) * 7, + height: '100%', + }, +}); + +export type DayPickerClassKey = keyof WithStyles['classes']; + +export const defaultReduceAnimations = + typeof navigator !== 'undefined' && /(android)/i.test(navigator.userAgent); + +/** + * @ignore - do not document. + */ +const DayPicker = React.forwardRef(function DayPicker< + TDate extends any, + TView extends DatePickerView = DatePickerView +>(props: DayPickerProps & WithStyles, ref: React.Ref) { + const { + allowKeyboardControl: allowKeyboardControlProp, + onViewChange, + date, + disableFuture, + disablePast, + defaultCalendarMonth, + classes, + loading, + maxDate: maxDateProp, + minDate: minDateProp, + onChange, + onMonthChange, + reduceAnimations = defaultReduceAnimations, + renderLoading, + shouldDisableDate, + shouldDisableYear, + view, + views = ['year', 'date'] as TView[], + openTo = 'date' as TView, + className, + ...other + } = props; + + const utils = useUtils(); + const isStatic = React.useContext(IsStaticVariantContext); + const allowKeyboardControl = allowKeyboardControlProp ?? !isStatic; + + const minDate = minDateProp || utils.date(defaultMinDate)!; + const maxDate = maxDateProp || utils.date(defaultMaxDate)!; + + const { openView, setOpenView } = useViews({ + view, + views, + openTo, + onChange, + onViewChange, + }); + + const { + calendarState, + changeFocusedDay, + changeMonth, + isDateDisabled, + handleChangeMonth, + onMonthSwitchingAnimationEnd, + } = useCalendarState({ + date, + defaultCalendarMonth, + reduceAnimations, + onMonthChange, + minDate, + maxDate, + shouldDisableDate, + disablePast, + disableFuture, + }); + + React.useEffect(() => { + if (date && isDateDisabled(date)) { + const closestEnabledDate = findClosestEnabledDate({ + utils, + date, + minDate, + maxDate, + disablePast: Boolean(disablePast), + disableFuture: Boolean(disableFuture), + shouldDisableDate: isDateDisabled, + }); + + onChange(closestEnabledDate, 'partial'); + } + // This call is too expensive to run it on each prop change. + // So just ensure that we are not rendering disabled as selected on mount. + }, []); // eslint-disable-line + + React.useEffect(() => { + if (date) { + changeMonth(date); + } + }, [date]); // eslint-disable-line + + return ( + + void} + onMonthChange={(newMonth, direction) => handleChangeMonth({ newMonth, direction })} + minDate={minDate} + maxDate={maxDate} + disablePast={disablePast} + disableFuture={disableFuture} + reduceAnimations={reduceAnimations} + /> + +
+ {openView === 'year' && ( + + )} + + {openView === 'month' && ( + + )} + + {openView === 'date' && ( + + )} +
+
+
+ ); +}); + +(DayPicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * @ignore + */ + classes: PropTypes.object.isRequired, + /** + * @ignore + */ + className: PropTypes.string, + /** + * @ignore + */ + date: PropTypes.any, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Max selectable date. @DateIOType + * @default Date(2099-31-12) + */ + maxDate: PropTypes.any, + /** + * Min selectable date. @DateIOType + * @default Date(1900-01-01) + */ + minDate: PropTypes.any, + /** + * Callback fired on date change + */ + onChange: PropTypes.func.isRequired, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Initially open view. + */ + openTo: PropTypes.oneOf(['date', 'month', 'year']), + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * Controlled open view. + */ + view: PropTypes.oneOf(['date', 'month', 'year']), + /** + * Views for day picker. + */ + views: PropTypes.arrayOf(PropTypes.oneOf(['date', 'month', 'year']).isRequired), +}; + +export default withStyles(styles, { name: 'MuiDayPicker' })(DayPicker) as ( + props: DayPickerProps & React.RefAttributes, +) => JSX.Element; diff --git a/packages/material-ui-lab/src/DayPicker/PickersCalendar.tsx b/packages/material-ui-lab/src/DayPicker/PickersCalendar.tsx new file mode 100644 index 00000000000000..fbf12e621914f2 --- /dev/null +++ b/packages/material-ui-lab/src/DayPicker/PickersCalendar.tsx @@ -0,0 +1,244 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Typography from '@material-ui/core/Typography'; +import { createStyles, WithStyles, withStyles, Theme, useTheme } from '@material-ui/core/styles'; +import PickersDay, { PickersDayProps } from '../PickersDay/PickersDay'; +import { useUtils, useNow } from '../internal/pickers/hooks/useUtils'; +import { PickerOnChangeFn } from '../internal/pickers/hooks/useViews'; +import { DAY_SIZE, DAY_MARGIN } from '../internal/pickers/constants/dimensions'; +import { PickerSelectionState } from '../internal/pickers/hooks/usePickerState'; +import { useGlobalKeyDown, keycode } from '../internal/pickers/hooks/useKeyDown'; +import SlideTransition, { SlideDirection, SlideTransitionProps } from './PickersSlideTransition'; + +export interface ExportedCalendarProps + extends Pick< + PickersDayProps, + 'disableHighlightToday' | 'showDaysOutsideCurrentMonth' | 'allowSameDateSelection' + > { + /** + * Calendar onChange. + */ + onChange: PickerOnChangeFn; + /** + * Custom renderer for day. Check [DayComponentProps api](https://material-ui-pickers.dev/api/Day) @DateIOType. + */ + renderDay?: ( + day: TDate, + selectedDates: Array, + DayComponentProps: PickersDayProps, + ) => JSX.Element; + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl?: boolean; + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading?: boolean; + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading?: () => React.ReactNode; +} + +export interface PickersCalendarProps extends ExportedCalendarProps { + date: TDate | null | Array; + isDateDisabled: (day: TDate) => boolean; + slideDirection: SlideDirection; + currentMonth: TDate; + reduceAnimations: boolean; + focusedDay: TDate | null; + onFocusedDayChange: (newFocusedDay: TDate) => void; + isMonthSwitchingAnimating: boolean; + onMonthSwitchingAnimationEnd: () => void; + TransitionProps?: Partial; + className?: string; +} + +const weeksContainerHeight = (DAY_SIZE + DAY_MARGIN * 4) * 6; +export const styles = (theme: Theme) => + createStyles({ + root: { + minHeight: weeksContainerHeight, + }, + loadingContainer: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minHeight: weeksContainerHeight, + }, + weekContainer: { + overflow: 'hidden', + }, + week: { + margin: `${DAY_MARGIN}px 0`, + display: 'flex', + justifyContent: 'center', + }, + iconButton: { + zIndex: 1, + backgroundColor: theme.palette.background.paper, + }, + previousMonthButton: { + marginRight: 12, + }, + daysHeader: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + weekDayLabel: { + width: 36, + height: 40, + margin: '0 2px', + textAlign: 'center', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + color: theme.palette.text.secondary, + }, + }); + +export type PickersCalendarClassKey = keyof WithStyles['classes']; + +/** + * @ignore - do not document. + */ +function PickersCalendar(props: PickersCalendarProps & WithStyles) { + const { + allowKeyboardControl, + allowSameDateSelection, + onFocusedDayChange: changeFocusedDay, + classes, + className, + currentMonth, + date, + disableHighlightToday, + focusedDay, + isDateDisabled, + isMonthSwitchingAnimating, + loading, + onChange, + onMonthSwitchingAnimationEnd, + reduceAnimations, + renderDay, + renderLoading = () => ..., + showDaysOutsideCurrentMonth, + slideDirection, + TransitionProps, + } = props; + + const now = useNow(); + const utils = useUtils(); + const theme = useTheme(); + + const handleDaySelect = React.useCallback( + (day: TDate, isFinish: PickerSelectionState = 'finish') => { + // TODO possibly buggy line figure out and add tests + const finalDate = Array.isArray(date) ? day : utils.mergeDateAndTime(day, date || now); + + onChange(finalDate, isFinish); + }, + [date, now, onChange, utils], + ); + + const initialDate = Array.isArray(date) ? date[0] : date; + + const nowFocusedDay = focusedDay || initialDate || now; + useGlobalKeyDown(Boolean(allowKeyboardControl), { + [keycode.ArrowUp]: () => changeFocusedDay(utils.addDays(nowFocusedDay, -7)), + [keycode.ArrowDown]: () => changeFocusedDay(utils.addDays(nowFocusedDay, 7)), + [keycode.ArrowLeft]: () => + changeFocusedDay(utils.addDays(nowFocusedDay, theme.direction === 'ltr' ? -1 : 1)), + [keycode.ArrowRight]: () => + changeFocusedDay(utils.addDays(nowFocusedDay, theme.direction === 'ltr' ? 1 : -1)), + [keycode.Home]: () => changeFocusedDay(utils.startOfWeek(nowFocusedDay)), + [keycode.End]: () => changeFocusedDay(utils.endOfWeek(nowFocusedDay)), + [keycode.PageUp]: () => changeFocusedDay(utils.getNextMonth(nowFocusedDay)), + [keycode.PageDown]: () => changeFocusedDay(utils.getPreviousMonth(nowFocusedDay)), + }); + + const currentMonthNumber = utils.getMonth(currentMonth); + const selectedDates = (Array.isArray(date) ? date : [date]) + .filter(Boolean) + .map((selectedDateItem) => selectedDateItem && utils.startOfDay(selectedDateItem)); + + return ( + +
+ {utils.getWeekdays().map((day, i) => ( + + {day.charAt(0).toUpperCase()} + + ))} +
+ + {loading ? ( +
{renderLoading()}
+ ) : ( + +
+ {utils.getWeekArray(currentMonth).map((week) => ( +
+ {week.map((day) => { + const dayProps: PickersDayProps = { + key: (day as any)?.toString(), + day, + role: 'cell', + isAnimating: isMonthSwitchingAnimating, + disabled: isDateDisabled(day), + allowKeyboardControl, + allowSameDateSelection, + focused: + allowKeyboardControl && + Boolean(focusedDay) && + utils.isSameDay(day, nowFocusedDay), + today: utils.isSameDay(day, now), + outsideCurrentMonth: utils.getMonth(day) !== currentMonthNumber, + selected: selectedDates.some( + (selectedDate) => selectedDate && utils.isSameDay(selectedDate, day), + ), + disableHighlightToday, + showDaysOutsideCurrentMonth, + focusable: + allowKeyboardControl && + Boolean(nowFocusedDay) && + utils.toJsDate(nowFocusedDay).getDate() === utils.toJsDate(day).getDate(), + onDayFocus: changeFocusedDay, + onDaySelect: handleDaySelect, + }; + + return renderDay ? ( + renderDay(day, selectedDates, dayProps) + ) : ( + + ); + })} +
+ ))} +
+
+ )} +
+ ); +} + +export default withStyles(styles, { name: 'MuiPickersCalendar' })(PickersCalendar) as ( + props: PickersCalendarProps, +) => JSX.Element; diff --git a/packages/material-ui-lab/src/DayPicker/PickersCalendarHeader.tsx b/packages/material-ui-lab/src/DayPicker/PickersCalendarHeader.tsx new file mode 100644 index 00000000000000..3bd1bf3dede347 --- /dev/null +++ b/packages/material-ui-lab/src/DayPicker/PickersCalendarHeader.tsx @@ -0,0 +1,222 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import Fade from '@material-ui/core/Fade'; +import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import { SlideDirection } from './PickersSlideTransition'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import FadeTransitionGroup from './PickersFadeTransitionGroup'; +import { DateValidationProps } from '../internal/pickers/date-utils'; +// tslint:disable-next-line no-relative-import-in-test +import ArrowDropDownIcon from '../internal/svg-icons/ArrowDropDown'; +import ArrowSwitcher, { + ExportedArrowSwitcherProps, +} from '../internal/pickers/PickersArrowSwitcher'; +import { + usePreviousMonthDisabled, + useNextMonthDisabled, +} from '../internal/pickers/hooks/date-helpers-hooks'; +import { DatePickerView } from '../internal/pickers/typings/Views'; + +export type ExportedCalendarHeaderProps = Pick< + PickersCalendarHeaderProps, + | 'leftArrowIcon' + | 'rightArrowIcon' + | 'leftArrowButtonProps' + | 'rightArrowButtonProps' + | 'leftArrowButtonText' + | 'rightArrowButtonText' + | 'getViewSwitchingButtonText' +>; + +export interface PickersCalendarHeaderProps + extends ExportedArrowSwitcherProps, + Omit, 'shouldDisableDate'> { + openView: DatePickerView; + views: DatePickerView[]; + currentMonth: TDate; + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText?: (currentView: DatePickerView) => string; + reduceAnimations: boolean; + onViewChange?: (view: DatePickerView) => void; + onMonthChange: (date: TDate, slideDirection: SlideDirection) => void; +} + +export const styles = (theme: Theme) => + createStyles({ + root: { + display: 'flex', + alignItems: 'center', + marginTop: 16, + marginBottom: 8, + paddingLeft: 24, + paddingRight: 12, + // prevent jumping in safari + maxHeight: 30, + minHeight: 30, + }, + yearSelectionSwitcher: { + marginRight: 'auto', + }, + previousMonthButton: { + marginRight: 24, + }, + switchViewDropdown: { + willChange: 'transform', + transition: theme.transitions.create('transform'), + transform: 'rotate(0deg)', + }, + switchViewDropdownDown: { + transform: 'rotate(180deg)', + }, + monthTitleContainer: { + display: 'flex', + maxHeight: 30, + overflow: 'hidden', + cursor: 'pointer', + marginRight: 'auto', + }, + monthText: { + marginRight: 4, + }, + }); + +export type PickersCalendarHeaderClassKey = keyof WithStyles['classes']; + +function getSwitchingViewAriaText(view: DatePickerView) { + return view === 'year' + ? 'year view is open, switch to calendar view' + : 'calendar view is open, switch to year view'; +} + +/** + * @ignore - do not document. + */ +function PickersCalendarHeader( + props: PickersCalendarHeaderProps & WithStyles, +) { + const { + onViewChange, + classes, + currentMonth: month, + disableFuture, + disablePast, + getViewSwitchingButtonText = getSwitchingViewAriaText, + leftArrowButtonProps, + leftArrowButtonText = 'previous month', + leftArrowIcon, + maxDate, + minDate, + onMonthChange, + reduceAnimations, + rightArrowButtonProps, + rightArrowButtonText = 'next month', + rightArrowIcon, + openView: currentView, + views, + } = props; + + const utils = useUtils(); + + const selectNextMonth = () => onMonthChange(utils.getNextMonth(month), 'left'); + const selectPreviousMonth = () => onMonthChange(utils.getPreviousMonth(month), 'right'); + + const isNextMonthDisabled = useNextMonthDisabled(month, { disableFuture, maxDate }); + const isPreviousMonthDisabled = usePreviousMonthDisabled(month, { disablePast, minDate }); + + const toggleView = () => { + if (views.length === 1 || !onViewChange) { + return; + } + + if (views.length === 2) { + onViewChange(views.find((view) => view !== currentView) || views[0]); + } else { + // switching only between first 2 + const nextIndexToOpen = views.indexOf(currentView) !== 0 ? 0 : 1; + onViewChange(views[nextIndexToOpen]); + } + }; + + return ( + +
+
+ + + {utils.format(month, 'month')} + + + + + {utils.format(month, 'year')} + + + {views.length > 1 && ( + + + + )} +
+ + + +
+
+ ); +} + +PickersCalendarHeader.propTypes = { + leftArrowButtonText: PropTypes.string, + leftArrowIcon: PropTypes.node, + rightArrowButtonText: PropTypes.string, + rightArrowIcon: PropTypes.node, +}; + +export default withStyles(styles, { name: 'MuiPickersCalendarHeader' })(PickersCalendarHeader) as < + TDate +>( + props: PickersCalendarHeaderProps, +) => JSX.Element; diff --git a/packages/material-ui-lab/src/DayPicker/PickersFadeTransitionGroup.tsx b/packages/material-ui-lab/src/DayPicker/PickersFadeTransitionGroup.tsx new file mode 100644 index 00000000000000..772e429ed416aa --- /dev/null +++ b/packages/material-ui-lab/src/DayPicker/PickersFadeTransitionGroup.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +interface FadeTransitionProps { + transKey: React.Key; + className?: string; + reduceAnimations: boolean; + children: React.ReactElement; +} + +const animationDuration = 500; +export const styles = (theme: Theme) => + createStyles({ + root: { + display: 'block', + position: 'relative', + }, + fadeEnter: { + willChange: 'transform', + opacity: 0, + }, + fadeEnterActive: { + opacity: 1, + transition: theme.transitions.create('opacity', { + duration: animationDuration, + }), + }, + fadeExit: { + opacity: 1, + }, + fadeExitActive: { + opacity: 0, + transition: theme.transitions.create('opacity', { + duration: animationDuration / 2, + }), + }, + }); + +export type PickersFadeTransitionGroupClassKey = keyof WithStyles['classes']; + +/** + * @ignore - do not document. + */ +const FadeTransitionGroup: React.FC> = ({ + classes, + children, + className, + reduceAnimations, + transKey, +}) => { + if (reduceAnimations) { + return children; + } + + const transitionClasses = { + exit: classes.fadeExit, + enterActive: classes.fadeEnterActive, + enter: classes.fadeEnter, + exitActive: classes.fadeExitActive, + }; + + return ( + + React.cloneElement(element, { + classNames: transitionClasses, + }) + } + > + + {children} + + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersFadeTransition' })(FadeTransitionGroup); diff --git a/packages/material-ui-lab/src/DayPicker/PickersSlideTransition.tsx b/packages/material-ui-lab/src/DayPicker/PickersSlideTransition.tsx new file mode 100644 index 00000000000000..f83ac0e7c6f3a8 --- /dev/null +++ b/packages/material-ui-lab/src/DayPicker/PickersSlideTransition.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { CSSTransitionProps } from 'react-transition-group/CSSTransition'; + +export type SlideDirection = 'right' | 'left'; +export interface SlideTransitionProps extends Omit { + transKey: React.Key; + className?: string; + reduceAnimations: boolean; + slideDirection: SlideDirection; + children: React.ReactElement; +} + +export const slideAnimationDuration = 350; +export const styles = (theme: Theme) => { + const slideTransition = theme.transitions.create('transform', { + duration: slideAnimationDuration, + easing: 'cubic-bezier(0.35, 0.8, 0.4, 1)', + }); + + return createStyles({ + root: { + display: 'block', + position: 'relative', + overflowX: 'hidden', + '& > *': { + position: 'absolute', + top: 0, + right: 0, + left: 0, + }, + }, + 'slideEnter-left': { + willChange: 'transform', + transform: 'translate(100%)', + zIndex: 1, + }, + 'slideEnter-right': { + willChange: 'transform', + transform: 'translate(-100%)', + zIndex: 1, + }, + slideEnterActive: { + transform: 'translate(0%)', + transition: slideTransition, + }, + slideExit: { + transform: 'translate(0%)', + }, + 'slideExitActiveLeft-left': { + willChange: 'transform', + transform: 'translate(-100%)', + transition: slideTransition, + zIndex: 0, + }, + 'slideExitActiveLeft-right': { + willChange: 'transform', + transform: 'translate(100%)', + transition: slideTransition, + zIndex: 0, + }, + }); +}; + +export type PickersSlideTransitionClassKey = keyof WithStyles['classes']; + +/** + * @ignore - do not document. + */ +const SlideTransition: React.FC> = ({ + children, + classes, + className, + reduceAnimations, + slideDirection, + transKey, + ...other +}) => { + if (reduceAnimations) { + return
{children}
; + } + + const transitionClasses = { + exit: classes.slideExit, + enterActive: classes.slideEnterActive, + enter: classes[`slideEnter-${slideDirection}` as 'slideEnter-left' | 'slideEnter-right'], + exitActive: + classes[ + `slideExitActiveLeft-${slideDirection}` as + | 'slideExitActiveLeft-left' + | 'slideExitActiveLeft-right' + ], + }; + + return ( + + React.cloneElement(element, { + classNames: transitionClasses, + }) + } + > + + {children} + + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersSlideTransition' })(SlideTransition); diff --git a/packages/material-ui-lab/src/DayPicker/index.ts b/packages/material-ui-lab/src/DayPicker/index.ts new file mode 100644 index 00000000000000..8bcf99014cb200 --- /dev/null +++ b/packages/material-ui-lab/src/DayPicker/index.ts @@ -0,0 +1,4 @@ +export { default } from './DayPicker'; + +export type DayPickerClassKey = import('./DayPicker').DayPickerClassKey; +export type DayPickerProps = import('./DayPicker').DayPickerProps; diff --git a/packages/material-ui-lab/src/DayPicker/useCalendarState.tsx b/packages/material-ui-lab/src/DayPicker/useCalendarState.tsx new file mode 100644 index 00000000000000..7704b58c4e8b81 --- /dev/null +++ b/packages/material-ui-lab/src/DayPicker/useCalendarState.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import { SlideDirection } from './PickersSlideTransition'; +import { validateDate } from '../internal/pickers/date-utils'; +import { MuiPickersAdapter, useUtils, useNow } from '../internal/pickers/hooks/useUtils'; + +interface CalendarState { + isMonthSwitchingAnimating: boolean; + currentMonth: TDate; + focusedDay: TDate | null; + slideDirection: SlideDirection; +} + +type ReducerAction = { type: TType } & TAdditional; + +interface ChangeMonthPayload { + direction: SlideDirection; + newMonth: TDate; +} + +export const createCalendarStateReducer = ( + reduceAnimations: boolean, + disableSwitchToMonthOnDayFocus: boolean, + utils: MuiPickersAdapter, +) => ( + state: CalendarState, + action: + | ReducerAction<'finishMonthSwitchingAnimation'> + | ReducerAction<'changeMonth', ChangeMonthPayload> + | ReducerAction<'changeFocusedDay', { focusedDay: TDate }>, +): CalendarState => { + switch (action.type) { + case 'changeMonth': + return { + ...state, + slideDirection: action.direction, + currentMonth: action.newMonth, + isMonthSwitchingAnimating: !reduceAnimations, + }; + + case 'finishMonthSwitchingAnimation': + return { + ...state, + isMonthSwitchingAnimating: false, + }; + + case 'changeFocusedDay': { + const needMonthSwitch = + Boolean(action.focusedDay) && + !disableSwitchToMonthOnDayFocus && + !utils.isSameMonth(state.currentMonth, action.focusedDay); + + return { + ...state, + focusedDay: action.focusedDay, + isMonthSwitchingAnimating: needMonthSwitch && !reduceAnimations, + currentMonth: needMonthSwitch ? utils.startOfMonth(action.focusedDay) : state.currentMonth, + slideDirection: utils.isAfterDay(action.focusedDay, state.currentMonth) ? 'left' : 'right', + }; + } + + default: + throw new Error('missing support'); + } +}; + +type CalendarStateInput = Pick< + import('./DayPicker').DayPickerProps, + | 'disableFuture' + | 'disablePast' + | 'shouldDisableDate' + | 'date' + | 'reduceAnimations' + | 'onMonthChange' + | 'defaultCalendarMonth' + | 'minDate' + | 'maxDate' +> & { + disableSwitchToMonthOnDayFocus?: boolean; +}; + +export function useCalendarState({ + date, + defaultCalendarMonth, + disableFuture, + disablePast, + disableSwitchToMonthOnDayFocus = false, + maxDate, + minDate, + onMonthChange, + reduceAnimations, + shouldDisableDate, +}: CalendarStateInput) { + const now = useNow(); + const utils = useUtils(); + + const reducerFn = React.useRef( + createCalendarStateReducer(Boolean(reduceAnimations), disableSwitchToMonthOnDayFocus, utils), + ).current; + + const [calendarState, dispatch] = React.useReducer(reducerFn, { + isMonthSwitchingAnimating: false, + focusedDay: date, + currentMonth: utils.startOfMonth(date ?? defaultCalendarMonth ?? now), + slideDirection: 'left', + }); + + const handleChangeMonth = React.useCallback( + (payload: ChangeMonthPayload) => { + dispatch({ + type: 'changeMonth', + ...payload, + }); + + if (onMonthChange) { + onMonthChange(payload.newMonth); + } + }, + [onMonthChange], + ); + + const changeMonth = React.useCallback( + (newDate: TDate) => { + const newDateRequested = newDate ?? now; + if (utils.isSameMonth(newDateRequested, calendarState.currentMonth)) { + return; + } + + handleChangeMonth({ + newMonth: utils.startOfMonth(newDateRequested), + direction: utils.isAfterDay(newDateRequested, calendarState.currentMonth) + ? 'left' + : 'right', + }); + }, + [calendarState.currentMonth, handleChangeMonth, now, utils], + ); + + const isDateDisabled = React.useCallback( + (day: TDate | null) => + validateDate(utils, day, { + disablePast, + disableFuture, + minDate, + maxDate, + shouldDisableDate, + }) !== null, + [disableFuture, disablePast, maxDate, minDate, shouldDisableDate, utils], + ); + + const onMonthSwitchingAnimationEnd = React.useCallback(() => { + dispatch({ type: 'finishMonthSwitchingAnimation' }); + }, []); + + const changeFocusedDay = React.useCallback( + (newFocusedDate: TDate) => { + if (!isDateDisabled(newFocusedDate)) { + dispatch({ type: 'changeFocusedDay', focusedDay: newFocusedDate }); + } + }, + [isDateDisabled], + ); + + return { + calendarState, + changeMonth, + changeFocusedDay, + isDateDisabled, + onMonthSwitchingAnimationEnd, + handleChangeMonth, + }; +} diff --git a/packages/material-ui-lab/src/DesktopDatePicker/DesktopDatePicker.tsx b/packages/material-ui-lab/src/DesktopDatePicker/DesktopDatePicker.tsx new file mode 100644 index 00000000000000..79766310bbf9ca --- /dev/null +++ b/packages/material-ui-lab/src/DesktopDatePicker/DesktopDatePicker.tsx @@ -0,0 +1,216 @@ +import PropTypes from 'prop-types'; +import { + datePickerConfig, + DatePickerGenericComponent, + BaseDatePickerProps, +} from '../DatePicker/DatePicker'; +import { DesktopWrapper } from '../internal/pickers/wrappers/Wrapper'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const DesktopDatePicker = makePickerWithStateAndWrapper>( + DesktopWrapper, + { + name: 'MuiDesktopDatePicker', + ...datePickerConfig, + }, +) as DatePickerGenericComponent; + +(DesktopDatePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Popper props passed down to [Popper](https://material-ui.com/api/popper/) component. + */ + PopperProps: PropTypes.object, + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * Custom component for popper [Transition](https://material-ui.com/components/transitions/#transitioncomponent-prop). + */ + TransitionComponent: PropTypes.elementType, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), +}; + +export type DesktopDatePickerProps = React.ComponentProps; + +export default DesktopDatePicker; diff --git a/packages/material-ui-lab/src/DesktopDatePicker/index.ts b/packages/material-ui-lab/src/DesktopDatePicker/index.ts new file mode 100644 index 00000000000000..27bff9dadd55ef --- /dev/null +++ b/packages/material-ui-lab/src/DesktopDatePicker/index.ts @@ -0,0 +1,2 @@ +export * from './DesktopDatePicker'; +export { default } from './DesktopDatePicker'; diff --git a/packages/material-ui-lab/src/DesktopDateRangePicker/DesktopDateRangePicker.tsx b/packages/material-ui-lab/src/DesktopDateRangePicker/DesktopDateRangePicker.tsx new file mode 100644 index 00000000000000..6650517ca45471 --- /dev/null +++ b/packages/material-ui-lab/src/DesktopDateRangePicker/DesktopDateRangePicker.tsx @@ -0,0 +1,335 @@ +import PropTypes from 'prop-types'; +import { makeDateRangePicker } from '../DateRangePicker/makeDateRangePicker'; +import DesktopTooltipWrapper from '../internal/pickers/wrappers/DesktopTooltipWrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const DesktopDateRangePicker = makeDateRangePicker( + 'MuiPickersDateRangePicker', + DesktopTooltipWrapper, +); + +(DesktopDateRangePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * The number of calendars that render on **desktop**. + * @default 2 + */ + calendars: PropTypes.oneOf([1, 2, 3]), + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * if `true` after selecting `start` date calendar will not automatically switch to the month of `end` date + * @default false + */ + disableAutoMonthSwitching: PropTypes.bool, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * Text for end input label and toolbar placeholder. + * @default "end" + */ + endText: PropTypes.node, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * Max selectable date. @DateIOType + * @default Date(2099-31-12) + */ + maxDate: PropTypes.any, + /** + * Min selectable date. @DateIOType + * @default Date(1900-01-01) + */ + minDate: PropTypes.any, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Popper props passed down to [Popper](https://material-ui.com/api/popper/) component. + */ + PopperProps: PropTypes.object, + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Custom renderer for `` days. @DateIOType + * @example (date, DateRangeDayProps) => + */ + renderDay: PropTypes.func, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `startProps` and `endProps` arguments of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api), + * that you need to forward to the range start/end inputs respectively. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example + * ```jsx + * ( + * + * + * to + * + * ; + * )} + * /> + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Text for start input label and toolbar placeholder. + * @default "Start" + */ + startText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * Custom component for popper [Transition](https://material-ui.com/components/transitions/#transitioncomponent-prop). + */ + TransitionComponent: PropTypes.elementType, + /** + * The value of the picker. + */ + value: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + ).isRequired, +}; + +export type DesktopDateRangePickerProps = React.ComponentProps; + +export type DateRange = import('../DateRangePicker/RangeTypes').DateRange; + +export default DesktopDateRangePicker; diff --git a/packages/material-ui-lab/src/DesktopDateRangePicker/index.ts b/packages/material-ui-lab/src/DesktopDateRangePicker/index.ts new file mode 100644 index 00000000000000..70a217d0a9ab86 --- /dev/null +++ b/packages/material-ui-lab/src/DesktopDateRangePicker/index.ts @@ -0,0 +1,2 @@ +export * from './DesktopDateRangePicker'; +export { default } from './DesktopDateRangePicker'; diff --git a/packages/material-ui-lab/src/DesktopDateTimePicker/DesktopDateTimePicker.tsx b/packages/material-ui-lab/src/DesktopDateTimePicker/DesktopDateTimePicker.tsx new file mode 100644 index 00000000000000..f5a78b963a9e53 --- /dev/null +++ b/packages/material-ui-lab/src/DesktopDateTimePicker/DesktopDateTimePicker.tsx @@ -0,0 +1,408 @@ +import PropTypes from 'prop-types'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; +import { + BaseDateTimePickerProps, + dateTimePickerConfig, + DateTimePickerGenericComponent, +} from '../DateTimePicker/DateTimePicker'; +import { DesktopWrapper } from '../internal/pickers/wrappers/Wrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const DesktopDateTimePicker = makePickerWithStateAndWrapper>( + DesktopWrapper, + { + name: 'MuiDesktopDateTimePicker', + ...dateTimePickerConfig, + }, +) as DateTimePickerGenericComponent; + +(DesktopDateTimePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Date tab icon. + */ + dateRangeIcon: PropTypes.node, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText: PropTypes.func, + /** + * To show tabs. + */ + hideTabs: PropTypes.bool, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Minimal selectable moment of time with binding to date, to set max time in each day use `maxTime`. + */ + maxDateTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + maxTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Minimal selectable moment of time with binding to date, to set min time in each day use `minTime`. + */ + minDateTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Callback firing on year change @DateIOType. + */ + onYearChange: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * First view to show. + */ + openTo: PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'seconds', 'year']), + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Popper props passed down to [Popper](https://material-ui.com/api/popper/) component. + */ + PopperProps: PropTypes.object, + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Custom renderer for day. Check [DayComponentProps api](https://material-ui-pickers.dev/api/Day) @DateIOType. + */ + renderDay: PropTypes.func, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Time tab icon. + */ + timeIcon: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * Custom component for popper [Transition](https://material-ui.com/components/transitions/#transitioncomponent-prop). + */ + TransitionComponent: PropTypes.elementType, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Array of views to show. + */ + views: PropTypes.arrayOf( + PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'year']).isRequired, + ), +}; + +export type DesktopDateTimePickerProps = React.ComponentProps; + +export default DesktopDateTimePicker; diff --git a/packages/material-ui-lab/src/DesktopDateTimePicker/index.ts b/packages/material-ui-lab/src/DesktopDateTimePicker/index.ts new file mode 100644 index 00000000000000..dd933ec258a6e7 --- /dev/null +++ b/packages/material-ui-lab/src/DesktopDateTimePicker/index.ts @@ -0,0 +1,2 @@ +export * from './DesktopDateTimePicker'; +export { default } from './DesktopDateTimePicker'; diff --git a/packages/material-ui-lab/src/DesktopTimePicker/DesktopTimePicker.tsx b/packages/material-ui-lab/src/DesktopTimePicker/DesktopTimePicker.tsx new file mode 100644 index 00000000000000..c795fe62f09599 --- /dev/null +++ b/packages/material-ui-lab/src/DesktopTimePicker/DesktopTimePicker.tsx @@ -0,0 +1,256 @@ +import PropTypes from 'prop-types'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; +import { + BaseTimePickerProps, + timePickerConfig, + TimePickerGenericComponent, +} from '../TimePicker/TimePicker'; +import { DesktopWrapper } from '../internal/pickers/wrappers/Wrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const DesktopTimePicker = makePickerWithStateAndWrapper(DesktopWrapper, { + name: 'MuiDesktopTimePicker', + ...timePickerConfig, +}) as TimePickerGenericComponent; + +(DesktopTimePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * First view to show. + */ + openTo: PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'seconds', 'year']), + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Popper props passed down to [Popper](https://material-ui.com/api/popper/) component. + */ + PopperProps: PropTypes.object, + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * Custom component for popper [Transition](https://material-ui.com/components/transitions/#transitioncomponent-prop). + */ + TransitionComponent: PropTypes.elementType, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Array of views to show. + */ + views: PropTypes.arrayOf(PropTypes.oneOf(['hours', 'minutes', 'seconds']).isRequired), +}; + +export type DesktopTimePickerProps = React.ComponentProps; + +export default DesktopTimePicker; diff --git a/packages/material-ui-lab/src/DesktopTimePicker/index.ts b/packages/material-ui-lab/src/DesktopTimePicker/index.ts new file mode 100644 index 00000000000000..49e5d11a63b727 --- /dev/null +++ b/packages/material-ui-lab/src/DesktopTimePicker/index.ts @@ -0,0 +1,2 @@ +export { default } from './DesktopTimePicker'; +export * from './DesktopTimePicker'; diff --git a/packages/material-ui-lab/src/LocalizationProvider/LocalizationProvider.tsx b/packages/material-ui-lab/src/LocalizationProvider/LocalizationProvider.tsx new file mode 100644 index 00000000000000..d4446d2c997d2f --- /dev/null +++ b/packages/material-ui-lab/src/LocalizationProvider/LocalizationProvider.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { DateIOFormats, IUtils } from '@date-io/core/IUtils'; + +export type MuiPickersAdapter = IUtils; + +export const MuiPickersAdapterContext = React.createContext(null); + +export interface LocalizationProviderProps { + children?: React.ReactNode; + /** DateIO adapter class function */ + dateAdapter: new (...args: any) => MuiPickersAdapter; + /** Formats that are used for any child pickers */ + dateFormats?: Partial; + /** + * Date library instance you are using, if it has some global overrides + * ```jsx + * dateLibInstance={momentTimeZone} + * ``` + */ + dateLibInstance?: any; + /** Locale for the date library you are using */ + locale?: string | object; +} + +/** + * @ignore - do not document. + */ +const LocalizationProvider: React.FC = (props) => { + const { children, dateAdapter: Utils, dateFormats, dateLibInstance, locale } = props; + const utils = React.useMemo( + () => new Utils({ locale, formats: dateFormats, instance: dateLibInstance }), + [Utils, locale, dateFormats, dateLibInstance], + ); + + return ( + {children} + ); +}; + +(LocalizationProvider as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, + /** + * DateIO adapter class function + */ + dateAdapter: PropTypes.func.isRequired, + /** + * Formats that are used for any child pickers + */ + dateFormats: PropTypes.shape({ + dayOfMonth: PropTypes.string, + fullDate: PropTypes.string, + fullDateTime: PropTypes.string, + fullDateTime12h: PropTypes.string, + fullDateTime24h: PropTypes.string, + fullDateWithWeekday: PropTypes.string, + fullTime: PropTypes.string, + fullTime12h: PropTypes.string, + fullTime24h: PropTypes.string, + hours12h: PropTypes.string, + hours24h: PropTypes.string, + keyboardDate: PropTypes.string, + keyboardDateTime: PropTypes.string, + keyboardDateTime12h: PropTypes.string, + keyboardDateTime24h: PropTypes.string, + minutes: PropTypes.string, + month: PropTypes.string, + monthAndDate: PropTypes.string, + monthAndYear: PropTypes.string, + monthShort: PropTypes.string, + normalDate: PropTypes.string, + normalDateWithWeekday: PropTypes.string, + seconds: PropTypes.string, + shortDate: PropTypes.string, + weekday: PropTypes.string, + weekdayShort: PropTypes.string, + year: PropTypes.string, + }), + /** + * Date library instance you are using, if it has some global overrides + * ```jsx + * dateLibInstance={momentTimeZone} + * ``` + */ + dateLibInstance: PropTypes.any, + /** + * Locale for the date library you are using + */ + locale: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), +}; + +export default LocalizationProvider; diff --git a/packages/material-ui-lab/src/LocalizationProvider/index.ts b/packages/material-ui-lab/src/LocalizationProvider/index.ts new file mode 100644 index 00000000000000..09d5ca51f422fb --- /dev/null +++ b/packages/material-ui-lab/src/LocalizationProvider/index.ts @@ -0,0 +1,2 @@ +export * from './LocalizationProvider'; +export { default } from './LocalizationProvider'; diff --git a/packages/material-ui-lab/src/MobileDatePicker/MobileDatePicker.tsx b/packages/material-ui-lab/src/MobileDatePicker/MobileDatePicker.tsx new file mode 100644 index 00000000000000..679eaa31c21315 --- /dev/null +++ b/packages/material-ui-lab/src/MobileDatePicker/MobileDatePicker.tsx @@ -0,0 +1,242 @@ +import PropTypes from 'prop-types'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; +import { + BaseDatePickerProps, + datePickerConfig, + DatePickerGenericComponent, +} from '../DatePicker/DatePicker'; +import { MobileWrapper } from '../internal/pickers/wrappers/Wrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const MobileDatePicker = makePickerWithStateAndWrapper>( + MobileWrapper, + { + name: 'MuiMobileDatePicker', + ...datePickerConfig, + }, +) as DatePickerGenericComponent; + +(MobileDatePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText: PropTypes.node, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable: PropTypes.bool, + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText: PropTypes.node, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * "OK" button text. + * @default "OK" + */ + okText: PropTypes.node, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), +}; + +export type MobileDatePickerProps = React.ComponentProps; + +export default MobileDatePicker; diff --git a/packages/material-ui-lab/src/MobileDatePicker/index.ts b/packages/material-ui-lab/src/MobileDatePicker/index.ts new file mode 100644 index 00000000000000..806f5f2e64f390 --- /dev/null +++ b/packages/material-ui-lab/src/MobileDatePicker/index.ts @@ -0,0 +1,2 @@ +export * from './MobileDatePicker'; +export { default } from './MobileDatePicker'; diff --git a/packages/material-ui-lab/src/MobileDateRangePicker/MobileDateRangePicker.tsx b/packages/material-ui-lab/src/MobileDateRangePicker/MobileDateRangePicker.tsx new file mode 100644 index 00000000000000..d1e0d286814d20 --- /dev/null +++ b/packages/material-ui-lab/src/MobileDateRangePicker/MobileDateRangePicker.tsx @@ -0,0 +1,358 @@ +import PropTypes from 'prop-types'; +import { makeDateRangePicker } from '../DateRangePicker/makeDateRangePicker'; +import MobileWrapper from '../internal/pickers/wrappers/MobileWrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const MobileDateRangePicker = makeDateRangePicker('MuiPickersDateRangePicker', MobileWrapper); + +(MobileDateRangePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * The number of calendars that render on **desktop**. + * @default 2 + */ + calendars: PropTypes.oneOf([1, 2, 3]), + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText: PropTypes.node, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable: PropTypes.bool, + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText: PropTypes.node, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps: PropTypes.object, + /** + * if `true` after selecting `start` date calendar will not automatically switch to the month of `end` date + * @default false + */ + disableAutoMonthSwitching: PropTypes.bool, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * Text for end input label and toolbar placeholder. + * @default "end" + */ + endText: PropTypes.node, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * Max selectable date. @DateIOType + * @default Date(2099-31-12) + */ + maxDate: PropTypes.any, + /** + * Min selectable date. @DateIOType + * @default Date(1900-01-01) + */ + minDate: PropTypes.any, + /** + * "OK" button text. + * @default "OK" + */ + okText: PropTypes.node, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Custom renderer for `` days. @DateIOType + * @example (date, DateRangeDayProps) => + */ + renderDay: PropTypes.func, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `startProps` and `endProps` arguments of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api), + * that you need to forward to the range start/end inputs respectively. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example + * ```jsx + * ( + * + * + * to + * + * ; + * )} + * /> + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Text for start input label and toolbar placeholder. + * @default "Start" + */ + startText: PropTypes.node, + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + ).isRequired, +}; + +export type MobileDateRangePickerProps = React.ComponentProps; + +export type DateRange = import('../DateRangePicker/RangeTypes').DateRange; + +export default MobileDateRangePicker; diff --git a/packages/material-ui-lab/src/MobileDateRangePicker/index.ts b/packages/material-ui-lab/src/MobileDateRangePicker/index.ts new file mode 100644 index 00000000000000..85184d22ee3d98 --- /dev/null +++ b/packages/material-ui-lab/src/MobileDateRangePicker/index.ts @@ -0,0 +1,2 @@ +export * from './MobileDateRangePicker'; +export { default } from './MobileDateRangePicker'; diff --git a/packages/material-ui-lab/src/MobileDateTimePicker/MobileDateTimePicker.tsx b/packages/material-ui-lab/src/MobileDateTimePicker/MobileDateTimePicker.tsx new file mode 100644 index 00000000000000..8f4e9aa91e0e98 --- /dev/null +++ b/packages/material-ui-lab/src/MobileDateTimePicker/MobileDateTimePicker.tsx @@ -0,0 +1,434 @@ +import PropTypes from 'prop-types'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; +import { + BaseDateTimePickerProps, + dateTimePickerConfig, + DateTimePickerGenericComponent, +} from '../DateTimePicker/DateTimePicker'; +import { MobileWrapper } from '../internal/pickers/wrappers/Wrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const MobileDateTimePicker = makePickerWithStateAndWrapper>( + MobileWrapper, + { + name: 'MuiMobileDateTimePicker', + ...dateTimePickerConfig, + }, +) as DateTimePickerGenericComponent; + +(MobileDateTimePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText: PropTypes.node, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable: PropTypes.bool, + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText: PropTypes.node, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Date tab icon. + */ + dateRangeIcon: PropTypes.node, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText: PropTypes.func, + /** + * To show tabs. + */ + hideTabs: PropTypes.bool, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Minimal selectable moment of time with binding to date, to set max time in each day use `maxTime`. + */ + maxDateTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + maxTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Minimal selectable moment of time with binding to date, to set min time in each day use `minTime`. + */ + minDateTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * "OK" button text. + * @default "OK" + */ + okText: PropTypes.node, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Callback firing on year change @DateIOType. + */ + onYearChange: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * First view to show. + */ + openTo: PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'seconds', 'year']), + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Custom renderer for day. Check [DayComponentProps api](https://material-ui-pickers.dev/api/Day) @DateIOType. + */ + renderDay: PropTypes.func, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Time tab icon. + */ + timeIcon: PropTypes.node, + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Array of views to show. + */ + views: PropTypes.arrayOf( + PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'year']).isRequired, + ), +}; + +export type MobileDateTimePickerProps = React.ComponentProps; + +export default MobileDateTimePicker; diff --git a/packages/material-ui-lab/src/MobileDateTimePicker/index.ts b/packages/material-ui-lab/src/MobileDateTimePicker/index.ts new file mode 100644 index 00000000000000..5b8406580eeb54 --- /dev/null +++ b/packages/material-ui-lab/src/MobileDateTimePicker/index.ts @@ -0,0 +1,2 @@ +export * from './MobileDateTimePicker'; +export { default } from './MobileDateTimePicker'; diff --git a/packages/material-ui-lab/src/MobileTimePicker/MobileTimePicker.tsx b/packages/material-ui-lab/src/MobileTimePicker/MobileTimePicker.tsx new file mode 100644 index 00000000000000..ef2682fe228f3b --- /dev/null +++ b/packages/material-ui-lab/src/MobileTimePicker/MobileTimePicker.tsx @@ -0,0 +1,282 @@ +import PropTypes from 'prop-types'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; +import { + BaseTimePickerProps, + timePickerConfig, + TimePickerGenericComponent, +} from '../TimePicker/TimePicker'; +import { MobileWrapper } from '../internal/pickers/wrappers/Wrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const MobileTimePicker = makePickerWithStateAndWrapper(MobileWrapper, { + name: 'MuiMobileTimePicker', + ...timePickerConfig, +}) as TimePickerGenericComponent; + +(MobileTimePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText: PropTypes.node, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable: PropTypes.bool, + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText: PropTypes.node, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * "OK" button text. + * @default "OK" + */ + okText: PropTypes.node, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * First view to show. + */ + openTo: PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'seconds', 'year']), + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Array of views to show. + */ + views: PropTypes.arrayOf(PropTypes.oneOf(['hours', 'minutes', 'seconds']).isRequired), +}; + +export type MobileTimePickerProps = React.ComponentProps; + +export default MobileTimePicker; diff --git a/packages/material-ui-lab/src/MobileTimePicker/index.ts b/packages/material-ui-lab/src/MobileTimePicker/index.ts new file mode 100644 index 00000000000000..7ad8bb56683686 --- /dev/null +++ b/packages/material-ui-lab/src/MobileTimePicker/index.ts @@ -0,0 +1,2 @@ +export * from './MobileTimePicker'; +export { default } from './MobileTimePicker'; diff --git a/packages/material-ui-lab/src/MonthPicker/MonthPicker.test.tsx b/packages/material-ui-lab/src/MonthPicker/MonthPicker.test.tsx new file mode 100644 index 00000000000000..12e39968181ca4 --- /dev/null +++ b/packages/material-ui-lab/src/MonthPicker/MonthPicker.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { spy } from 'sinon'; +import { expect } from 'chai'; +import { getClasses, createMount, fireEvent, screen, describeConformance } from 'test/utils'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import MonthPicker from '@material-ui/lab/MonthPicker'; +import { createPickerRender } from '../internal/pickers/test-utils'; + +describe('', () => { + const mount = createMount(); + const render = createPickerRender({ strict: false }); + let classes: Record; + + const localizedMount = (node: React.ReactNode) => { + return mount({node}); + }; + + before(() => { + classes = getClasses( + {}} + />, + ); + }); + + describeConformance( + {}} + />, + () => ({ + classes, + inheritComponent: 'div', + mount: localizedMount, + refInstanceof: window.HTMLDivElement, + // cannot test reactTestRenderer because of required context + skip: ['componentProp', 'propsSpread', 'reactTestRenderer'], + }), + ); + + it('allows to pick year standalone', () => { + const onChangeMock = spy(); + render( + , + ); + + fireEvent.click(screen.getByText('May', { selector: 'button' })); + expect((onChangeMock.args[0][0] as Date).getMonth()).to.equal(4); // month index starting from 0 + }); +}); diff --git a/packages/material-ui-lab/src/MonthPicker/MonthPicker.tsx b/packages/material-ui-lab/src/MonthPicker/MonthPicker.tsx new file mode 100644 index 00000000000000..97e7c2ecf1bb2d --- /dev/null +++ b/packages/material-ui-lab/src/MonthPicker/MonthPicker.tsx @@ -0,0 +1,151 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; +import clsx from 'clsx'; +import PickersMonth from './PickersMonth'; +import { useUtils, useNow } from '../internal/pickers/hooks/useUtils'; +import { PickerOnChangeFn } from '../internal/pickers/hooks/useViews'; + +export interface MonthPickerProps { + /** Date value for the MonthPicker */ + date: TDate | null; + /** Minimal selectable date. */ + minDate: TDate; + /** Maximal selectable date. */ + maxDate: TDate; + /** Callback fired on date change. */ + onChange: PickerOnChangeFn; + /** If `true` past days are disabled. */ + disablePast?: boolean | null; + /** If `true` future days are disabled. */ + disableFuture?: boolean | null; + className?: string; + onMonthChange?: (date: TDate) => void | Promise; +} + +export const styles = createStyles({ + root: { + width: 310, + display: 'flex', + flexWrap: 'wrap', + alignContent: 'stretch', + }, +}); + +export type MonthPickerClassKey = keyof WithStyles['classes']; + +/** + * @ignore - do not document. + */ +const MonthPicker = React.forwardRef(function MonthPicker( + props: MonthPickerProps & WithStyles, + ref: React.Ref, +) { + const { + className, + classes, + date, + disableFuture, + disablePast, + maxDate, + minDate, + onChange, + onMonthChange, + } = props; + + const utils = useUtils(); + const now = useNow(); + const currentMonth = utils.getMonth(date || now); + + const shouldDisableMonth = (month: TDate) => { + const firstEnabledMonth = utils.startOfMonth( + disablePast && utils.isAfter(now, minDate) ? now : minDate, + ); + + const lastEnabledMonth = utils.startOfMonth( + disableFuture && utils.isBefore(now, maxDate) ? now : maxDate, + ); + + const isBeforeFirstEnabled = utils.isBefore(month, firstEnabledMonth); + const isAfterLastEnabled = utils.isAfter(month, lastEnabledMonth); + + return isBeforeFirstEnabled || isAfterLastEnabled; + }; + + const onMonthSelect = (month: number) => { + const newDate = utils.setMonth(date || now, month); + + onChange(newDate, 'finish'); + if (onMonthChange) { + onMonthChange(newDate); + } + }; + + return ( +
+ {utils.getMonthArray(date || now).map((month) => { + const monthNumber = utils.getMonth(month); + const monthText = utils.format(month, 'monthShort'); + + return ( + + {monthText} + + ); + })} +
+ ); +}); + +(MonthPicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + classes: PropTypes.object.isRequired, + /** + * @ignore + */ + className: PropTypes.string, + /** + * Date value for the MonthPicker + */ + date: PropTypes.any, + /** + * If `true` future days are disabled. + */ + disableFuture: PropTypes.bool, + /** + * If `true` past days are disabled. + */ + disablePast: PropTypes.bool, + /** + * Maximal selectable date. + */ + maxDate: PropTypes.any.isRequired, + /** + * Minimal selectable date. + */ + minDate: PropTypes.any.isRequired, + /** + * Callback fired on date change. + */ + onChange: PropTypes.func.isRequired, + /** + * @ignore + */ + onMonthChange: PropTypes.func, +}; + +export default withStyles(styles, { name: 'MuiMonthPicker' })(MonthPicker) as ( + props: MonthPickerProps & React.RefAttributes, +) => JSX.Element; diff --git a/packages/material-ui-lab/src/MonthPicker/PickersMonth.tsx b/packages/material-ui-lab/src/MonthPicker/PickersMonth.tsx new file mode 100644 index 00000000000000..1ab1454700f6b9 --- /dev/null +++ b/packages/material-ui-lab/src/MonthPicker/PickersMonth.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Typography from '@material-ui/core/Typography'; +import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles'; +import { onSpaceOrEnter } from '../internal/pickers/utils'; + +export interface MonthProps { + children: React.ReactNode; + disabled?: boolean; + onSelect: (value: any) => void; + selected?: boolean; + value: any; +} + +export const styles = (theme: Theme) => + createStyles({ + root: { + flex: '1 0 33.33%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + outline: 'none', + height: 64, + transition: theme.transitions.create('font-size', { duration: '100ms' }), + '&:focus': { + color: theme.palette.primary.main, + fontWeight: theme.typography.fontWeightMedium, + }, + '&:disabled': { + pointerEvents: 'none', + color: theme.palette.text.secondary, + }, + '&$selected': { + color: theme.palette.primary.main, + fontWeight: theme.typography.fontWeightMedium, + }, + }, + selected: {}, + }); + +export type PickersMonthClassKey = keyof WithStyles['classes']; + +/** + * @ignore - do not document. + */ +const PickersMonth: React.FC> = (props) => { + const { classes, disabled, onSelect, selected, value, ...other } = props; + const handleSelection = () => { + onSelect(value); + }; + + return ( + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersMonth' })(PickersMonth); diff --git a/packages/material-ui-lab/src/MonthPicker/index.ts b/packages/material-ui-lab/src/MonthPicker/index.ts new file mode 100644 index 00000000000000..abb37b0aca9426 --- /dev/null +++ b/packages/material-ui-lab/src/MonthPicker/index.ts @@ -0,0 +1,4 @@ +export { default } from './MonthPicker'; + +export type MonthPickerClassKey = import('./MonthPicker').MonthPickerClassKey; +export type MonthPickerProps = import('./MonthPicker').MonthPickerProps; diff --git a/packages/material-ui-lab/src/PickersCalendarSkeleton/PickersCalendarSkeleton.tsx b/packages/material-ui-lab/src/PickersCalendarSkeleton/PickersCalendarSkeleton.tsx new file mode 100644 index 00000000000000..a264649af809c3 --- /dev/null +++ b/packages/material-ui-lab/src/PickersCalendarSkeleton/PickersCalendarSkeleton.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import Skeleton from '@material-ui/core/Skeleton'; +import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles'; +import { DAY_SIZE, DAY_MARGIN } from '../internal/pickers/constants/dimensions'; +import { styles as calendarStyles } from '../DayPicker/PickersCalendar'; + +export interface PickersCalendarSkeletonProps extends React.HTMLProps {} + +export const styles = (theme: Theme) => + createStyles({ + ...calendarStyles(theme), + root: { + alignSelf: 'start', + }, + daySkeleton: { + margin: `0 ${DAY_MARGIN}px`, + }, + hidden: { + visibility: 'hidden', + }, + }); + +export type PickersCalendarSkeletonClassKey = keyof WithStyles['classes']; + +const monthMap = [ + [0, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 0, 0, 0], +]; + +/** + * @ignore - do not document. + */ +const PickersCalendarSkeleton: React.FC< + PickersCalendarSkeletonProps & WithStyles +> = (props) => { + const { className, classes, ...other } = props; + + return ( +
+ {monthMap.map((week, index) => ( +
+ {week.map((day, index2) => ( + + ))} +
+ ))} +
+ ); +}; + +(PickersCalendarSkeleton as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + classes: PropTypes.object.isRequired, + /** + * @ignore + */ + className: PropTypes.string, +}; + +export default withStyles(styles, { name: 'MuiCalendarSkeleton' })(PickersCalendarSkeleton); diff --git a/packages/material-ui-lab/src/PickersCalendarSkeleton/index.ts b/packages/material-ui-lab/src/PickersCalendarSkeleton/index.ts new file mode 100644 index 00000000000000..6cae7d8b33c215 --- /dev/null +++ b/packages/material-ui-lab/src/PickersCalendarSkeleton/index.ts @@ -0,0 +1,3 @@ +export * from './PickersCalendarSkeleton'; + +export { default } from './PickersCalendarSkeleton'; diff --git a/packages/material-ui-lab/src/PickersDay/PickersDay.test.tsx b/packages/material-ui-lab/src/PickersDay/PickersDay.test.tsx new file mode 100644 index 00000000000000..e016eb5cfbcba0 --- /dev/null +++ b/packages/material-ui-lab/src/PickersDay/PickersDay.test.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { getClasses, createMount, describeConformance } from 'test/utils'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import PickersDay from '@material-ui/lab/PickersDay'; + +describe('', () => { + const mount = createMount(); + let classes: Record; + + const localizedMount = (node: React.ReactNode) => { + return mount({node}); + }; + + before(() => { + classes = getClasses( + {}} + />, + ); + }); + + describeConformance( + {}} + />, + () => ({ + classes, + inheritComponent: 'button', + mount: localizedMount, + refInstanceof: window.HTMLButtonElement, + // cannot test reactTestRenderer because of required context + skip: ['componentProp', 'reactTestRenderer'], + }), + ); +}); diff --git a/packages/material-ui-lab/src/PickersDay/PickersDay.tsx b/packages/material-ui-lab/src/PickersDay/PickersDay.tsx new file mode 100644 index 00000000000000..e74ef05239f06e --- /dev/null +++ b/packages/material-ui-lab/src/PickersDay/PickersDay.tsx @@ -0,0 +1,366 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import ButtonBase, { ButtonBaseProps } from '@material-ui/core/ButtonBase'; +import { createStyles, WithStyles, withStyles, Theme, alpha } from '@material-ui/core/styles'; +import { useForkRef } from '@material-ui/core'; +import { ExtendMui } from '../internal/pickers/typings/helpers'; +import { onSpaceOrEnter } from '../internal/pickers/utils'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import { DAY_SIZE, DAY_MARGIN } from '../internal/pickers/constants/dimensions'; +import { useCanAutoFocus } from '../internal/pickers/hooks/useCanAutoFocus'; +import { PickerSelectionState } from '../internal/pickers/hooks/usePickerState'; + +export const styles = (theme: Theme) => + createStyles({ + root: { + ...theme.typography.caption, + width: DAY_SIZE, + height: DAY_SIZE, + borderRadius: '50%', + padding: 0, + // background required here to prevent collides with the other days when animating with transition group + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.primary, + '&:hover': { + backgroundColor: alpha(theme.palette.action.active, theme.palette.action.hoverOpacity), + }, + '&:focus': { + backgroundColor: alpha(theme.palette.action.active, theme.palette.action.hoverOpacity), + '&$selected': { + willChange: 'background-color', + backgroundColor: theme.palette.primary.dark, + }, + }, + '&$selected': { + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, + fontWeight: theme.typography.fontWeightMedium, + transition: theme.transitions.create('background-color', { + duration: theme.transitions.duration.short, + }), + '&:hover': { + willChange: 'background-color', + backgroundColor: theme.palette.primary.dark, + }, + }, + '&$disabled': { + color: theme.palette.text.secondary, + }, + }, + dayWithMargin: { + margin: `0 ${DAY_MARGIN}px`, + }, + dayOutsideMonth: { + color: theme.palette.text.secondary, + }, + hiddenDaySpacingFiller: { + visibility: 'hidden', + }, + today: { + '&:not($selected)': { + border: `1px solid ${theme.palette.text.secondary}`, + }, + }, + dayLabel: { + // need for overrides + }, + selected: {}, + disabled: {}, + }); + +export type PickersDayClassKey = keyof WithStyles['classes']; + +export interface PickersDayProps extends ExtendMui { + /** + * The date to show. + */ + day: TDate; + /** + * If `true`, the day element will be focused during the first mount. + */ + focused?: boolean; + /** + * If `true`, allows to focus by tabbing. + */ + focusable?: boolean; + /** + * If `true`, day is outside of month and will be hidden. + */ + outsideCurrentMonth: boolean; + /** + * If `true`, renders as today date. + */ + today?: boolean; + /** + * If `true`, renders as disabled. + */ + disabled?: boolean; + /** + * If `true`, renders as selected. + */ + selected?: boolean; + /** + * If `true`, keyboard control and focus management is enabled. + */ + allowKeyboardControl?: boolean; + /** + * If `true`, days are rendering without margin. Useful for displaying linked range of days. + */ + disableMargin?: boolean; + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth?: boolean; + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday?: boolean; + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection?: boolean; + isAnimating?: boolean; + onDayFocus?: (day: TDate) => void; + onDaySelect: (day: TDate, isFinish: PickerSelectionState) => void; +} + +/** + * @ignore - do not document. + */ +const PickersDay = React.forwardRef(function PickersDay( + props: PickersDayProps & WithStyles, + forwardedRef: React.Ref, +) { + const { + allowKeyboardControl, + allowSameDateSelection = false, + classes, + className, + day, + disabled = false, + disableHighlightToday = false, + disableMargin = false, + focusable = false, + focused = false, + hidden, + isAnimating, + onClick, + onDayFocus, + onDaySelect, + onFocus, + onKeyDown, + outsideCurrentMonth, + selected = false, + showDaysOutsideCurrentMonth = false, + today: isToday = false, + ...other + } = props; + + const utils = useUtils(); + const canAutoFocus = useCanAutoFocus(); + const ref = React.useRef(null); + const handleRef = useForkRef(ref, forwardedRef); + + React.useEffect(() => { + if ( + focused && + !disabled && + !isAnimating && + !outsideCurrentMonth && + ref.current && + allowKeyboardControl && + canAutoFocus + ) { + ref.current.focus(); + } + }, [allowKeyboardControl, canAutoFocus, disabled, focused, isAnimating, outsideCurrentMonth]); + + const handleFocus = (event: React.FocusEvent) => { + if (!focused && onDayFocus) { + onDayFocus(day); + } + + if (onFocus) { + onFocus(event); + } + }; + + const handleClick = (event: React.MouseEvent) => { + if (!allowSameDateSelection && selected) return; + + if (!disabled) { + onDaySelect(day, 'finish'); + } + + if (onClick) { + onClick(event); + } + }; + + const handleKeyDown = onSpaceOrEnter(() => { + if (!disabled) { + onDaySelect(day, 'finish'); + } + }, onKeyDown); + + const dayClassName = clsx( + classes.root, + { + [classes.selected]: selected, + [classes.dayWithMargin]: !disableMargin, + [classes.today]: !disableHighlightToday && isToday, + [classes.dayOutsideMonth]: outsideCurrentMonth && showDaysOutsideCurrentMonth, + }, + className, + ); + + if (outsideCurrentMonth && !showDaysOutsideCurrentMonth) { + return
; + } + + return ( + + {utils.format(day, 'dayOfMonth')} + + ); +}); + +export const areDayPropsEqual = ( + prevProps: PickersDayProps, + nextProps: PickersDayProps, +) => { + return ( + prevProps.focused === nextProps.focused && + prevProps.focusable === nextProps.focusable && + prevProps.isAnimating === nextProps.isAnimating && + prevProps.today === nextProps.today && + prevProps.disabled === nextProps.disabled && + prevProps.selected === nextProps.selected && + prevProps.allowKeyboardControl === nextProps.allowKeyboardControl && + prevProps.disableMargin === nextProps.disableMargin && + prevProps.showDaysOutsideCurrentMonth === nextProps.showDaysOutsideCurrentMonth && + prevProps.disableHighlightToday === nextProps.disableHighlightToday && + prevProps.className === nextProps.className && + prevProps.outsideCurrentMonth === nextProps.outsideCurrentMonth && + prevProps.onDayFocus === nextProps.onDayFocus && + prevProps.onDaySelect === nextProps.onDaySelect + ); +}; + +(PickersDay as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * If `true`, keyboard control and focus management is enabled. + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * @ignore + */ + classes: PropTypes.object.isRequired, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The date to show. + */ + day: PropTypes.any.isRequired, + /** + * If `true`, renders as disabled. + */ + disabled: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * If `true`, days are rendering without margin. Useful for displaying linked range of days. + */ + disableMargin: PropTypes.bool, + /** + * If `true`, allows to focus by tabbing. + */ + focusable: PropTypes.bool, + /** + * If `true`, the day element will be focused during the first mount. + */ + focused: PropTypes.bool, + /** + * @ignore + */ + hidden: PropTypes.bool, + /** + * @ignore + */ + isAnimating: PropTypes.bool, + /** + * @ignore + */ + onClick: PropTypes.func, + /** + * @ignore + */ + onDayFocus: PropTypes.func, + /** + * @ignore + */ + onDaySelect: PropTypes.func.isRequired, + /** + * @ignore + */ + onFocus: PropTypes.func, + /** + * @ignore + */ + onKeyDown: PropTypes.func, + /** + * If `true`, day is outside of month and will be hidden. + */ + outsideCurrentMonth: PropTypes.bool.isRequired, + /** + * If `true`, renders as selected. + */ + selected: PropTypes.bool, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, renders as today date. + */ + today: PropTypes.bool, +}; + +export default withStyles(styles, { name: 'MuiPickersDay' })( + React.memo(PickersDay, areDayPropsEqual), +) as (props: PickersDayProps & React.RefAttributes) => JSX.Element; diff --git a/packages/material-ui-lab/src/PickersDay/index.ts b/packages/material-ui-lab/src/PickersDay/index.ts new file mode 100644 index 00000000000000..3eb1b8d27e818b --- /dev/null +++ b/packages/material-ui-lab/src/PickersDay/index.ts @@ -0,0 +1,4 @@ +export { default } from './PickersDay'; + +export type PickersDayClassKey = import('./PickersDay').PickersDayClassKey; +export type PickersDayProps = import('./PickersDay').PickersDayProps; diff --git a/packages/material-ui-lab/src/StaticDatePicker/StaticDatePicker.tsx b/packages/material-ui-lab/src/StaticDatePicker/StaticDatePicker.tsx new file mode 100644 index 00000000000000..aaac410cbd092e --- /dev/null +++ b/packages/material-ui-lab/src/StaticDatePicker/StaticDatePicker.tsx @@ -0,0 +1,213 @@ +import PropTypes from 'prop-types'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; +import { + BaseDatePickerProps, + datePickerConfig, + DatePickerGenericComponent, +} from '../DatePicker/DatePicker'; +import { StaticWrapper } from '../internal/pickers/wrappers/Wrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const StaticDatePicker = makePickerWithStateAndWrapper>( + StaticWrapper, + { + name: 'MuiStaticDatePicker', + ...datePickerConfig, + }, +) as DatePickerGenericComponent; + +(StaticDatePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Force static wrapper inner components to be rendered in mobile or desktop mode + * @default "static" + */ + displayStaticWrapperAs: PropTypes.oneOf(['desktop', 'mobile']), + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), +}; + +export type StaticDatePickerProps = React.ComponentProps; + +export default StaticDatePicker; diff --git a/packages/material-ui-lab/src/StaticDatePicker/index.ts b/packages/material-ui-lab/src/StaticDatePicker/index.ts new file mode 100644 index 00000000000000..be42d67e460d2d --- /dev/null +++ b/packages/material-ui-lab/src/StaticDatePicker/index.ts @@ -0,0 +1,2 @@ +export * from './StaticDatePicker'; +export { default } from './StaticDatePicker'; diff --git a/packages/material-ui-lab/src/StaticDateRangePicker/StaticDateRangePicker.tsx b/packages/material-ui-lab/src/StaticDateRangePicker/StaticDateRangePicker.tsx new file mode 100644 index 00000000000000..5559b1729626c1 --- /dev/null +++ b/packages/material-ui-lab/src/StaticDateRangePicker/StaticDateRangePicker.tsx @@ -0,0 +1,329 @@ +import PropTypes from 'prop-types'; +import { makeDateRangePicker } from '../DateRangePicker/makeDateRangePicker'; +import StaticWrapper from '../internal/pickers/wrappers/StaticWrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const StaticDateRangePicker = makeDateRangePicker('MuiPickersDateRangePicker', StaticWrapper); + +(StaticDateRangePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * The number of calendars that render on **desktop**. + * @default 2 + */ + calendars: PropTypes.oneOf([1, 2, 3]), + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * if `true` after selecting `start` date calendar will not automatically switch to the month of `end` date + * @default false + */ + disableAutoMonthSwitching: PropTypes.bool, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * Force static wrapper inner components to be rendered in mobile or desktop mode + * @default "static" + */ + displayStaticWrapperAs: PropTypes.oneOf(['desktop', 'mobile']), + /** + * Text for end input label and toolbar placeholder. + * @default "end" + */ + endText: PropTypes.node, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * Max selectable date. @DateIOType + * @default Date(2099-31-12) + */ + maxDate: PropTypes.any, + /** + * Min selectable date. @DateIOType + * @default Date(1900-01-01) + */ + minDate: PropTypes.any, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Custom renderer for `` days. @DateIOType + * @example (date, DateRangeDayProps) => + */ + renderDay: PropTypes.func, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `startProps` and `endProps` arguments of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api), + * that you need to forward to the range start/end inputs respectively. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example + * ```jsx + * ( + * + * + * to + * + * ; + * )} + * /> + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Text for start input label and toolbar placeholder. + * @default "Start" + */ + startText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + ).isRequired, +}; + +export type StaticDateRangePickerProps = React.ComponentProps; + +export type DateRange = import('../DateRangePicker/RangeTypes').DateRange; + +export default StaticDateRangePicker; diff --git a/packages/material-ui-lab/src/StaticDateRangePicker/index.ts b/packages/material-ui-lab/src/StaticDateRangePicker/index.ts new file mode 100644 index 00000000000000..2fdb62bb7b73f1 --- /dev/null +++ b/packages/material-ui-lab/src/StaticDateRangePicker/index.ts @@ -0,0 +1,2 @@ +export * from './StaticDateRangePicker'; +export { default } from './StaticDateRangePicker'; diff --git a/packages/material-ui-lab/src/StaticDateTimePicker/StaticDateTimePicker.tsx b/packages/material-ui-lab/src/StaticDateTimePicker/StaticDateTimePicker.tsx new file mode 100644 index 00000000000000..d5cecf135339e2 --- /dev/null +++ b/packages/material-ui-lab/src/StaticDateTimePicker/StaticDateTimePicker.tsx @@ -0,0 +1,405 @@ +import PropTypes from 'prop-types'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; +import { + BaseDateTimePickerProps, + dateTimePickerConfig, + DateTimePickerGenericComponent, +} from '../DateTimePicker/DateTimePicker'; +import { StaticWrapper } from '../internal/pickers/wrappers/Wrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const StaticDateTimePicker = makePickerWithStateAndWrapper>( + StaticWrapper, + { + name: 'MuiStaticDateTimePicker', + ...dateTimePickerConfig, + }, +) as DateTimePickerGenericComponent; + +(StaticDateTimePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * If `true`, `onChange` is fired on click even if the same date is selected. + * @default false + */ + allowSameDateSelection: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * Date tab icon. + */ + dateRangeIcon: PropTypes.node, + /** + * Default calendar month displayed when `value={null}`. + * @default `new Date()` + */ + defaultCalendarMonth: PropTypes.any, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Disable future dates. + * @default false + */ + disableFuture: PropTypes.bool, + /** + * If `true`, todays date is rendering without highlighting with circle. + * @default false + */ + disableHighlightToday: PropTypes.bool, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Disable past dates. + * @default false + */ + disablePast: PropTypes.bool, + /** + * Force static wrapper inner components to be rendered in mobile or desktop mode + * @default "static" + */ + displayStaticWrapperAs: PropTypes.oneOf(['desktop', 'mobile']), + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * Get aria-label text for switching between views button. + */ + getViewSwitchingButtonText: PropTypes.func, + /** + * To show tabs. + */ + hideTabs: PropTypes.bool, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps: PropTypes.object, + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText: PropTypes.string, + /** + * Left arrow icon. + */ + leftArrowIcon: PropTypes.node, + /** + * If `true` renders `LoadingComponent` in calendar instead of calendar view. + * Can be used to preload information and show it in calendar. + * @default false + */ + loading: PropTypes.bool, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Minimal selectable moment of time with binding to date, to set max time in each day use `maxTime`. + */ + maxDateTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + maxTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minDate: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Minimal selectable moment of time with binding to date, to set min time in each day use `minTime`. + */ + minDateTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback firing on month change. @DateIOType + */ + onMonthChange: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Callback fired on view change. + */ + onViewChange: PropTypes.func, + /** + * Callback firing on year change @DateIOType. + */ + onYearChange: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * First view to show. + */ + openTo: PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'seconds', 'year']), + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * Disable heavy animations. + * @default /(android)/i.test(window.navigator.userAgent). + */ + reduceAnimations: PropTypes.bool, + /** + * Custom renderer for day. Check [DayComponentProps api](https://material-ui-pickers.dev/api/Day) @DateIOType. + */ + renderDay: PropTypes.func, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Component displaying when passed `loading` true. + * @default () => "..." + */ + renderLoading: PropTypes.func, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps: PropTypes.object, + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText: PropTypes.string, + /** + * Right arrow icon. + */ + rightArrowIcon: PropTypes.node, + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate: PropTypes.func, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, + /** + * If `true`, days that have `outsideCurrentMonth={true}` are displayed. + * @default false + */ + showDaysOutsideCurrentMonth: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Time tab icon. + */ + timeIcon: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Array of views to show. + */ + views: PropTypes.arrayOf( + PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'year']).isRequired, + ), +}; + +export type StaticDateTimePickerProps = React.ComponentProps; + +export default StaticDateTimePicker; diff --git a/packages/material-ui-lab/src/StaticDateTimePicker/index.ts b/packages/material-ui-lab/src/StaticDateTimePicker/index.ts new file mode 100644 index 00000000000000..50a219b185cb90 --- /dev/null +++ b/packages/material-ui-lab/src/StaticDateTimePicker/index.ts @@ -0,0 +1,2 @@ +export * from './StaticDateTimePicker'; +export { default } from './StaticDateTimePicker'; diff --git a/packages/material-ui-lab/src/StaticTimePicker/StaticTimePicker.tsx b/packages/material-ui-lab/src/StaticTimePicker/StaticTimePicker.tsx new file mode 100644 index 00000000000000..83d1449f6232f1 --- /dev/null +++ b/packages/material-ui-lab/src/StaticTimePicker/StaticTimePicker.tsx @@ -0,0 +1,253 @@ +import PropTypes from 'prop-types'; +import { makePickerWithStateAndWrapper } from '../internal/pickers/Picker/makePickerWithState'; +import { + BaseTimePickerProps, + timePickerConfig, + TimePickerGenericComponent, +} from '../TimePicker/TimePicker'; +import { StaticWrapper } from '../internal/pickers/wrappers/Wrapper'; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const StaticTimePicker = makePickerWithStateAndWrapper(StaticWrapper, { + name: 'MuiStaticTimePicker', + ...timePickerConfig, +}) as TimePickerGenericComponent; + +(StaticTimePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Force static wrapper inner components to be rendered in mobile or desktop mode + * @default "static" + */ + displayStaticWrapperAs: PropTypes.oneOf(['desktop', 'mobile']), + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * First view to show. + */ + openTo: PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'seconds', 'year']), + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Array of views to show. + */ + views: PropTypes.arrayOf(PropTypes.oneOf(['hours', 'minutes', 'seconds']).isRequired), +}; + +export type StaticTimePickerProps = React.ComponentProps; + +export default StaticTimePicker; diff --git a/packages/material-ui-lab/src/StaticTimePicker/index.ts b/packages/material-ui-lab/src/StaticTimePicker/index.ts new file mode 100644 index 00000000000000..708e01be9ccbb7 --- /dev/null +++ b/packages/material-ui-lab/src/StaticTimePicker/index.ts @@ -0,0 +1,2 @@ +export * from './StaticTimePicker'; +export { default } from './StaticTimePicker'; diff --git a/packages/material-ui-lab/src/TimePicker/TimePicker.spec.tsx b/packages/material-ui-lab/src/TimePicker/TimePicker.spec.tsx new file mode 100644 index 00000000000000..f37cd553bc4cdd --- /dev/null +++ b/packages/material-ui-lab/src/TimePicker/TimePicker.spec.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import moment from 'moment'; +import { TimePicker, ClockPicker } from '@material-ui/lab'; + + date?.set({ second: 0 })} + renderInput={() => } +/>; + +// Allows inferring for side props + date?.set({ second: 0 })} + renderInput={() => } +/>; + +// External components are generic as well + view="hours" date={null} onChange={(date) => date?.getDate()} />; diff --git a/packages/material-ui-lab/src/TimePicker/TimePicker.test.tsx b/packages/material-ui-lab/src/TimePicker/TimePicker.test.tsx new file mode 100644 index 00000000000000..375fefb1730051 --- /dev/null +++ b/packages/material-ui-lab/src/TimePicker/TimePicker.test.tsx @@ -0,0 +1,287 @@ +import * as React from 'react'; +import TextField from '@material-ui/core/TextField'; +import { spy } from 'sinon'; +import { expect } from 'chai'; +import { fireEvent, fireTouchChangedEvent, screen } from 'test/utils'; +import { TimePickerProps } from '@material-ui/lab/TimePicker'; +import MobileTimePicker from '@material-ui/lab/MobileTimePicker'; +import DesktopTimePicker from '@material-ui/lab/DesktopTimePicker'; +import { createPickerRender, adapterToUse, getByMuiTest } from '../internal/pickers/test-utils'; + +describe('', () => { + const render = createPickerRender({ strict: false }); + + function createMouseEventWithOffsets( + type: 'mousedown' | 'mousemove' | 'mouseup', + { offsetX, offsetY, ...eventOptions }: { offsetX: number; offsetY: number } & MouseEventInit, + ) { + const event = new window.MouseEvent(type, { + bubbles: true, + cancelable: true, + ...eventOptions, + }); + + Object.defineProperty(event, 'offsetX', { get: () => offsetX }); + Object.defineProperty(event, 'offsetY', { get: () => offsetY }); + + return event; + } + + it('accepts time on clock mouse move', () => { + const onChangeMock = spy(); + render( + } + />, + ); + + const fakeEventOptions = { + buttons: 1, + offsetX: 20, + offsetY: 15, + }; + + fireEvent(getByMuiTest('clock'), createMouseEventWithOffsets('mousemove', fakeEventOptions)); + fireEvent(getByMuiTest('clock'), createMouseEventWithOffsets('mouseup', fakeEventOptions)); + + expect(getByMuiTest('hours')).to.have.text('11'); + expect(onChangeMock.callCount).to.equal(1); + }); + + it('accepts time on clock touch move', function test() { + if (typeof window.Touch === 'undefined' || typeof window.TouchEvent === 'undefined') { + this.skip(); + } + + const onChangeMock = spy(); + render( + } + />, + ); + + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', { + changedTouches: [{ clientX: 20, clientY: 15 }], + }); + expect(getByMuiTest('minutes')).to.have.text('53'); + }); + + it('allows to navigate between timepicker views using arrow switcher', () => { + render( + {}} + renderInput={(params) => } + />, + ); + + const prevViewButton = screen.getByLabelText('open previous view'); + const nextViewButton = screen.getByLabelText('open next view'); + + expect(screen.getByLabelText(/Select Hours/i)).toBeVisible(); + expect(prevViewButton).to.have.attribute('disabled'); + + fireEvent.click(nextViewButton); + expect(screen.getByLabelText(/Select minutes/)).toBeVisible(); + + expect(prevViewButton).not.to.have.attribute('disabled'); + expect(nextViewButton).not.to.have.attribute('disabled'); + + fireEvent.click(nextViewButton); + expect(screen.getByLabelText(/Select seconds/)).toBeVisible(); + expect(nextViewButton).to.have.attribute('disabled'); + }); + + it('allows to select full date from empty', function test() { + if (typeof window.Touch === 'undefined' || typeof window.TouchEvent === 'undefined') { + this.skip(); + } + + function TimePickerWithState() { + const [time, setTime] = React.useState(null); + + return ( + setTime(newTime)} + renderInput={(params) => } + /> + ); + } + + render(); + + expect(getByMuiTest('hours')).to.have.text('--'); + expect(getByMuiTest('minutes')).to.have.text('--'); + + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', { + changedTouches: [ + { + clientX: 20, + clientY: 15, + }, + ], + }); + + expect(getByMuiTest('hours')).not.to.have.text('--'); + expect(getByMuiTest('minutes')).not.to.have.text('--'); + }); + + context('Time validation on touch ', () => { + before(function beforeHook() { + if (typeof window.Touch === 'undefined' || typeof window.TouchEvent === 'undefined') { + this.skip(); + } + }); + + const clockMouseEvent = { + '13:--': { + changedTouches: [ + { + clientX: 166, + clientY: 76, + }, + ], + }, + '20:--': { + changedTouches: [ + { + clientX: 66, + clientY: 157, + }, + ], + }, + '--:10': { + changedTouches: [ + { + clientX: 220, + clientY: 72, + }, + ], + }, + '--:20': { + changedTouches: [ + { + clientX: 222, + clientY: 180, + }, + ], + }, + }; + + beforeEach(() => { + render( + } + open + ampm={false} + onChange={() => {}} + views={['hours', 'minutes', 'seconds']} + value={adapterToUse.date('2018-01-01T00:00:00.000')} + minTime={new Date(0, 0, 0, 12, 15, 15)} + maxTime={new Date(0, 0, 0, 15, 45, 30)} + />, + ); + }); + + it('should select enabled hour', () => { + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['13:--']); + expect(getByMuiTest('hours')).to.have.text('13'); + }); + + it('should select enabled minute', () => { + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['13:--']); + fireTouchChangedEvent(getByMuiTest('clock'), 'touchend', clockMouseEvent['13:--']); + + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['--:20']); + + expect(getByMuiTest('minutes')).to.have.text('20'); + }); + + it('should not select minute when hour is disabled ', () => { + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['20:--']); + fireTouchChangedEvent(getByMuiTest('clock'), 'touchend', clockMouseEvent['20:--']); + + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['--:20']); + }); + + it('should not select disabled hour', () => { + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['20:--']); + expect(getByMuiTest('hours')).to.have.text('00'); + }); + + it('should not select disabled second', () => { + fireEvent.click(getByMuiTest('seconds')); + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['--:20']); + + expect(getByMuiTest('seconds')).to.have.text('00'); + }); + + it('should select enabled second', () => { + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['13:--']); + fireTouchChangedEvent(getByMuiTest('clock'), 'touchend', clockMouseEvent['13:--']); + + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['--:20']); + fireTouchChangedEvent(getByMuiTest('clock'), 'touchend', clockMouseEvent['--:20']); + + fireTouchChangedEvent(getByMuiTest('clock'), 'touchmove', clockMouseEvent['--:10']); + + expect(getByMuiTest('seconds')).to.have.text('10'); + }); + }); + + context('input validation', () => { + const createTime = (time: string) => new Date(`01/01/2000 ${time}`); + const shouldDisableTime: TimePickerProps['shouldDisableTime'] = (value) => value === 10; + + [ + { expectedError: 'invalidDate', props: {}, input: 'invalidText' }, + { expectedError: 'minTime', props: { minTime: createTime('08:00') }, input: '03:00' }, + { expectedError: 'maxTime', props: { maxTime: createTime('08:00') }, input: '12:00' }, + { expectedError: 'shouldDisableTime-hours', props: { shouldDisableTime }, input: '10:00' }, + { expectedError: 'shouldDisableTime-minutes', props: { shouldDisableTime }, input: '00:10' }, + ].forEach(({ props, input, expectedError }) => { + it(`should dispatch "${expectedError}" error`, () => { + const onErrorMock = spy(); + + // we are running validation on value change + function TimePickerInput() { + const [time, setTime] = React.useState(null); + + return ( + setTime(newTime)} + renderInput={(inputProps) => } + {...props} + /> + ); + } + + render(); + + fireEvent.change(screen.getByRole('textbox'), { + target: { + value: input, + }, + }); + + expect(onErrorMock.calledWith(expectedError)).to.be.equal(true); + }); + }); + }); +}); diff --git a/packages/material-ui-lab/src/TimePicker/TimePicker.tsx b/packages/material-ui-lab/src/TimePicker/TimePicker.tsx new file mode 100644 index 00000000000000..e1cb5457b17cd1 --- /dev/null +++ b/packages/material-ui-lab/src/TimePicker/TimePicker.tsx @@ -0,0 +1,367 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ClockIcon from '../internal/svg-icons/Clock'; +import { ParsableDate } from '../internal/pickers/constants/prop-types'; +import TimePickerToolbar from './TimePickerToolbar'; +import { ExportedClockPickerProps } from '../ClockPicker/ClockPicker'; +import { ResponsiveWrapper } from '../internal/pickers/wrappers/ResponsiveWrapper'; +import { pick12hOr24hFormat } from '../internal/pickers/text-field-helper'; +import { useUtils, MuiPickersAdapter } from '../internal/pickers/hooks/useUtils'; +import { validateTime, TimeValidationError } from '../internal/pickers/time-utils'; +import { WithViewsProps, AllSharedPickerProps } from '../internal/pickers/Picker/SharedPickerProps'; +import { ValidationProps, makeValidationHook } from '../internal/pickers/hooks/useValidation'; +import { + useParsedDate, + OverrideParsableDateProps, +} from '../internal/pickers/hooks/date-helpers-hooks'; +import { SomeWrapper } from '../internal/pickers/wrappers/Wrapper'; +import { + SharedPickerProps, + makePickerWithStateAndWrapper, +} from '../internal/pickers/Picker/makePickerWithState'; + +export interface BaseTimePickerProps + extends ValidationProps>, + WithViewsProps<'hours' | 'minutes' | 'seconds'>, + OverrideParsableDateProps, 'minTime' | 'maxTime'> {} + +export function getTextFieldAriaText(value: ParsableDate, utils: MuiPickersAdapter) { + return value && utils.isValid(utils.date(value)) + ? `Choose time, selected time is ${utils.format(utils.date(value), 'fullTime')}` + : 'Choose time'; +} + +function useInterceptProps({ + ampm, + inputFormat, + maxTime: __maxTime, + minTime: __minTime, + openTo = 'hours', + views = ['hours', 'minutes'], + ...other +}: BaseTimePickerProps & AllSharedPickerProps) { + const utils = useUtils(); + + const minTime = useParsedDate(__minTime); + const maxTime = useParsedDate(__maxTime); + const willUseAmPm = ampm ?? utils.is12HourCycleInCurrentLocale(); + + return { + views, + openTo, + minTime, + maxTime, + ampm: willUseAmPm, + acceptRegex: willUseAmPm ? /[\dapAP]/gi : /\d/gi, + mask: '__:__', + disableMaskedInput: willUseAmPm, + getOpenDialogAriaText: getTextFieldAriaText, + openPickerIcon: , + inputFormat: pick12hOr24hFormat(inputFormat, willUseAmPm, { + localized: utils.formats.fullTime, + '12h': utils.formats.fullTime12h, + '24h': utils.formats.fullTime24h, + }), + ...other, + }; +} + +export const timePickerConfig = { + useInterceptProps, + useValidation: makeValidationHook( + validateTime, + ), + DefaultToolbarComponent: TimePickerToolbar, +}; + +export type TimePickerGenericComponent = ( + props: BaseTimePickerProps & SharedPickerProps, +) => JSX.Element; + +/** + * @ignore - do not document. + */ +/* @GeneratePropTypes */ +const TimePicker = makePickerWithStateAndWrapper(ResponsiveWrapper, { + name: 'MuiTimePicker', + ...timePickerConfig, +}) as TimePickerGenericComponent; + +(TimePicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex: PropTypes.instanceOf(RegExp), + /** + * Enables keyboard listener for moving between days in calendar. + * @default currentWrapper !== 'static' + */ + allowKeyboardControl: PropTypes.bool, + /** + * 12h/24h view for hour selection clock. + * @default true + */ + ampm: PropTypes.bool, + /** + * Display ampm controls under the clock (instead of in the toolbar). + * @default false + */ + ampmInClock: PropTypes.bool, + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText: PropTypes.node, + /** + * className applied to the root component. + */ + className: PropTypes.string, + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable: PropTypes.bool, + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText: PropTypes.node, + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter: PropTypes.object, + /** + * CSS media query when `Mobile` mode will be changed to `Desktop`. + * @default "@media (pointer: fine)" + * @example "@media (min-width: 720px)" or theme.breakpoints.up("sm") + */ + desktopModeMediaQuery: PropTypes.string, + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps: PropTypes.object, + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect: PropTypes.bool, + /** + * If `true`, the picker and text field are disabled. + */ + disabled: PropTypes.bool, + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation: PropTypes.bool, + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput: PropTypes.bool, + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker: PropTypes.bool, + /** + * Accessible text that helps user to understand which time and view is selected. + * @default (view, time) => `Select ${view}. Selected time is ${format(time, 'fullTime')}` + */ + getClockLabelText: PropTypes.func, + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText: PropTypes.func, + /** + * @ignore + */ + ignoreInvalidInputs: PropTypes.bool, + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps: PropTypes.object, + /** + * Format string. + */ + inputFormat: PropTypes.string, + /** + * @ignore + */ + InputProps: PropTypes.object, + /** + * @ignore + */ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** + * @ignore + */ + label: PropTypes.node, + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask: PropTypes.string, + /** + * @ignore + */ + maxTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * @ignore + */ + minTime: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Step over minutes. + * @default 1 + */ + minutesStep: PropTypes.number, + /** + * "OK" button text. + * @default "OK" + */ + okText: PropTypes.node, + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept: PropTypes.func, + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose: PropTypes.func, + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError: PropTypes.func, + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen: PropTypes.func, + /** + * Control the popup or dialog open state. + */ + open: PropTypes.bool, + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps: PropTypes.object, + /** + * Icon displaying for open picker button. + */ + openPickerIcon: PropTypes.node, + /** + * First view to show. + */ + openTo: PropTypes.oneOf(['date', 'hours', 'minutes', 'month', 'seconds', 'year']), + /** + * Force rendering in particular orientation. + */ + orientation: PropTypes.oneOf(['landscape', 'portrait']), + /** + * Popper props passed down to [Popper](https://material-ui.com/api/popper/) component. + */ + PopperProps: PropTypes.object, + /** + * Make picker read only. + */ + readOnly: PropTypes.bool, + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: PropTypes.func.isRequired, + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter: PropTypes.func, + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime: PropTypes.func, + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton: PropTypes.bool, + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar: PropTypes.bool, + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText: PropTypes.node, + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent: PropTypes.elementType, + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat: PropTypes.string, + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder: PropTypes.node, + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle: PropTypes.node, + /** + * Custom component for popper [Transition](https://material-ui.com/components/transitions/#transitioncomponent-prop). + */ + TransitionComponent: PropTypes.elementType, + /** + * The value of the picker. + */ + value: PropTypes.oneOfType([ + PropTypes.any, + PropTypes.instanceOf(Date), + PropTypes.number, + PropTypes.string, + ]), + /** + * Array of views to show. + */ + views: PropTypes.arrayOf(PropTypes.oneOf(['hours', 'minutes', 'seconds']).isRequired), +}; + +export type TimePickerProps = React.ComponentProps; + +export default TimePicker; diff --git a/packages/material-ui-lab/src/TimePicker/TimePickerToolbar.tsx b/packages/material-ui-lab/src/TimePicker/TimePickerToolbar.tsx new file mode 100644 index 00000000000000..72d98620e8fd1a --- /dev/null +++ b/packages/material-ui-lab/src/TimePicker/TimePickerToolbar.tsx @@ -0,0 +1,168 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { useTheme, createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; +import ToolbarText from '../internal/pickers/PickersToolbarText'; +import ToolbarButton from '../internal/pickers/PickersToolbarButton'; +import PickerToolbar from '../internal/pickers/PickersToolbar'; +import { arrayIncludes } from '../internal/pickers/utils'; +import { useUtils } from '../internal/pickers/hooks/useUtils'; +import { useMeridiemMode } from '../internal/pickers/hooks/date-helpers-hooks'; +import { ToolbarComponentProps } from '../internal/pickers/typings/BasePicker'; + +export const styles = createStyles({ + separator: { + outline: 0, + margin: '0 4px 0 2px', + cursor: 'default', + }, + hourMinuteLabel: { + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'flex-end', + }, + hourMinuteLabelLandscape: { + marginTop: 'auto', + }, + hourMinuteLabelReverse: { + flexDirection: 'row-reverse', + }, + ampmSelection: { + display: 'flex', + flexDirection: 'column', + marginRight: 'auto', + marginLeft: 12, + }, + ampmLandscape: { + margin: '4px 0 auto', + flexDirection: 'row', + justifyContent: 'space-around', + flexBasis: '100%', + }, + ampmLabel: { + fontSize: 17, + }, + penIconLandscape: { + marginTop: 'auto', + }, +}); + +export type TimePickerToolbarClassKey = keyof WithStyles['classes']; + +const clockTypographyVariant = 'h3'; + +/** + * @ignore - internal component. + */ +const TimePickerToolbar: React.FC> = (props) => { + const { + ampm, + ampmInClock, + classes, + date, + isLandscape, + isMobileKeyboardViewOpen, + onChange, + openView, + setOpenView, + toggleMobileKeyboardView, + toolbarTitle = 'SELECT TIME', + views, + ...other + } = props; + const utils = useUtils(); + const theme = useTheme(); + const showAmPmControl = Boolean(ampm && !ampmInClock); + const { meridiemMode, handleMeridiemChange } = useMeridiemMode(date, ampm, onChange); + + const formatHours = (time: unknown) => + ampm ? utils.format(time, 'hours12h') : utils.format(time, 'hours24h'); + + const separator = ( + + ); + + return ( + +
+ {arrayIncludes(views, 'hours') && ( + setOpenView('hours')} + selected={openView === 'hours'} + value={date ? formatHours(date) : '--'} + /> + )} + {arrayIncludes(views, ['hours', 'minutes']) && separator} + {arrayIncludes(views, 'minutes') && ( + setOpenView('minutes')} + selected={openView === 'minutes'} + value={date ? utils.format(date, 'minutes') : '--'} + /> + )} + {arrayIncludes(views, ['minutes', 'seconds']) && separator} + {arrayIncludes(views, 'seconds') && ( + setOpenView('seconds')} + selected={openView === 'seconds'} + value={date ? utils.format(date, 'seconds') : '--'} + /> + )} +
+ {showAmPmControl && ( +
+ handleMeridiemChange('am')} + /> + handleMeridiemChange('pm')} + /> +
+ )} +
+ ); +}; + +export default withStyles(styles, { name: 'MuiTimePickerToolbar' })(TimePickerToolbar); diff --git a/packages/material-ui-lab/src/TimePicker/index.tsx b/packages/material-ui-lab/src/TimePicker/index.tsx new file mode 100644 index 00000000000000..7b71a6a9b5ccbf --- /dev/null +++ b/packages/material-ui-lab/src/TimePicker/index.tsx @@ -0,0 +1,2 @@ +export * from './TimePicker'; +export { default } from './TimePicker'; diff --git a/packages/material-ui-lab/src/YearPicker/PickersYear.tsx b/packages/material-ui-lab/src/YearPicker/PickersYear.tsx new file mode 100644 index 00000000000000..e89b844c896640 --- /dev/null +++ b/packages/material-ui-lab/src/YearPicker/PickersYear.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { useForkRef } from '@material-ui/core/utils'; +import { createStyles, WithStyles, withStyles, Theme, alpha } from '@material-ui/core/styles'; +import { onSpaceOrEnter } from '../internal/pickers/utils'; +import { useCanAutoFocus } from '../internal/pickers/hooks/useCanAutoFocus'; +import { WrapperVariantContext } from '../internal/pickers/wrappers/WrapperVariantContext'; + +export interface YearProps { + children: React.ReactNode; + disabled?: boolean; + onSelect: (value: number) => void; + selected: boolean; + focused: boolean; + value: number; + allowKeyboardControl?: boolean; + forwardedRef?: React.Ref; +} + +export const styles = (theme: Theme) => + createStyles({ + root: { + flexBasis: '33.3%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + modeDesktop: { + flexBasis: '25%', + }, + yearButton: { + color: 'unset', + backgroundColor: 'transparent', + border: 'none', + outline: 0, + ...theme.typography.subtitle1, + margin: '8px 0', + height: 36, + width: 72, + borderRadius: 16, + cursor: 'pointer', + '&:focus, &:hover': { + backgroundColor: alpha(theme.palette.action.active, theme.palette.action.hoverOpacity), + }, + '&$disabled': { + color: theme.palette.text.secondary, + }, + '&$selected': { + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, + '&:focus, &:hover': { + backgroundColor: theme.palette.primary.dark, + }, + }, + }, + disabled: {}, + selected: {}, + }); + +export type PickersYearClassKey = keyof WithStyles['classes']; + +/** + * @ignore - internal component. + */ +const PickersYear = React.forwardRef>( + (props, forwardedRef) => { + const { + allowKeyboardControl, + classes, + children, + disabled, + focused, + onSelect, + selected, + value, + } = props; + const ref = React.useRef(null); + const refHandle = useForkRef(ref, forwardedRef as React.Ref); + const canAutoFocus = useCanAutoFocus(); + const wrapperVariant = React.useContext(WrapperVariantContext); + + React.useEffect(() => { + if (canAutoFocus && focused && ref.current && !disabled && allowKeyboardControl) { + ref.current.focus(); + } + }, [allowKeyboardControl, canAutoFocus, disabled, focused]); + + return ( +
+ +
+ ); + }, +); + +export default withStyles(styles, { name: 'MuiPickersYear' })(PickersYear); diff --git a/packages/material-ui-lab/src/YearPicker/YearPicker.test.tsx b/packages/material-ui-lab/src/YearPicker/YearPicker.test.tsx new file mode 100644 index 00000000000000..2028aa1eb76328 --- /dev/null +++ b/packages/material-ui-lab/src/YearPicker/YearPicker.test.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { spy } from 'sinon'; +import { expect } from 'chai'; +import { getClasses, createMount, fireEvent, screen, describeConformance } from 'test/utils'; +import LocalizationProvider from '@material-ui/lab/LocalizationProvider'; +import DateFnsAdapter from '@material-ui/lab/dateAdapter/date-fns'; +import YearPicker from '@material-ui/lab/YearPicker'; +import { createPickerRender } from '../internal/pickers/test-utils'; + +describe('', () => { + const mount = createMount(); + const render = createPickerRender({ strict: false }); + let classes: Record; + + const localizedMount = (node: React.ReactNode) => { + return mount({node}); + }; + + before(() => { + classes = getClasses( + false} + date={new Date()} + onChange={() => {}} + />, + ); + }); + + describeConformance( + false} + date={new Date()} + onChange={() => {}} + />, + () => ({ + classes, + inheritComponent: 'div', + mount: localizedMount, + refInstanceof: window.HTMLDivElement, + // cannot test reactTestRenderer because of required context + skip: ['componentProp', 'propsSpread', 'reactTestRenderer'], + }), + ); + + it('allows to pick year standalone', () => { + const onChangeMock = spy(); + render( + false} + date={new Date('2019-02-02T00:00:00.000')} + onChange={onChangeMock} + />, + ); + + fireEvent.click(screen.getByText('2025', { selector: 'button' })); + expect(onChangeMock.calledWith(new Date('2025-02-02T00:00:00.000'))).to.equal(true); + }); +}); diff --git a/packages/material-ui-lab/src/YearPicker/YearPicker.tsx b/packages/material-ui-lab/src/YearPicker/YearPicker.tsx new file mode 100644 index 00000000000000..7c84cb70e21b01 --- /dev/null +++ b/packages/material-ui-lab/src/YearPicker/YearPicker.tsx @@ -0,0 +1,239 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { createStyles, WithStyles, withStyles, useTheme } from '@material-ui/core/styles'; +import clsx from 'clsx'; +import PickersYear from './PickersYear'; +import { useUtils, useNow } from '../internal/pickers/hooks/useUtils'; +import { PickerOnChangeFn } from '../internal/pickers/hooks/useViews'; +import { findClosestEnabledDate } from '../internal/pickers/date-utils'; +import { PickerSelectionState } from '../internal/pickers/hooks/usePickerState'; +import { WrapperVariantContext } from '../internal/pickers/wrappers/WrapperVariantContext'; +import { useGlobalKeyDown, keycode as keys } from '../internal/pickers/hooks/useKeyDown'; + +export interface ExportedYearPickerProps { + /** + * Callback firing on year change @DateIOType. + */ + onYearChange?: (date: TDate) => void; + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear?: (day: TDate) => boolean; +} + +export interface YearPickerProps extends ExportedYearPickerProps { + allowKeyboardControl?: boolean; + onFocusedDayChange?: (day: TDate) => void; + date: TDate | null; + disableFuture?: boolean | null; + disablePast?: boolean | null; + isDateDisabled: (day: TDate) => boolean; + maxDate: TDate; + minDate: TDate; + onChange: PickerOnChangeFn; + className?: string; +} + +export const styles = createStyles({ + root: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + overflowY: 'auto', + height: '100%', + margin: '0 4px', + }, +}); + +export type YearPickerClassKey = keyof WithStyles['classes']; + +/** + * @ignore - do not document. + */ +const YearPicker = React.forwardRef(function YearPicker( + props: YearPickerProps & WithStyles, + ref: React.Ref, +) { + const { + allowKeyboardControl, + classes, + className, + date, + disableFuture, + disablePast, + isDateDisabled, + maxDate, + minDate, + onChange, + onFocusedDayChange, + onYearChange, + shouldDisableYear, + } = props; + + const now = useNow(); + const theme = useTheme(); + const utils = useUtils(); + + const selectedDate = date || now; + const currentYear = utils.getYear(selectedDate); + const wrapperVariant = React.useContext(WrapperVariantContext); + const selectedYearRef = React.useRef(null); + const [focusedYear, setFocusedYear] = React.useState(currentYear); + + const handleYearSelection = React.useCallback( + (year: number, isFinish: PickerSelectionState = 'finish') => { + const submitDate = (newDate: TDate) => { + onChange(newDate, isFinish); + + if (onFocusedDayChange) { + onFocusedDayChange(newDate || now); + } + + if (onYearChange) { + onYearChange(newDate); + } + }; + + const newDate = utils.setYear(selectedDate, year); + if (isDateDisabled(newDate)) { + const closestEnabledDate = findClosestEnabledDate({ + utils, + date: newDate, + minDate, + maxDate, + disablePast: Boolean(disablePast), + disableFuture: Boolean(disableFuture), + shouldDisableDate: isDateDisabled, + }); + + submitDate(closestEnabledDate || now); + } else { + submitDate(newDate); + } + }, + [ + utils, + now, + selectedDate, + isDateDisabled, + onChange, + onFocusedDayChange, + onYearChange, + minDate, + maxDate, + disablePast, + disableFuture, + ], + ); + + const focusYear = React.useCallback( + (year: number) => { + if (!isDateDisabled(utils.setYear(selectedDate, year))) { + setFocusedYear(year); + } + }, + [selectedDate, isDateDisabled, utils], + ); + + const yearsInRow = wrapperVariant === 'desktop' ? 4 : 3; + const nowFocusedYear = focusedYear || currentYear; + useGlobalKeyDown(Boolean(allowKeyboardControl), { + [keys.ArrowUp]: () => focusYear(nowFocusedYear - yearsInRow), + [keys.ArrowDown]: () => focusYear(nowFocusedYear + yearsInRow), + [keys.ArrowLeft]: () => focusYear(nowFocusedYear + (theme.direction === 'ltr' ? -1 : 1)), + [keys.ArrowRight]: () => focusYear(nowFocusedYear + (theme.direction === 'ltr' ? 1 : -1)), + }); + + return ( +
+ {utils.getYearRange(minDate, maxDate).map((year) => { + const yearNumber = utils.getYear(year); + const selected = yearNumber === currentYear; + + return ( + + {utils.format(year, 'year')} + + ); + })} +
+ ); +}); + +(YearPicker as any).propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + allowKeyboardControl: PropTypes.bool, + /** + * @ignore + */ + classes: PropTypes.object.isRequired, + /** + * @ignore + */ + className: PropTypes.string, + /** + * @ignore + */ + date: PropTypes.any, + /** + * @ignore + */ + disableFuture: PropTypes.bool, + /** + * @ignore + */ + disablePast: PropTypes.bool, + /** + * @ignore + */ + isDateDisabled: PropTypes.func.isRequired, + /** + * @ignore + */ + maxDate: PropTypes.any.isRequired, + /** + * @ignore + */ + minDate: PropTypes.any.isRequired, + /** + * @ignore + */ + onChange: PropTypes.func.isRequired, + /** + * @ignore + */ + onFocusedDayChange: PropTypes.func, + /** + * Callback firing on year change @DateIOType. + */ + onYearChange: PropTypes.func, + /** + * Disable specific years dynamically. + * Works like `shouldDisableDate` but for year selection view. @DateIOType. + */ + shouldDisableYear: PropTypes.func, +}; + +export default withStyles(styles, { name: 'MuiPickersYearSelection' })(YearPicker) as ( + props: YearPickerProps & React.RefAttributes, +) => JSX.Element; diff --git a/packages/material-ui-lab/src/YearPicker/index.ts b/packages/material-ui-lab/src/YearPicker/index.ts new file mode 100644 index 00000000000000..1814639f1b5e13 --- /dev/null +++ b/packages/material-ui-lab/src/YearPicker/index.ts @@ -0,0 +1,4 @@ +export { default } from './YearPicker'; + +export type YearPickerClassKey = import('./YearPicker').YearPickerClassKey; +export type YearPickerProps = import('./YearPicker').YearPickerProps; diff --git a/packages/material-ui-lab/src/dateAdapter/date-fns.ts b/packages/material-ui-lab/src/dateAdapter/date-fns.ts new file mode 100644 index 00000000000000..9879279e131d03 --- /dev/null +++ b/packages/material-ui-lab/src/dateAdapter/date-fns.ts @@ -0,0 +1 @@ +export { default } from '@date-io/date-fns'; diff --git a/packages/material-ui-lab/src/dateAdapter/dayjs.ts b/packages/material-ui-lab/src/dateAdapter/dayjs.ts new file mode 100644 index 00000000000000..9d3c887bb2b1e7 --- /dev/null +++ b/packages/material-ui-lab/src/dateAdapter/dayjs.ts @@ -0,0 +1 @@ +export { default } from '@date-io/dayjs'; diff --git a/packages/material-ui-lab/src/dateAdapter/luxon.ts b/packages/material-ui-lab/src/dateAdapter/luxon.ts new file mode 100644 index 00000000000000..210b94785849ea --- /dev/null +++ b/packages/material-ui-lab/src/dateAdapter/luxon.ts @@ -0,0 +1 @@ +export { default } from '@date-io/luxon'; diff --git a/packages/material-ui-lab/src/dateAdapter/moment.ts b/packages/material-ui-lab/src/dateAdapter/moment.ts new file mode 100644 index 00000000000000..7871646cec03cc --- /dev/null +++ b/packages/material-ui-lab/src/dateAdapter/moment.ts @@ -0,0 +1 @@ +export { default } from '@date-io/moment'; diff --git a/packages/material-ui-lab/src/index.d.ts b/packages/material-ui-lab/src/index.d.ts index d0c63e8c4a56d6..1d3613aa221b68 100644 --- a/packages/material-ui-lab/src/index.d.ts +++ b/packages/material-ui-lab/src/index.d.ts @@ -10,12 +10,18 @@ export * from './AvatarGroup'; export { default as LoadingButton } from './LoadingButton'; export * from './LoadingButton'; +export { default as LocalizationProvider } from './LocalizationProvider'; +export * from './LocalizationProvider'; + export { default as Pagination } from './Pagination'; export * from './Pagination'; export { default as PaginationItem } from './PaginationItem'; export * from './PaginationItem'; +export { default as PickersDay } from './PickersDay'; +export * from './PickersDay'; + export { default as Rating } from './Rating'; export * from './Rating'; @@ -78,3 +84,66 @@ export * from './TreeView'; export { default as useAutocomplete } from './useAutocomplete'; export * from './useAutocomplete'; + +export { default as DayPicker } from './DayPicker'; +export * from './DayPicker'; + +export { default as DatePicker } from './DatePicker'; +export * from './DatePicker'; + +export { default as DesktopDatePicker } from './DesktopDatePicker'; +export * from './DesktopDatePicker'; + +export { default as MobileDatePicker } from './MobileDatePicker'; +export * from './MobileDatePicker'; + +export { default as StaticDatePicker } from './StaticDatePicker'; +export * from './StaticDatePicker'; + +export { default as TimePicker } from './TimePicker'; +export * from './TimePicker'; + +export { default as YearPicker } from './YearPicker'; +export * from './YearPicker'; + +export { default as DesktopTimePicker } from './DesktopTimePicker'; +export * from './DesktopTimePicker'; + +export { default as MobileTimePicker } from './MobileTimePicker'; +export * from './MobileTimePicker'; + +export { default as StaticTimePicker } from './StaticTimePicker'; +export * from './StaticTimePicker'; + +export { default as DateTimePicker } from './DateTimePicker'; +export * from './DateTimePicker'; + +export { default as DesktopDateTimePicker } from './DesktopDateTimePicker'; +export * from './DesktopDateTimePicker'; + +export { default as MobileDateTimePicker } from './MobileDateTimePicker'; +export * from './MobileDateTimePicker'; + +export { default as StaticDateTimePicker } from './StaticDateTimePicker'; +export * from './StaticDateTimePicker'; + +export { default as DateRangePicker } from './DateRangePicker'; +export * from './DateRangePicker'; + +export { + default as DesktopDateRangePicker, + DesktopDateRangePickerProps, +} from './DesktopDateRangePicker'; + +export { + default as MobileDateRangePicker, + MobileDateRangePickerProps, +} from './MobileDateRangePicker'; + +export { + default as StaticDateRangePicker, + StaticDateRangePickerProps, +} from './StaticDateRangePicker'; + +export { default as ClockPicker } from './ClockPicker'; +export * from './ClockPicker'; diff --git a/packages/material-ui-lab/src/index.js b/packages/material-ui-lab/src/index.js index f2ca8bff18413d..90c6b78f5959cb 100644 --- a/packages/material-ui-lab/src/index.js +++ b/packages/material-ui-lab/src/index.js @@ -11,15 +11,75 @@ export * from './Autocomplete'; export { default as AvatarGroup } from './AvatarGroup'; export * from './AvatarGroup'; +export { default as DayPicker } from './DayPicker'; +export * from './DayPicker'; + +export { default as DatePicker } from './DatePicker'; +export * from './DatePicker'; + +export { default as DesktopDatePicker } from './DesktopDatePicker'; +export * from './DesktopDatePicker'; + +export { default as MobileDatePicker } from './MobileDatePicker'; +export * from './MobileDatePicker'; + +export { default as StaticDatePicker } from './StaticDatePicker'; +export * from './StaticDatePicker'; + +export { default as TimePicker } from './TimePicker'; +export * from './TimePicker'; + +export { default as DesktopTimePicker } from './DesktopTimePicker'; +export * from './DesktopTimePicker'; + +export { default as MobileTimePicker } from './MobileTimePicker'; +export * from './MobileTimePicker'; + +export { default as StaticTimePicker } from './StaticTimePicker'; +export * from './StaticTimePicker'; + +export { default as DateTimePicker } from './DateTimePicker'; +export * from './DateTimePicker'; + +export { default as DesktopDateTimePicker } from './DesktopDateTimePicker'; +export * from './DesktopDateTimePicker'; + +export { default as MobileDateTimePicker } from './MobileDateTimePicker'; +export * from './MobileDateTimePicker'; + +export { default as StaticDateTimePicker } from './StaticDateTimePicker'; +export * from './StaticDateTimePicker'; + +export { default as DateRangePicker } from './DateRangePicker'; +export * from './DateRangePicker'; + +export { default as DesktopDateRangePicker } from './DesktopDateRangePicker'; +export * from './DesktopDateRangePicker'; + +export { default as MobileDateRangePicker } from './MobileDateRangePicker'; +export * from './MobileDateRangePicker'; + +export { default as StaticDateRangePicker } from './StaticDateRangePicker'; +export * from './StaticDateRangePicker'; + +export { default as ClockPicker } from './ClockPicker'; +export * from './ClockPicker'; + export { default as LoadingButton } from './LoadingButton'; export * from './LoadingButton'; +export { default as LocalizationProvider } from './LocalizationProvider'; +export * from './LocalizationProvider'; + export { default as Pagination } from './Pagination'; export * from './Pagination'; export { default as PaginationItem } from './PaginationItem'; export * from './PaginationItem'; +export { default as PickersDay } from './PickersDay'; +export * from './PickersDay'; + export { default as Rating } from './Rating'; export * from './Rating'; @@ -68,6 +128,8 @@ export * from './TimelineOppositeContent'; export { default as TimelineSeparator } from './TimelineSeparator'; export * from './TimelineSeparator'; +export * from './TimePicker'; + export { default as ToggleButton } from './ToggleButton'; export * from './ToggleButton'; @@ -80,5 +142,8 @@ export * from './TreeItem'; export { default as TreeView } from './TreeView'; export * from './TreeView'; +export { default as YearPicker } from './YearPicker'; +export * from './YearPicker'; + // createFilterOptions is exported from Autocomplete export { default as useAutocomplete } from './useAutocomplete'; diff --git a/packages/material-ui-lab/src/index.test.js b/packages/material-ui-lab/src/index.test.js index 2b364d08f0a9e6..ea9342587cacf8 100644 --- a/packages/material-ui-lab/src/index.test.js +++ b/packages/material-ui-lab/src/index.test.js @@ -14,7 +14,7 @@ describe('@material-ui/lab', () => { it('should not have undefined exports', () => { Object.keys(MaterialUI).forEach((exportKey) => - expect(Boolean(MaterialUI[exportKey])).to.equal(true), + expect(Boolean(MaterialUI[exportKey]), `${exportKey} is not truthy`).to.equal(true), ); }); }); diff --git a/packages/material-ui-lab/src/internal/pickers/KeyboardDateInput.tsx b/packages/material-ui-lab/src/internal/pickers/KeyboardDateInput.tsx new file mode 100644 index 00000000000000..45c84a8d476b42 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/KeyboardDateInput.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from '@material-ui/core/IconButton'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import { useForkRef } from '@material-ui/core/utils'; +import { useUtils } from './hooks/useUtils'; +import CalendarIcon from '../svg-icons/Calendar'; +import { useMaskedInput } from './hooks/useMaskedInput'; +import { DateInputProps, DateInputRefs } from './PureDateInput'; +import { getTextFieldAriaText } from './text-field-helper'; + +export const KeyboardDateInput: React.FC = ({ + containerRef, + inputRef = null, + forwardedRef = null, + disableOpenPicker: hideOpenPickerButton, + getOpenDialogAriaText = getTextFieldAriaText, + InputAdornmentProps, + InputProps, + openPicker: onOpen, + OpenPickerButtonProps, + openPickerIcon = , + renderInput, + ...other +}) => { + const utils = useUtils(); + const inputRefHandle = useForkRef(inputRef, forwardedRef); + const textFieldProps = useMaskedInput(other); + const adornmentPosition = InputAdornmentProps?.position || 'end'; + + return renderInput({ + ref: containerRef, + inputRef: inputRefHandle, + ...textFieldProps, + InputProps: { + ...InputProps, + [`${adornmentPosition}Adornment`]: hideOpenPickerButton ? undefined : ( + + + {openPickerIcon} + + + ), + }, + }); +}; + +KeyboardDateInput.propTypes = { + acceptRegex: PropTypes.instanceOf(RegExp), + getOpenDialogAriaText: PropTypes.func, + mask: PropTypes.string, + OpenPickerButtonProps: PropTypes.object, + openPickerIcon: PropTypes.node, + renderInput: PropTypes.func.isRequired, + rifmFormatter: PropTypes.func, +}; + +export default KeyboardDateInput; diff --git a/packages/material-ui-lab/src/internal/pickers/Picker/Picker.tsx b/packages/material-ui-lab/src/internal/pickers/Picker/Picker.tsx new file mode 100644 index 00000000000000..2e2100b2921069 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/Picker/Picker.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { styled, createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; +import { useViews } from '../hooks/useViews'; +import ClockPicker from '../../../ClockPicker/ClockPicker'; +import DayPicker from '../../../DayPicker/DayPicker'; +import { KeyboardDateInput } from '../KeyboardDateInput'; +import { useIsLandscape } from '../hooks/useIsLandscape'; +import { DIALOG_WIDTH, VIEW_HEIGHT } from '../constants/dimensions'; +import { WrapperVariantContext } from '../wrappers/WrapperVariantContext'; +import { PickerSelectionState } from '../hooks/usePickerState'; +import { BasePickerProps, CalendarAndClockProps } from '../typings/BasePicker'; +import { WithViewsProps, SharedPickerProps } from './SharedPickerProps'; +import { AllAvailableViews, TimePickerView, DatePickerView } from '../typings/Views'; +import PickerView from './PickerView'; + +export interface ExportedPickerProps + extends Omit, + CalendarAndClockProps, + WithViewsProps { + dateRangeIcon?: React.ReactNode; + timeIcon?: React.ReactNode; +} + +export type PickerProps< + TView extends AllAvailableViews, + TInputValue = any, + TDateValue = any +> = ExportedPickerProps & SharedPickerProps; + +export const MobileKeyboardInputView = styled('div')( + { + padding: '16px 24px', + }, + { name: 'MuiPickersMobileKeyboardInputView' }, +); + +export const styles = createStyles({ + root: { + display: 'flex', + flexDirection: 'column', + }, + landscape: { + flexDirection: 'row', + }, + pickerView: { + overflowX: 'hidden', + width: DIALOG_WIDTH, + maxHeight: VIEW_HEIGHT, + display: 'flex', + flexDirection: 'column', + margin: '0 auto', + }, +}); + +export type PickerClassKey = keyof WithStyles['classes']; + +const MobileKeyboardTextFieldProps = { fullWidth: true }; + +const isDatePickerView = (view: AllAvailableViews): view is DatePickerView => + view === 'year' || view === 'month' || view === 'date'; + +const isTimePickerView = (view: AllAvailableViews): view is TimePickerView => + view === 'hours' || view === 'minutes' || view === 'seconds'; + +function Picker({ + classes, + className, + date, + DateInputProps, + isMobileKeyboardViewOpen, + onDateChange, + openTo = 'date', + orientation, + showToolbar, + toggleMobileKeyboardView, + ToolbarComponent = () => null, + toolbarFormat, + toolbarPlaceholder, + toolbarTitle, + views = ['year', 'month', 'date', 'hours', 'minutes', 'seconds'], + ...other +}: PickerProps & WithStyles) { + const isLandscape = useIsLandscape(views, orientation); + const wrapperVariant = React.useContext(WrapperVariantContext); + + const toShowToolbar = + typeof showToolbar === 'undefined' ? wrapperVariant !== 'desktop' : showToolbar; + + const handleDateChange = React.useCallback( + (newDate: unknown, selectionState?: PickerSelectionState) => { + onDateChange(newDate, wrapperVariant, selectionState); + }, + [onDateChange, wrapperVariant], + ); + + const { openView, nextView, previousView, setOpenView, handleChangeAndOpenNext } = useViews({ + view: undefined, + views, + openTo, + onChange: handleDateChange, + }); + + React.useEffect(() => { + if (isMobileKeyboardViewOpen && toggleMobileKeyboardView) { + toggleMobileKeyboardView(); + } + // React on `openView` change + }, [openView]); // eslint-disable-line + + return ( +
+ {toShowToolbar && ( + + )} + + + {isMobileKeyboardViewOpen ? ( + + + + ) : ( + + {isDatePickerView(openView) && ( + + )} + + {isTimePickerView(openView) && ( + setOpenView(nextView)} + openPreviousView={() => setOpenView(previousView)} + nextViewAvailable={!nextView} + previousViewAvailable={!previousView || isDatePickerView(previousView)} + showViewSwitcher={wrapperVariant === 'desktop'} + /> + )} + + )} + +
+ ); +} + +export default withStyles(styles, { name: 'MuiPicker' })(Picker); diff --git a/packages/material-ui-lab/src/internal/pickers/Picker/PickerView.tsx b/packages/material-ui-lab/src/internal/pickers/Picker/PickerView.tsx new file mode 100644 index 00000000000000..fb4ef718c084d3 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/Picker/PickerView.tsx @@ -0,0 +1,16 @@ +import { styled } from '@material-ui/core/styles'; +import { DIALOG_WIDTH, VIEW_HEIGHT } from '../constants/dimensions'; + +const PickerView = styled('div')( + { + overflowX: 'hidden', + width: DIALOG_WIDTH, + maxHeight: VIEW_HEIGHT, + display: 'flex', + flexDirection: 'column', + margin: '0 auto', + }, + { name: 'MuiPickerView' }, +); + +export default PickerView; diff --git a/packages/material-ui-lab/src/internal/pickers/Picker/SharedPickerProps.tsx b/packages/material-ui-lab/src/internal/pickers/Picker/SharedPickerProps.tsx new file mode 100644 index 00000000000000..ae066525df032d --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/Picker/SharedPickerProps.tsx @@ -0,0 +1,37 @@ +import { BasePickerProps } from '../typings/BasePicker'; +import { ExportedDateInputProps } from '../PureDateInput'; +import { WithDateAdapterProps } from '../withDateAdapterProp'; +import { PickerSelectionState } from '../hooks/usePickerState'; +import { DateInputPropsLike } from '../wrappers/WrapperProps'; +import { AllAvailableViews } from '../typings/Views'; +import { WrapperVariant } from '../wrappers/Wrapper'; + +export type AllSharedPickerProps = BasePickerProps< + TInputValue, + TDateValue +> & + ExportedDateInputProps & + WithDateAdapterProps; + +export interface SharedPickerProps { + isMobileKeyboardViewOpen: boolean; + toggleMobileKeyboardView: () => void; + DateInputProps: TInputProps; + date: TDateValue; + onDateChange: ( + date: TDateValue, + currentWrapperVariant: WrapperVariant, + isFinish?: PickerSelectionState, + ) => void; +} + +export interface WithViewsProps { + /** + * Array of views to show. + */ + views?: T[]; + /** + * First view to show. + */ + openTo?: T; +} diff --git a/packages/material-ui-lab/src/internal/pickers/Picker/makePickerWithState.tsx b/packages/material-ui-lab/src/internal/pickers/Picker/makePickerWithState.tsx new file mode 100644 index 00000000000000..c1b84345df0c6a --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/Picker/makePickerWithState.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import Picker, { ExportedPickerProps } from './Picker'; +import { ParsableDate } from '../constants/prop-types'; +import { MuiPickersAdapter } from '../hooks/useUtils'; +import { parsePickerInputValue } from '../date-utils'; +import { withDefaultProps } from '../withDefaultProps'; +import { KeyboardDateInput } from '../KeyboardDateInput'; +import { SomeWrapper, ExtendWrapper } from '../wrappers/Wrapper'; +import { ResponsiveWrapper } from '../wrappers/ResponsiveWrapper'; +import { withDateAdapterProp } from '../withDateAdapterProp'; +import { makeWrapperComponent } from '../wrappers/makeWrapperComponent'; +import { PureDateInput } from '../PureDateInput'; +import { usePickerState, PickerStateValueManager } from '../hooks/usePickerState'; +import { AllAvailableViews } from '../typings/Views'; +import { AllSharedPickerProps } from './SharedPickerProps'; +import { ToolbarComponentProps } from '../typings/BasePicker'; + +type AllAvailableForOverrideProps = ExportedPickerProps; + +export type AllPickerProps = T & + AllSharedPickerProps & + ExtendWrapper; + +export interface MakePickerOptions { + name: string; + /** + * Hook that running validation for the `value` and input. + */ + useValidation: (value: ParsableDate, props: T) => string | null; + /** + * Intercept props to override or inject default props specifically for picker. + */ + useInterceptProps: (props: AllPickerProps) => AllPickerProps & { inputFormat: string }; + DefaultToolbarComponent: React.ComponentType; +} + +const valueManager: PickerStateValueManager = { + emptyValue: null, + parseInput: parsePickerInputValue, + areValuesEqual: (utils: MuiPickersAdapter, a: unknown, b: unknown) => utils.isEqual(a, b), +}; + +export type SharedPickerProps = ExtendWrapper & + AllSharedPickerProps, TDate | null> & + React.RefAttributes; + +type PickerComponent< + TViewProps extends AllAvailableForOverrideProps, + TWrapper extends SomeWrapper +> = (props: TViewProps & SharedPickerProps) => JSX.Element; + +export function makePickerWithStateAndWrapper< + T extends AllAvailableForOverrideProps, + TWrapper extends SomeWrapper = typeof ResponsiveWrapper +>( + Wrapper: TWrapper, + { name, useInterceptProps, useValidation, DefaultToolbarComponent }: MakePickerOptions, +): PickerComponent { + const WrapperComponent = makeWrapperComponent(Wrapper, { + KeyboardDateInputComponent: KeyboardDateInput, + PureDateInputComponent: PureDateInput, + }); + + function PickerWithState( + __props: T & AllSharedPickerProps, TDate> & ExtendWrapper, + ) { + const allProps = useInterceptProps(__props) as AllPickerProps; + + const validationError = useValidation(allProps.value, allProps) !== null; + const { pickerProps, inputProps, wrapperProps } = usePickerState, TDate>( + allProps, + valueManager as PickerStateValueManager, TDate>, + ); + + // Note that we are passing down all the value without spread. + // It saves us >1kb gzip and make any prop available automatically on any level down. + const { value, onChange, ...other } = allProps; + const AllDateInputProps = { ...inputProps, ...other, validationError }; + + return ( + + + + ); + } + + const FinalPickerComponent = withDefaultProps({ name }, withDateAdapterProp(PickerWithState)); + + // tslint:disable-next-line + // @ts-ignore Simply ignore generic values in props, because it is impossible + // to keep generics without additional cast when using forwardRef + // @see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35834 + return React.forwardRef>( + (props, ref) => , + ); +} diff --git a/packages/material-ui-lab/src/internal/pickers/PickersArrowSwitcher.tsx b/packages/material-ui-lab/src/internal/pickers/PickersArrowSwitcher.tsx new file mode 100644 index 00000000000000..4c21178585629a --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/PickersArrowSwitcher.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Typography from '@material-ui/core/Typography'; +import { createStyles, WithStyles, withStyles, Theme, useTheme } from '@material-ui/core/styles'; +import IconButton, { IconButtonProps } from '@material-ui/core/IconButton'; +import ArrowLeftIcon from '../svg-icons/ArrowLeft'; +import ArrowRightIcon from '../svg-icons/ArrowRight'; + +export interface ExportedArrowSwitcherProps { + /** + * Left arrow icon. + */ + leftArrowIcon?: React.ReactNode; + /** + * Right arrow icon. + */ + rightArrowIcon?: React.ReactNode; + /** + * Left arrow icon aria-label text. + */ + leftArrowButtonText?: string; + /** + * Right arrow icon aria-label text. + */ + rightArrowButtonText?: string; + /** + * Props to pass to left arrow button. + */ + leftArrowButtonProps?: Partial; + /** + * Props to pass to right arrow button. + */ + rightArrowButtonProps?: Partial; +} + +interface ArrowSwitcherProps extends ExportedArrowSwitcherProps, React.HTMLProps { + isLeftDisabled: boolean; + isLeftHidden?: boolean; + isRightDisabled: boolean; + isRightHidden?: boolean; + onLeftClick: () => void; + onRightClick: () => void; + text?: string; +} + +export const styles = (theme: Theme) => + createStyles({ + root: {}, + iconButton: { + zIndex: 1, + backgroundColor: theme.palette.background.paper, + }, + previousMonthButtonMargin: { + marginRight: 24, + }, + hidden: { + visibility: 'hidden', + }, + }); + +export type PickersArrowSwitcherClassKey = keyof WithStyles['classes']; + +const PickersArrowSwitcher = React.forwardRef< + HTMLDivElement, + ArrowSwitcherProps & WithStyles +>((props, ref) => { + const { + classes, + className, + isLeftDisabled, + isLeftHidden, + isRightDisabled, + isRightHidden, + leftArrowButtonProps, + leftArrowButtonText, + leftArrowIcon = , + onLeftClick, + onRightClick, + rightArrowButtonProps, + rightArrowButtonText, + rightArrowIcon = , + text, + ...other + } = props; + const theme = useTheme(); + const isRtl = theme.direction === 'rtl'; + + return ( +
+ + {isRtl ? rightArrowIcon : leftArrowIcon} + + {text && ( + + {text} + + )} + + {isRtl ? leftArrowIcon : rightArrowIcon} + +
+ ); +}); + +export default withStyles(styles, { name: 'MuiPickersArrowSwitcher' })( + React.memo(PickersArrowSwitcher), +); diff --git a/packages/material-ui-lab/src/internal/pickers/PickersModalDialog.tsx b/packages/material-ui-lab/src/internal/pickers/PickersModalDialog.tsx new file mode 100644 index 00000000000000..c941c7acd83f4e --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/PickersModalDialog.tsx @@ -0,0 +1,158 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Button from '@material-ui/core/Button'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import Dialog, { DialogProps as MuiDialogProps } from '@material-ui/core/Dialog'; +import { createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; +import { DIALOG_WIDTH, DIALOG_WIDTH_WIDER } from './constants/dimensions'; + +export interface ExportedPickerModalProps { + /** + * "OK" button text. + * @default "OK" + */ + okText?: React.ReactNode; + /** + * "CANCEL" Text message + * @default "CANCEL" + */ + cancelText?: React.ReactNode; + /** + * "CLEAR" Text message + * @default "CLEAR" + */ + clearText?: React.ReactNode; + /** + * "TODAY" Text message + * @default "TODAY" + */ + todayText?: React.ReactNode; + /** + * If `true`, it shows the clear action in the picker dialog. + * @default false + */ + clearable?: boolean; + /** + * If `true`, the today button will be displayed. **Note** that `showClearButton` has a higher priority. + * @default false + */ + showTodayButton?: boolean; + /** + * Props to be passed directly to material-ui [Dialog](https://material-ui.com/components/dialogs) + */ + DialogProps?: Partial; +} + +export interface PickerModalDialogProps extends ExportedPickerModalProps { + onAccept: () => void; + onClear: () => void; + onDismiss: () => void; + onSetToday: () => void; + wider?: boolean; + open: boolean; +} + +export const styles = createStyles({ + dialogRoot: { + minWidth: DIALOG_WIDTH, + }, + dialogRootWider: { + minWidth: DIALOG_WIDTH_WIDER, + }, + dialogContainer: { + '&:focus > $dialogRoot': { + outline: 'auto', + '@media (pointer:coarse)': { + outline: 0, + }, + }, + }, + dialog: { + '&:first-child': { + padding: 0, + }, + }, + dialogAction: { + // requested for overrides + }, + withAdditionalAction: { + // set justifyContent to default value to fix IE11 layout bug + // see https://github.com/mui-org/material-ui-pickers/pull/267 + justifyContent: 'flex-start', + + '& > *:first-child': { + marginRight: 'auto', + }, + }, +}); + +export type PickersModalDialogClassKey = keyof WithStyles['classes']; + +const PickersModalDialog: React.FC> = ( + props, +) => { + const { + open, + classes, + cancelText = 'Cancel', + children, + clearable = false, + clearText = 'Clear', + okText = 'OK', + onAccept, + onClear, + onDismiss, + onSetToday, + showTodayButton = false, + todayText = 'Today', + wider, + DialogProps, + } = props; + + const MuiDialogClasses = DialogProps?.classes; + return ( + + {children} + + {clearable && ( + + )} + {showTodayButton && ( + + )} + {cancelText && ( + + )} + {okText && ( + + )} + + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersModalDialog' })(PickersModalDialog); diff --git a/packages/material-ui-lab/src/internal/pickers/PickersPopper.tsx b/packages/material-ui-lab/src/internal/pickers/PickersPopper.tsx new file mode 100644 index 00000000000000..a3537b89b546dc --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/PickersPopper.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Grow from '@material-ui/core/Grow'; +import Paper, { PaperProps as MuiPaperProps } from '@material-ui/core/Paper'; +import Popper, { PopperProps as MuiPopperProps } from '@material-ui/core/Popper'; +import TrapFocus, { + TrapFocusProps as MuiTrapFocusProps, +} from '@material-ui/core/Unstable_TrapFocus'; +import { useForkRef, setRef, useEventCallback } from '@material-ui/core/utils'; +import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles'; +import { TransitionProps as MuiTransitionProps } from '@material-ui/core/transitions'; +import { useGlobalKeyDown, keycode } from './hooks/useKeyDown'; +import { IS_TOUCH_DEVICE_MEDIA } from './constants/dimensions'; +import { executeInTheNextEventLoopTick } from './utils'; + +export interface ExportedPickerPopperProps { + /** + * Popper props passed down to [Popper](https://material-ui.com/api/popper/) component. + */ + PopperProps?: Partial; + /** + * Custom component for popper [Transition](https://material-ui.com/components/transitions/#transitioncomponent-prop). + */ + TransitionComponent?: React.ComponentType; +} + +export interface PickerPopperProps extends ExportedPickerPopperProps, MuiPaperProps { + role: 'tooltip' | 'dialog'; + TrapFocusProps?: Partial; + anchorEl: MuiPopperProps['anchorEl']; + open: MuiPopperProps['open']; + containerRef?: React.Ref; + onClose: () => void; + onOpen: () => void; +} + +export const styles = (theme: Theme) => + createStyles({ + root: { + zIndex: theme.zIndex.modal, + }, + paper: { + transformOrigin: 'top center', + '&:focus': { + [IS_TOUCH_DEVICE_MEDIA]: { + outline: 0, + }, + }, + }, + topTransition: { + transformOrigin: 'bottom center', + }, + }); + +export type PickersPopperClassKey = keyof WithStyles['classes']; + +const PickersPopper: React.FC> = (props) => { + const { + anchorEl, + children, + classes, + containerRef = null, + onClose, + onOpen, + open, + PopperProps, + role, + TransitionComponent = Grow, + TrapFocusProps, + } = props; + const paperRef = React.useRef(null); + const handleRef = useForkRef(paperRef, containerRef); + const lastFocusedElementRef = React.useRef(null); + + const handlePaperRef = useEventCallback((node) => { + setRef(handleRef, node); + + if (node) { + onOpen(); + } + }); + + useGlobalKeyDown(open, { + [keycode.Esc]: onClose, + }); + + React.useEffect(() => { + if (role === 'tooltip') { + return; + } + + if (open) { + lastFocusedElementRef.current = document.activeElement; + } else if ( + lastFocusedElementRef.current && + lastFocusedElementRef.current instanceof HTMLElement + ) { + lastFocusedElementRef.current.focus(); + } + }, [open, role]); + + const handleBlur = () => { + if (!open) { + return; + } + + // document.activeElement is updating on the next tick after `blur` called + executeInTheNextEventLoopTick(() => { + if (paperRef.current?.contains(document.activeElement)) { + return; + } + + onClose(); + }); + }; + + return ( + + {({ TransitionProps, placement }) => ( + true} + getDoc={() => paperRef.current?.ownerDocument ?? document} + {...TrapFocusProps} + > + + + {children} + + + + )} + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersPopper' })(PickersPopper); diff --git a/packages/material-ui-lab/src/internal/pickers/PickersToolbar.tsx b/packages/material-ui-lab/src/internal/pickers/PickersToolbar.tsx new file mode 100644 index 00000000000000..0ab9f8efebd1d2 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/PickersToolbar.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles'; +import Toolbar, { ToolbarProps } from '@material-ui/core/Toolbar'; +import { ExtendMui } from './typings/helpers'; +import PenIcon from '../svg-icons/Pen'; +import CalendarIcon from '../svg-icons/Calendar'; +import { ToolbarComponentProps } from './typings/BasePicker'; + +export const styles = (theme: Theme) => { + const toolbarBackground = + theme.palette.mode === 'light' ? theme.palette.primary.main : theme.palette.background.default; + + return createStyles({ + root: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'space-between', + paddingTop: 16, + paddingBottom: 16, + backgroundColor: toolbarBackground, + color: theme.palette.getContrastText(toolbarBackground), + }, + toolbarLandscape: { + height: 'auto', + maxWidth: 160, + padding: 16, + justifyContent: 'flex-start', + flexWrap: 'wrap', + }, + dateTitleContainer: { + flex: 1, + }, + }); +}; + +export type PickersToolbarClassKey = keyof WithStyles['classes']; + +export interface PickersToolbarProps + extends ExtendMui, + Pick< + ToolbarComponentProps, + | 'getMobileKeyboardInputViewButtonText' + | 'isMobileKeyboardViewOpen' + | 'toggleMobileKeyboardView' + > { + toolbarTitle: React.ReactNode; + landscapeDirection?: 'row' | 'column'; + isLandscape: boolean; + penIconClassName?: string; +} + +function defaultGetKeyboardInputSwitchingButtonText(isKeyboardInputOpen: boolean) { + return isKeyboardInputOpen + ? 'text input view is open, go to calendar view' + : 'calendar view is open, go to text input view'; +} + +const PickerToolbar: React.FC> = ({ + children, + classes, + className, + getMobileKeyboardInputViewButtonText = defaultGetKeyboardInputSwitchingButtonText, + isLandscape, + isMobileKeyboardViewOpen, + landscapeDirection = 'column', + penIconClassName, + toggleMobileKeyboardView, + toolbarTitle, +}) => { + return ( + + + {toolbarTitle} + + + {children} + + {isMobileKeyboardViewOpen ? ( + + ) : ( + + )} + + + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersToolbar' })(PickerToolbar); diff --git a/packages/material-ui-lab/src/internal/pickers/PickersToolbarButton.tsx b/packages/material-ui-lab/src/internal/pickers/PickersToolbarButton.tsx new file mode 100644 index 00000000000000..99c028ad8a5cfd --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/PickersToolbarButton.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Button, { ButtonProps } from '@material-ui/core/Button'; +import { createStyles, WithStyles, withStyles } from '@material-ui/core/styles'; +import { TypographyProps } from '@material-ui/core/Typography'; +import ToolbarText from './PickersToolbarText'; +import { ExtendMui } from './typings/helpers'; + +export interface ToolbarButtonProps extends ExtendMui { + align?: TypographyProps['align']; + selected: boolean; + typographyClassName?: string; + value: React.ReactNode; + variant: TypographyProps['variant']; +} + +export const styles = createStyles({ + root: { + padding: 0, + minWidth: '16px', + textTransform: 'none', + }, +}); + +export type PickersToolbarButtonClassKey = keyof WithStyles['classes']; + +const ToolbarButton: React.FunctionComponent> = ( + props, +) => { + const { + align, + classes, + className, + selected, + typographyClassName, + value, + variant, + ...other + } = props; + + return ( + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersToolbarButton' })(ToolbarButton); diff --git a/packages/material-ui-lab/src/internal/pickers/PickersToolbarText.tsx b/packages/material-ui-lab/src/internal/pickers/PickersToolbarText.tsx new file mode 100644 index 00000000000000..7a21fab72e6de8 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/PickersToolbarText.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Typography, { TypographyProps } from '@material-ui/core/Typography'; +import { createStyles, WithStyles, withStyles, Theme, alpha } from '@material-ui/core/styles'; +import { ExtendMui } from './typings/helpers'; + +export interface ToolbarTextProps extends ExtendMui { + selected?: boolean; + value: React.ReactNode; +} + +export const styles = (theme: Theme) => { + const textColor = + theme.palette.mode === 'light' + ? theme.palette.primary.contrastText + : theme.palette.getContrastText(theme.palette.background.default); + + return createStyles({ + root: { + transition: theme.transitions.create('color'), + color: alpha(textColor, 0.54), + '&$selected': { + color: textColor, + }, + }, + selected: {}, + }); +}; + +export type PickersToolbarTextClassKey = keyof WithStyles['classes']; + +const ToolbarText: React.FC> = (props) => { + const { className, classes, selected, value, ...other } = props; + + return ( + + {value} + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersToolbarText' })(ToolbarText); diff --git a/packages/material-ui-lab/src/internal/pickers/PureDateInput.tsx b/packages/material-ui-lab/src/internal/pickers/PureDateInput.tsx new file mode 100644 index 00000000000000..dc9eccffd131fe --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/PureDateInput.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { TextFieldProps as MuiTextFieldPropsType } from '@material-ui/core/TextField'; +import { IconButtonProps } from '@material-ui/core/IconButton'; +import { InputAdornmentProps } from '@material-ui/core/InputAdornment'; +import { onSpaceOrEnter } from './utils'; +import { ParsableDate } from './constants/prop-types'; +import { useUtils, MuiPickersAdapter } from './hooks/useUtils'; +import { getDisplayDate, getTextFieldAriaText } from './text-field-helper'; + +// make `variant` optional +export type MuiTextFieldProps = MuiTextFieldPropsType | Omit; + +export interface DateInputProps { + open: boolean; + rawValue: TInputValue; + inputFormat: string; + onChange: (date: TDateValue, keyboardInputValue?: string) => void; + openPicker: () => void; + readOnly?: boolean; + disabled?: boolean; + validationError?: boolean; + label?: MuiTextFieldProps['label']; + InputProps?: MuiTextFieldProps['InputProps']; + TextFieldProps?: Partial; + // lib/src/wrappers/DesktopPopperWrapper.tsx:87 + onBlur?: () => void; + // ?? TODO when it will be possible to display "empty" date in datepicker use it instead of ignoring invalid inputs + ignoreInvalidInputs?: boolean; + /** + * The `renderInput` prop allows you to customize the rendered input. + * The `props` argument of this render prop contains props of [TextField](https://material-ui.com/api/text-field/#textfield-api) that you need to forward. + * Pay specific attention to the `ref` and `inputProps` keys. + * @example ```jsx + * renderInput={props => } + * ```` + */ + renderInput: (props: MuiTextFieldPropsType) => React.ReactElement; + /** + * Icon displaying for open picker button. + */ + openPickerIcon?: React.ReactNode; + /** + * Custom mask. Can be used to override generate from format. (e.g. __/__/____ __:__ or __/__/____ __:__ _M) + */ + mask?: string; + /** + * Regular expression to detect "accepted" symbols. + * @default /\dap/gi + */ + acceptRegex?: RegExp; + /** + * Props to pass to keyboard input adornment. + */ + InputAdornmentProps?: Partial; + /** + * Props to pass to keyboard adornment button. + */ + OpenPickerButtonProps?: Partial; + /** + * Custom formatter to be passed into Rifm component. + */ + rifmFormatter?: (str: string) => string; + /** + * Do not render open picker button (renders only text field with validation). + * @default false + */ + disableOpenPicker?: boolean; + /** + * Disable mask on the keyboard, this should be used rarely. Consider passing proper mask for your format. + * @default false + */ + disableMaskedInput?: boolean; + /** + * Get aria-label text for control that opens picker dialog. Aria-label text must include selected date. @DateIOType + * @default (value, utils) => `Choose date, selected date is ${utils.format(utils.date(value), 'fullDate')}` + */ + getOpenDialogAriaText?: (value: ParsableDate, utils: MuiPickersAdapter) => string; +} + +export type ExportedDateInputProps = Omit< + DateInputProps, + | 'openPicker' + | 'inputValue' + | 'onChange' + | 'inputFormat' + | 'validationError' + | 'rawValue' + | 'forwardedRef' + | 'open' + | 'TextFieldProps' + | 'onBlur' +>; + +export interface DateInputRefs { + inputRef?: React.Ref; + containerRef?: React.Ref; + forwardedRef?: React.Ref; +} + +export const PureDateInput: React.FC = ({ + containerRef, + disabled, + forwardedRef, + getOpenDialogAriaText = getTextFieldAriaText, + inputFormat, + InputProps, + label, + openPicker: onOpen, + rawValue, + renderInput, + TextFieldProps = {}, + validationError, +}) => { + const utils = useUtils(); + const PureDateInputProps = React.useMemo( + () => ({ + ...InputProps, + readOnly: true, + }), + [InputProps], + ); + + const inputValue = getDisplayDate(utils, rawValue, inputFormat); + + return renderInput({ + label, + disabled, + ref: containerRef, + inputRef: forwardedRef, + error: validationError, + InputProps: PureDateInputProps, + inputProps: { + disabled, + readOnly: true, + 'aria-readonly': true, + 'aria-label': getOpenDialogAriaText(rawValue, utils), + value: inputValue, + onClick: onOpen, + onKeyDown: onSpaceOrEnter(onOpen), + }, + ...TextFieldProps, + }); +}; + +PureDateInput.propTypes = { + acceptRegex: PropTypes.instanceOf(RegExp), + getOpenDialogAriaText: PropTypes.func, + mask: PropTypes.string, + OpenPickerButtonProps: PropTypes.object, + openPickerIcon: PropTypes.node, + renderInput: PropTypes.func.isRequired, + rifmFormatter: PropTypes.func, +}; diff --git a/packages/material-ui-lab/src/internal/pickers/ToolbarText.tsx b/packages/material-ui-lab/src/internal/pickers/ToolbarText.tsx new file mode 100644 index 00000000000000..1376d4cbdd28e9 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/ToolbarText.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Typography, { TypographyProps } from '@material-ui/core/Typography'; +import { createStyles, WithStyles, withStyles, Theme, alpha } from '@material-ui/core/styles'; +import { ExtendMui } from './typings/helpers'; + +export interface ToolbarTextProps extends ExtendMui { + selected?: boolean; + value: React.ReactNode; +} + +export const styles = (theme: Theme) => { + const textColor = + theme.palette.mode === 'light' + ? theme.palette.primary.contrastText + : theme.palette.getContrastText(theme.palette.background.default); + + return createStyles({ + root: { + transition: theme.transitions.create('color'), + color: alpha(textColor, 0.54), + '&$selected': { + color: textColor, + }, + }, + selected: {}, + }); +}; + +const ToolbarText: React.FC> = (props) => { + const { className, classes, selected, value, ...other } = props; + + return ( + + {value} + + ); +}; + +export default withStyles(styles, { name: 'MuiPickersToolbarText' })(ToolbarText); diff --git a/packages/material-ui-lab/src/internal/pickers/constants/ClockType.ts b/packages/material-ui-lab/src/internal/pickers/constants/ClockType.ts new file mode 100644 index 00000000000000..097bd12acfa620 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/constants/ClockType.ts @@ -0,0 +1 @@ +export type ClockViewType = 'hours' | 'minutes' | 'seconds'; diff --git a/packages/material-ui-lab/src/internal/pickers/constants/dimensions.ts b/packages/material-ui-lab/src/internal/pickers/constants/dimensions.ts new file mode 100644 index 00000000000000..a47273e62c4e76 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/constants/dimensions.ts @@ -0,0 +1,11 @@ +export const DIALOG_WIDTH = 320; + +export const DIALOG_WIDTH_WIDER = 325; + +export const VIEW_HEIGHT = 358; + +export const DAY_SIZE = 36; + +export const DAY_MARGIN = 2; + +export const IS_TOUCH_DEVICE_MEDIA = '@media (pointer: fine)'; diff --git a/packages/material-ui-lab/src/internal/pickers/constants/prop-types.ts b/packages/material-ui-lab/src/internal/pickers/constants/prop-types.ts new file mode 100644 index 00000000000000..a6c28f8c211a77 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/constants/prop-types.ts @@ -0,0 +1,5 @@ +export type ParsableDate = string | number | Date | null | undefined | TDate; + +export const defaultMinDate = new Date('1900-01-01') as unknown; + +export const defaultMaxDate = new Date('2099-12-31') as unknown; diff --git a/packages/material-ui-lab/src/internal/pickers/date-utils.test.ts b/packages/material-ui-lab/src/internal/pickers/date-utils.test.ts new file mode 100644 index 00000000000000..7b0748af2d43cb --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/date-utils.test.ts @@ -0,0 +1,199 @@ +import { expect } from 'chai'; +import { adapterToUse } from './test-utils'; +import { findClosestEnabledDate } from './date-utils'; + +describe('findClosestEnabledDate', () => { + const day18thText = adapterToUse.format( + adapterToUse.date('2018-08-18T00:00:00.000'), + 'dayOfMonth', + ); + const only18th = (date: any) => adapterToUse.format(date, 'dayOfMonth') !== day18thText; + + // TODO + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('should fallback to today if all dates are disabled', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2000-01-01T00:00:00.000'), + minDate: adapterToUse.date('1999-01-01T00:00:00.000'), // Use close-by min/max dates to reduce the test runtime. + maxDate: adapterToUse.date('2001-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: () => true, + disableFuture: false, + disablePast: false, + }); + + expect(adapterToUse.isEqual(result, adapterToUse.date())).to.be.equal(true); + }); + + it('should return given date if it is enabled', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2000-01-01T00:00:00.000'), + minDate: adapterToUse.date('1900-01-01T00:00:00.000'), + maxDate: adapterToUse.date('2100-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: () => false, + disableFuture: false, + disablePast: false, + })!; + + expect(adapterToUse.isSameDay(result, adapterToUse.date('2000-01-01T00:00:00.000'))).to.equal( + true, + ); + }); + + it('should return next 18th going from 10th', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2018-08-10T00:00:00.000'), + minDate: adapterToUse.date('1900-01-01T00:00:00.000'), + maxDate: adapterToUse.date('2100-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: only18th, + disableFuture: false, + disablePast: false, + })!; + + expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-08-18T00:00:00.000'))).to.equal( + true, + ); + }); + + it('should return previous 18th going from 1st', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2018-08-01T00:00:00.000'), + minDate: adapterToUse.date('1900-01-01T00:00:00.000'), + maxDate: adapterToUse.date('2100-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: only18th, + disableFuture: false, + disablePast: false, + })!; + + expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-07-18T00:00:00.000'))).to.equal( + true, + ); + }); + + it('should return future 18th if disablePast', () => { + const today = adapterToUse.startOfDay(adapterToUse.date()); + const result = findClosestEnabledDate({ + date: adapterToUse.date('2000-01-01T00:00:00.000'), + minDate: adapterToUse.date('1900-01-01T00:00:00.000'), + maxDate: adapterToUse.date('2100-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: only18th, + disableFuture: false, + disablePast: true, + })!; + + expect(adapterToUse.isBefore(result, today)).to.equal(false); + expect(adapterToUse.isBefore(result, adapterToUse.addDays(today, 31))).to.equal(true); + }); + + it('should return now if disablePast+disableFuture and now is valid', () => { + const today = adapterToUse.startOfDay(adapterToUse.date()); + const result = findClosestEnabledDate({ + date: adapterToUse.date('2000-01-01T00:00:00.000'), + minDate: adapterToUse.date('1900-01-01T00:00:00.000'), + maxDate: adapterToUse.date('2100-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: () => false, + disableFuture: true, + disablePast: true, + })!; + + expect(adapterToUse.isSameDay(result, today)).to.equal(true); + }); + + it('should fallback to today if disablePast+disableFuture and now is invalid', () => { + const today = adapterToUse.date(); + const result = findClosestEnabledDate({ + date: adapterToUse.date('2000-01-01T00:00:00.000'), + minDate: adapterToUse.date('1900-01-01T00:00:00.000'), + maxDate: adapterToUse.date('2100-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: (date) => adapterToUse.isSameDay(date, today), + disableFuture: true, + disablePast: true, + }); + + expect(adapterToUse.isEqual(result, adapterToUse.date())); + }); + + it('should return minDate if it is after the date and valid', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2000-01-01T00:00:00.000'), + minDate: adapterToUse.date('2018-08-18T00:00:00.000'), + maxDate: adapterToUse.date('2100-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: only18th, + disableFuture: false, + disablePast: false, + })!; + + expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-08-18T00:00:00.000'))).to.equal( + true, + ); + }); + + it('should return next 18th after minDate', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2000-01-01T00:00:00.000'), + minDate: adapterToUse.date('2018-08-01T00:00:00.000'), + maxDate: adapterToUse.date('2100-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: only18th, + disableFuture: false, + disablePast: false, + })!; + + expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-08-18T00:00:00.000'))).to.equal( + true, + ); + }); + + it('should return maxDate if it is before the date and valid', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2050-01-01T00:00:00.000'), + minDate: adapterToUse.date('1900-01-01T00:00:00.000'), + maxDate: adapterToUse.date('2018-07-18T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: only18th, + disableFuture: false, + disablePast: false, + })!; + + expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-07-18T00:00:00.000'))).to.equal( + true, + ); + }); + + it('should return previous 18th before maxDate', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2050-01-01T00:00:00.000'), + minDate: adapterToUse.date('1900-01-01T00:00:00.000'), + maxDate: adapterToUse.date('2018-08-17T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: only18th, + disableFuture: false, + disablePast: false, + })!; + + expect(adapterToUse.isSameDay(result, adapterToUse.date('2018-07-18T00:00:00.000'))).to.equal( + true, + ); + }); + + it('should fallback to today if minDate is after maxDate', () => { + const result = findClosestEnabledDate({ + date: adapterToUse.date('2000-01-01T00:00:00.000'), + minDate: adapterToUse.date('2000-01-01T00:00:00.000'), + maxDate: adapterToUse.date('1999-01-01T00:00:00.000'), + utils: adapterToUse, + shouldDisableDate: () => false, + disableFuture: false, + disablePast: false, + })!; + + expect(adapterToUse.isEqual(result, adapterToUse.date())).to.be.equal(true); + }); +}); diff --git a/packages/material-ui-lab/src/internal/pickers/date-utils.ts b/packages/material-ui-lab/src/internal/pickers/date-utils.ts new file mode 100644 index 00000000000000..c3d17b5703bb86 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/date-utils.ts @@ -0,0 +1,253 @@ +import { RangeInput, NonEmptyDateRange, DateRange } from '../../DateRangePicker/RangeTypes'; +import { arrayIncludes } from './utils'; +import { ParsableDate } from './constants/prop-types'; +import { BasePickerProps } from './typings/BasePicker'; +import { DatePickerView } from './typings/Views'; +import { MuiPickersAdapter } from './hooks/useUtils'; + +interface FindClosestDateParams { + date: TDate; + utils: MuiPickersAdapter; + minDate: TDate; + maxDate: TDate; + disableFuture: boolean; + disablePast: boolean; + shouldDisableDate: (date: TDate) => boolean; +} + +export const findClosestEnabledDate = ({ + date, + utils, + minDate, + maxDate, + disableFuture, + disablePast, + shouldDisableDate, +}: FindClosestDateParams) => { + const today = utils.startOfDay(utils.date()!); + + if (disablePast && utils.isBefore(minDate!, today)) { + minDate = today; + } + + if (disableFuture && utils.isAfter(maxDate, today)) { + maxDate = today; + } + + let forward: TDate | null = date; + let backward: TDate | null = date; + if (utils.isBefore(date, minDate)) { + forward = utils.date(minDate); + backward = null; + } + + if (utils.isAfter(date, maxDate)) { + if (backward) { + backward = utils.date(maxDate); + } + + forward = null; + } + + while (forward || backward) { + if (forward && utils.isAfter(forward, maxDate)) { + forward = null; + } + if (backward && utils.isBefore(backward, minDate)) { + backward = null; + } + + if (forward) { + if (!shouldDisableDate(forward)) { + return forward; + } + forward = utils.addDays(forward, 1); + } + + if (backward) { + if (!shouldDisableDate(backward)) { + return backward; + } + backward = utils.addDays(backward, -1); + } + } + + // fallback to today if no enabled days + return utils.date(); +}; + +export const isYearOnlyView = (views: readonly DatePickerView[]) => + views.length === 1 && views[0] === 'year'; + +export const isYearAndMonthViews = (views: readonly DatePickerView[]) => + views.length === 2 && arrayIncludes(views, 'month') && arrayIncludes(views, 'year'); + +export const getFormatAndMaskByViews = ( + views: readonly DatePickerView[], + utils: MuiPickersAdapter, +) => { + if (isYearOnlyView(views)) { + return { + mask: '____', + inputFormat: utils.formats.year, + }; + } + + if (isYearAndMonthViews(views)) { + return { + disableMaskedInput: true, + inputFormat: utils.formats.monthAndYear, + }; + } + + return { + mask: '__/__/____', + inputFormat: utils.formats.keyboardDate, + }; +}; + +export function parsePickerInputValue( + utils: MuiPickersAdapter, + { value }: BasePickerProps, +): unknown | null { + const parsedValue = utils.date(value); + + return utils.isValid(parsedValue) ? parsedValue : null; +} + +export function parseRangeInputValue( + utils: MuiPickersAdapter, + { value = [null, null] }: BasePickerProps, DateRange>, +) { + return value.map((date) => + !utils.isValid(date) || date === null ? null : utils.startOfDay(utils.date(date)), + ) as DateRange; +} + +export const isRangeValid = ( + utils: MuiPickersAdapter, + range: DateRange | null, +): range is NonEmptyDateRange => { + return Boolean(range && range[0] && range[1] && utils.isBefore(range[0], range[1])); +}; + +export const isWithinRange = ( + utils: MuiPickersAdapter, + day: TDate, + range: DateRange | null, +) => { + return isRangeValid(utils, range) && utils.isWithinRange(day, range); +}; + +export const isStartOfRange = ( + utils: MuiPickersAdapter, + day: TDate, + range: DateRange | null, +) => { + return isRangeValid(utils, range) && utils.isSameDay(day, range[0]!); +}; + +export const isEndOfRange = ( + utils: MuiPickersAdapter, + day: TDate, + range: DateRange | null, +) => { + return isRangeValid(utils, range) && utils.isSameDay(day, range[1]!); +}; + +export interface DateValidationProps { + /** + * Min selectable date. @DateIOType + * @default Date(1900-01-01) + */ + minDate?: TDate; + /** + * Max selectable date. @DateIOType + * @default Date(2099-31-12) + */ + maxDate?: TDate; + /** + * Disable specific date. @DateIOType + */ + shouldDisableDate?: (day: TDate) => boolean; + /** + * Disable past dates. + * @default false + */ + disablePast?: boolean; + /** + * Disable future dates. + * @default false + */ + disableFuture?: boolean; +} + +export const validateDate = ( + utils: MuiPickersAdapter, + value: TDate | ParsableDate, + { minDate, maxDate, disableFuture, shouldDisableDate, disablePast }: DateValidationProps, +) => { + const now = utils.date()!; + const date = utils.date(value); + + if (date === null) { + return null; + } + + switch (true) { + case !utils.isValid(value): + return 'invalidDate'; + + case Boolean(shouldDisableDate && shouldDisableDate(date)): + return 'shouldDisableDate'; + + case Boolean(disableFuture && utils.isAfterDay(date, now)): + return 'disableFuture'; + + case Boolean(disablePast && utils.isBeforeDay(date, now)): + return 'disablePast'; + + case Boolean(minDate && utils.isBeforeDay(date, minDate)): + return 'minDate'; + + case Boolean(maxDate && utils.isAfterDay(date, maxDate)): + return 'maxDate'; + + default: + return null; + } +}; + +export type DateValidationError = ReturnType; + +type DateRangeValidationErrorValue = DateValidationError | 'invalidRange' | null; + +export const validateDateRange = ( + utils: MuiPickersAdapter, + value: RangeInput, + dateValidationProps: DateValidationProps, +): [DateRangeValidationErrorValue, DateRangeValidationErrorValue] => { + const [start, end] = value; + + // for partial input + if (start === null || end === null) { + return [null, null]; + } + + const dateValidations = [ + validateDate(utils, start, dateValidationProps), + validateDate(utils, end, dateValidationProps), + ] as [DateRangeValidationErrorValue, DateRangeValidationErrorValue]; + + if (dateValidations[0] || dateValidations[1]) { + return dateValidations; + } + + if (!isRangeValid(utils, [utils.date(start), utils.date(end)])) { + return ['invalidRange', 'invalidRange']; + } + + return [null, null]; +}; + +export type DateRangeValidationError = ReturnType; diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/date-helpers-hooks.tsx b/packages/material-ui-lab/src/internal/pickers/hooks/date-helpers-hooks.tsx new file mode 100644 index 00000000000000..c81607a722230b --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/date-helpers-hooks.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { useUtils } from './useUtils'; +import { ParsableDate } from '../constants/prop-types'; +import { PickerOnChangeFn } from './useViews'; +import { getMeridiem, convertToMeridiem } from '../time-utils'; + +export type OverrideParsableDateProps = Omit< + TProps, + TKey +> & + Partial>>; + +export function useParsedDate( + possiblyUnparsedValue: ParsableDate, +): TDate | undefined { + const utils = useUtils(); + return React.useMemo( + () => + typeof possiblyUnparsedValue === 'undefined' ? undefined : utils.date(possiblyUnparsedValue)!, + [possiblyUnparsedValue, utils], + ); +} + +interface MonthValidationOptions { + disablePast?: boolean; + disableFuture?: boolean; + minDate: unknown; + maxDate: unknown; +} + +export function useNextMonthDisabled( + month: unknown, + { disableFuture, maxDate }: Pick, +) { + const utils = useUtils(); + return React.useMemo(() => { + const now = utils.date(); + const lastEnabledMonth = utils.startOfMonth( + disableFuture && utils.isBefore(now, maxDate) ? now : maxDate, + ); + return !utils.isAfter(lastEnabledMonth, month); + }, [disableFuture, maxDate, month, utils]); +} + +export function usePreviousMonthDisabled( + month: unknown, + { disablePast, minDate }: Pick, +) { + const utils = useUtils(); + + return React.useMemo(() => { + const now = utils.date(); + const firstEnabledMonth = utils.startOfMonth( + disablePast && utils.isAfter(now, minDate) ? now : minDate, + ); + return !utils.isBefore(firstEnabledMonth, month); + }, [disablePast, minDate, month, utils]); +} + +export function useMeridiemMode( + date: TDate, + ampm: boolean | undefined, + onChange: PickerOnChangeFn, +) { + const utils = useUtils(); + const meridiemMode = getMeridiem(date, utils); + + const handleMeridiemChange = React.useCallback( + (mode: 'am' | 'pm') => { + const timeWithMeridiem = convertToMeridiem(date, mode, Boolean(ampm), utils); + onChange(timeWithMeridiem, 'shallow'); + }, + [ampm, date, onChange, utils], + ); + + return { meridiemMode, handleMeridiemChange }; +} diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/useCanAutoFocus.tsx b/packages/material-ui-lab/src/internal/pickers/hooks/useCanAutoFocus.tsx new file mode 100644 index 00000000000000..80580ba30f109d --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/useCanAutoFocus.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +export const CanAutoFocusContext = React.createContext(true); + +export const useCanAutoFocus = () => React.useContext(CanAutoFocusContext); + +export function useAutoFocusControl(open: boolean) { + const [canAutoFocus, setCanAutoFocus] = React.useState(false); + + React.useEffect(() => { + if (!open) { + setCanAutoFocus(false); + } + }, [open]); + + // TODO rething approach. It is a temporal fix to allow tests that are rendering Popper to update the state using + if (process.env.NODE_ENV === 'test') { + return { + canAutoFocus: true, + onOpen: () => {}, + }; + } + + return { + canAutoFocus, + onOpen: () => setCanAutoFocus(true), + }; +} diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/useIsLandscape.tsx b/packages/material-ui-lab/src/internal/pickers/hooks/useIsLandscape.tsx new file mode 100644 index 00000000000000..17c65559d0f0ef --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/useIsLandscape.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { useIsomorphicEffect } from './useKeyDown'; +import { arrayIncludes } from '../utils'; +import { BasePickerProps } from '../typings/BasePicker'; +import { AllAvailableViews } from '../typings/Views'; + +// tslint:disable deprecation +const getOrientation = () => { + if (typeof window === 'undefined') { + return 'portrait'; + } + + if (window.screen && window.screen.orientation && window.screen.orientation.angle) { + return Math.abs(window.screen.orientation.angle) === 90 ? 'landscape' : 'portrait'; + } + + // Support IOS safar + if (window.orientation) { + return Math.abs(Number(window.orientation)) === 90 ? 'landscape' : 'portrait'; + } + + return 'portrait'; +}; + +export function useIsLandscape( + views: AllAvailableViews[], + customOrientation?: BasePickerProps['orientation'], +): boolean { + const [orientation, setOrientation] = React.useState( + getOrientation(), + ); + + useIsomorphicEffect(() => { + const eventHandler = () => { + setOrientation(getOrientation()); + }; + window.addEventListener('orientationchange', eventHandler); + return () => { + window.removeEventListener('orientationchange', eventHandler); + }; + }, []); + + if (arrayIncludes(views, ['hours', 'minutes', 'seconds'])) { + // could not display 13:34:44 in landscape mode + return false; + } + + const orientationToUse = customOrientation || orientation; + return orientationToUse === 'landscape'; +} + +export default useIsLandscape; diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/useKeyDown.ts b/packages/material-ui-lab/src/internal/pickers/hooks/useKeyDown.ts new file mode 100644 index 00000000000000..9fa36538d12bca --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/useKeyDown.ts @@ -0,0 +1,65 @@ +import * as React from 'react'; + +export const useIsomorphicEffect = + typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; + +type KeyHandlers = Record void>; + +export function runKeyHandler( + event: KeyboardEvent | React.KeyboardEvent, + keyHandlers: KeyHandlers, +) { + // tslint:disable-next-line deprecation IE11 + const handler = keyHandlers[event.keyCode]; + if (handler) { + handler(); + // if event was handled prevent other side effects (e.g. page scroll) + event.preventDefault(); + } +} + +export function useKeyDownHandler(active: boolean, keyHandlers: KeyHandlers) { + const keyHandlersRef = React.useRef(keyHandlers); + keyHandlersRef.current = keyHandlers; + + return React.useCallback( + (event: React.KeyboardEvent) => { + if (active) { + runKeyHandler(event, keyHandlersRef.current); + } + }, + [active], + ); +} + +export function useGlobalKeyDown(active: boolean, keyHandlers: KeyHandlers) { + const keyHandlersRef = React.useRef(keyHandlers); + keyHandlersRef.current = keyHandlers; + + useIsomorphicEffect(() => { + if (active) { + const handleKeyDown = (event: KeyboardEvent) => { + runKeyHandler(event, keyHandlersRef.current); + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + } + + return undefined; + }, [active]); +} + +export const keycode = { + ArrowUp: 38, + ArrowDown: 40, + ArrowLeft: 37, + ArrowRight: 39, + Enter: 13, + Home: 36, + End: 35, + PageUp: 33, + PageDown: 34, + Esc: 27, +}; diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/useMaskedInput.tsx b/packages/material-ui-lab/src/internal/pickers/hooks/useMaskedInput.tsx new file mode 100644 index 00000000000000..0b7d18000f8c87 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/useMaskedInput.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { useRifm } from 'rifm'; +import { useUtils } from './useUtils'; +import { createDelegatedEventHandler } from '../utils'; +import { DateInputProps, MuiTextFieldProps } from '../PureDateInput'; +import { + maskedDateFormatter, + getDisplayDate, + checkMaskIsValidForCurrentFormat, +} from '../text-field-helper'; + +type MaskedInputProps = Omit< + DateInputProps, + | 'open' + | 'adornmentPosition' + | 'renderInput' + | 'openPicker' + | 'InputProps' + | 'InputAdornmentProps' + | 'openPickerIcon' + | 'disableOpenPicker' + | 'getOpenDialogAriaText' + | 'OpenPickerButtonProps' +> & { inputProps?: Partial> }; + +export function useMaskedInput({ + acceptRegex = /[\d]/gi, + disabled, + disableMaskedInput, + ignoreInvalidInputs, + inputFormat, + inputProps, + label, + mask, + onChange, + rawValue, + readOnly, + rifmFormatter, + TextFieldProps, + validationError, +}: MaskedInputProps): MuiTextFieldProps { + const utils = useUtils(); + const isFocusedRef = React.useRef(false); + + const getInputValue = React.useCallback(() => getDisplayDate(utils, rawValue, inputFormat), [ + inputFormat, + rawValue, + utils, + ]); + + const formatHelperText = utils.getFormatHelperText(inputFormat); + const [innerInputValue, setInnerInputValue] = React.useState(getInputValue()); + + const shouldUseMaskedInput = React.useMemo(() => { + // formatting of dates is a quite slow thing, so do not make useless .format calls + if (!mask || disableMaskedInput) { + return false; + } + + return checkMaskIsValidForCurrentFormat(mask, inputFormat, acceptRegex, utils); + }, [acceptRegex, disableMaskedInput, inputFormat, mask, utils]); + + const formatter = React.useMemo( + () => + shouldUseMaskedInput && mask ? maskedDateFormatter(mask, acceptRegex) : (st: string) => st, + [acceptRegex, mask, shouldUseMaskedInput], + ); + + React.useEffect(() => { + // We do not need to update the input value on keystroke + // Because library formatters can change inputs from 12/12/2 to 12/12/0002 + if ((rawValue === null || utils.isValid(rawValue)) && !isFocusedRef.current) { + setInnerInputValue(getInputValue()); + } + }, [utils, getInputValue, rawValue]); + + const handleChange = (text: string) => { + const finalString = text === '' || text === mask ? '' : text; + setInnerInputValue(finalString); + + const date = finalString === null ? null : utils.parse(finalString, inputFormat); + if (ignoreInvalidInputs && !utils.isValid(date)) { + return; + } + + onChange(date, finalString || undefined); + }; + + const rifmProps = useRifm({ + value: innerInputValue, + onChange: handleChange, + format: rifmFormatter || formatter, + }); + + const inputStateArgs = shouldUseMaskedInput + ? rifmProps + : { + value: innerInputValue, + onChange: (event: React.ChangeEvent) => { + handleChange(event.currentTarget.value); + }, + }; + + return { + label, + disabled, + error: validationError, + helperText: formatHelperText, + inputProps: { + ...inputStateArgs, + disabled, // make spreading in custom input easier + placeholder: formatHelperText, + readOnly, + type: shouldUseMaskedInput ? 'tel' : 'text', + ...inputProps, + onFocus: createDelegatedEventHandler(() => { + isFocusedRef.current = true; + }, inputProps?.onFocus), + onBlur: createDelegatedEventHandler(() => { + isFocusedRef.current = false; + }, inputProps?.onBlur), + }, + ...TextFieldProps, + }; +} + +export default useMaskedInput; diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/useOpenState.ts b/packages/material-ui-lab/src/internal/pickers/hooks/useOpenState.ts new file mode 100644 index 00000000000000..3eefd2229a5bee --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/useOpenState.ts @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { BasePickerProps } from '../typings/BasePicker'; + +export function useOpenState({ open, onOpen, onClose }: BasePickerProps) { + const isControllingOpenProp = React.useRef(typeof open === 'boolean').current; + const [openState, setIsOpenState] = React.useState(false); + + // It is required to update inner state in useEffect in order to avoid situation when + // Our component is not mounted yet, but `open` state is set to `true` (e.g. initially opened) + React.useEffect(() => { + if (isControllingOpenProp) { + if (typeof open !== 'boolean') { + throw new Error('You must not mix controlling and uncontrolled mode for `open` prop'); + } + + setIsOpenState(open); + } + }, [isControllingOpenProp, open]); + + const setIsOpen = React.useCallback( + (newIsOpen: boolean) => { + if (!isControllingOpenProp) { + setIsOpenState(newIsOpen); + } + + if (newIsOpen && onOpen) { + onOpen(); + } + + if (!newIsOpen && onClose) { + onClose(); + } + }, + [isControllingOpenProp, onOpen, onClose], + ); + + return { isOpen: openState, setIsOpen }; +} + +export default useOpenState; diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/usePickerState.ts b/packages/material-ui-lab/src/internal/pickers/hooks/usePickerState.ts new file mode 100644 index 00000000000000..4fbb3f518535bc --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/usePickerState.ts @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { useOpenState } from './useOpenState'; +import { WrapperVariant } from '../wrappers/Wrapper'; +import { BasePickerProps } from '../typings/BasePicker'; +import { useUtils, useNow, MuiPickersAdapter } from './useUtils'; + +export interface PickerStateValueManager { + parseInput: (utils: MuiPickersAdapter, props: BasePickerProps) => TDateValue; + emptyValue: TDateValue; + areValuesEqual: ( + utils: MuiPickersAdapter, + valueLeft: TDateValue, + valueRight: TDateValue, + ) => boolean; +} + +export type PickerSelectionState = 'partial' | 'shallow' | 'finish'; + +export function usePickerState( + props: BasePickerProps, + valueManager: PickerStateValueManager, +) { + const { + inputFormat, + disabled, + readOnly, + onAccept, + onChange, + disableCloseOnSelect, + value, + } = props; + + if (!inputFormat) { + throw new Error('inputFormat prop is required'); + } + + const now = useNow(); + const utils = useUtils(); + const { isOpen, setIsOpen } = useOpenState(props); + const [pickerDate, setPickerDate] = React.useState(valueManager.parseInput(utils, props)); + + // Mobile keyboard view is a special case. + // When it's open picker should work like closed, cause we are just showing text field + const [isMobileKeyboardViewOpen, setMobileKeyboardViewOpen] = React.useState(false); + + React.useEffect(() => { + const parsedDateValue = valueManager.parseInput(utils, props); + setPickerDate((currentPickerDate) => { + if (!valueManager.areValuesEqual(utils, currentPickerDate, parsedDateValue)) { + return parsedDateValue; + } + + return currentPickerDate; + }); + // We need to react only on value change, because `date` could potentially return new Date() on each render + }, [value, utils]); // eslint-disable-line + + const acceptDate = React.useCallback( + (acceptedDate: TDateValue, needClosePicker: boolean) => { + onChange(acceptedDate); + + if (needClosePicker) { + setIsOpen(false); + + if (onAccept) { + onAccept(acceptedDate); + } + } + }, + [onAccept, onChange, setIsOpen], + ); + + const wrapperProps = React.useMemo( + () => ({ + open: isOpen, + onClear: () => acceptDate(valueManager.emptyValue, true), + onAccept: () => acceptDate(pickerDate, true), + onDismiss: () => setIsOpen(false), + onSetToday: () => { + // TODO FIX ME + setPickerDate(now as any); + acceptDate(now as any, !disableCloseOnSelect); + }, + }), + [acceptDate, disableCloseOnSelect, isOpen, now, pickerDate, setIsOpen, valueManager.emptyValue], + ); + + const pickerProps = React.useMemo( + () => ({ + date: pickerDate, + isMobileKeyboardViewOpen, + toggleMobileKeyboardView: () => setMobileKeyboardViewOpen(!isMobileKeyboardViewOpen), + onDateChange: ( + newDate: TDateValue, + wrapperVariant: WrapperVariant, + selectionState: PickerSelectionState = 'partial', + ) => { + setPickerDate(newDate); + if (selectionState === 'partial') { + acceptDate(newDate, false); + } + + if (selectionState === 'finish') { + const shouldCloseOnSelect = !(disableCloseOnSelect ?? wrapperVariant === 'mobile'); + acceptDate(newDate, shouldCloseOnSelect); + } + + // if selectionState === "shallow" do nothing (we already update picker state) + }, + }), + [acceptDate, disableCloseOnSelect, isMobileKeyboardViewOpen, pickerDate], + ); + + const inputProps = React.useMemo( + () => ({ + onChange, + inputFormat, + open: isOpen, + rawValue: value, + openPicker: () => !readOnly && !disabled && setIsOpen(true), + }), + [onChange, inputFormat, isOpen, value, readOnly, disabled, setIsOpen], + ); + + const pickerState = { pickerProps, inputProps, wrapperProps }; + React.useDebugValue(pickerState, () => ({ + MuiPickerState: { + pickerDate, + other: pickerState, + }, + })); + + return pickerState; +} diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/useUtils.ts b/packages/material-ui-lab/src/internal/pickers/hooks/useUtils.ts new file mode 100644 index 00000000000000..16fbdb7e7b089e --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/useUtils.ts @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { MuiPickersAdapterContext } from '../../../LocalizationProvider'; + +// Required for babel https://github.com/vercel/next.js/issues/7882. Replace with `export type` in future +export type MuiPickersAdapter< + T = unknown +> = import('../../../LocalizationProvider/LocalizationProvider').MuiPickersAdapter; + +// TODO uncomment when syntax will be allowed by next babel +function checkUtils(utils: MuiPickersAdapter | null) /* :asserts utils is MuiPickersAdapter */ { + if (!utils) { + throw new Error( + 'Can not find utils in context. It looks like you forgot to wrap your component in LocalizationProvider, or pass dateAdapter prop directly.', + ); + } +} + +export function useUtils() { + const utils = React.useContext(MuiPickersAdapterContext); + checkUtils(utils); + + return utils as MuiPickersAdapter; +} + +export function useNow() { + const utils = useUtils(); + const now = React.useRef(utils.date()); + + return now.current!; +} diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/useValidation.ts b/packages/material-ui-lab/src/internal/pickers/hooks/useValidation.ts new file mode 100644 index 00000000000000..499e6ac61d8cae --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/useValidation.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { useUtils, MuiPickersAdapter } from './useUtils'; + +export interface ValidationProps { + /** + * Callback that fired when input value or new `value` prop validation returns **new** validation error (or value is valid after error). + * In case of validation error detected `reason` prop return non-null value and `TextField` must be displayed in `error` state. + * This can be used to render appropriate form error. + * + * [Read the guide](https://next.material-ui-pickers.dev/guides/forms) about form integration and error displaying. + * @DateIOType + */ + onError?: (reason: TError, value: TDateValue) => void; +} + +export interface ValidationHookOptions { + defaultValidationError?: TError; + isSameError?: (a: TError, b: TError) => boolean; +} + +const defaultIsSameError = (a: unknown, b: unknown) => a === b; + +export function makeValidationHook< + TError, + TDateValue, + TProps extends ValidationProps +>( + validateFn: (utils: MuiPickersAdapter, value: TDateValue, props: TProps) => TError, + { defaultValidationError, isSameError = defaultIsSameError }: ValidationHookOptions = {}, +) { + return (value: TDateValue, props: TProps) => { + const utils = useUtils(); + const previousValidationErrorRef = React.useRef( + defaultValidationError || null, + ) as React.MutableRefObject; + + const validationError = validateFn(utils, value, props); + + React.useEffect(() => { + if (props.onError && !isSameError(validationError, previousValidationErrorRef.current)) { + props.onError(validationError, value); + } + + previousValidationErrorRef.current = validationError; + }, [previousValidationErrorRef, props, validationError, value]); + + return validationError; + }; +} diff --git a/packages/material-ui-lab/src/internal/pickers/hooks/useViews.tsx b/packages/material-ui-lab/src/internal/pickers/hooks/useViews.tsx new file mode 100644 index 00000000000000..dc03b5fd22b103 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/hooks/useViews.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { useControlled } from '@material-ui/core'; +import { arrayIncludes } from '../utils'; +import { PickerSelectionState } from './usePickerState'; +import { AllAvailableViews } from '../typings/Views'; + +export type PickerOnChangeFn = ( + date: TDate | null, + selectionState?: PickerSelectionState, +) => void; + +interface UseViewsOptions { + onChange: PickerOnChangeFn; + views: TView[]; + view: TView | undefined; + openTo?: TView; + onViewChange?: (newView: TView) => void; +} + +export function useViews({ + view, + views, + openTo, + onChange, + onViewChange, +}: UseViewsOptions) { + const [openView, setOpenView] = useControlled({ + name: 'Picker', + state: 'view', + controlled: view, + default: openTo && arrayIncludes(views, openTo) ? openTo : views[0], + }); + + const previousView: TView | null = views[views.indexOf(openView) - 1] ?? null; + const nextView: TView | null = views[views.indexOf(openView) + 1] ?? null; + + const changeView = React.useCallback( + (newView: TView) => { + setOpenView(newView); + + if (onViewChange) { + onViewChange(newView); + } + }, + [setOpenView, onViewChange], + ); + + const openNext = React.useCallback(() => { + if (nextView) { + changeView(nextView); + } + }, [nextView, changeView]); + + const handleChangeAndOpenNext = React.useCallback( + (date: TDate, currentViewSelectionState?: PickerSelectionState) => { + const isSelectionFinishedOnCurrentView = currentViewSelectionState === 'finish'; + const globalSelectionState = + isSelectionFinishedOnCurrentView && Boolean(nextView) + ? 'partial' + : currentViewSelectionState; + + onChange(date, globalSelectionState); + if (isSelectionFinishedOnCurrentView) { + openNext(); + } + }, + [nextView, onChange, openNext], + ); + + return { + nextView, + previousView, + openNext, + handleChangeAndOpenNext, + openView, + setOpenView: changeView, + }; +} diff --git a/packages/material-ui-lab/src/internal/pickers/test-utils.tsx b/packages/material-ui-lab/src/internal/pickers/test-utils.tsx new file mode 100644 index 00000000000000..6884593c66bee9 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/test-utils.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { createClientRender, fireEvent, screen } from 'test/utils'; +import { queryHelpers, Matcher, MatcherOptions } from '@testing-library/react/pure'; +import { TransitionProps } from '@material-ui/core/transitions'; +import DateFnsAdapter from '../../dateAdapter/date-fns'; +import LocalizationProvider from '../../LocalizationProvider'; + +// TODO make possible to pass here any utils using cli +export const AdapterClassToUse = DateFnsAdapter; +export const adapterToUse = new AdapterClassToUse(); + +export const FakeTransitionComponent = React.forwardRef( + function FakeTransitionComponent({ children }, ref) { + // set tabIndex in case it is used as a child of + return ( +
+ {children} +
+ ); + }, +); + +interface PickerRenderOptions { + // object for date-fns, string for other adapters + locale?: string | object; +} + +export function createPickerRender({ + locale, + ...renderOptions +}: PickerRenderOptions & import('test/utils').RenderOptions) { + const clientRender = createClientRender(renderOptions); + + return (node: React.ReactNode) => + clientRender( + + {node} + , + ); +} + +export const queryByMuiTest = queryHelpers.queryByAttribute.bind(null, 'data-mui-test'); +export const queryAllByMuiTest = queryHelpers.queryAllByAttribute.bind(null, 'data-mui-test'); + +export function getAllByMuiTest( + id: Matcher, + container: HTMLElement = document.body, + options?: MatcherOptions, +): Element[] { + const els = queryAllByMuiTest(container, id, options); + if (!els.length) { + throw queryHelpers.getElementError( + `Unable to find an element by: [data-mui-test="${id}"]`, + container, + ); + } + return els; +} + +export function getByMuiTest(...args: Parameters): Element { + const result = getAllByMuiTest(...args); + if (result.length > 0) { + return result[0]; + } + + throw queryHelpers.getElementError( + `Unable to find an element by: [data-mui-test="${args[0]}"]`, + document.body, + ); +} + +export function openDesktopPicker() { + fireEvent.click(screen.getByLabelText(/choose date/i)); +} + +export function openMobilePicker() { + fireEvent.click(screen.getByRole('textbox')); +} diff --git a/packages/material-ui-lab/src/internal/pickers/text-field-helper.test.ts b/packages/material-ui-lab/src/internal/pickers/text-field-helper.test.ts new file mode 100644 index 00000000000000..98e30e3aa19db9 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/text-field-helper.test.ts @@ -0,0 +1,74 @@ +import { expect } from 'chai'; +import { adapterToUse } from './test-utils'; +import { + maskedDateFormatter, + pick12hOr24hFormat, + checkMaskIsValidForCurrentFormat, +} from './text-field-helper'; + +describe('text-field-helper', () => { + it('maskedDateFormatter for date', () => { + const formatterFn = maskedDateFormatter('__/__/____', /[\d]/gi); + + expect(formatterFn('21')).to.equal('21/'); + expect(formatterFn('21/1')).to.equal('21/1'); + expect(formatterFn('211/')).to.equal('21/1'); + expect(formatterFn('21/12')).to.equal('21/12/'); + expect(formatterFn('21/12/21')).to.equal('21/12/21'); + expect(formatterFn('21/12/2010')).to.equal('21/12/2010'); + expect(formatterFn('21-12-2010')).to.equal('21/12/2010'); + expect(formatterFn('2f')).to.equal('2'); + }); + + it('maskedDateFormatter for time', () => { + const formatterFn = maskedDateFormatter('__:__ _M', /[\dap]/gi); + + expect(formatterFn('10')).to.equal('10:'); + expect(formatterFn('10:00')).to.equal('10:00 '); + expect(formatterFn('10:00 A')).to.equal('10:00 AM'); + }); + + it('pick12hOr24hFormat', () => { + expect( + pick12hOr24hFormat(undefined, true, { localized: 'T', '12h': 'hh:mm a', '24h': 'HH:mm' }), + ).to.equal('hh:mm a'); + expect( + pick12hOr24hFormat(undefined, undefined, { + localized: 'T', + '12h': 'hh:mm a', + '24h': 'HH:mm', + }), + ).to.equal('T'); + expect( + pick12hOr24hFormat(undefined, false, { localized: 'T', '12h': 'hh:mm a', '24h': 'HH:mm' }), + ).to.equal('HH:mm'); + }); + + [ + { mask: '__.__.____', format: adapterToUse.formats.keyboardDate, isValid: false }, + { mask: '__/__/____', format: adapterToUse.formats.keyboardDate, isValid: true }, + { mask: '__:__ _M', format: adapterToUse.formats.fullTime, isValid: false }, + { mask: '__/__/____ __:__ _M', format: adapterToUse.formats.keyboardDateTime, isValid: false }, + { mask: '__/__/____ __:__', format: adapterToUse.formats.keyboardDateTime24h, isValid: true }, + { mask: '__/__/____', format: 'MM/dd/yyyy', isValid: true }, + { mask: '__/__/____', format: 'MMMM yyyy', isValid: false }, + { + mask: '__/__/____ __:__ _M', + format: adapterToUse.formats.keyboardDateTime12h, + isValid: true, + }, + ].forEach(({ mask, format, isValid }) => { + it(`checkMaskIsValidFormat returns ${isValid} for mask ${mask} and format ${format}`, () => { + const runMaskValidation = () => + checkMaskIsValidForCurrentFormat(mask, format, /[\dap]/gi, adapterToUse); + + if (isValid) { + expect(runMaskValidation()).to.be.equal(true); + } else { + expect(runMaskValidation).toWarnDev( + `The mask "${mask}" you passed is not valid for the format used ${format}. Falling down to uncontrolled not-masked input.`, + ); + } + }); + }); +}); diff --git a/packages/material-ui-lab/src/internal/pickers/text-field-helper.ts b/packages/material-ui-lab/src/internal/pickers/text-field-helper.ts new file mode 100644 index 00000000000000..1b6cb54ce6a4e0 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/text-field-helper.ts @@ -0,0 +1,101 @@ +import { ParsableDate } from './constants/prop-types'; +import { MuiPickersAdapter } from './hooks/useUtils'; + +export function getTextFieldAriaText(rawValue: ParsableDate, utils: MuiPickersAdapter) { + return rawValue && utils.isValid(utils.date(rawValue)) + ? `Choose date, selected date is ${utils.format(utils.date(rawValue), 'fullDate')}` + : 'Choose date'; +} + +export const getDisplayDate = ( + utils: MuiPickersAdapter, + value: ParsableDate, + inputFormat: string, +) => { + const date = utils.date(value); + const isEmpty = value === null; + + if (isEmpty) { + return ''; + } + + return utils.isValid(date) ? utils.formatByString(date, inputFormat) : ''; +}; + +export function pick12hOr24hFormat( + userFormat: string | undefined, + ampm: boolean | undefined, + formats: { localized: string; '12h': string; '24h': string }, +) { + if (userFormat) { + return userFormat; + } + + if (typeof ampm === 'undefined') { + return formats.localized; + } + + return ampm ? formats['12h'] : formats['24h']; +} + +const MASK_USER_INPUT_SYMBOL = '_'; +export const staticDateWith2DigitTokens = new Date('2019-11-21T22:30:00.000'); +export const staticDateWith1DigitTokens = new Date('2019-01-01T09:00:00.000'); + +export function checkMaskIsValidForCurrentFormat( + mask: string, + format: string, + acceptRegex: RegExp, + utils: MuiPickersAdapter, +) { + const formattedDateWith1Digit = utils.formatByString( + utils.date(staticDateWith1DigitTokens), + format, + ); + const inferredFormatPatternWith1Digits = formattedDateWith1Digit.replace( + acceptRegex, + MASK_USER_INPUT_SYMBOL, + ); + + const inferredFormatPatternWith2Digits = utils + .formatByString(utils.date(staticDateWith2DigitTokens), format) + .replace(acceptRegex, '_'); + + const isMaskValid = + inferredFormatPatternWith2Digits === mask && inferredFormatPatternWith1Digits === mask; + + if (!isMaskValid && utils.lib !== 'luxon' && process.env.NODE_ENV !== 'production') { + console.warn( + `The mask "${mask}" you passed is not valid for the format used ${format}. Falling down to uncontrolled not-masked input.`, + ); + } + + return isMaskValid; +} + +export const maskedDateFormatter = (mask: string, acceptRegexp: RegExp) => (value: string) => { + return value + .split('') + .map((char, i) => { + acceptRegexp.lastIndex = 0; + + if (i > mask.length - 1) { + return ''; + } + + const maskChar = mask[i]; + const nextMaskChar = mask[i + 1]; + + const acceptedChar = acceptRegexp.test(char) ? char : ''; + const formattedChar = + maskChar === MASK_USER_INPUT_SYMBOL ? acceptedChar : maskChar + acceptedChar; + + if (i === value.length - 1 && nextMaskChar && nextMaskChar !== MASK_USER_INPUT_SYMBOL) { + // when cursor at the end of mask part (e.g. month) prerender next symbol "21" -> "21/" + return formattedChar ? formattedChar + nextMaskChar : ''; + } + + return formattedChar; + }) + .join(''); +}; diff --git a/packages/material-ui-lab/src/internal/pickers/time-utils.ts b/packages/material-ui-lab/src/internal/pickers/time-utils.ts new file mode 100644 index 00000000000000..55c781a568b674 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/time-utils.ts @@ -0,0 +1,173 @@ +import { ParsableDate } from './constants/prop-types'; +import { MuiPickersAdapter } from './hooks/useUtils'; + +type Meridiem = 'am' | 'pm' | null; + +export const getMeridiem = (date: unknown, utils: MuiPickersAdapter): Meridiem => { + if (!date) { + return null; + } + + return utils.getHours(date) >= 12 ? 'pm' : 'am'; +}; + +export const convertValueToMeridiem = (value: number, meridiem: Meridiem, ampm: boolean) => { + if (ampm) { + const currentMeridiem = value >= 12 ? 'pm' : 'am'; + if (currentMeridiem !== meridiem) { + return meridiem === 'am' ? value - 12 : value + 12; + } + } + + return value; +}; + +export const convertToMeridiem = ( + time: TDate, + meridiem: 'am' | 'pm', + ampm: boolean, + utils: MuiPickersAdapter, +) => { + const newHoursAmount = convertValueToMeridiem(utils.getHours(time), meridiem, ampm); + return utils.setHours(time, newHoursAmount); +}; + +const clockCenter = { + x: 260 / 2, + y: 260 / 2, +}; + +const baseClockPoint = { + x: clockCenter.x, + y: 0, +}; + +const cx = baseClockPoint.x - clockCenter.x; +const cy = baseClockPoint.y - clockCenter.y; + +const rad2deg = (rad: number) => rad * 57.29577951308232; + +const getAngleValue = (step: number, offsetX: number, offsetY: number) => { + const x = offsetX - clockCenter.x; + const y = offsetY - clockCenter.y; + + const atan = Math.atan2(cx, cy) - Math.atan2(x, y); + + let deg = rad2deg(atan); + deg = Math.round(deg / step) * step; + deg %= 360; + + const value = Math.floor(deg / step) || 0; + const delta = x ** 2 + y ** 2; + const distance = Math.sqrt(delta); + + return { value, distance }; +}; + +export const getMinutes = (offsetX: number, offsetY: number, step = 1) => { + const angleStep = step * 6; + let { value } = getAngleValue(angleStep, offsetX, offsetY); + value = (value * step) % 60; + + return value; +}; + +export const getHours = (offsetX: number, offsetY: number, ampm: boolean) => { + const { value, distance } = getAngleValue(30, offsetX, offsetY); + let hour = value || 12; + + if (!ampm) { + if (distance < 90) { + hour += 12; + hour %= 24; + } + } else { + hour %= 12; + } + + return hour; +}; + +export function getSecondsInDay(date: unknown, utils: MuiPickersAdapter) { + return utils.getHours(date) * 3600 + utils.getMinutes(date) * 60 + utils.getSeconds(date); +} + +export const createIsAfterIgnoreDatePart = ( + disableIgnoringDatePartForTimeValidation: boolean, + utils: MuiPickersAdapter, +) => (dateLeft: unknown, dateRight: unknown) => { + if (disableIgnoringDatePartForTimeValidation) { + return utils.isAfter(dateLeft, dateRight); + } + + return getSecondsInDay(dateLeft, utils) > getSecondsInDay(dateRight, utils); +}; + +export interface TimeValidationProps { + /** + * Min time acceptable time. + * For input validation date part of passed object will be ignored if `disableIgnoringDatePartForTimeValidation` not specified. + */ + minTime?: TDate; + /** + * Max time acceptable time. + * For input validation date part of passed object will be ignored if `disableIgnoringDatePartForTimeValidation` not specified. + */ + maxTime?: TDate; + /** + * Dynamically check if time is disabled or not. + * If returns `false` appropriate time point will ot be acceptable. + */ + shouldDisableTime?: (timeValue: number, clockType: 'hours' | 'minutes' | 'seconds') => boolean; + /** + * Do not ignore date part when validating min/max time. + * @default false + */ + disableIgnoringDatePartForTimeValidation?: boolean; +} + +export const validateTime = ( + utils: MuiPickersAdapter, + value: TDate | ParsableDate, + { + minTime, + maxTime, + shouldDisableTime, + disableIgnoringDatePartForTimeValidation, + }: TimeValidationProps, +) => { + const date = utils.date(value); + const isAfterComparingFn = createIsAfterIgnoreDatePart( + Boolean(disableIgnoringDatePartForTimeValidation), + utils, + ); + + if (value === null) { + return null; + } + + switch (true) { + case !utils.isValid(value): + return 'invalidDate'; + + case Boolean(minTime && isAfterComparingFn(minTime, date)): + return 'minTime'; + + case Boolean(maxTime && isAfterComparingFn(date, maxTime)): + return 'maxTime'; + + case Boolean(shouldDisableTime && shouldDisableTime(utils.getHours(date), 'hours')): + return 'shouldDisableTime-hours'; + + case Boolean(shouldDisableTime && shouldDisableTime(utils.getMinutes(date), 'minutes')): + return 'shouldDisableTime-minutes'; + + case Boolean(shouldDisableTime && shouldDisableTime(utils.getSeconds(date), 'seconds')): + return 'shouldDisableTime-seconds'; + + default: + return null; + } +}; + +export type TimeValidationError = ReturnType; diff --git a/packages/material-ui-lab/src/internal/pickers/typings/BasePicker.tsx b/packages/material-ui-lab/src/internal/pickers/typings/BasePicker.tsx new file mode 100644 index 00000000000000..8e629ac06ec3b9 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/typings/BasePicker.tsx @@ -0,0 +1,105 @@ +import { ParsableDate } from '../constants/prop-types'; +import { AllAvailableViews } from './Views'; + +export type CalendarAndClockProps< + TDate +> = import('@material-ui/lab/DayPicker/DayPicker').ExportedDayPickerProps & + import('@material-ui/lab/ClockPicker/ClockPicker').ExportedClockPickerProps; + +export type ToolbarComponentProps< + TDate = unknown, + TView extends AllAvailableViews = AllAvailableViews +> = CalendarAndClockProps & { + ampmInClock?: boolean; + date: TDate; + dateRangeIcon?: React.ReactNode; + getMobileKeyboardInputViewButtonText?: () => string; + hideTabs?: boolean; + isLandscape: boolean; + isMobileKeyboardViewOpen: boolean; + onChange: import('../hooks/useViews').PickerOnChangeFn; + openView: TView; + setOpenView: (view: TView) => void; + timeIcon?: React.ReactNode; + toggleMobileKeyboardView: () => void; + toolbarFormat?: string; + toolbarPlaceholder?: React.ReactNode; + toolbarTitle?: React.ReactNode; + views: TView[]; +}; + +export interface BasePickerProps { + /** + * The value of the picker. + */ + value: TInputValue; + /** + * Callback fired when the value (the selected date) changes. @DateIOType. + */ + onChange: (date: TDateValue, keyboardInputValue?: string) => void; + /** + * If `true` the popup or dialog will immediately close after submitting full date. + * @default `true` for Desktop, `false` for Mobile (based on the chosen wrapper and `desktopModeMediaQuery` prop). + */ + disableCloseOnSelect?: boolean; + /** + * Format string. + */ + inputFormat?: string; + /** + * If `true`, the picker and text field are disabled. + */ + disabled?: boolean; + /** + * Make picker read only. + */ + readOnly?: boolean; + /** + * Callback fired when date is accepted @DateIOType. + */ + onAccept?: (date: TDateValue | null) => void; + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + */ + onOpen?: () => void; + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + */ + onClose?: () => void; + /** + * Control the popup or dialog open state. + */ + open?: boolean; + /** + * If `true`, show the toolbar even in desktop mode. + */ + showToolbar?: boolean; + /** + * Force rendering in particular orientation. + */ + orientation?: 'portrait' | 'landscape'; + /** + * Component that will replace default toolbar renderer. + */ + ToolbarComponent?: React.ComponentType; + /** + * Mobile picker title, displaying in the toolbar. + * @default "SELECT DATE" + */ + toolbarTitle?: React.ReactNode; + /** + * Mobile picker date value placeholder, displaying if `value` === `null`. + * @default "โ€“" + */ + toolbarPlaceholder?: React.ReactNode; + /** + * Date format, that is displaying in toolbar. + */ + toolbarFormat?: string; + /** + * className applied to the root component. + */ + className?: string; +} diff --git a/packages/material-ui-lab/src/internal/pickers/typings/Views.ts b/packages/material-ui-lab/src/internal/pickers/typings/Views.ts new file mode 100644 index 00000000000000..60e9e632ce3321 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/typings/Views.ts @@ -0,0 +1,7 @@ +export type AllAvailableViews = 'year' | 'date' | 'month' | 'hours' | 'minutes' | 'seconds'; + +export type DateTimePickerView = 'year' | 'date' | 'month' | 'hours' | 'minutes'; + +export type DatePickerView = 'year' | 'date' | 'month'; + +export type TimePickerView = 'hours' | 'minutes' | 'seconds'; diff --git a/packages/material-ui-lab/src/internal/pickers/typings/helpers.ts b/packages/material-ui-lab/src/internal/pickers/typings/helpers.ts new file mode 100644 index 00000000000000..a242f78e81d773 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/typings/helpers.ts @@ -0,0 +1,25 @@ +import { WithStyles } from '@material-ui/core'; + +/** + * All standard components exposed by `material-ui` are `StyledComponents` with + * certain `classes`, on which one can also set a top-level `className` and inline + * `style`. + */ +export type ExtendMui = Omit< + C, + 'classes' | 'theme' | Removals +>; + +export type MakeOptional = { + [P in K]?: T[P] | undefined; +} & + Omit; + +export type MakeRequired = { + [X in Exclude]?: T[X]; +} & + { + [P in K]-?: T[P]; + }; + +export type WithoutClasses> = Omit>; diff --git a/packages/material-ui-lab/src/internal/pickers/utils.ts b/packages/material-ui-lab/src/internal/pickers/utils.ts new file mode 100644 index 00000000000000..72335d25117e74 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/utils.ts @@ -0,0 +1,66 @@ +import * as React from 'react'; + +/* Use it instead of .includes method for IE support */ +export function arrayIncludes(array: T[] | readonly T[], itemOrItems: T | T[]) { + if (Array.isArray(itemOrItems)) { + return itemOrItems.every((item) => array.indexOf(item) !== -1); + } + + return array.indexOf(itemOrItems) !== -1; +} + +export const onSpaceOrEnter = ( + innerFn: () => void, + onFocus?: (event: React.KeyboardEvent) => void, +) => (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + innerFn(); + + // prevent any side effects + event.preventDefault(); + event.stopPropagation(); + } + + if (onFocus) { + onFocus(event); + } +}; + +/* Quick untyped helper to improve function composition readability */ +export const pipe = (...fns: Array<(...args: any[]) => any>) => + fns.reduceRight( + (prevFn, nextFn) => (...args) => nextFn(prevFn(...args)), + (value) => value, + ); + +export const executeInTheNextEventLoopTick = (fn: () => void) => { + setTimeout(fn, 0); +}; + +export function createDelegatedEventHandler( + fn: (event: TEvent) => void, + onEvent?: (event: TEvent) => void, +) { + return (event: TEvent) => { + fn(event); + + if (onEvent) { + onEvent(event); + } + }; +} + +export function mergeRefs(refs: Array | undefined>) { + return (value: T) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value); + } else if (typeof ref === 'object' && ref != null) { + // @ts-expect-error do not use MutableRefObject here for easier type inference from useRef + ref.current = value; + } + }); + }; +} + +export const doNothing = () => {}; diff --git a/packages/material-ui-lab/src/internal/pickers/withDateAdapterProp.tsx b/packages/material-ui-lab/src/internal/pickers/withDateAdapterProp.tsx new file mode 100644 index 00000000000000..ebc6568dfe2b0a --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/withDateAdapterProp.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { MuiPickersAdapter } from './hooks/useUtils'; +import { MuiPickersAdapterContext } from '../../LocalizationProvider'; + +export interface WithDateAdapterProps { + /** + * Allows to pass configured date-io adapter directly. More info [here](https://next.material-ui-pickers.dev/guides/date-adapter-passing) + * ```jsx + * dateAdapter={new DateFnsAdapter({ locale: ruLocale })} + * ``` + */ + dateAdapter?: MuiPickersAdapter; +} + +export function withDateAdapterProp( + Component: React.ComponentType, +): React.FC> { + return ({ dateAdapter, ...other }: TProps & WithDateAdapterProps) => { + if (dateAdapter) { + return ( + + + + ); + } + return ; + }; +} diff --git a/packages/material-ui-lab/src/internal/pickers/withDefaultProps.tsx b/packages/material-ui-lab/src/internal/pickers/withDefaultProps.tsx new file mode 100644 index 00000000000000..41c83507cd69c3 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/withDefaultProps.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import getThemeProps from '@material-ui/styles/getThemeProps'; +import { useTheme } from '@material-ui/core/styles'; + +export function useDefaultProps(props: T, { name }: { name: string }) { + const theme = useTheme(); + + return getThemeProps({ + props, + theme, + name, + }); +} + +export function withDefaultProps( + componentConfig: { name: string }, + Component: React.ComponentType, +): React.FC { + const componentName = componentConfig.name.replace('Mui', ''); + + const WithDefaultProps = (props: T) => { + Component.displayName = componentName; + const propsWithDefault = useDefaultProps(props, componentConfig); + + return ; + }; + + WithDefaultProps.displayName = `WithDefaultProps(${componentName})`; + return WithDefaultProps; +} diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/DesktopTooltipWrapper.tsx b/packages/material-ui-lab/src/internal/pickers/wrappers/DesktopTooltipWrapper.tsx new file mode 100644 index 00000000000000..2fbc8839b28cd4 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/DesktopTooltipWrapper.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { WrapperVariantContext } from './WrapperVariantContext'; +import { KeyboardDateInput } from '../KeyboardDateInput'; +import { executeInTheNextEventLoopTick } from '../utils'; +import PickersPopper from '../PickersPopper'; +import { CanAutoFocusContext, useAutoFocusControl } from '../hooks/useCanAutoFocus'; +import { PrivateWrapperProps, DesktopWrapperProps } from './WrapperProps'; + +const DesktopTooltipWrapper: React.FC = (props) => { + const { + open, + children, + PopperProps, + onDismiss, + DateInputProps, + TransitionComponent, + KeyboardDateInputComponent = KeyboardDateInput, + } = props; + const inputRef = React.useRef(null); + const popperRef = React.useRef(null); + const { canAutoFocus, onOpen } = useAutoFocusControl(open); + + const handleBlur = () => { + executeInTheNextEventLoopTick(() => { + if ( + inputRef.current?.contains(document.activeElement) || + popperRef.current?.contains(document.activeElement) + ) { + return; + } + + onDismiss(); + }); + }; + + return ( + + + + + {children} + + + + ); +}; + +export default DesktopTooltipWrapper; diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/DesktopWrapper.tsx b/packages/material-ui-lab/src/internal/pickers/wrappers/DesktopWrapper.tsx new file mode 100644 index 00000000000000..53ed6c9d10bfce --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/DesktopWrapper.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { WrapperVariantContext } from './WrapperVariantContext'; +import { KeyboardDateInput } from '../KeyboardDateInput'; +import PickersPopper from '../PickersPopper'; +import { CanAutoFocusContext, useAutoFocusControl } from '../hooks/useCanAutoFocus'; +import { PrivateWrapperProps, DesktopWrapperProps } from './WrapperProps'; + +const DesktopWrapper: React.FC = (props) => { + const { + children, + DateInputProps, + KeyboardDateInputComponent = KeyboardDateInput, + onDismiss, + open, + PopperProps, + TransitionComponent, + } = props; + const inputRef = React.useRef(null); + const { canAutoFocus, onOpen } = useAutoFocusControl(open); + + return ( + + + + + {children} + + + + ); +}; + +DesktopWrapper.propTypes = { + onOpen: PropTypes.func, + onClose: PropTypes.func, +} as any; + +export default DesktopWrapper; diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/MobileWrapper.tsx b/packages/material-ui-lab/src/internal/pickers/wrappers/MobileWrapper.tsx new file mode 100644 index 00000000000000..4ea93525ba4133 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/MobileWrapper.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { PureDateInput } from '../PureDateInput'; +import { WrapperVariantContext } from './WrapperVariantContext'; +import PickersModalDialog from '../PickersModalDialog'; +import { MobileWrapperProps, PrivateWrapperProps } from './WrapperProps'; + +const MobileWrapper: React.FC = (props) => { + const { + cancelText, + children, + clearable, + clearText, + DateInputProps, + DialogProps, + KeyboardDateInputComponent, + okText, + onAccept, + onClear, + onDismiss, + onSetToday, + open, + PureDateInputComponent = PureDateInput, + showTodayButton, + todayText, + ...other + } = props; + + return ( + + + + {children} + + + ); +}; + +MobileWrapper.propTypes = { + cancelText: PropTypes.node, + clearable: PropTypes.bool, + clearText: PropTypes.node, + DialogProps: PropTypes.object, + okText: PropTypes.node, + showTodayButton: PropTypes.bool, + todayText: PropTypes.node, +}; + +export default MobileWrapper; diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/ResponsiveWrapper.tsx b/packages/material-ui-lab/src/internal/pickers/wrappers/ResponsiveWrapper.tsx new file mode 100644 index 00000000000000..543a1d6b542d6e --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/ResponsiveWrapper.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; +import { IS_TOUCH_DEVICE_MEDIA } from '../constants/dimensions'; +import MobileWrapper from './MobileWrapper'; +import DesktopWrapper from './DesktopWrapper'; +import DesktopTooltipWrapper from './DesktopTooltipWrapper'; +import { + MobileWrapperProps, + DesktopWrapperProps, + WrapperProps, + PrivateWrapperProps, +} from './WrapperProps'; + +export interface ResponsiveWrapperProps extends MobileWrapperProps, DesktopWrapperProps { + /** + * CSS media query when `Mobile` mode will be changed to `Desktop`. + * @default "@media (pointer: fine)" + * @example "@media (min-width: 720px)" or theme.breakpoints.up("sm") + */ + desktopModeMediaQuery?: string; +} + +export const makeResponsiveWrapper = ( + DesktopWrapperComponent: React.FC, + MobileWrapperComponent: React.FC, +) => { + const ResponsiveWrapper: React.FC = ({ + cancelText, + clearable, + clearText, + desktopModeMediaQuery = IS_TOUCH_DEVICE_MEDIA, + DialogProps, + okText, + PopperProps, + showTodayButton, + todayText, + TransitionComponent, + ...other + }) => { + const isDesktop = useMediaQuery(desktopModeMediaQuery); + + return isDesktop ? ( + + ) : ( + + ); + }; + + return ResponsiveWrapper; +}; + +export const ResponsiveWrapper = makeResponsiveWrapper(DesktopWrapper, MobileWrapper); + +export const ResponsiveTooltipWrapper = makeResponsiveWrapper(DesktopTooltipWrapper, MobileWrapper); diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/StaticWrapper.tsx b/packages/material-ui-lab/src/internal/pickers/wrappers/StaticWrapper.tsx new file mode 100644 index 00000000000000..33fbd21bf2766a --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/StaticWrapper.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { createStyles, WithStyles, withStyles, Theme } from '@material-ui/core/styles'; +import { DIALOG_WIDTH } from '../constants/dimensions'; +import { WrapperVariantContext, IsStaticVariantContext } from './WrapperVariantContext'; +import { StaticWrapperProps, PrivateWrapperProps } from './WrapperProps'; + +const styles = (theme: Theme) => + createStyles({ + root: { + overflow: 'hidden', + minWidth: DIALOG_WIDTH, + display: 'flex', + flexDirection: 'column', + backgroundColor: theme.palette.background.paper, + }, + }); + +const StaticWrapper: React.FC< + PrivateWrapperProps & StaticWrapperProps & WithStyles +> = (props) => { + const { classes, displayStaticWrapperAs = 'mobile', children } = props; + + const isStatic = true; + + return ( + + +
{children}
+
+
+ ); +}; + +export default withStyles(styles, { name: 'MuiPickersStaticWrapper' })(StaticWrapper) as React.FC< + PrivateWrapperProps & StaticWrapperProps +>; diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/Wrapper.tsx b/packages/material-ui-lab/src/internal/pickers/wrappers/Wrapper.tsx new file mode 100644 index 00000000000000..43691dfa35dc0e --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/Wrapper.tsx @@ -0,0 +1,41 @@ +import StaticWrapper from './StaticWrapper'; +import MobileWrapper from './MobileWrapper'; +import DesktopWrapper from './DesktopWrapper'; +import { ResponsiveWrapper, ResponsiveWrapperProps } from './ResponsiveWrapper'; +import DesktopTooltipWrapper from './DesktopTooltipWrapper'; +import { + StaticWrapperProps, + MobileWrapperProps, + DesktopWrapperProps, + PrivateWrapperProps, +} from './WrapperProps'; + +type UniqueWrapperComponentProps> = Omit< + React.ComponentProps, + keyof PrivateWrapperProps +>; + +export type SomeWrapper = + | typeof ResponsiveWrapper + | typeof StaticWrapper + | typeof MobileWrapper + | typeof DesktopWrapper + | typeof DesktopTooltipWrapper; + +// prettier-ignore +export type ExtendWrapper = + UniqueWrapperComponentProps extends StaticWrapperProps + ? StaticWrapperProps + // make sure that ResponsiveWrapper extends props for mobile and desktop so we must check it before them and only for unique prop + : UniqueWrapperComponentProps extends Pick + ? ResponsiveWrapperProps + : UniqueWrapperComponentProps extends DesktopWrapperProps + ? DesktopWrapperProps + : UniqueWrapperComponentProps extends MobileWrapperProps + ? MobileWrapperProps + : {}; + +// Required for babel https://github.com/vercel/next.js/issues/7882. Replace with `export type` in future +export type WrapperVariant = import('./WrapperVariantContext').WrapperVariant; + +export { StaticWrapper, MobileWrapper, DesktopWrapper }; diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/WrapperProps.ts b/packages/material-ui-lab/src/internal/pickers/wrappers/WrapperProps.ts new file mode 100644 index 00000000000000..bc2193aa3d8273 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/WrapperProps.ts @@ -0,0 +1,41 @@ +import { DateInputProps } from '../PureDateInput'; +import { ExportedPickerPopperProps } from '../PickersPopper'; +import { ExportedPickerModalProps } from '../PickersModalDialog'; + +export type DateInputPropsLike = Omit< + DateInputProps, + 'renderInput' | 'validationError' | 'forwardedRef' +> & { + renderInput: (...args: any) => JSX.Element; + validationError?: any; + forwardedRef?: any; +}; + +export interface StaticWrapperProps { + /** + * Force static wrapper inner components to be rendered in mobile or desktop mode + * @default "static" + */ + displayStaticWrapperAs?: 'desktop' | 'mobile'; +} + +export interface MobileWrapperProps extends ExportedPickerModalProps {} + +export interface DesktopWrapperProps extends ExportedPickerPopperProps {} + +export interface PrivateWrapperProps { + open: boolean; + onAccept: () => void; + onDismiss: () => void; + onClear: () => void; + onSetToday: () => void; + DateInputProps: DateInputPropsLike; + KeyboardDateInputComponent?: React.ComponentType; + PureDateInputComponent?: React.ComponentType; +} + +/** Root interface for all wrappers props. Any wrapper can accept all the props and must spread them. */ +export type WrapperProps = StaticWrapperProps & + MobileWrapperProps & + DesktopWrapperProps & + PrivateWrapperProps; diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/WrapperVariantContext.tsx b/packages/material-ui-lab/src/internal/pickers/wrappers/WrapperVariantContext.tsx new file mode 100644 index 00000000000000..a819fce06d8901 --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/WrapperVariantContext.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +export type WrapperVariant = 'mobile' | 'desktop' | null; + +/** + * TODO consider getting rid from wrapper variant + * @ignore - internal component. + */ +export const WrapperVariantContext = React.createContext(null); + +/** + * @ignore - internal component. + */ +export const IsStaticVariantContext = React.createContext(false); diff --git a/packages/material-ui-lab/src/internal/pickers/wrappers/makeWrapperComponent.tsx b/packages/material-ui-lab/src/internal/pickers/wrappers/makeWrapperComponent.tsx new file mode 100644 index 00000000000000..2bda113086d3bf --- /dev/null +++ b/packages/material-ui-lab/src/internal/pickers/wrappers/makeWrapperComponent.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { BasePickerProps } from '../typings/BasePicker'; +import { ResponsiveWrapperProps } from './ResponsiveWrapper'; +import { SomeWrapper } from './Wrapper'; +import { StaticWrapperProps, DateInputPropsLike, WrapperProps } from './WrapperProps'; + +interface MakePickerOptions { + PureDateInputComponent?: React.ComponentType; + KeyboardDateInputComponent?: React.ComponentType; +} + +interface WithWrapperProps { + children: React.ReactNode; + DateInputProps: DateInputPropsLike; + wrapperProps: Omit; +} + +/* Creates a component that rendering modal/popover/nothing and spreading props down to text field */ +export function makeWrapperComponent( + Wrapper: TWrapper, + { KeyboardDateInputComponent, PureDateInputComponent }: MakePickerOptions, +) { + function WrapperComponent( + props: Partial> & + WithWrapperProps & + ResponsiveWrapperProps & + StaticWrapperProps, + ) { + const { + disableCloseOnSelect, + cancelText, + children, + clearable, + clearText, + DateInputProps, + DialogProps, + displayStaticWrapperAs, + inputFormat, + okText, + onAccept, + onChange, + onClose, + onOpen, + open, + PopperProps, + todayText, + value, + wrapperProps, + ...restPropsForTextField + } = props; + + const TypedWrapper = Wrapper as SomeWrapper; + + return ( + + {children} + + ); + } + + return WrapperComponent; +} + +export default makeWrapperComponent; diff --git a/packages/material-ui-lab/src/internal/svg-icons/ArrowDropDown.js b/packages/material-ui-lab/src/internal/svg-icons/ArrowDropDown.js new file mode 100644 index 00000000000000..fdaa9c00cb395f --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/ArrowDropDown.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon(, 'ArrowDropDown'); diff --git a/packages/material-ui-lab/src/internal/svg-icons/ArrowDropDown.tsx b/packages/material-ui-lab/src/internal/svg-icons/ArrowDropDown.tsx new file mode 100644 index 00000000000000..fdaa9c00cb395f --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/ArrowDropDown.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon(, 'ArrowDropDown'); diff --git a/packages/material-ui-lab/src/internal/svg-icons/ArrowLeft.tsx b/packages/material-ui-lab/src/internal/svg-icons/ArrowLeft.tsx new file mode 100644 index 00000000000000..42dca39397f1d6 --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/ArrowLeft.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon( + , + 'ArrowLeft', +); diff --git a/packages/material-ui-lab/src/internal/svg-icons/ArrowRight.tsx b/packages/material-ui-lab/src/internal/svg-icons/ArrowRight.tsx new file mode 100644 index 00000000000000..9927259c99044c --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/ArrowRight.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon( + , + 'ArrowRight', +); diff --git a/packages/material-ui-lab/src/internal/svg-icons/Calendar.tsx b/packages/material-ui-lab/src/internal/svg-icons/Calendar.tsx new file mode 100644 index 00000000000000..7a504c29bab6e9 --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/Calendar.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon( + , + 'Calendar', +); diff --git a/packages/material-ui-lab/src/internal/svg-icons/Clock.tsx b/packages/material-ui-lab/src/internal/svg-icons/Clock.tsx new file mode 100644 index 00000000000000..bf543cf5cc4fc0 --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/Clock.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon( + + + + , + 'Clock', +); diff --git a/packages/material-ui-lab/src/internal/svg-icons/DateRange.tsx b/packages/material-ui-lab/src/internal/svg-icons/DateRange.tsx new file mode 100644 index 00000000000000..7a67f36cff1474 --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/DateRange.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon( + , + 'DateRange', +); diff --git a/packages/material-ui-lab/src/internal/svg-icons/Pen.tsx b/packages/material-ui-lab/src/internal/svg-icons/Pen.tsx new file mode 100644 index 00000000000000..0adf7029b8c2c4 --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/Pen.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon( + , + 'Pen', +); diff --git a/packages/material-ui-lab/src/internal/svg-icons/Time.tsx b/packages/material-ui-lab/src/internal/svg-icons/Time.tsx new file mode 100644 index 00000000000000..d8c0eace3bbaeb --- /dev/null +++ b/packages/material-ui-lab/src/internal/svg-icons/Time.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { createSvgIcon } from '@material-ui/core/utils'; + +/** + * @ignore - internal component. + */ +export default createSvgIcon( + + + + , + 'Time', +); diff --git a/packages/material-ui-lab/src/themeAugmentation/overrides.d.ts b/packages/material-ui-lab/src/themeAugmentation/overrides.d.ts index 17c8211089c62e..d0cbdf0a1bdfe0 100644 --- a/packages/material-ui-lab/src/themeAugmentation/overrides.d.ts +++ b/packages/material-ui-lab/src/themeAugmentation/overrides.d.ts @@ -1,3 +1,26 @@ +import { ClockClassKey } from '../ClockPicker/Clock'; +import { ClockNumberClassKey } from '../ClockPicker/ClockNumber'; +import { ClockPointerClassKey } from '../ClockPicker/ClockPointer'; +import { DatePickerToolbarClassKey } from '../DatePicker/DatePickerToolbar'; +import { DateTimePickerTabsClassKey } from '../DateTimePicker/DateTimePickerTabs'; +import { DateTimePickerToolbarClassKey } from '../DateTimePicker/DateTimePickerToolbar'; +import { DayPickerClassKey } from '../DayPicker'; +import { MonthPickerClassKey } from '../MonthPicker'; +import { PickerClassKey } from '../internal/pickers/Picker/Picker'; +import { PickersArrowSwitcherClassKey } from '../internal/pickers/PickersArrowSwitcher'; +import { PickersCalendarClassKey } from '../DayPicker/PickersCalendar'; +import { PickersCalendarHeaderClassKey } from '../DayPicker/PickersCalendarHeader'; +import { PickersCalendarSkeletonClassKey } from '../PickersCalendarSkeleton'; +import { PickersDayClassKey } from '../PickersDay'; +import { PickersFadeTransitionGroupClassKey } from '../DayPicker/PickersFadeTransitionGroup'; +import { PickersModalDialogClassKey } from '../internal/pickers/PickersModalDialog'; +import { PickersMonthClassKey } from '../MonthPicker/PickersMonth'; +import { PickersPopperClassKey } from '../internal/pickers/PickersPopper'; +import { PickersSlideTransitionClassKey } from '../DayPicker/PickersSlideTransition'; +import { PickersToolbarButtonClassKey } from '../internal/pickers/PickersToolbarButton'; +import { PickersToolbarClassKey } from '../internal/pickers/PickersToolbar'; +import { PickersToolbarTextClassKey } from '../internal/pickers/PickersToolbarText'; +import { PickersYearClassKey } from '../YearPicker/PickersYear'; import { TabListClassKey } from '../TabList'; import { TabPanelClassKey } from '../TabPanel'; import { TimelineClassKey } from '../Timeline'; @@ -7,10 +30,36 @@ import { TimelineDotClassKey } from '../TimelineDot'; import { TimelineItemClassKey } from '../TimelineItem'; import { TimelineOppositeContentClassKey } from '../TimelineOppositeContent'; import { TimelineSeparatorClassKey } from '../TimelineSeparator'; +import { TimePickerToolbarClassKey } from '../TimePicker/TimePickerToolbar'; import { TreeItemClassKey } from '../TreeItem'; import { TreeViewClassKey } from '../TreeView'; +import { YearPickerClassKey } from '../YearPicker'; +// prettier-ignore export interface LabComponentNameToClassKey { + MuiClock: ClockClassKey; + MuiClockNumber: ClockNumberClassKey; + MuiClockPointer: ClockPointerClassKey; + MuiDatePickerToolbar: DatePickerToolbarClassKey; + MuiDateTimePickerTabs: DateTimePickerTabsClassKey; + MuiDateTimePickerToolbar: DateTimePickerToolbarClassKey; + MuiDayPicker: DayPickerClassKey; + MuiMonthPicker: MonthPickerClassKey; + MuiPicker: PickerClassKey; + MuiPickersArrowSwitcher: PickersArrowSwitcherClassKey; + MuiPickersCalendar: PickersCalendarClassKey; + MuiPickersCalendarHeader: PickersCalendarHeaderClassKey; + MuiPickersCalendarSkeleton: PickersCalendarSkeletonClassKey; + MuiPickersDay: PickersDayClassKey; + MuiPickersFadeTransition: PickersFadeTransitionGroupClassKey; + MuiPickersModalDialog: PickersModalDialogClassKey; + MuiPickersMonth: PickersMonthClassKey; + MuiPickersPopper: PickersPopperClassKey; + MuiPickersSlideTransition: PickersSlideTransitionClassKey; + MuiPickersToolbar: PickersToolbarClassKey; + MuiPickersToolbarButton: PickersToolbarButtonClassKey; + MuiPickersToolbarText: PickersToolbarTextClassKey; + MuiPickersYear: PickersYearClassKey; MuiTabList: TabListClassKey; MuiTabPanel: TabPanelClassKey; MuiTimeline: TimelineClassKey; @@ -20,8 +69,10 @@ export interface LabComponentNameToClassKey { MuiTimelineItem: TimelineItemClassKey; MuiTimelineOppositeContent: TimelineOppositeContentClassKey; MuiTimelineSeparator: TimelineSeparatorClassKey; + MuiTimePickerToolbar: TimePickerToolbarClassKey; MuiTreeItem: TreeItemClassKey; MuiTreeView: TreeViewClassKey; + MuiYearPicker: YearPickerClassKey; } declare module '@material-ui/core/styles/overrides' { diff --git a/packages/material-ui-lab/src/themeAugmentation/props.d.ts b/packages/material-ui-lab/src/themeAugmentation/props.d.ts index 4e8865584c4ee9..a4412eccce07bf 100644 --- a/packages/material-ui-lab/src/themeAugmentation/props.d.ts +++ b/packages/material-ui-lab/src/themeAugmentation/props.d.ts @@ -1,16 +1,49 @@ +import { ClockPickerProps } from '../ClockPicker'; +import { DatePickerProps } from '../DatePicker'; +import { DateTimePickerProps } from '../DateTimePicker'; +import { DayPickerProps } from '../DayPicker'; +import { DesktopDateTimePickerProps } from '../DesktopDateTimePicker'; +import { DesktopTimePickerProps } from '../DesktopTimePicker'; +import { MobileDatePickerProps } from '../MobileDatePicker'; +import { MobileDateTimePickerProps } from '../MobileDateTimePicker'; +import { MobileTimePickerProps } from '../MobileTimePicker'; +import { MonthPickerProps } from '../MonthPicker/MonthPicker'; +import { PickersCalendarSkeletonProps } from '../PickersCalendarSkeleton'; +import { PickersDayProps } from '../PickersDay'; +import { StaticDatePickerProps } from '../StaticDatePicker'; +import { StaticDateTimePickerProps } from '../StaticDateTimePicker'; +import { StaticTimePickerProps } from '../StaticTimePicker'; import { TabListProps } from '../TabList'; import { TabPanelProps } from '../TabPanel'; -import { TimelineProps } from '../Timeline'; import { TimelineConnectorProps } from '../TimelineConnector'; import { TimelineContentProps } from '../TimelineContent'; import { TimelineDotProps } from '../TimelineDot'; import { TimelineItemProps } from '../TimelineItem'; import { TimelineOppositeContentProps } from '../TimelineOppositeContent'; +import { TimelineProps } from '../Timeline'; import { TimelineSeparatorProps } from '../TimelineSeparator'; +import { TimePickerProps } from '../TimePicker'; import { TreeItemProps } from '../TreeItem'; import { TreeViewProps } from '../TreeView'; +import { YearPickerProps } from '../YearPicker'; export interface LabComponentsPropsList { + MuiAvatarGroup: AvatarGroupProps; + MuiClockPicker: ClockPickerProps; + MuiDatePicker: DatePickerProps; + MuiDateTimePicker: DateTimePickerProps; + MuiDayPicker: DayPickerProps; + MuiDesktopDateTimePicker: DesktopDateTimePickerProps; + MuiDesktopTimePicker: DesktopTimePickerProps; + MuiMobileDatePicker: MobileDatePickerProps; + MuiMobileDateTimePicker: MobileDateTimePickerProps; + MuiMobileTimePicker: MobileTimePickerProps; + MuiMonthPicker: MonthPickerProps; + MuiPickersCalendarSkeleton: PickersCalendarSkeletonProps; + MuiPickersDay: PickersDayProps; + MuiStaticDatePicker: StaticDatePickerProps; + MuiStaticDateTimePicker: StaticDateTimePickerProps; + MuiStaticTimePicker: StaticTimePickerProps; MuiTabList: TabListProps; MuiTabPanel: TabPanelProps; MuiTimeline: TimelineProps; @@ -20,8 +53,10 @@ export interface LabComponentsPropsList { MuiTimelineItem: TimelineItemProps; MuiTimelineOppositeContent: TimelineOppositeContentProps; MuiTimelineSeparator: TimelineSeparatorProps; + MuiTimePicker: TimePickerProps; MuiTreeItem: TreeItemProps; MuiTreeView: TreeViewProps; + MuiYearPicker: YearPickerProps; } declare module '@material-ui/core/styles/props' { diff --git a/packages/material-ui-lab/tsconfig.json b/packages/material-ui-lab/tsconfig.json index 57220954971e1e..c7f6a6f12afcc9 100644 --- a/packages/material-ui-lab/tsconfig.json +++ b/packages/material-ui-lab/tsconfig.json @@ -1,4 +1,9 @@ { "extends": "../../tsconfig", - "include": ["src/**/*", "test/**/*"] + "compilerOptions": { + // @date-io libraries produce duplicate type `DateType` + "skipLibCheck": true + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["./**/*.spec.*"] } diff --git a/packages/material-ui/src/Rating/Rating.d.ts b/packages/material-ui/src/Rating/Rating.d.ts index d8747fc1818896..3753f02843b044 100644 --- a/packages/material-ui/src/Rating/Rating.d.ts +++ b/packages/material-ui/src/Rating/Rating.d.ts @@ -68,10 +68,8 @@ export interface RatingProps * Accepts a function which returns a string value that provides a user-friendly name for the current value of the rating. * * For localization purposes, you can use the provided [translations](/guides/localization/). - * * @param {number} value The rating label's value to format. * @returns {string} - * * @default function defaultLabelText(value) { * return `${value} Star${value !== 1 ? 's' : ''}`; * } @@ -103,14 +101,12 @@ export interface RatingProps name?: string; /** * Callback fired when the value changes. - * * @param {object} event The event source of the callback. * @param {number} value The new value. */ onChange?: (event: React.SyntheticEvent, value: number | null) => void; /** * Callback function that is fired when the hover state changes. - * * @param {object} event The event source of the callback. * @param {number} value The new value. */ diff --git a/packages/material-ui/src/Rating/Rating.js b/packages/material-ui/src/Rating/Rating.js index dfeb294e67875c..ecc38b96119b6b 100644 --- a/packages/material-ui/src/Rating/Rating.js +++ b/packages/material-ui/src/Rating/Rating.js @@ -498,10 +498,8 @@ Rating.propTypes = { * Accepts a function which returns a string value that provides a user-friendly name for the current value of the rating. * * For localization purposes, you can use the provided [translations](/guides/localization/). - * * @param {number} value The rating label's value to format. * @returns {string} - * * @default function defaultLabelText(value) { * return `${value} Star${value !== 1 ? 's' : ''}`; * } @@ -533,14 +531,12 @@ Rating.propTypes = { name: PropTypes.string, /** * Callback fired when the value changes. - * * @param {object} event The event source of the callback. * @param {number} value The new value. */ onChange: PropTypes.func, /** * Callback function that is fired when the hover state changes. - * * @param {object} event The event source of the callback. * @param {number} value The new value. */ diff --git a/packages/material-ui/tsconfig.build.json b/packages/material-ui/tsconfig.build.json index 38573df329bab7..722cf118a0eedf 100644 --- a/packages/material-ui/tsconfig.build.json +++ b/packages/material-ui/tsconfig.build.json @@ -3,6 +3,7 @@ // Actual .ts source files are transpiled via babel "extends": "./tsconfig", "compilerOptions": { + "composite": true, "declaration": true, "noEmit": false, "emitDeclarationOnly": true, diff --git a/packages/typescript-to-proptypes/src/generator.ts b/packages/typescript-to-proptypes/src/generator.ts index 5eb978145978aa..c48a8781fdd23a 100644 --- a/packages/typescript-to-proptypes/src/generator.ts +++ b/packages/typescript-to-proptypes/src/generator.ts @@ -2,6 +2,14 @@ import _ from 'lodash'; import * as t from './types'; export interface GenerateOptions { + /** + * If source itself written in typescript prop-types disable prop-types validation + * by injecting propTypes as + * ```jsx + * .propTypes = { ... } as any + * ``` + */ + disableTypescriptPropTypesValidation?: boolean; /** * Enable/disable the default sorting (ascending) or provide your own sort function * @default true @@ -86,9 +94,10 @@ function defaultSortLiteralUnions(a: t.LiteralType, b: t.LiteralType) { */ export function generate(component: t.Component, options: GenerateOptions = {}): string { const { - sortProptypes = true, + disableTypescriptPropTypesValidation = false, importedName = 'PropTypes', includeJSDoc = true, + sortProptypes = true, previousPropTypesSource = new Map(), reconcilePropTypes = (_prop: t.PropTypeDefinition, _previous: string, generated: string) => generated, @@ -182,7 +191,10 @@ export function generate(component: t.Component, options: GenerateOptions = {}): if (propType.type === 'UnionNode') { const uniqueTypes = t.uniqueUnionTypes(propType).types; - const isOptional = uniqueTypes.some((type) => type.type === 'UndefinedNode'); + const isOptional = uniqueTypes.some( + (type) => + type.type === 'UndefinedNode' || (type.type === 'LiteralNode' && type.value === 'null'), + ); const nonNullishUniqueTypes = uniqueTypes.filter((type) => { return ( type.type !== 'UndefinedNode' && !(type.type === 'LiteralNode' && type.value === 'null') @@ -304,5 +316,11 @@ export function generate(component: t.Component, options: GenerateOptions = {}): options.comment && `// ${options.comment.split(/\r?\n/gm).reduce((prev, curr) => `${prev}\n// ${curr}`)}\n`; - return `${component.name}.propTypes = {\n${comment !== undefined ? comment : ''}${generated}\n}`; + const componentNameNode = disableTypescriptPropTypesValidation + ? `(${component.name} as any)` + : component.name; + + return `${componentNameNode}.propTypes = {\n${ + comment !== undefined ? comment : '' + }${generated}\n}`; } diff --git a/packages/typescript-to-proptypes/src/injector.ts b/packages/typescript-to-proptypes/src/injector.ts index 239daec0b5f8b1..29dec0572288cd 100644 --- a/packages/typescript-to-proptypes/src/injector.ts +++ b/packages/typescript-to-proptypes/src/injector.ts @@ -5,6 +5,14 @@ import * as t from './types'; import { generate, GenerateOptions } from './generator'; export type InjectOptions = { + /** + * If source itself written in typescript prop-types disable prop-types validation + * by injecting propTypes as + * ```jsx + * .propTypes = { ... } as any + * ``` + */ + disableTypescriptPropTypesValidation?: boolean; /** * By default all unused props are omitted from the result. * Set this to true to include them instead. @@ -112,6 +120,14 @@ function getUsedProps( return usedProps; } +function flattenTsAsExpression(node: object | null | undefined) { + if (babelTypes.isTSAsExpression(node)) { + return node.expression as babel.Node; + } + + return node; +} + function plugin( propTypes: t.Program, options: InjectOptions = {}, @@ -284,6 +300,31 @@ function plugin( props, }); }, + VariableDeclaration(path) { + const { node } = path; + + if (!babelTypes.isIdentifier(node.declarations[0].id)) return; + const nodeName = node.declarations[0].id.name; + + // Handle any variable with /* @GeneratePropTypes */ + if ( + node.leadingComments && + node.leadingComments.some((comment) => comment.value.includes('@GeneratePropTypes')) + ) { + if (!propTypes.body.some((prop) => prop.name === nodeName)) { + console.warn( + `It looks like the variable at ${node.loc} with /* @GeneratePropTypes */ is not a component, or props can not be inferred from typescript definitions.`, + ); + } + + injectPropTypes({ + nodeName, + usedProps: [], + path: path as babel.NodePath, + props: propTypes.body.find((prop) => prop.name === nodeName)!, + }); + } + }, VariableDeclarator(path) { const { node } = path; @@ -315,14 +356,16 @@ function plugin( }); } + const nodeInit = flattenTsAsExpression(node.init); + if ( - babelTypes.isArrowFunctionExpression(node.init) || - babelTypes.isFunctionExpression(node.init) + babelTypes.isArrowFunctionExpression(nodeInit) || + babelTypes.isFunctionExpression(nodeInit) ) { - getFromProp(node.init.params[0]); - } else if (babelTypes.isCallExpression(node.init)) { + getFromProp(nodeInit.params[0]); + } else if (babelTypes.isCallExpression(nodeInit)) { // x = react.memo(props =>
) - const arg = node.init.arguments[0]; + const arg = nodeInit.arguments[0]; if (babelTypes.isArrowFunctionExpression(arg) || babelTypes.isFunctionExpression(arg)) { getFromProp(arg.params[0]); } @@ -376,7 +419,6 @@ export function inject( const propTypesToInject = new Map(); const { plugins: babelPlugins = [], ...babelOptions } = options.babelOptions || {}; - const result = babel.transformSync(target, { plugins: [ require.resolve('@babel/plugin-syntax-class-properties'), diff --git a/packages/typescript-to-proptypes/src/parser.ts b/packages/typescript-to-proptypes/src/parser.ts index 417f740f7050ab..dd31563859e7d8 100644 --- a/packages/typescript-to-proptypes/src/parser.ts +++ b/packages/typescript-to-proptypes/src/parser.ts @@ -173,6 +173,12 @@ export function parseFromProgram( return t.createObjectType(); } + const defaultGenericType = type.getDefault(); + // This is generic type โ€“ use default type + if (defaultGenericType) { + return checkType(defaultGenericType, typeStack, name); + } + { const typeNode = type as any; @@ -196,6 +202,12 @@ export function parseFromProgram( case 'HTMLElement': { return t.createDOMElementType(); } + case 'RegExp': { + return t.createInstanceOfType('RegExp'); + } + case 'Date': { + return t.createInstanceOfType('Date'); + } default: // continue with function execution break; @@ -209,6 +221,14 @@ export function parseFromProgram( return t.createArrayType(checkType(arrayType, typeStack, name)); } + // @ts-ignore Potentially dangerous undocumented stuff + if (checker.isTupleType(type)) { + return t.createArrayType( + // @ts-ignore + t.createUnionType(type.typeArguments.map((x) => checkType(x, typeStack, name))), + ); + } + if (type.isUnion()) { const node = t.createUnionType(type.types.map((x) => checkType(x, typeStack, name))); @@ -249,6 +269,11 @@ export function parseFromProgram( return t.createFunctionType(); } + // () => new ClassInstance + if (type.getConstructSignatures().length) { + return t.createFunctionType(); + } + // Object-like type { const properties = type.getProperties(); @@ -488,7 +513,15 @@ export function parseFromProgram( function visit(node: ts.Node) { // function x(props: type) { return
} - if (ts.isFunctionDeclaration(node) && node.name && node.parameters.length === 1) { + if ( + ts.isFunctionDeclaration(node) && + node.name && + node.parameters.length === 1 && + checker + .getTypeAtLocation(node.name) + .getCallSignatures() + .some((signature) => isTypeJSXElementLike(signature.getReturnType())) + ) { parseFunctionComponent(node); } // const x = ... @@ -523,7 +556,7 @@ export function parseFromProgram( ) { parseFunctionComponent(variableNode); } - // x = react.memo((props:type) { return
}) + // x = react.memo((props:type) { return
}) else if ( ts.isCallExpression(variableNode.initializer) && variableNode.initializer.arguments.length > 0 @@ -545,6 +578,18 @@ export function parseFromProgram( } } } + // handle component factories: x = createComponent() + if (variableNode.initializer) { + if (checkDeclarations && type.aliasSymbol && type.aliasTypeArguments) { + if ( + type + .getCallSignatures() + .some((signature) => isTypeJSXElementLike(signature.getReturnType())) + ) { + parseFunctionComponent(variableNode); + } + } + } } }); } else if ( diff --git a/scripts/generateProptypes.ts b/scripts/generateProptypes.ts index da289cd3c48881..baa60bfd5ebaa9 100644 --- a/scripts/generateProptypes.ts +++ b/scripts/generateProptypes.ts @@ -21,6 +21,33 @@ enum GenerateResult { */ const todoComponents: string[] = []; +const todoComponentsTs: string[] = [ + 'ClockPicker', + 'DatePicker', + 'DateRangePicker', + 'DateRangePickerDay', + 'DayPicker', + 'DesktopDatePicker', + 'DesktopDateRangePicker', + 'StaticDateRangePicker', + 'MobileDateRangePicker', + 'DateTimePicker', + 'DesktopDateTimePicker', + 'DesktopTimePicker', + 'LocalizationProvider', + 'MobileDatePicker', + 'MobileDateTimePicker', + 'MobileTimePicker', + 'MonthPicker', + 'PickersCalendarSkeleton', + 'PickersDay', + 'StaticDatePicker', + 'StaticDateTimePicker', + 'StaticTimePicker', + 'TimePicker', + 'YearPicker', +]; + const useExternalPropsFromInputBase = [ 'autoComplete', 'autoFocus', @@ -53,12 +80,28 @@ const useExternalPropsFromInputBase = [ * of dynamically loading them. At that point this list should be removed. * TODO: typecheck values */ -const useExternalDocumentation: Record = { +const useExternalDocumentation: Record = { Button: ['disableRipple'], // `classes` is always external since it is applied from a HOC // In DialogContentText we pass it through // Therefore it's considered "unused" in the actual component but we still want to document it. DialogContentText: ['classes'], + DatePicker: '*', + MobileDatePicker: '*', + StaticDatePicker: '*', + DesktopDatePicker: '*', + TimePicker: '*', + MobileTimePicker: '*', + StaticTimePicker: '*', + DesktopTimePicker: '*', + DateTimePicker: '*', + MobileDateTimePicker: '*', + StaticDateTimePicker: '*', + DesktopDateTimePicker: '*', + DateRangePicker: '*', + MobileDateRangePicker: '*', + StaticDateRangePicker: '*', + DesktopDateRangePicker: '*', FilledInput: useExternalPropsFromInputBase, IconButton: ['disableRipple'], Input: useExternalPropsFromInputBase, @@ -152,6 +195,7 @@ async function generateProptypes( program: ttp.ts.Program, sourceFile: string, tsFile: string = sourceFile, + tsTodo: boolean = false, ): Promise { const proptypes = ttp.parseFromProgram(tsFile, program, { shouldResolveObject: ({ name }) => { @@ -191,6 +235,7 @@ async function generateProptypes( : null; const result = ttp.inject(proptypes, sourceContent, { + disableTypescriptPropTypesValidation: tsTodo, removeExistingPropTypes: true, babelOptions: { filename: sourceFile, @@ -246,7 +291,8 @@ async function generateProptypes( const { name: componentName } = component; if ( useExternalDocumentation[componentName] && - useExternalDocumentation[componentName].includes(prop.name) + (useExternalDocumentation[componentName] === '*' || + useExternalDocumentation[componentName].includes(prop.name)) ) { shouldDocument = true; } @@ -310,14 +356,16 @@ async function run(argv: HandlerArgv) { const program = ttp.createTSProgram(files, tsconfig); const promises = files.map>(async (tsFile) => { - const jsFile = tsFile.replace('.d.ts', '.js'); + const componentName = path.basename(tsFile).replace(/(\.d\.ts|\.tsx|\.js)/g, ''); - if (todoComponents.includes(path.basename(jsFile, '.js'))) { + if (todoComponents.includes(componentName)) { return GenerateResult.TODO; } + const tsTodo = todoComponentsTs.includes(componentName); + const sourceFile = tsFile.includes('.d.ts') ? tsFile.replace('.d.ts', '.js') : tsFile; - return generateProptypes(program, sourceFile, tsFile); + return generateProptypes(program, sourceFile, tsFile, tsTodo); }); const results = await Promise.all(promises); diff --git a/test/regressions/index.js b/test/regressions/index.js index b742dc2c4b3863..0c5d60e1ff88d3 100644 --- a/test/regressions/index.js +++ b/test/regressions/index.js @@ -46,6 +46,17 @@ const blacklist = [ 'docs-components-chips/ChipsPlayground.png', // Redux isolation 'docs-components-click-away-listener', // Needs interaction 'docs-components-container', // Can't see the impact + 'docs-components-date-picker/CustomInput.png', // Redundant + 'docs-components-date-picker/LocalizedDatePicker.png', // Redundant + 'docs-components-date-picker/ResponsiveDatePickers.png', // Redundant + 'docs-components-date-picker/ServerRequestDatePicker.png', // Redundant + 'docs-components-date-picker/ViewsDatePicker.png', // Redundant + 'docs-components-date-range-picker/CalendarsDateRangePicker.png', // Redundant + 'docs-components-date-range-picker/CustomDateRangeInputs.png', // Redundant + 'docs-components-date-range-picker/MinMaxDateRangePicker.png', // Redundant + 'docs-components-date-range-picker/ResponsiveDateRangePicker.png', // Redundant + 'docs-components-date-time-picker/BasicDateTimePicker.png', // Redundant + 'docs-components-date-time-picker/ResponsiveDateTimePickers.png', // Redundant 'docs-components-dialogs', // Needs interaction 'docs-components-drawers/SwipeableTemporaryDrawer.png', // Needs interaction 'docs-components-drawers/TemporaryDrawer.png', // Needs interaction @@ -96,6 +107,8 @@ const blacklist = [ 'docs-components-steppers/TextMobileStepper.png', // Flaky image loading 'docs-components-tabs/AccessibleTabs.png', // Need interaction 'docs-components-textarea-autosize', // Superseded by a dedicated regression test + 'docs-components-time-picker/LocalizedTimePicker.png', // Redundant + 'docs-components-time-picker/ResponsiveTimePickers.png', // Redundant 'docs-components-tooltips', // Needs interaction 'docs-components-transitions', // Needs interaction 'docs-components-trap-focus', // Need interaction @@ -113,7 +126,6 @@ const blacklist = [ 'docs-discover-more-languages', // No public components 'docs-discover-more-showcase', // No public components 'docs-discover-more-team', // No public components - 'docs-getting-started-templates', // No public components 'docs-getting-started-templates-album/Album.png', // Flaky image loading 'docs-getting-started-templates-blog', // Flaky random images 'docs-getting-started-templates-checkout/AddressForm.png', // Already tested in docs-getting-started-templates-checkout/Checkout @@ -125,8 +137,8 @@ const blacklist = [ 'docs-getting-started-templates-dashboard/Orders.png', // Already tested in docs-getting-started-templates-dashboard/Dashboard 'docs-getting-started-templates-dashboard/Title.png', // Already tested in docs-getting-started-templates-dashboard/Dashboard 'docs-getting-started-templates-sign-in-side/SignInSide.png', // Flaky + 'docs-getting-started-templates', // No public components 'docs-getting-started-usage/Usage.png', // No public components - /^docs-guides-.*/, // No public components 'docs-landing', // Mostly images, redundant 'docs-production-error', // No components, page for DX 'docs-styles-advanced', // Redudant @@ -141,6 +153,7 @@ const blacklist = [ 'docs-system-spacing', // Unit tests are enough 'docs-system-typography', // Unit tests are enough 'docs-versions', // No public components + /^docs-guides-.*/, // No public components ]; const unusedBlacklistPatterns = new Set(blacklist); diff --git a/test/utils/createClientRender.js b/test/utils/createClientRender.js index 07d79d3500a2cd..a199afa7bc3a83 100644 --- a/test/utils/createClientRender.js +++ b/test/utils/createClientRender.js @@ -167,7 +167,7 @@ Object.assign(fireEvent, rtlFireEvent, { // `element` shouldn't be `document` but we catch this later anyway const document = element.ownerDocument || element; const target = document.activeElement || document.body || document.documentElement; - if (target !== element) { + if (options.force !== true && target !== element) { // see https://www.w3.org/TR/uievents/#keydown const error = new Error( `\`keydown\` events can only be targeted at the active element which is ${prettyDOM( @@ -211,6 +211,46 @@ Object.assign(fireEvent, rtlFireEvent, { }, }); +/** + * + * @param {Element} target + * @param {'touchmove' | 'touchend'} type + * @param {object} options + * @param {Array} options.changedTouches + * @returns void + */ +export function fireTouchChangedEvent(target, type, options) { + const { changedTouches } = options; + const originalGetBoundingClientRect = target.getBoundingClientRect; + target.getBoundingClientRect = () => ({ + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + }); + + const event = new window.TouchEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + changedTouches: changedTouches.map( + (opts) => + new window.Touch({ + target, + identifier: 0, + ...opts, + }), + ), + }); + + fireEvent(target, event); + target.getBoundingClientRect = originalGetBoundingClientRect; +} + export * from '@testing-library/react/pure'; export { act, cleanup, fireEvent }; // We import from `@testing-library/react` and `@testing-library/dom` before creating a JSDOM. diff --git a/test/utils/initMatchers.ts b/test/utils/initMatchers.ts index d312d24cb0485f..8c905a047c013c 100644 --- a/test/utils/initMatchers.ts +++ b/test/utils/initMatchers.ts @@ -71,6 +71,10 @@ declare global { * @see [Excluding Elements from the Accessibility Tree](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion) */ toBeInaccessible(): void; + /** + * Matcher with useful error messages if the dates don't match. + */ + toEqualDateTime(expected: Date): void; /** * Checks if the accessible name computation (according to `accname` spec) * matches the expectation. @@ -379,6 +383,15 @@ chai.use((chaiAPI, utils) => { styleTypeHint: 'computed', }); }); + + chai.Assertion.addMethod('toEqualDateTime', function toEqualDateTime(expectedDate, message) { + // eslint-disable-next-line no-underscore-dangle + const actualDate = this._obj; + const assertion = new chai.Assertion(actualDate.toISOString(), message); + // TODO: Investigate if `as any` can be removed after https://github.com/DefinitelyTyped/DefinitelyTyped/issues/48634 is resolved. + utils.transferFlags(this as any, assertion, false); + assertion.to.equal(expectedDate.toISOString()); + }); }); chai.use((chaiAPI, utils) => { diff --git a/yarn.lock b/yarn.lock index a6bc0a1e492ccd..cda22e0f5385b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1089,7 +1089,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@7.0.0", "@babel/runtime@7.11.2", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.1.5", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.1", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@7.0.0", "@babel/runtime@7.11.2", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.1.5", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.1.tgz#b4116a6b6711d010b2dad3b7b6e43bf1b9954740" integrity sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA== @@ -2211,20 +2211,6 @@ npmlog "^4.1.2" write-file-atomic "^2.3.0" -"@material-ui/pickers@^4.0.0-alpha.11": - version "4.0.0-alpha.12" - resolved "https://registry.yarnpkg.com/@material-ui/pickers/-/pickers-4.0.0-alpha.12.tgz#44ee222686ef537954f23bae58a713247106a1b8" - integrity sha512-Axk+9VDm2JE79mCNxhHnBVk6nEGUvQyckP2GQcbZnMiKrepBxvg9+Xkn7fXgMlBWsgbOtNp03aiyW11dpEMs9A== - dependencies: - "@date-io/date-fns" "^2.8.0" - "@date-io/dayjs" "^2.8.0" - "@date-io/luxon" "^2.8.0" - "@date-io/moment" "^2.8.0" - clsx "^1.0.2" - prop-types "^15.7.2" - react-transition-group "^4.4.1" - rifm "^0.12.0" - "@material-ui/types@^4.0.0", "@material-ui/types@^4.1.1": version "4.1.1" resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-4.1.1.tgz#b65e002d926089970a3271213a3ad7a21b17f02b" @@ -2952,6 +2938,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== +"@types/luxon@^0.5.2": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-0.5.3.tgz#87c5fb059b881e621620869633023e27b15e1b41" + integrity sha512-YiVw0M9q9CeynfRKhZYaX2/aCXlCIBpM4eARlPXdv+XVoGVb5iPFaZIlKiMUJ8eWKOhlqi8U6GvOAn8yhR4//Q== + "@types/markdown-to-jsx@^6.11.1": version "6.11.3" resolved "https://registry.yarnpkg.com/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz#cdd1619308fecbc8be7e6a26f3751260249b020e" @@ -5393,7 +5384,7 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= -clsx@^1.0.2, clsx@^1.0.4, clsx@^1.1.0: +clsx@^1.0.4, clsx@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== @@ -6385,7 +6376,7 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -date-fns@^2.15.0: +date-fns@^2.0.0: version "2.16.1" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.16.1.tgz#05775792c3f3331da812af253e1a935851d3834b" integrity sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ== @@ -6410,6 +6401,11 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +dayjs@^1.8.17: + version "1.9.4" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.9.4.tgz#fcde984e227f4296f04e7b05720adad2e1071f1b" + integrity sha512-ABSF3alrldf7nM9sQ2U+Ln67NRwmzlLOqG7kK03kck0mw3wlSSEKv/XhKGGxUjQcS57QeiCyNdrFgtj9nWlrng== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -10872,6 +10868,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +luxon@^1.21.3: + version "1.25.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" + integrity sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -11516,6 +11517,11 @@ modify-values@^1.0.0: resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== +moment@^2.24.0: + version "2.27.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d" + integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ== + moo@^0.4.3: version "0.4.3" resolved "https://registry.yarnpkg.com/moo/-/moo-0.4.3.tgz#3f847a26f31cf625a956a87f2b10fbc013bfd10e"