From 23ada5ca0a1361a2e8ff394f91fb616d8a8f29e0 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Fri, 1 May 2020 10:53:02 +0300 Subject: [PATCH] [DateRangePicker] renderInput for date range (#1719) * Move masked input logic to the hook * Implement renderInput for DateRangePicker * Better type inference for generic `renderInput` prop * More precise type checking for mobile keyboard input views props * Update DateRange.spec.ts * Fix bugs found by tests * Update tests to use new KeyboardButtonProps prop * Fix not closing date range picker * [DateRangePicker] Properly attach onFocus/onClick for mobile and desktop mode --- .../BasicDateRangePicker.example.tsx | 10 +- .../CalendarsDateRangePicker.example.tsx | 28 +++- .../MinMaxDateRangePicker.example.tsx | 10 +- .../ResponsiveDateRangePicker.example.tsx | 23 ++- .../StaticDateRangePicker.example.tsx | 18 ++- docs/pages/regression/Regression.tsx | 13 +- docs/prop-types.json | 134 ++++++++--------- e2e/integration/DatePicker.spec.ts | 2 +- e2e/integration/DateRange.spec.ts | 31 ++-- lib/.size-snapshot.json | 12 +- lib/package.json | 2 +- .../DateRangePicker/DateRangeDelimiter.tsx | 6 + lib/src/DateRangePicker/DateRangePicker.tsx | 11 +- .../DateRangePicker/DateRangePickerInput.tsx | 112 ++++++++------ .../DateRangePicker/DateRangePickerView.tsx | 14 +- lib/src/DateRangePicker/RangeTypes.ts | 7 + lib/src/Picker/SharedPickerProps.tsx | 12 +- .../__tests__/KeyboardDateTimePicker.test.tsx | 2 +- lib/src/_shared/KeyboardDateInput.tsx | 138 ++---------------- lib/src/_shared/PureDateInput.tsx | 14 +- lib/src/_shared/hooks/useMaskedInput.tsx | 126 ++++++++++++++++ lib/src/wrappers/DesktopPopperWrapper.tsx | 8 +- lib/src/wrappers/MobileWrapper.tsx | 2 +- lib/src/wrappers/Wrapper.tsx | 9 +- lib/src/wrappers/makeWrapperComponent.tsx | 4 +- yarn.lock | 8 +- 26 files changed, 439 insertions(+), 317 deletions(-) create mode 100644 lib/src/DateRangePicker/DateRangeDelimiter.tsx create mode 100644 lib/src/_shared/hooks/useMaskedInput.tsx diff --git a/docs/pages/demo/daterangepicker/BasicDateRangePicker.example.tsx b/docs/pages/demo/daterangepicker/BasicDateRangePicker.example.tsx index 58cb6d2fce838b..a595ce30560f05 100644 --- a/docs/pages/demo/daterangepicker/BasicDateRangePicker.example.tsx +++ b/docs/pages/demo/daterangepicker/BasicDateRangePicker.example.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { TextField } from '@material-ui/core'; -import { DateRangePicker, DateRange } from '@material-ui/pickers'; +import { DateRangePicker, DateRange, DateRangeDelimiter } from '@material-ui/pickers'; function BasicDateRangePicker() { const [selectedDate, handleDateChange] = React.useState([null, null]); @@ -11,7 +11,13 @@ function BasicDateRangePicker() { endText="Check-out" value={selectedDate} onChange={date => handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> ); } diff --git a/docs/pages/demo/daterangepicker/CalendarsDateRangePicker.example.tsx b/docs/pages/demo/daterangepicker/CalendarsDateRangePicker.example.tsx index d65a603dc921af..a24509716fc4af 100644 --- a/docs/pages/demo/daterangepicker/CalendarsDateRangePicker.example.tsx +++ b/docs/pages/demo/daterangepicker/CalendarsDateRangePicker.example.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import Grid from '@material-ui/core/Grid'; import { Typography, TextField } from '@material-ui/core'; -import { DateRangePicker, DateRange } from '@material-ui/pickers'; +import { DateRangePicker, DateRangeDelimiter, DateRange } from '@material-ui/pickers'; function CalendarsDateRangePicker() { const [selectedDate, handleDateChange] = React.useState([null, null]); @@ -13,21 +13,41 @@ function CalendarsDateRangePicker() { calendars={1} value={selectedDate} onChange={date => handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> + 2 calendars handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> + 3 calendars handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> ); diff --git a/docs/pages/demo/daterangepicker/MinMaxDateRangePicker.example.tsx b/docs/pages/demo/daterangepicker/MinMaxDateRangePicker.example.tsx index db38825b95cc42..43aaaec67f3e73 100644 --- a/docs/pages/demo/daterangepicker/MinMaxDateRangePicker.example.tsx +++ b/docs/pages/demo/daterangepicker/MinMaxDateRangePicker.example.tsx @@ -5,7 +5,7 @@ import { Moment } from 'moment'; import { DateTime } from 'luxon'; import { TextField } from '@material-ui/core'; import { makeJSDateObject } from '../../../utils/helpers'; -import { DateRangePicker, DateRange } from '@material-ui/pickers'; +import { DateRangePicker, DateRangeDelimiter, DateRange } from '@material-ui/pickers'; function getWeeksAfter(date: Moment | DateTime | Dayjs | Date, amount: number) { // TODO: replace with implementation for your date library @@ -21,7 +21,13 @@ function MinMaxDateRangePicker() { value={selectedRange} maxDate={getWeeksAfter(selectedRange[0], 4)} onChange={date => handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> ); } diff --git a/docs/pages/demo/daterangepicker/ResponsiveDateRangePicker.example.tsx b/docs/pages/demo/daterangepicker/ResponsiveDateRangePicker.example.tsx index 4224a087652bad..c090ed0620f75a 100644 --- a/docs/pages/demo/daterangepicker/ResponsiveDateRangePicker.example.tsx +++ b/docs/pages/demo/daterangepicker/ResponsiveDateRangePicker.example.tsx @@ -1,6 +1,11 @@ import * as React from 'react'; import { TextField } from '@material-ui/core'; -import { MobileDateRangePicker, DesktopDateRangePicker, DateRange } from '@material-ui/pickers'; +import { + MobileDateRangePicker, + DateRangeDelimiter, + DesktopDateRangePicker, + DateRange, +} from '@material-ui/pickers'; function ResponsiveDateRangePicker() { const [selectedDate, handleDateChange] = React.useState([null, null]); @@ -11,14 +16,26 @@ function ResponsiveDateRangePicker() { startText="Mobile start" value={selectedDate} onChange={date => handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> ); diff --git a/docs/pages/demo/daterangepicker/StaticDateRangePicker.example.tsx b/docs/pages/demo/daterangepicker/StaticDateRangePicker.example.tsx index 61b6ae11df60ac..d1f51961b35cc1 100644 --- a/docs/pages/demo/daterangepicker/StaticDateRangePicker.example.tsx +++ b/docs/pages/demo/daterangepicker/StaticDateRangePicker.example.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { TextField } from '@material-ui/core'; -import { StaticDateRangePicker, DateRange } from '@material-ui/pickers'; +import { StaticDateRangePicker, DateRangeDelimiter, DateRange } from '@material-ui/pickers'; function StaticDateRangePickerExample() { const [selectedDate, handleDateChange] = React.useState([null, null]); @@ -11,14 +11,26 @@ function StaticDateRangePickerExample() { displayStaticWrapperAs="desktop" value={selectedDate} onChange={date => handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> handleDateChange(date)} - renderInput={props => } + renderInput={(startProps, endProps) => ( + <> + + to + + + )} /> ); diff --git a/docs/pages/regression/Regression.tsx b/docs/pages/regression/Regression.tsx index 4700e15308807e..3e151b7af53ec2 100644 --- a/docs/pages/regression/Regression.tsx +++ b/docs/pages/regression/Regression.tsx @@ -3,8 +3,8 @@ import LeftArrowIcon from '@material-ui/icons/KeyboardArrowLeft'; import RightArrowIcon from '@material-ui/icons/KeyboardArrowRight'; import { Grid, Typography } from '@material-ui/core'; import { TextField, TextFieldProps } from '@material-ui/core'; -import { MuiPickersContext, DateRangePicker } from '@material-ui/pickers'; import { createRegressionDay as createRegressionDayRenderer } from './RegressionDay'; +import { MuiPickersContext, DateRangePicker, DateRangeDelimiter } from '@material-ui/pickers'; import { DateRange, MobileDatePicker, @@ -29,9 +29,6 @@ function Regression() { leftArrowIcon: , rightArrowIcon: , renderDay: createRegressionDayRenderer(utils!), - KeyboardButtonProps: { - className: 'keyboard-btn', - }, }; return ( @@ -99,9 +96,15 @@ function Regression() { ( + <> + + to + + + )} /> ); diff --git a/docs/prop-types.json b/docs/prop-types.json index e24b68ce30341f..ba11f0eb6fd9a5 100644 --- a/docs/prop-types.json +++ b/docs/prop-types.json @@ -845,7 +845,7 @@ }, "renderInput": { "defaultValue": null, - "description": "Override input component", + "description": "Render input component. Where `props` – [TextField](https://material-ui.com/api/text-field/#textfield-api) component props\n@example ```jsx\nrenderInput={props => }\n````", "name": "renderInput", "parent": { "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", @@ -912,10 +912,10 @@ "name": "Partial" } }, - "KeyboardButtonProps": { + "OpenPickerButtonProps": { "defaultValue": null, "description": "Props to pass to keyboard adornment button", - "name": "KeyboardButtonProps", + "name": "OpenPickerButtonProps", "parent": { "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", "name": "DateInputProps" @@ -1621,7 +1621,7 @@ }, "renderInput": { "defaultValue": null, - "description": "Override input component", + "description": "Render input component. Where `props` – [TextField](https://material-ui.com/api/text-field/#textfield-api) component props\n@example ```jsx\nrenderInput={props => }\n````", "name": "renderInput", "parent": { "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", @@ -1688,10 +1688,10 @@ "name": "Partial" } }, - "KeyboardButtonProps": { + "OpenPickerButtonProps": { "defaultValue": null, "description": "Props to pass to keyboard adornment button", - "name": "KeyboardButtonProps", + "name": "OpenPickerButtonProps", "parent": { "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", "name": "DateInputProps" @@ -2721,7 +2721,7 @@ }, "renderInput": { "defaultValue": null, - "description": "Override input component", + "description": "Render input component. Where `props` – [TextField](https://material-ui.com/api/text-field/#textfield-api) component props\n@example ```jsx\nrenderInput={props => }\n````", "name": "renderInput", "parent": { "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", @@ -2788,10 +2788,10 @@ "name": "Partial" } }, - "KeyboardButtonProps": { + "OpenPickerButtonProps": { "defaultValue": null, "description": "Props to pass to keyboard adornment button", - "name": "KeyboardButtonProps", + "name": "OpenPickerButtonProps", "parent": { "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", "name": "DateInputProps" @@ -3479,90 +3479,77 @@ "name": "boolean" } }, - "onAccept": { - "defaultValue": null, - "description": "Callback fired when date is accepted", - "name": "onAccept", - "parent": { - "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", - "name": "BasePickerProps" + "autoOk": { + "defaultValue": { + "value": "false" }, - "required": false, - "type": { - "name": "((date: DateIOType) => void) & ((date: DateRange) => void)" - } - }, - "className": { - "defaultValue": null, - "description": "className applied to the root component", - "name": "className", + "description": "Auto accept date on selection", + "name": "autoOk", "parent": { "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", "name": "BasePickerProps" }, "required": false, "type": { - "name": "string" + "name": "boolean" } }, - "onError": { + "defaultHighlight": { "defaultValue": null, - "description": "Callback fired when new error should be displayed\n(!! This is a side effect. Be careful if you want to rerender the component)", - "name": "onError", + "description": "Date that will be initially highlighted if null was passed", + "name": "defaultHighlight", "parent": { "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", "name": "BasePickerProps" }, "required": false, "type": { - "name": "((error: ReactNode, value: DateIOType) => void) & ((error: ReactNode, value: RangeInput | DateRange) => void)" + "name": "any" } }, - "onClose": { + "onAccept": { "defaultValue": null, - "description": "On close callback", - "name": "onClose", + "description": "Callback fired when date is accepted", + "name": "onAccept", "parent": { "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", "name": "BasePickerProps" }, "required": false, "type": { - "name": "() => void" + "name": "((date: DateIOType) => void) & ((date: DateRange) => void)" } }, - "autoOk": { - "defaultValue": { - "value": "false" - }, - "description": "Auto accept date on selection", - "name": "autoOk", + "onError": { + "defaultValue": null, + "description": "Callback fired when new error should be displayed\n(!! This is a side effect. Be careful if you want to rerender the component)", + "name": "onError", "parent": { "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", "name": "BasePickerProps" }, "required": false, "type": { - "name": "boolean" + "name": "((error: ReactNode, value: DateIOType) => void) & ((error: ReactNode, value: RangeInput | DateRange) => void)" } }, - "defaultHighlight": { + "onOpen": { "defaultValue": null, - "description": "Date that will be initially highlighted if null was passed", - "name": "defaultHighlight", + "description": "On open callback", + "name": "onOpen", "parent": { "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", "name": "BasePickerProps" }, "required": false, "type": { - "name": "any" + "name": "() => void" } }, - "onOpen": { + "onClose": { "defaultValue": null, - "description": "On open callback", - "name": "onOpen", + "description": "On close callback", + "name": "onClose", "parent": { "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", "name": "BasePickerProps" @@ -3639,30 +3626,30 @@ "name": "string" } }, - "value": { + "className": { "defaultValue": null, - "description": "Picker value", - "name": "value", + "description": "className applied to the root component", + "name": "className", "parent": { "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", "name": "BasePickerProps" }, - "required": true, + "required": false, "type": { - "name": "RangeInput" + "name": "string" } }, - "onChange": { + "renderInput": { "defaultValue": null, - "description": "onChange callback", - "name": "onChange", + "description": "Render input component for date range. Where `props` – [TextField](https://material-ui.com/api/text-field/#textfield-api) component props\n@example ```jsx\n (\n<>\n\n to \n\n;\n)}\n/>\n````", + "name": "renderInput", "parent": { - "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", - "name": "BasePickerProps" + "fileName": "material-ui-pickers/lib/src/DateRangePicker/DateRangePickerInput.tsx", + "name": "ExportedDateRangePickerInputProps" }, "required": true, "type": { - "name": "(date: DateRange, keyboardInputValue?: string) => void" + "name": "(props: any) => ReactElement Component)> | null) | (new (props: any) => Component<...>" } }, "mask": { @@ -3678,17 +3665,17 @@ "name": "string" } }, - "renderInput": { + "onChange": { "defaultValue": null, - "description": "Override input component", - "name": "renderInput", + "description": "onChange callback", + "name": "onChange", "parent": { - "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", - "name": "DateInputProps" + "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", + "name": "BasePickerProps" }, "required": true, "type": { - "name": "(props: any) => ReactElement Component)> | null) | (new (props: any) => Component<...>" + "name": "(date: DateRange, keyboardInputValue?: string) => void" } }, "emptyInputText": { @@ -3747,10 +3734,10 @@ "name": "Partial" } }, - "KeyboardButtonProps": { + "OpenPickerButtonProps": { "defaultValue": null, "description": "Props to pass to keyboard adornment button", - "name": "KeyboardButtonProps", + "name": "OpenPickerButtonProps", "parent": { "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", "name": "DateInputProps" @@ -3818,6 +3805,19 @@ "name": "(value: any, utils: MuiPickersAdapter) => string" } }, + "value": { + "defaultValue": null, + "description": "Picker value", + "name": "value", + "parent": { + "fileName": "material-ui-pickers/lib/src/typings/BasePicker.tsx", + "name": "BasePickerProps" + }, + "required": true, + "type": { + "name": "RangeInput" + } + }, "dateAdapter": { "defaultValue": null, "description": "Allows to pass configured date-io adapter directly. More info [here](https://material-ui-pickers.dev/guides/date-adapter-passing)\n```jsx\ndateAdapter={new DateFnsAdapter({ locale: ruLocale })}\n```", diff --git a/e2e/integration/DatePicker.spec.ts b/e2e/integration/DatePicker.spec.ts index 421f79b2dbf713..a6ac7b8434c4ec 100644 --- a/e2e/integration/DatePicker.spec.ts +++ b/e2e/integration/DatePicker.spec.ts @@ -95,7 +95,7 @@ describe('DatePicker', () => { }); it('Should open calendar by the keyboard icon', () => { - cy.get('.keyboard-btn') + cy.get('[data-mui-test="open-picker-from-keyboard"]') .first() .click(); cy.get(`[data-day="19/01/2019"]`).click(); diff --git a/e2e/integration/DateRange.spec.ts b/e2e/integration/DateRange.spec.ts index 9669554c2a0810..88950f2793a25b 100644 --- a/e2e/integration/DateRange.spec.ts +++ b/e2e/integration/DateRange.spec.ts @@ -5,19 +5,23 @@ describe('DateRangePicker', () => { }); it('Opens and selecting a range in DateRangePicker', () => { - cy.get('[data-mui-test="desktop-range-picker"]') - .first() - .focus(); + cy.get('[data-mui-test="desktop-range-picker"]').focus(); cy.get('[aria-label="Jan 1, 2019"]').click(); cy.get('[aria-label="Jan 24, 2019"]').click(); cy.get('[data-mui-test="DateRangeHighlight"]').should('have.length', 24); }); + it('Should close when focused moved outside of picker popper', () => { + cy.get('[data-mui-test="desktop-range-picker"]').click(); + cy.get('div[role="tooltip"]').should('be.visible'); + + cy.get('#basic-datepicker').focus(); + cy.get('div[role="tooltip"]').should('not.be.visible'); + }); + it('Opens and selecting a range on the next month', () => { - cy.get('[data-mui-test="desktop-range-picker"]') - .first() - .focus(); + cy.get('[data-mui-test="desktop-range-picker"]').focus(); cy.get('[aria-label="Jan 1, 2019"]').click(); cy.get('[data-mui-test="next-arrow-button"]') @@ -38,13 +42,9 @@ describe('DateRangePicker', () => { }); it('Properly handles selection when starting from end', () => { - cy.get('[data-mui-test="desktop-range-picker"]') - .first() - .clear(); + cy.get('[data-mui-test="desktop-range-picker"]').clear(); - cy.get('[data-mui-test="desktop-range-picker"]') - .eq(1) - .focus(); + cy.get('[data-mui-test="desktop-range-picker-end"]').focus(); cy.get('[aria-label="Jan 30, 2019"]') .first() @@ -68,8 +68,7 @@ describe('DateRangePicker', () => { cy.contains('June 2019'); cy.contains('July 2019'); - cy.get('[data-mui-test="desktop-range-picker"]') - .eq(1) + cy.get('[data-mui-test="desktop-range-picker-end"]') .focus() .clear() .type('08/08/2019'); @@ -112,9 +111,7 @@ describe('DateRangePicker', () => { cy.get('[aria-label="Mar 19, 2019"]').click(); // reopen picker - cy.get('[data-mui-test="desktop-range-picker"]') - .eq(1) - .click(); + cy.get('[data-mui-test="desktop-range-picker-end"]').click(); cy.contains('February 2019'); cy.contains('March 2019'); diff --git a/lib/.size-snapshot.json b/lib/.size-snapshot.json index 4cc425849addbb..323455eb308767 100644 --- a/lib/.size-snapshot.json +++ b/lib/.size-snapshot.json @@ -1,15 +1,15 @@ { "build/dist/material-ui-pickers.esm.js": { - "bundled": 184206, - "minified": 99456, - "gzipped": 25950, + "bundled": 186211, + "minified": 100412, + "gzipped": 26056, "treeshaked": { "rollup": { - "code": 82029, - "import_statements": 2118 + "code": 82896, + "import_statements": 2182 }, "webpack": { - "code": 91248 + "code": 92271 } } }, diff --git a/lib/package.json b/lib/package.json index acc996e89cd3f8..8eb2f8a572dba0 100644 --- a/lib/package.json +++ b/lib/package.json @@ -51,7 +51,7 @@ "clsx": "^1.0.2", "prop-types": "^15.7.2", "react-transition-group": "^4.0.0", - "rifm": "^0.10.1" + "rifm": "^0.11.0" }, "scripts": { "test": "jest", diff --git a/lib/src/DateRangePicker/DateRangeDelimiter.tsx b/lib/src/DateRangePicker/DateRangeDelimiter.tsx new file mode 100644 index 00000000000000..9d9dc2ff3204f3 --- /dev/null +++ b/lib/src/DateRangePicker/DateRangeDelimiter.tsx @@ -0,0 +1,6 @@ +import Typography from '@material-ui/core/Typography'; +import { styled } from '@material-ui/core/styles'; + +export const DateRangeDelimiter = styled(Typography)({ + margin: '0 16px', +}); diff --git a/lib/src/DateRangePicker/DateRangePicker.tsx b/lib/src/DateRangePicker/DateRangePicker.tsx index 16f155b32ca89b..c6e09d7b30f44f 100644 --- a/lib/src/DateRangePicker/DateRangePicker.tsx +++ b/lib/src/DateRangePicker/DateRangePicker.tsx @@ -5,8 +5,6 @@ import { MobileWrapper } from '../wrappers/MobileWrapper'; import { DateRangeInputProps } from './DateRangePickerInput'; import { parsePickerInputValue } from '../_helpers/date-utils'; import { usePickerState } from '../_shared/hooks/usePickerState'; -import { AllSharedPickerProps } from '../Picker/SharedPickerProps'; -import { DateRange as DateRangeType, RangeInput } from './RangeTypes'; import { DesktopPopperWrapper } from '../wrappers/DesktopPopperWrapper'; import { MuiPickersAdapter, useUtils } from '../_shared/hooks/useUtils'; import { makeWrapperComponent } from '../wrappers/makeWrapperComponent'; @@ -14,6 +12,11 @@ import { ResponsivePopperWrapper } from '../wrappers/ResponsiveWrapper'; import { SomeWrapper, ExtendWrapper, StaticWrapper } from '../wrappers/Wrapper'; import { DateRangePickerView, ExportedDateRangePickerViewProps } from './DateRangePickerView'; import { DateRangePickerInput, ExportedDateRangePickerInputProps } from './DateRangePickerInput'; +import { + DateRange as DateRangeType, + RangeInput, + AllSharedDateRangePickerProps, +} from './RangeTypes'; export function parseRangeInputValue( now: MaterialUiPickersDate, @@ -69,7 +72,7 @@ export function makeRangePicker(Wrapper: TWrapper) endText = 'End', inputFormat: passedInputFormat, ...restPropsForTextField - }: DateRangePickerProps & AllSharedPickerProps & ExtendWrapper) { + }: DateRangePickerProps & AllSharedDateRangePickerProps & ExtendWrapper) { const utils = useUtils(); const [currentlySelectingRangeEnd, setCurrentlySelectingRangeEnd] = React.useState< 'start' | 'end' @@ -147,3 +150,5 @@ export const DesktopDateRangePicker = makeRangePicker(DesktopPopperWrapper); export const MobileDateRangePicker = makeRangePicker(MobileWrapper); export const StaticDateRangePicker = makeRangePicker(StaticWrapper); + +export { DateRangeDelimiter } from './DateRangeDelimiter'; diff --git a/lib/src/DateRangePicker/DateRangePickerInput.tsx b/lib/src/DateRangePicker/DateRangePickerInput.tsx index f778d60642453f..e430a781be2375 100644 --- a/lib/src/DateRangePicker/DateRangePickerInput.tsx +++ b/lib/src/DateRangePicker/DateRangePickerInput.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; -import Typography from '@material-ui/core/Typography'; -import KeyboardDateInput from '../_shared/KeyboardDateInput'; import { RangeInput, DateRange } from './RangeTypes'; import { useUtils } from '../_shared/hooks/useUtils'; import { makeStyles } from '@material-ui/core/styles'; import { MaterialUiPickersDate } from '../typings/date'; -import { DateInputProps } from '../_shared/PureDateInput'; import { CurrentlySelectingRangeEndProps } from './RangeTypes'; -import { mergeRefs, doNothing, executeInTheNextEventLoopTick } from '../_helpers/utils'; +import { useMaskedInput } from '../_shared/hooks/useMaskedInput'; +import { WrapperVariantContext } from '../wrappers/WrapperVariantContext'; +import { DateInputProps, MuiTextFieldProps } from '../_shared/PureDateInput'; +import { mergeRefs, executeInTheNextEventLoopTick } from '../_helpers/utils'; export const useStyles = makeStyles( theme => ({ @@ -30,13 +30,27 @@ export const useStyles = makeStyles( ); export interface ExportedDateRangePickerInputProps { - toText?: React.ReactNode; + /** + * Render input component for date range. Where `props` – [TextField](https://material-ui.com/api/text-field/#textfield-api) component props + * @example ```jsx + * ( + <> + + to + + ; + )} + /> + * ```` + */ + renderInput: (startProps: MuiTextFieldProps, endProps: MuiTextFieldProps) => React.ReactElement; } export interface DateRangeInputProps extends ExportedDateRangePickerInputProps, CurrentlySelectingRangeEndProps, - Omit, 'forwardedRef'> { + Omit, 'renderInput' | 'forwardedRef'> { startText: React.ReactNode; endText: React.ReactNode; forwardedRef?: React.Ref; @@ -44,7 +58,6 @@ export interface DateRangeInputProps } export const DateRangePickerInput: React.FC = ({ - toText = 'to', rawValue, onChange, parsedDateValue: [start, end], @@ -54,16 +67,20 @@ export const DateRangePickerInput: React.FC = ({ currentlySelectingRangeEnd, setCurrentlySelectingRangeEnd, openPicker, - readOnly, disableOpenPicker, startText, endText, + readOnly, + renderInput, + TextFieldProps, + onBlur, ...other }) => { const utils = useUtils(); const classes = useStyles(); const startRef = React.useRef(null); const endRef = React.useRef(null); + const wrapperVariant = React.useContext(WrapperVariantContext); React.useEffect(() => { if (!open) { @@ -115,47 +132,48 @@ export const DateRangePickerInput: React.FC = ({ } }; - return ( -
- + const openOnFocus = wrapperVariant === 'desktop'; + const startInputProps = useMaskedInput({ + ...other, + readOnly, + rawValue: start, + parsedDateValue: start, + onChange: handleStartChange, + label: startText, + TextFieldProps: { + ...TextFieldProps, + ref: startRef, + variant: 'outlined', + focused: open && currentlySelectingRangeEnd === 'start', + onClick: !openOnFocus ? openRangeStartSelection : undefined, + onFocus: openOnFocus ? openRangeStartSelection : undefined, + }, + }); - {toText} + const endInputProps = useMaskedInput({ + ...other, + readOnly, + label: endText, + rawValue: end, + parsedDateValue: end, + onChange: handleEndChange, + TextFieldProps: { + ...TextFieldProps, + ref: endRef, + variant: 'outlined', + focused: open && currentlySelectingRangeEnd === 'end', + onClick: !openOnFocus ? openRangeEndSelection : undefined, + onFocus: openOnFocus ? openRangeEndSelection : undefined, + }, + }); - + return ( +
+ {renderInput(startInputProps, endInputProps)}
); }; diff --git a/lib/src/DateRangePicker/DateRangePickerView.tsx b/lib/src/DateRangePicker/DateRangePickerView.tsx index 5bad6d9a415aea..705a362a00aa41 100644 --- a/lib/src/DateRangePicker/DateRangePickerView.tsx +++ b/lib/src/DateRangePicker/DateRangePickerView.tsx @@ -4,7 +4,6 @@ import { MaterialUiPickersDate } from '../typings/date'; import { BasePickerProps } from '../typings/BasePicker'; import { calculateRangeChange } from './date-range-manager'; import { useUtils, useNow } from '../_shared/hooks/useUtils'; -import { DateRangePickerInput } from './DateRangePickerInput'; import { SharedPickerProps } from '../Picker/SharedPickerProps'; import { DateRangePickerToolbar } from './DateRangePickerToolbar'; import { useParsedDate } from '../_shared/hooks/date-helpers-hooks'; @@ -13,6 +12,7 @@ import { FORCE_FINISH_PICKER } from '../_shared/hooks/usePickerState'; import { DateRangePickerViewMobile } from './DateRangePickerViewMobile'; import { WrapperVariantContext } from '../wrappers/WrapperVariantContext'; import { MobileKeyboardInputView } from '../views/MobileKeyboardInputView'; +import { DateRangePickerInput, DateRangeInputProps } from './DateRangePickerInput'; import { RangeInput, DateRange, CurrentlySelectingRangeEndProps } from './RangeTypes'; import { ExportedCalendarViewProps, defaultReduceAnimations } from '../views/Calendar/CalendarView'; import { @@ -36,7 +36,7 @@ export interface ExportedDateRangePickerViewProps interface DateRangePickerViewProps extends ExportedDateRangePickerViewProps, CurrentlySelectingRangeEndProps, - SharedPickerProps { + SharedPickerProps { open: boolean; startText: React.ReactNode; endText: React.ReactNode; @@ -204,15 +204,7 @@ export const DateRangePickerView: React.FC = ({ {isMobileKeyboardViewOpen ? ( - + ) : ( renderView() diff --git a/lib/src/DateRangePicker/RangeTypes.ts b/lib/src/DateRangePicker/RangeTypes.ts index 5a39025e3a66f2..d44c1ecfc39030 100644 --- a/lib/src/DateRangePicker/RangeTypes.ts +++ b/lib/src/DateRangePicker/RangeTypes.ts @@ -1,8 +1,15 @@ import { ParsableDate } from '../constants/prop-types'; import { MaterialUiPickersDate } from '../typings/date'; +import { AllSharedPickerProps } from '../Picker/SharedPickerProps'; +import { ExportedDateRangePickerInputProps } from './DateRangePickerInput'; export type RangeInput = [ParsableDate, ParsableDate]; export type DateRange = [MaterialUiPickersDate, MaterialUiPickersDate]; +export type AllSharedDateRangePickerProps = Omit< + AllSharedPickerProps, + 'renderInput' +> & + ExportedDateRangePickerInputProps; export interface CurrentlySelectingRangeEndProps { currentlySelectingRangeEnd: 'start' | 'end'; diff --git a/lib/src/Picker/SharedPickerProps.tsx b/lib/src/Picker/SharedPickerProps.tsx index 1ae3d8db2ff275..7e38f05df3cb1f 100644 --- a/lib/src/Picker/SharedPickerProps.tsx +++ b/lib/src/Picker/SharedPickerProps.tsx @@ -1,11 +1,11 @@ -import { WrapperVariant } from '../wrappers/Wrapper'; import { DateTimePickerView } from '../DateTimePicker'; import { ParsableDate } from '../constants/prop-types'; import { BasePickerProps } from '../typings/BasePicker'; import { MaterialUiPickersDate } from '../typings/date'; +import { ExportedDateInputProps } from '../_shared/PureDateInput'; import { DateValidationProps } from '../_helpers/text-field-helper'; import { WithDateAdapterProps } from '../_shared/withDateAdapterProp'; -import { ExportedDateInputProps, DateInputProps } from '../_shared/PureDateInput'; +import { WrapperVariant, DateInputPropsLike } from '../wrappers/Wrapper'; export type AnyPickerView = DateTimePickerView; @@ -17,10 +17,14 @@ export type AllSharedPickerProps< WithDateAdapterProps & DateValidationProps; -export interface SharedPickerProps { +export interface SharedPickerProps< + TInputValue, + TDateValue, + TInputProps = DateInputPropsLike +> { isMobileKeyboardViewOpen: boolean; toggleMobileKeyboardView: () => void; - DateInputProps: DateInputProps; + DateInputProps: TInputProps; date: TDateValue; onDateChange: ( date: TDateValue, diff --git a/lib/src/__tests__/KeyboardDateTimePicker.test.tsx b/lib/src/__tests__/KeyboardDateTimePicker.test.tsx index 88114533e0a5b9..28e396b567faa0 100644 --- a/lib/src/__tests__/KeyboardDateTimePicker.test.tsx +++ b/lib/src/__tests__/KeyboardDateTimePicker.test.tsx @@ -21,7 +21,7 @@ describe('e2e - DesktopDateTimePicker', () => { onClose={onCloseMock} onOpen={onOpenMock} inputFormat={format} - KeyboardButtonProps={{ id: 'keyboard-button' }} + OpenPickerButtonProps={{ id: 'keyboard-button' }} renderInput={props => } value={utilsToUse.date('2018-01-01T00:00:00.000Z')} /> diff --git a/lib/src/_shared/KeyboardDateInput.tsx b/lib/src/_shared/KeyboardDateInput.tsx index 4775bd72d9615e..a3f6bee27cf20e 100644 --- a/lib/src/_shared/KeyboardDateInput.tsx +++ b/lib/src/_shared/KeyboardDateInput.tsx @@ -1,105 +1,33 @@ import * as React from 'react'; import IconButton from '@material-ui/core/IconButton'; import InputAdornment from '@material-ui/core/InputAdornment'; -import { Rifm } from 'rifm'; import { useUtils } from './hooks/useUtils'; import { CalendarIcon } from './icons/CalendarIcon'; +import { useMaskedInput } from './hooks/useMaskedInput'; import { DateInputProps, DateInputRefs } from './PureDateInput'; -import { createDelegatedEventHandler } from '../_helpers/utils'; -import { - maskedDateFormatter, - getDisplayDate, - checkMaskIsValidForCurrentFormat, - getTextFieldAriaText, -} from '../_helpers/text-field-helper'; +import { getTextFieldAriaText } from '../_helpers/text-field-helper'; export const KeyboardDateInput: React.FC = ({ - disableMaskedInput, - rawValue, - validationError, - KeyboardButtonProps, - InputAdornmentProps, + renderInput, openPicker: onOpen, - onChange, InputProps, - mask, - acceptRegex = /[\d]/gi, - inputFormat, - disabled, - rifmFormatter, - renderInput, + InputAdornmentProps, openPickerIcon = , - emptyInputText: emptyLabel, + OpenPickerButtonProps, disableOpenPicker: hideOpenPickerButton, - ignoreInvalidInputs, - forwardedRef, - containerRef, - readOnly, - TextFieldProps, - label, getOpenDialogAriaText = getTextFieldAriaText, + containerRef, + forwardedRef, + ...other }) => { const utils = useUtils(); - const isFocusedRef = React.useRef(false); - - const getInputValue = React.useCallback( - () => - getDisplayDate(rawValue, utils, { - inputFormat, - emptyInputText: emptyLabel, - }), - [emptyLabel, 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, - [shouldUseMaskedInput, mask, acceptRegex] - ); - - 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()); - } - }, [rawValue, utils, inputFormat, getInputValue]); - - const handleChange = (text: string) => { - const finalString = text === '' || text === mask ? null : text; - setInnerInputValue(finalString); - - const date = finalString === null ? null : utils.parse(finalString, inputFormat); - if (ignoreInvalidInputs && !utils.isValid(date)) { - return; - } - - onChange(date, finalString || undefined); - }; - + const textFieldProps = useMaskedInput(other); const adornmentPosition = InputAdornmentProps?.position || 'end'; - const inputProps = { - label, - disabled, + + return renderInput({ ref: containerRef, inputRef: forwardedRef, - type: shouldUseMaskedInput ? 'tel' : 'text', - placeholder: formatHelperText, - error: Boolean(validationError), - helperText: formatHelperText || validationError, - 'data-mui-test': 'keyboard-date-input', - inputProps: { readOnly }, + ...textFieldProps, InputProps: { ...InputProps, [`${adornmentPosition}Adornment`]: hideOpenPickerButton ? ( @@ -109,9 +37,9 @@ export const KeyboardDateInput: React.FC = ({ {openPickerIcon} @@ -119,41 +47,7 @@ export const KeyboardDateInput: React.FC = ({ ), }, - ...TextFieldProps, - onFocus: createDelegatedEventHandler( - () => (isFocusedRef.current = true), - TextFieldProps?.onFocus - ), - onBlur: createDelegatedEventHandler( - () => (isFocusedRef.current = false), - TextFieldProps?.onBlur - ), - }; - - if (!shouldUseMaskedInput) { - return renderInput({ - ...inputProps, - value: innerInputValue || '', - onChange: e => handleChange(e.currentTarget.value), - }); - } - - return ( - - {rifmProps => - renderInput({ - ...inputProps, - ...rifmProps, - }) - } - - ); + }); }; export default KeyboardDateInput; diff --git a/lib/src/_shared/PureDateInput.tsx b/lib/src/_shared/PureDateInput.tsx index 0c9018d8b0d10c..92a74cefe47ebd 100644 --- a/lib/src/_shared/PureDateInput.tsx +++ b/lib/src/_shared/PureDateInput.tsx @@ -8,7 +8,7 @@ import { useUtils, MuiPickersAdapter } from './hooks/useUtils'; import { InputAdornmentProps } from '@material-ui/core/InputAdornment'; import { getDisplayDate, getTextFieldAriaText } from '../_helpers/text-field-helper'; -type MuiTextFieldProps = TextFieldProps | Omit; +export type MuiTextFieldProps = TextFieldProps | Omit; export interface DateInputProps { open: boolean; @@ -23,9 +23,16 @@ export interface DateInputProps; + // 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; - /** Override input component */ + /** + * Render input component. Where `props` – [TextField](https://material-ui.com/api/text-field/#textfield-api) component props + * @example ```jsx + * renderInput={props => } + * ```` + */ renderInput: (props: MuiTextFieldProps) => React.ReactElement; /** * Message displaying in read-only text field when null passed @@ -52,7 +59,7 @@ export interface DateInputProps} */ - KeyboardButtonProps?: Partial; + OpenPickerButtonProps?: Partial; /** Custom formatter to be passed into Rifm component */ rifmFormatter?: (str: string) => string; /** @@ -84,6 +91,7 @@ export type ExportedDateInputProps = Omit< | 'parsedDateValue' | 'open' | 'TextFieldProps' + | 'onBlur' >; export interface DateInputRefs { diff --git a/lib/src/_shared/hooks/useMaskedInput.tsx b/lib/src/_shared/hooks/useMaskedInput.tsx new file mode 100644 index 00000000000000..a9f8b270f85cf2 --- /dev/null +++ b/lib/src/_shared/hooks/useMaskedInput.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import { useRifm } from 'rifm'; +import { useUtils } from './useUtils'; +import { createDelegatedEventHandler } from '../../_helpers/utils'; +import { DateInputProps, MuiTextFieldProps } from '../PureDateInput'; +import { + maskedDateFormatter, + getDisplayDate, + checkMaskIsValidForCurrentFormat, +} from '../../_helpers/text-field-helper'; + +type MaskedInputProps = Omit< + DateInputProps, + | 'open' + | 'adornmentPosition' + | 'renderInput' + | 'openPicker' + | 'InputProps' + | 'InputAdornmentProps' + | 'openPickerIcon' + | 'disableOpenPicker' + | 'getOpenDialogAriaText' + | 'OpenPickerButtonProps' +>; + +export function useMaskedInput({ + disableMaskedInput, + rawValue, + validationError, + onChange, + mask, + acceptRegex = /[\d]/gi, + inputFormat, + disabled, + rifmFormatter, + emptyInputText: emptyLabel, + ignoreInvalidInputs, + readOnly, + TextFieldProps, + label, +}: MaskedInputProps): MuiTextFieldProps { + const utils = useUtils(); + const isFocusedRef = React.useRef(false); + + const getInputValue = React.useCallback( + () => + getDisplayDate(rawValue, utils, { + inputFormat, + emptyInputText: emptyLabel, + }), + [emptyLabel, 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: (e: React.ChangeEvent) => handleChange(e.currentTarget.value), + }; + + return { + ...inputStateArgs, + label, + disabled, + type: shouldUseMaskedInput ? 'tel' : 'text', + placeholder: formatHelperText, + error: Boolean(validationError), + helperText: formatHelperText || validationError, + // @ts-ignore ??? fix typings for textfield finally + 'data-mui-test': 'keyboard-date-input', + inputProps: { readOnly }, + ...TextFieldProps, + onFocus: createDelegatedEventHandler( + () => (isFocusedRef.current = true), + TextFieldProps?.onFocus + ), + onBlur: createDelegatedEventHandler( + () => (isFocusedRef.current = false), + TextFieldProps?.onBlur + ), + }; +} diff --git a/lib/src/wrappers/DesktopPopperWrapper.tsx b/lib/src/wrappers/DesktopPopperWrapper.tsx index 0cabd86356870c..75ec59449c0136 100644 --- a/lib/src/wrappers/DesktopPopperWrapper.tsx +++ b/lib/src/wrappers/DesktopPopperWrapper.tsx @@ -79,13 +79,7 @@ export const DesktopPopperWrapper: React.FC = ({ return ( - + = ({ }) => { return ( - + > { +export type DateInputPropsLike = Omit< + DateInputProps, + 'renderInput' +> & { + renderInput: (...args: any) => JSX.Element; +}; + +export interface WrapperProps> { open: boolean; onAccept: () => void; onDismiss: () => void; diff --git a/lib/src/wrappers/makeWrapperComponent.tsx b/lib/src/wrappers/makeWrapperComponent.tsx index 18df39d3cd93ac..9bc7d5ebd969a3 100644 --- a/lib/src/wrappers/makeWrapperComponent.tsx +++ b/lib/src/wrappers/makeWrapperComponent.tsx @@ -4,7 +4,7 @@ import { BasePickerProps } from '../typings/BasePicker'; import { DateInputProps } from '../_shared/PureDateInput'; import { ResponsiveWrapperProps } from './ResponsiveWrapper'; import { DateValidationProps } from '../_helpers/text-field-helper'; -import { OmitInnerWrapperProps, SomeWrapper, WrapperProps } from './Wrapper'; +import { OmitInnerWrapperProps, SomeWrapper, WrapperProps, DateInputPropsLike } from './Wrapper'; interface MakePickerOptions { PureDateInputComponent?: React.FC; @@ -19,7 +19,7 @@ interface WithWrapperProps { /** Creates a component that rendering modal/popover/nothing and spreading props down to text field */ export function makeWrapperComponent< - TInputProps extends DateInputProps, + TInputProps extends DateInputPropsLike, TInputValue, TDateValue, TWrapper extends SomeWrapper = any diff --git a/yarn.lock b/yarn.lock index ac01200d323770..fb64742c621db8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11681,10 +11681,10 @@ reusify@^1.0.0: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rifm@^0.10.1: - version "0.10.1" - resolved "https://registry.yarnpkg.com/rifm/-/rifm-0.10.1.tgz#2db1b2e006e1fd3540caffd012bdbcd96e5d6e5a" - integrity sha512-Y1kCEFNCEkqRlmjHhQ91fVXfv8VxeXW1UBIbaCYqFdMWPF/8Lf10/1dUdtuAuZ44Krdj/n1eYIt/20gs2HZoKA== +rifm@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/rifm/-/rifm-0.11.0.tgz#817a3f3319f2c3461ca42630dfe9f0c5cb84288b" + integrity sha512-h1bkVl1n69e7hYErPZF1rdhq6mTeUKecSsfimo++caU/gkAje5M8wepyKVaoQ8oLSgoC1L/2EGCr+vYAYgaM3A== rimraf@2.6.3: version "2.6.3"