diff --git a/README.md b/README.md index 033622fa..873ce087 100644 --- a/README.md +++ b/README.md @@ -73,27 +73,28 @@ More examples [here](https://react-semantic-ui-datepickers.now.sh). ### Own Props -| property | type | required | default | description | -| -------------------- | ----------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| allowOnlyNumbers | boolean | no | true | Allows the user enter only numbers | -| autoComplete | string | no | -- | Specifies if the input should have autocomplete enabled | -| clearIcon | SemanticICONS \| React.ReactElement | no | 'close' | An [icon from semantic-ui-react](https://react.semantic-ui.com/elements/icon/) or a custom component. The custom component will get two props: `data-testid` and `onClick`. | -| clearOnSameDateClick | boolean | no | true | Controls whether the datepicker's state resets if the same date is selected in succession. | -| clearable | boolean | no | true | Allows the user to clear the value | -| datePickerOnly | boolean | no | false | Allows the date to be selected only via the date picker and disables the text input | -| filterDate | function | no | () => true | Function that receives each date and returns a boolean to indicate whether it is enabled or not | -| format | string | no | 'YYYY-MM-DD' | Specifies how the date will be formatted using the [date-fns' format](https://date-fns.org/v1.29.0/docs/format) | -| icon | SemanticICONS \| React.ReactElement | no | 'calendar' | An [icon from semantic-ui-react](https://react.semantic-ui.com/elements/icon/) or a custom component. The custom component will get two props: `data-testid` and `onClick`. | -| inline | boolean | no | false | Uses an inline variant, without the input and the features related to it, e.g. clearable datepicker | -| keepOpenOnClear | boolean | no | false | Keeps the datepicker open (or opens it if it's closed) when the clear icon is clicked | -| keepOpenOnSelect | boolean | no | false | Keeps the datepicker open when a date is selected | -| locale | string | no | 'en-US' | Filename of the locale to be used. PS: Feel free to submit PR's with more locales! | -| onBlur | function | no | () => {} | Callback fired when the input loses focus | -| onChange | function | no | () => {} | Callback fired when the value changes | -| pointing | string | no | 'left' | Location to render the component around input. Available options: 'left', 'right', 'top left', 'top right' | -| showToday | boolean | no | true | Hides the "Today" button if false | -| type | string | no | basic | Type of input to render. Available options: 'basic' and 'range' | -| value | Date\|Date[] | no | -- | The value of the datepicker | +| property | type | required | default | description | +| -------------------- | ----------------------------------- | -------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| allowOnlyNumbers | boolean | no | true | Allows the user enter only numbers | +| autoComplete | string | no | -- | Specifies if the input should have autocomplete enabled | +| clearIcon | SemanticICONS \| React.ReactElement | no | 'close' | An [icon from semantic-ui-react](https://react.semantic-ui.com/elements/icon/) or a custom component. The custom component will get two props: `data-testid` and `onClick`. | +| clearOnSameDateClick | boolean | no | true | Controls whether the datepicker's state resets if the same date is selected in succession. | +| clearable | boolean | no | true | Allows the user to clear the value | +| datePickerOnly | boolean | no | false | Allows the date to be selected only via the date picker and disables the text input | +| filterDate | function | no | (date) => true | Function that receives each date and returns a boolean to indicate whether it is enabled or not | +| format | string | no | 'YYYY-MM-DD' | Specifies how the date will be formatted using the [date-fns' format](https://date-fns.org/v1.29.0/docs/format) | +| icon | SemanticICONS \| React.ReactElement | no | 'calendar' | An [icon from semantic-ui-react](https://react.semantic-ui.com/elements/icon/) or a custom component. The custom component will get two props: `data-testid` and `onClick`. | +| inline | boolean | no | false | Uses an inline variant, without the input and the features related to it, e.g. clearable datepicker | +| keepOpenOnClear | boolean | no | false | Keeps the datepicker open (or opens it if it's closed) when the clear icon is clicked | +| keepOpenOnSelect | boolean | no | false | Keeps the datepicker open when a date is selected | +| locale | string | no | 'en-US' | Filename of the locale to be used. PS: Feel free to submit PR's with more locales! | +| onBlur | function | no | (event) => {} | Callback fired when the input loses focus | +| onFocus | function | no | (event) => {} | Callback fired when the input gets focused focus | +| onChange | function | no | (event, data) => {} | Callback fired when the value changes | +| pointing | string | no | 'left' | Location to render the component around input. Available options: 'left', 'right', 'top left', 'top right' | +| showToday | boolean | no | true | Hides the "Today" button if false | +| type | string | no | basic | Type of input to render. Available options: 'basic' and 'range' | +| value | Date\|Date[] | no | -- | The value of the datepicker | ### Form.Input Props @@ -136,8 +137,6 @@ In order to customize the elements, you can override the styles of the classes b ## Roadmap -- Improve accessibility - > @donysukardi did some work on accessibility in the BaseDatePicker, but I couldn't get it working correcly. Feel free to help on this! - Add more tests (including e2e) > Feel free to open issues and/or create PRs to improve other aspects of the library! @@ -180,6 +179,7 @@ Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds + This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! diff --git a/src/__tests__/_utils.tsx b/src/__tests__/_utils.tsx index 0175b649..c904dc1d 100644 --- a/src/__tests__/_utils.tsx +++ b/src/__tests__/_utils.tsx @@ -6,17 +6,19 @@ import DatePicker from '../'; export const setup = (props: Partial = {}) => { const options = render(); const getQuery = props.inline ? options.queryByTestId : options.getByTestId; + const getClearIcon = () => options.getByTestId('datepicker-clear-icon'); const getIcon = () => options.getByTestId('datepicker-icon'); + const datePickerInput = getQuery('datepicker-input') as HTMLInputElement; return { ...options, + datePickerInput, + getClearIcon, getIcon, - openDatePicker: () => fireEvent.click(getIcon()), + openDatePicker: () => fireEvent.focus(datePickerInput), rerender: (newProps?: Partial) => options.rerender( ), - datePickerInput: getQuery('datepicker-input') - ?.firstChild as HTMLInputElement, }; }; diff --git a/src/__tests__/datepicker.test.tsx b/src/__tests__/datepicker.test.tsx index 89cf2f74..ee9e3d85 100644 --- a/src/__tests__/datepicker.test.tsx +++ b/src/__tests__/datepicker.test.tsx @@ -26,7 +26,7 @@ describe('Basic datepicker', () => { expect(getByText('Today')).toBeDefined(); fireEvent.keyDown(getByText('Today'), { keyCode: 27 }); expect(queryByText('Today')).toBeNull(); - expect(onBlur).toHaveBeenCalledTimes(1); + expect(onBlur).not.toHaveBeenCalled(); }); it('ignore keys different from Enter', async () => { @@ -41,7 +41,7 @@ describe('Basic datepicker', () => { fireEvent.keyDown(datePickerInput, { keyCode: 13 }); expect(datePickerInput.value).toBe(''); - expect(onBlur).toHaveBeenCalledTimes(1); + expect(onBlur).not.toHaveBeenCalled(); }); it('accepts valid input followed by Enter key', async () => { @@ -50,8 +50,7 @@ describe('Basic datepicker', () => { fireEvent.keyDown(datePickerInput, { keyCode: 13 }); expect(datePickerInput.value).toBe('2020-02-02'); - expect(onBlur).toHaveBeenCalledTimes(1); - expect(onBlur).toHaveBeenCalledWith(undefined); + expect(onBlur).not.toHaveBeenCalled(); }); it("doesn't accept invalid input followed by Enter key", async () => { @@ -60,8 +59,7 @@ describe('Basic datepicker', () => { fireEvent.keyDown(datePickerInput, { keyCode: 13 }); expect(datePickerInput.value).toBe(''); - expect(onBlur).toHaveBeenCalledTimes(1); - expect(onBlur).toHaveBeenCalledWith(undefined); + expect(onBlur).not.toHaveBeenCalled(); }); }); @@ -105,9 +103,7 @@ describe('Basic datepicker', () => { describe('with datePickerOnly', () => { it('does not accept input', async () => { - const { getByTestId } = setup({ datePickerOnly: true }); - const datePickerInput = getByTestId('datepicker-input') - .firstChild as HTMLInputElement; + const { datePickerInput } = setup({ datePickerOnly: true }); expect(datePickerInput.readOnly).toBeTruthy(); }); @@ -201,14 +197,19 @@ describe('Basic datepicker', () => { }); it('reset its state on clear', () => { - const { datePickerInput, getByTestId, getByText, openDatePicker } = setup(); + const { + datePickerInput, + getByText, + getClearIcon, + openDatePicker, + } = setup(); openDatePicker(); fireEvent.click(getByText('Today')); expect(datePickerInput.value).not.toBe(''); - fireEvent.click(getByTestId('datepicker-icon')); + fireEvent.click(getClearIcon()); expect(datePickerInput.value).toBe(''); }); @@ -251,7 +252,7 @@ describe('Basic datepicker', () => { expect(datePickerInput.value).not.toBe(''); fireEvent.click(getByText('Today')); expect(datePickerInput.value).not.toBe(''); - expect(onBlur).not.toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalledTimes(1); }); }); @@ -298,8 +299,7 @@ describe('Range datepicker', () => { fireEvent.keyDown(datePickerInput, { keyCode: 13 }); expect(datePickerInput.value).toBe('2020-02-02'); - expect(onBlur).toHaveBeenCalledTimes(1); - expect(onBlur).toHaveBeenCalledWith(undefined); + expect(onBlur).not.toHaveBeenCalled(); }); it("doesn't accept invalid input followed by Enter key", async () => { @@ -308,8 +308,7 @@ describe('Range datepicker', () => { fireEvent.keyDown(datePickerInput, { keyCode: 13 }); expect(datePickerInput.value).toBe(''); - expect(onBlur).toHaveBeenCalledTimes(1); - expect(onBlur).toHaveBeenCalledWith(undefined); + expect(onBlur).not.toHaveBeenCalled(); }); }); @@ -331,7 +330,7 @@ describe('Range datepicker', () => { const tomorrowCell = getByTestId(RegExp(tomorrow)); fireEvent.click(todayCell); - expect(onBlur).toHaveBeenCalledTimes(0); + expect(onBlur).toHaveBeenCalledTimes(1); fireEvent.click(tomorrowCell); expect(onBlur).toHaveBeenCalledTimes(1); @@ -409,7 +408,7 @@ describe('Range datepicker', () => { }); it('reset its state on clear', () => { - const { datePickerInput, getByTestId, getByText, openDatePicker } = setup({ + const { datePickerInput, getByText, getClearIcon, openDatePicker } = setup({ type: 'range', }); @@ -418,7 +417,7 @@ describe('Range datepicker', () => { expect(datePickerInput.value).not.toBe(''); - fireEvent.click(getByTestId('datepicker-icon')); + fireEvent.click(getClearIcon()); expect(datePickerInput.value).toBe(''); }); @@ -487,7 +486,9 @@ describe('Inline datepicker', () => { describe('Custom icons', () => { it('should allow for custom Semantic UI icons', () => { const icon = 'search'; - const { getByText, getIcon, openDatePicker } = setup({ icon }); + const { getByText, getClearIcon, getIcon, openDatePicker } = setup({ + icon, + }); // Assert custom icon expect(getIcon()).toHaveClass(icon, 'icon'); @@ -495,14 +496,14 @@ describe('Inline datepicker', () => { openDatePicker(); fireEvent.click(getByText('Today')); // Assert datepicker is clearable - expect(getIcon()).toHaveClass('close', 'icon'); - fireEvent.click(getIcon()); + expect(getClearIcon()).toHaveClass('close', 'icon'); + fireEvent.click(getClearIcon()); // Assert datepicker was cleared expect(getIcon()).toHaveClass(icon, 'icon'); }); it('should allow for custom icon components', () => { - const { getByText, getIcon, openDatePicker } = setup({ + const { getByText, getClearIcon, getIcon, openDatePicker } = setup({ icon: Custom icon, }); @@ -512,30 +513,32 @@ describe('Inline datepicker', () => { openDatePicker(); fireEvent.click(getByText('Today')); // Assert datepicker is clearable - expect(getIcon()).toHaveClass('close', 'icon'); - fireEvent.click(getIcon()); + expect(getClearIcon()).toHaveClass('close', 'icon'); + fireEvent.click(getClearIcon()); // Assert datepicker was cleared expect(getIcon().textContent).toBe('Custom icon'); }); it('should allow for custom clear Semantic UI icons', () => { const clearIcon = 'ban'; - const { getByText, getIcon, openDatePicker } = setup({ clearIcon }); + const { getByText, getClearIcon, getIcon, openDatePicker } = setup({ + clearIcon, + }); // Select current date openDatePicker(); fireEvent.click(getByText('Today')); // Assert custom icon - expect(getIcon()).toHaveClass(clearIcon, 'icon'); + expect(getClearIcon()).toHaveClass(clearIcon, 'icon'); // Assert datepicker is clearable - fireEvent.click(getIcon()); + fireEvent.click(getClearIcon()); // Assert datepicker was cleared expect(getIcon()).toHaveClass('calendar', 'icon'); }); it('should allow for custom clear icon components', () => { const customClearIcon = Custom icon; - const { getByText, getIcon, openDatePicker } = setup({ + const { getByText, getClearIcon, getIcon, openDatePicker } = setup({ clearIcon: customClearIcon, }); @@ -543,9 +546,9 @@ describe('Inline datepicker', () => { openDatePicker(); fireEvent.click(getByText('Today')); // Assert custom icon - expect(getIcon().textContent).toBe('Custom icon'); + expect(getClearIcon().textContent).toBe('Custom icon'); // Assert datepicker is clearable - fireEvent.click(getIcon()); + fireEvent.click(getClearIcon()); // Assert datepicker was cleared expect(getIcon()).toHaveClass('calendar', 'icon'); }); diff --git a/src/__tests__/usecases.test.tsx b/src/__tests__/usecases.test.tsx index 38801bbd..7709d843 100644 --- a/src/__tests__/usecases.test.tsx +++ b/src/__tests__/usecases.test.tsx @@ -1,5 +1,7 @@ import { fireEvent, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import subDays from 'date-fns/subDays'; +import { getShortDate } from '../utils'; import { setup } from './_utils'; it('onChange is fired when invalid date is typed', () => { @@ -11,14 +13,54 @@ it('onChange is fired when invalid date is typed', () => { fireEvent.click(screen.getByText('Today')); expect(datePickerInput).toHaveFocus(); + expect(onBlur).not.toHaveBeenCalled(); expect(onChange).toHaveBeenCalledTimes(1); - expect(onBlur).toHaveBeenCalledTimes(1); userEvent.type(datePickerInput, '{backspace}'); userEvent.type(datePickerInput, '{backspace}'); fireEvent.keyDown(datePickerInput, { keyCode: 13 }); expect(datePickerInput.value).toBe(''); - expect(onBlur).toHaveBeenCalledTimes(2); + expect(onBlur).not.toHaveBeenCalled(); expect(onChange).toHaveBeenCalledTimes(2); }); + +it('onFocus is fired when input is focused', () => { + const onFocus = jest.fn(); + const { datePickerInput, openDatePicker } = setup({ onFocus }); + + openDatePicker(); + + expect(datePickerInput).toHaveFocus(); + expect(onFocus).toHaveBeenCalled(); +}); + +it('navigation with keyboard is possible', () => { + const now = new Date(); + const today = getShortDate(now)!; + const yesterday = getShortDate(subDays(now, 1))!; + const eightDaysAgo = getShortDate(subDays(now, 8))!; + const sevenDaysAgo = getShortDate(subDays(now, 7))!; + const { getByTestId } = setup({ + autoFocus: true, + keepOpenOnSelect: true, + showOutsideDays: true, + }); + + const todayCell = getByTestId(RegExp(today)); + + fireEvent.click(todayCell); + expect(todayCell).toHaveFocus(); + + fireEvent.keyDown(document.activeElement!, { keyCode: 37 }); + expect(getByTestId(RegExp(yesterday))).toHaveFocus(); + + fireEvent.keyDown(document.activeElement!, { keyCode: 38 }); + expect(getByTestId(RegExp(eightDaysAgo))).toHaveFocus(); + + fireEvent.keyDown(document.activeElement!, { keyCode: 39 }); + expect(getByTestId(RegExp(sevenDaysAgo))).toHaveFocus(); + + fireEvent.keyDown(document.activeElement!, { keyCode: 40 }); + expect(todayCell).toHaveFocus(); +}); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index b72c85a7..6de467d6 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,4 +1,4 @@ -import { parseISO } from 'date-fns'; +import parseISO from 'date-fns/parseISO'; import startOfDay from 'date-fns/startOfDay'; import localeEn from '../locales/en-US.json'; import { diff --git a/src/components/calendar/calendar.tsx b/src/components/calendar/calendar.tsx index c200fe2a..073f7251 100644 --- a/src/components/calendar/calendar.tsx +++ b/src/components/calendar/calendar.tsx @@ -1,6 +1,6 @@ import cn from 'classnames'; -import React, { Fragment } from 'react'; -import { Segment } from 'semantic-ui-react'; +import React, { Fragment, SyntheticEvent, useEffect, useRef } from 'react'; +import { Ref, Segment } from 'semantic-ui-react'; import { Locale, RenderProps, SemanticDatepickerProps } from 'types'; import { getShortDate, getToday } from '../../utils'; import Button from '../button'; @@ -10,6 +10,7 @@ import './calendar.css'; interface CalendarProps extends RenderProps { filterDate: (date: Date) => boolean; + getRootProps: () => Record; inline: SemanticDatepickerProps['inline']; inverted: SemanticDatepickerProps['inverted']; maxDate?: Date; @@ -43,6 +44,7 @@ const Calendar: React.FC = ({ getBackProps, getDateProps, getForwardProps, + getRootProps, inline, inverted, maxDate, @@ -56,132 +58,164 @@ const Calendar: React.FC = ({ todayButton, weekdays, pointing, -}) => ( - -
- {calendars.map((calendar, calendarIdx) => ( -
-
-
- {calendarIdx === 0 && ( - -
+}) => { + const { ref: rootRef, ...rootProps } = getRootProps(); + const pressedBtnRef = useRef(); + const onPressBtn = (evt: SyntheticEvent) => { + pressedBtnRef.current = (evt.target as HTMLButtonElement).getAttribute( + 'aria-label' + ); + }; - - {months[calendar.month].slice(0, 3)} {calendar.year} - + useEffect(() => { + if (pressedBtnRef.current) { + const selector = `[aria-label="${pressedBtnRef.current}"]`; + const prevBtn = document.querySelector(selector); -
- {calendarIdx === calendars.length - 1 && ( - -
-
-
- {weekdays.map((weekday) => ( - - {weekday.slice(0, 2)} - - ))} - {calendar.weeks.map((week) => - week.map((dateObj, weekIdx) => { - const key = `${calendar.year}-${calendar.month}-${weekIdx}`; + if (prevBtn && document.activeElement !== prevBtn) { + prevBtn.focus(); + } + } + }); - if (!dateObj) { - return ; - } + return ( + + +
+ {calendars.map((calendar, calendarIdx) => ( +
+
+
+ {calendarIdx === 0 && ( + +
- const selectable = - dateObj.selectable && filterDate(dateObj.date); - const shortDate = getShortDate(dateObj.date); + + {months[calendar.month].slice(0, 3)} {calendar.year} + - return ( +
+ {calendarIdx === calendars.length - 1 && ( + +
+
+
+ {weekdays.map((weekday) => ( - {dateObj.date.getDate()} + {weekday.slice(0, 2)} - ); - }) - )} -
+ ))} + {calendar.weeks.map((week) => + week.map((dateObj, weekIdx) => { + const key = `${calendar.year}-${calendar.month}-${weekIdx}`; + + if (!dateObj) { + return ; + } + + const selectable = + dateObj.selectable && filterDate(dateObj.date); + const shortDate = getShortDate(dateObj.date); + + return ( + + {dateObj.date.getDate()} + + ); + }) + )} +
+
+ ))}
- ))} -
- {showToday && ( - - {todayButton} - - )} - -); + {showToday && ( + + {todayButton} + + )} + + + ); +}; export default Calendar; diff --git a/src/components/cell/cell.tsx b/src/components/cell/cell.tsx index 765fb54a..0b50f260 100644 --- a/src/components/cell/cell.tsx +++ b/src/components/cell/cell.tsx @@ -39,16 +39,16 @@ const CalendarCell: React.FC = ({ 'clndr-cell-selected': selected, }); - if (!children || !selectable) { + if (!children) { return ( - + {children} ); } return ( - ); diff --git a/src/components/icon.tsx b/src/components/icon.tsx index 36e3163d..ee405908 100644 --- a/src/components/icon.tsx +++ b/src/components/icon.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { Icon as SUIIcon } from 'semantic-ui-react'; import { SemanticDatepickerProps } from '../types'; +import { keys } from '../utils'; type CustomIconProps = { clearIcon: SemanticDatepickerProps['clearIcon']; icon: SemanticDatepickerProps['icon']; isClearIconVisible: boolean; onClear: () => void; - onClick: () => void; }; const CustomIcon = ({ @@ -15,11 +15,16 @@ const CustomIcon = ({ icon, isClearIconVisible, onClear, - onClick, }: CustomIconProps) => { + const onKeydown = (evt: KeyboardEvent) => { + if (evt.keyCode === keys.enter || evt.keyCode === keys.space) { + onClear(); + } + }; + if (isClearIconVisible && clearIcon && React.isValidElement(clearIcon)) { return React.cloneElement(clearIcon, { - 'data-testid': 'datepicker-icon', + 'data-testid': 'datepicker-clear-icon', onClick: onClear, }); } @@ -27,10 +32,14 @@ const CustomIcon = ({ if (isClearIconVisible && clearIcon && !React.isValidElement(clearIcon)) { return ( ); } @@ -38,13 +47,10 @@ const CustomIcon = ({ if (icon && React.isValidElement(icon)) { return React.cloneElement(icon, { 'data-testid': 'datepicker-icon', - onClick, }); } - return ( - - ); + return ; }; export default CustomIcon; diff --git a/src/components/input.tsx b/src/components/input.tsx index b9beb46a..16826f45 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -9,6 +9,10 @@ type InputProps = FormInputProps & { isClearIconVisible: boolean; }; +const inputData = { + 'data-testid': 'datepicker-input', +}; + const CustomInput = React.forwardRef((props, ref) => { const { clearIcon, @@ -26,7 +30,6 @@ const CustomInput = React.forwardRef((props, ref) => { {label && } ((props, ref) => { icon={icon} isClearIconVisible={isClearIconVisible} onClear={onClear} - onClick={onFocus} /> } + input={inputData} onFocus={onFocus} value={value} /> diff --git a/src/index.tsx b/src/index.tsx index e4a15c23..c9a32f69 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ import isEqual from 'react-fast-compare'; import { Input as SUIInput } from 'semantic-ui-react'; import { formatSelectedDate, + keys, moveElementsByN, omit, onlyNumbers, @@ -23,6 +24,7 @@ const style: React.CSSProperties = { }; const semanticInputProps = [ 'autoComplete', + 'autoFocus', 'className', 'clearIcon', 'disabled', @@ -72,6 +74,7 @@ class SemanticDatepicker extends React.Component< > { static defaultProps: SemanticDatepickerProps = { allowOnlyNumbers: false, + autoFocus: false, clearIcon: 'close', clearOnSameDateClick: true, clearable: true, @@ -89,6 +92,7 @@ class SemanticDatepicker extends React.Component< name: undefined, onBlur: () => {}, onChange: () => {}, + onFocus: () => {}, placeholder: undefined, pointing: 'left', readOnly: false, @@ -195,6 +199,10 @@ class SemanticDatepicker extends React.Component< selectedDateFormatted: '', }; + if (keepOpenOnClear) { + this.focusOnInput(); + } + this.setState(newState, () => { onChange(event, { ...this.props, value: null }); }); @@ -202,7 +210,6 @@ class SemanticDatepicker extends React.Component< clearInput = (event) => { this.resetState(event); - this.handleBlur(event); }; mousedownCb = (mousedownEvent) => { @@ -210,24 +217,23 @@ class SemanticDatepicker extends React.Component< if (isVisible && this.el) { if (this.el.current && !this.el.current.contains(mousedownEvent.target)) { - this.close(mousedownEvent); + this.close(); } } }; keydownCb = (keydownEvent) => { const { isVisible } = this.state; - if (keydownEvent.keyCode === 27 && isVisible) { - // Escape - this.close(keydownEvent); + + if (keydownEvent.keyCode === keys.escape && isVisible) { + this.close(); } }; - close = (event) => { + close = () => { window.removeEventListener('keydown', this.keydownCb); window.removeEventListener('mousedown', this.mousedownCb); - this.handleBlur(event); this.setState({ isVisible: false, }); @@ -244,7 +250,9 @@ class SemanticDatepicker extends React.Component< }; showCalendar = (event) => { - event.preventDefault(); + const { onFocus } = this.props; + + onFocus(event); window.addEventListener('mousedown', this.mousedownCb); window.addEventListener('keydown', this.keydownCb); @@ -254,16 +262,12 @@ class SemanticDatepicker extends React.Component< }); }; - handleRangeInput = (newDates, event, fromBlur = false) => { + handleRangeInput = (newDates, event) => { const { format, keepOpenOnSelect, onChange } = this.props; if (!newDates || !newDates.length) { this.resetState(event); - if (!fromBlur) { - this.handleBlur(event); - } - return; } @@ -278,21 +282,11 @@ 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, fromBlur = false) => { + handleBasicInput = (newDate, event) => { const { format, keepOpenOnSelect, @@ -306,10 +300,6 @@ 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. @@ -318,30 +308,18 @@ class SemanticDatepicker extends React.Component< this.setState({ isVisible: keepOpenOnSelect, }); - - if (keepOpenOnSelect) { - this.focusOnInput(); - } else if (!fromBlur) { - this.handleBlur(event); - } } return; } const newState = { - isVisible: fromBlur || keepOpenOnSelect, + isVisible: keepOpenOnSelect, selectedDate: newDate, selectedDateFormatted: formatSelectedDate(newDate, format), typedValue: null, }; - if (keepOpenOnSelect) { - this.focusOnInput(); - } else if (!fromBlur) { - this.handleBlur(event); - } - this.setState(newState, () => { onChange(event, { ...this.props, value: newDate }); }); @@ -351,7 +329,9 @@ class SemanticDatepicker extends React.Component< const { format, onBlur, onChange } = this.props; const { typedValue } = this.state; - onBlur(event); + if (event) { + onBlur(event); + } if (!typedValue) { return; @@ -362,7 +342,7 @@ class SemanticDatepicker extends React.Component< const areDatesValid = parsedValue.every(isValid); if (areDatesValid) { - this.handleRangeInput(parsedValue, event, true); + this.handleRangeInput(parsedValue, event); return; } } else { @@ -370,7 +350,7 @@ class SemanticDatepicker extends React.Component< const isDateValid = isValid(parsedValue); if (isDateValid) { - this.handleBasicInput(parsedValue, event, true); + this.handleBasicInput(parsedValue, event); return; } } @@ -407,8 +387,7 @@ class SemanticDatepicker extends React.Component< }; handleKeyDown = (evt) => { - // If the Enter key was pressed... - if (evt.keyCode === 13) { + if (evt.keyCode === keys.enter) { this.handleBlur(); } }; diff --git a/src/pickers/base.tsx b/src/pickers/base.tsx index e5f8a5c6..8035931b 100644 --- a/src/pickers/base.tsx +++ b/src/pickers/base.tsx @@ -8,61 +8,59 @@ class BaseDatePicker extends React.Component { offset: 0, }; - rootNode = React.createRef(); + rootNode = React.createRef(); handleArrowKeys = getArrowKeyHandlers({ - left: () => { - this.getKeyOffset(-1); - }, - right: () => { - this.getKeyOffset(1); - }, - up: () => { - this.getKeyOffset(-7); - }, - down: () => { - this.getKeyOffset(7); - }, + left: () => this.getKeyOffset(-1), + right: () => this.getKeyOffset(1), + up: () => this.getKeyOffset(-7), + down: () => this.getKeyOffset(7), }); - getKeyOffset(number) { + getKeyOffset(number: number) { if (!this.rootNode.current) { return; } - const e = document.activeElement; - const buttons = this.rootNode.current.querySelectorAll('button'); - buttons.forEach((el, i) => { + const activeEl = document.activeElement; + const buttons = Array.from( + this.rootNode.current.querySelectorAll( + 'button:not(:disabled)' + ) + ); + + buttons.some((btn, i) => { const newNodeKey = i + number; - if (el === e) { - if (newNodeKey <= buttons.length - 1 && newNodeKey >= 0) { - buttons[newNodeKey].focus(); - } else { - buttons[0].focus(); - } + + if (btn !== activeEl) { + return false; } + + if (newNodeKey <= buttons.length - 1 && newNodeKey >= 0) { + buttons[newNodeKey].focus(); + return true; + } + + buttons[0].focus(); + return true; }); } - setRootNode = (ref) => { - this.rootNode = ref; - }; - getRootProps = ({ refKey = 'ref', ...rest } = {}) => { return { - [refKey]: this.setRootNode, + [refKey]: this.rootNode, onKeyDown: this.handleArrowKeys, ...rest, }; }; - _handleOffsetChanged = (offset) => { + _handleOffsetChanged = (offset: number) => { this.setState({ offset, }); }; - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: BaseDatePickerProps) { if (this.props.date !== prevProps.date) { this._handleOffsetChanged(0); } diff --git a/src/pickers/range.tsx b/src/pickers/range.tsx index 351b5b01..3f9984e3 100644 --- a/src/pickers/range.tsx +++ b/src/pickers/range.tsx @@ -19,7 +19,7 @@ class RangeDatePicker extends React.Component< state = { hoveredDate: null }; - setHoveredDate = (date) => { + setHoveredDate = (date: Date | null) => { this.setState((state) => state.hoveredDate === date ? null : { hoveredDate: date } ); @@ -31,10 +31,11 @@ class RangeDatePicker extends React.Component< }; // Date level - onHoverFocusDate(date) { + onHoverFocusDate(date: Date | null) { if (this.props.selected.length !== 1) { return; } + this.setHoveredDate(date); } diff --git a/src/types/index.ts b/src/types/index.ts index ef06a645..dfb8e8cb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -43,6 +43,7 @@ export type PickedDayzedProps = Pick< export type PickedFormInputProps = Pick< FormInputProps, + | 'autoFocus' | 'className' | 'disabled' | 'error' @@ -73,7 +74,8 @@ export type SemanticDatepickerProps = PickedDayzedProps & inline: boolean; inverted: boolean; locale: LocaleOptions; - onBlur: (event?: React.SyntheticEvent) => void; + onBlur: (event: React.SyntheticEvent) => void; + onFocus: (event: React.SyntheticEvent) => void; onChange: ( event: React.SyntheticEvent | undefined, data: SemanticDatepickerProps diff --git a/src/utils.ts b/src/utils.ts index d53d990f..672fb607 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,17 @@ -import { convertTokens } from '@date-fns/upgrade/v2'; -import { parse } from 'date-fns'; +import { convertTokens } from '@date-fns/upgrade/v2/convertTokens'; import format from 'date-fns/format'; import isBefore from 'date-fns/isBefore'; +import parse from 'date-fns/parse'; import startOfDay from 'date-fns/startOfDay'; import { DateObj } from 'dayzed'; import { Object } from './types'; +export const keys = { + enter: 13, + escape: 27, + space: 32, +}; + export const isSelectable = (date: Date, minDate?: Date, maxDate?: Date) => { if ( (minDate && isBefore(date, minDate)) || @@ -91,5 +97,5 @@ export function getShortDate(date?: Date) { return undefined; } - return date.toISOString().slice(0, 10); + return format(date, 'yyyy-MM-dd'); }