diff --git a/package.json b/package.json index c1630cb6..af5ee1a9 100644 --- a/package.json +++ b/package.json @@ -33,38 +33,38 @@ "author": "Arthur Denner ", "license": "MIT", "dependencies": { - "@babel/runtime": "7.9.2", + "@babel/runtime": "7.9.6", "@date-fns/upgrade": "1.0.3", "classnames": "2.2.6", - "core-js": "3.6.4", + "core-js": "3.6.5", "date-fns": "2.12.0", "dayzed": "3.1.0", "format-string-by-pattern": "1.2.1", - "react-fast-compare": "3.0.1" + "react-fast-compare": "3.0.2" }, "devDependencies": { - "@babel/core": "7.9.0", - "@storybook/addon-actions": "5.3.17", + "@babel/core": "7.9.6", + "@storybook/addon-actions": "5.3.18", "@storybook/addon-links": "5.3.18", "@storybook/addons": "5.3.18", "@storybook/react": "5.3.18", - "@testing-library/react": "10.0.1", - "@types/jest": "25.1.4", + "@testing-library/react": "10.0.4", + "@types/jest": "25.2.1", "@types/storybook__react": "5.2.1", - "autoprefixer": "9.7.4", + "autoprefixer": "9.7.6", "awesome-typescript-loader": "5.2.1", "babel-loader": "8.1.0", "cssnano": "4.1.10", - "eslint-plugin-prettier": "3.1.2", - "husky": "4.2.3", + "eslint-plugin-prettier": "3.1.3", + "husky": "4.2.5", "jest-transform-css": "2.0.0", - "prettier": "2.0.4", + "prettier": "2.0.5", "pretty-quick": "2.0.1", "react": "16.13.1", "react-dom": "16.13.1", "rimraf": "3.0.2", "rollup-plugin-copy": "3.3.0", - "rollup-plugin-postcss": "2.5.0", + "rollup-plugin-postcss": "3.1.1", "rollup-plugin-typescript2": "0.27.0", "semantic-ui-css": "2.4.1", "semantic-ui-react": "0.88.2", diff --git a/src/__tests__/datepicker.test.tsx b/src/__tests__/datepicker.test.tsx index 2916fde4..c48c4fc3 100644 --- a/src/__tests__/datepicker.test.tsx +++ b/src/__tests__/datepicker.test.tsx @@ -24,6 +24,7 @@ const setup = (props?: Partial) => { .firstChild as HTMLInputElement, }; }; +const onBlur = jest.fn(); let spy: jest.SpyInstance; beforeEach(() => { @@ -31,6 +32,7 @@ beforeEach(() => { }); afterEach(() => { + onBlur.mockRestore(); spy.mockRestore(); }); @@ -41,18 +43,16 @@ describe('Basic datepicker', () => { describe('reacts to keyboard events', () => { it('closes datepicker on Esc', async () => { - const { getByText, openDatePicker, queryByText } = setup(); + const { getByText, openDatePicker, queryByText } = setup({ onBlur }); openDatePicker(); expect(getByText('Today')).toBeDefined(); - fireEvent.keyDown(getByText('Today'), { keyCode: 27 }); - expect(queryByText('Today')).toBeNull(); + expect(onBlur).toHaveBeenCalledTimes(1); }); it('ignore keys different from Enter', async () => { - const onBlur = jest.fn(); const { datePickerInput } = setup({ onBlur }); fireEvent.keyDown(datePickerInput); @@ -60,7 +60,6 @@ describe('Basic datepicker', () => { }); it('only return if Enter is pressed without any value', async () => { - const onBlur = jest.fn(); const { datePickerInput } = setup({ onBlur }); fireEvent.keyDown(datePickerInput, { keyCode: 13 }); @@ -69,7 +68,6 @@ describe('Basic datepicker', () => { }); it('accepts valid input followed by Enter key', async () => { - const onBlur = jest.fn(); const { datePickerInput } = setup({ onBlur }); fireEvent.input(datePickerInput, { target: { value: '2020-02-02' } }); fireEvent.keyDown(datePickerInput, { keyCode: 13 }); @@ -80,7 +78,6 @@ describe('Basic datepicker', () => { }); it("doesn't accept invalid input followed by Enter key", async () => { - const onBlur = jest.fn(); const { datePickerInput } = setup({ onBlur }); fireEvent.input(datePickerInput, { target: { value: '2020-02' } }); fireEvent.keyDown(datePickerInput, { keyCode: 13 }); @@ -243,32 +240,30 @@ describe('Basic datepicker', () => { it('reset its state when prop is true', () => { const { datePickerInput, getByText, openDatePicker } = setup({ keepOpenOnSelect: true, + onBlur, }); openDatePicker(); fireEvent.click(getByText('Today')); - expect(datePickerInput.value).not.toBe(''); - fireEvent.click(getByText('Today')); - expect(datePickerInput.value).toBe(''); + expect(onBlur).toHaveBeenCalledTimes(1); }); it("doesn't reset its state when prop is false", () => { const { datePickerInput, getByText, openDatePicker } = setup({ clearOnSameDateClick: false, keepOpenOnSelect: true, + onBlur, }); openDatePicker(); fireEvent.click(getByText('Today')); - expect(datePickerInput.value).not.toBe(''); - fireEvent.click(getByText('Today')); - expect(datePickerInput.value).not.toBe(''); + expect(onBlur).not.toHaveBeenCalled(); }); }); }); @@ -280,7 +275,6 @@ describe('Range datepicker', () => { describe('reacts to keyboard events', () => { it('accepts valid input followed by Enter key', async () => { - const onBlur = jest.fn(); const { datePickerInput } = setup({ onBlur, type: 'range' }); fireEvent.input(datePickerInput, { target: { value: '2020-02-02' } }); fireEvent.keyDown(datePickerInput, { keyCode: 13 }); @@ -291,7 +285,6 @@ describe('Range datepicker', () => { }); it("doesn't accept invalid input followed by Enter key", async () => { - const onBlur = jest.fn(); const { datePickerInput } = setup({ onBlur, type: 'range' }); fireEvent.input(datePickerInput, { target: { value: '2020-02' } }); fireEvent.keyDown(datePickerInput, { keyCode: 13 }); @@ -302,6 +295,30 @@ describe('Range datepicker', () => { }); }); + it('fires onBlur prop when selecting both dates', async () => { + const onChange = jest.fn(); + const now = new Date(); + const today = getShortDate(now) as string; + const tomorrow = getShortDate( + new Date(now.setDate(now.getDate() + 1)) + ) as string; + const { getByTestId, openDatePicker } = setup({ + onBlur, + onChange, + type: 'range', + }); + + openDatePicker(); + const todayCell = getByTestId(RegExp(today)); + const tomorrowCell = getByTestId(RegExp(tomorrow)); + + fireEvent.click(todayCell); + expect(onBlur).toHaveBeenCalledTimes(0); + + fireEvent.click(tomorrowCell); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + it('updates the locale if the prop changes', async () => { const { getByTestId, openDatePicker, rerender } = setup({ type: 'range' }); diff --git a/src/components/input.tsx b/src/components/input.tsx index 7912243a..4d59cba9 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -1,33 +1,42 @@ import React from 'react'; -import { Form, Icon, FormInputProps } from 'semantic-ui-react'; +import { Form, Icon, Input, FormInputProps } from 'semantic-ui-react'; type InputProps = FormInputProps & { isClearIconVisible: boolean; }; -const CustomInput = ({ - icon, - isClearIconVisible, - onClear, - onClick, - value, - ...rest -}: InputProps) => ( - ((props, ref) => { + const { + icon, + isClearIconVisible, + label, + onClear, + onClick, + value, + ...rest + } = props; + + return ( + + {label && } + + } + onClick={onClick} + value={value} /> - } - onClick={onClick} - value={value} - /> -); + + ); +}); CustomInput.defaultProps = { icon: 'calendar', diff --git a/src/index.tsx b/src/index.tsx index 8cce5441..58e2a466 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import isValid from 'date-fns/isValid'; import formatStringByPattern from 'format-string-by-pattern'; import React from 'react'; import isEqual from 'react-fast-compare'; +import { Input as SUIInput } from 'semantic-ui-react'; import { formatSelectedDate, moveElementsByN, @@ -93,6 +94,7 @@ class SemanticDatepicker extends React.Component< }; el = React.createRef(); + inputRef = React.createRef(); componentDidUpdate(prevProps: SemanticDatepickerProps) { const { locale, value } = this.props; @@ -189,12 +191,17 @@ class SemanticDatepicker extends React.Component< }); }; + clearInput = (event) => { + this.resetState(event); + this.handleBlur(event); + }; + mousedownCb = (mousedownEvent) => { const { isVisible } = this.state; if (isVisible && this.el) { if (this.el.current && !this.el.current.contains(mousedownEvent.target)) { - this.close(); + this.close(mousedownEvent); } } }; @@ -203,35 +210,47 @@ class SemanticDatepicker extends React.Component< const { isVisible } = this.state; if (keydownEvent.keyCode === 27 && isVisible) { // Escape - this.close(); + this.close(keydownEvent); } }; - close = () => { + close = (event) => { window.removeEventListener('keydown', this.keydownCb); window.removeEventListener('mousedown', this.mousedownCb); + this.handleBlur(event); this.setState({ isVisible: false, }); }; + focusOnInput = () => { + if (this.inputRef?.current?.focus) { + this.inputRef.current.focus(); + } + }; + showCalendar = (event) => { event.preventDefault(); window.addEventListener('mousedown', this.mousedownCb); window.addEventListener('keydown', this.keydownCb); + this.focusOnInput(); this.setState({ isVisible: true, }); }; - handleRangeInput = (newDates, event) => { + handleRangeInput = (newDates, event, fromBlur = false) => { const { format, keepOpenOnSelect, onChange } = this.props; if (!newDates || !newDates.length) { this.resetState(event); + if (!fromBlur) { + this.handleBlur(event); + } + return; } @@ -246,11 +265,21 @@ class SemanticDatepicker extends React.Component< if (newDates.length === 2) { this.setState({ isVisible: keepOpenOnSelect }); + + if (keepOpenOnSelect) { + this.focusOnInput(); + } else if (!fromBlur) { + this.handleBlur(event); + } + } else if (newDates.length === 1) { + this.focusOnInput(); + } else if (!fromBlur) { + this.handleBlur(event); } }); }; - handleBasicInput = (newDate, event) => { + handleBasicInput = (newDate, event, fromBlur = false) => { const { format, keepOpenOnSelect, @@ -264,6 +293,10 @@ class SemanticDatepicker extends React.Component< // behavior, without a specific prop. if (clearOnSameDateClick) { this.resetState(event); + + if (!fromBlur) { + this.handleBlur(event); + } } else { // Don't reset the state. Instead, close or keep open the // datepicker according to the value of keepOpenOnSelect. @@ -272,7 +305,14 @@ class SemanticDatepicker extends React.Component< this.setState({ isVisible: keepOpenOnSelect, }); + + if (keepOpenOnSelect) { + this.focusOnInput(); + } else if (!fromBlur) { + this.handleBlur(event); + } } + return; } @@ -283,6 +323,12 @@ class SemanticDatepicker extends React.Component< typedValue: null, }; + if (keepOpenOnSelect) { + this.focusOnInput(); + } else if (!fromBlur) { + this.handleBlur(event); + } + this.setState(newState, () => { onChange(event, { ...this.props, value: newDate }); }); @@ -303,7 +349,7 @@ class SemanticDatepicker extends React.Component< const areDatesValid = parsedValue.every(isValid); if (areDatesValid) { - this.handleRangeInput(parsedValue, event); + this.handleRangeInput(parsedValue, event, true); return; } } else { @@ -311,7 +357,7 @@ class SemanticDatepicker extends React.Component< const isDateValid = isValid(parsedValue); if (isDateValid) { - this.handleBasicInput(parsedValue, event); + this.handleBasicInput(parsedValue, event, true); return; } } @@ -381,13 +427,14 @@ class SemanticDatepicker extends React.Component< {}} onChange={this.handleChange} - onClear={this.resetState} + onClear={this.clearInput} onClick={readOnly ? null : this.showCalendar} onKeyDown={this.handleKeyDown} - value={typedValue || selectedDateFormatted} readOnly={readOnly || datePickerOnly} + ref={this.inputRef} + value={typedValue || selectedDateFormatted} /> {isVisible && ( =5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== -safe-identifier@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.3.1.tgz#466b956ef8558b10bbe15b87fedf470ab283cd39" - integrity sha512-+vr9lVsmciuoP1fz8w30qDcohwH2S/tb5dPGQ8zHmG9jQf7YHU2fIKGxxcDpeY38J0Dep+DdPMz8FszVZT0Mbw== +safe-identifier@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.1.tgz#b6516bf72594f03142b5f914f4c01842ccb1b678" + integrity sha512-73tOz5TXsq3apuCc3vC8c9QRhhdNZGiBhHmPPjqpH4TO5oCDqk8UIsDcSs/RG6dYcFAkOOva0pqHS3u7hh7XXA== safe-regex@^1.1.0: version "1.1.0"