Skip to content

Commit

Permalink
fix(datepicker): fire onBlur callback properly (#241)
Browse files Browse the repository at this point in the history
* refactor(input): use low-level components to allow forwarding refs

* feat: setup ref for input

* feat(datepicker): fire onBlur callback manually in the correct moments

* test: simplify mocking of onBlur callback

* test: improve assertions on manually fired onBlur
  • Loading branch information
arthurdenner committed May 4, 2020
1 parent 2cb8091 commit 3f50f9d
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 48 deletions.
47 changes: 32 additions & 15 deletions src/__tests__/datepicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ const setup = (props?: Partial<SemanticDatepickerProps>) => {
.firstChild as HTMLInputElement,
};
};
const onBlur = jest.fn();
let spy: jest.SpyInstance;

beforeEach(() => {
spy = jest.spyOn(console, 'warn').mockImplementation();
});

afterEach(() => {
onBlur.mockRestore();
spy.mockRestore();
});

Expand All @@ -41,26 +43,23 @@ 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);

expect(onBlur).not.toHaveBeenCalled();
});

it('only return if Enter is pressed without any value', async () => {
const onBlur = jest.fn();
const { datePickerInput } = setup({ onBlur });
fireEvent.keyDown(datePickerInput, { keyCode: 13 });

Expand All @@ -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 });
Expand All @@ -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 });
Expand Down Expand Up @@ -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();
});
});
});
Expand All @@ -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 });
Expand All @@ -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 });
Expand All @@ -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' });

Expand Down
55 changes: 32 additions & 23 deletions src/components/input.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Form.Input
data-testid="datepicker-input"
{...rest}
icon={
<Icon
data-testid="datepicker-icon"
link
name={isClearIconVisible ? 'close' : icon}
onClick={isClearIconVisible ? onClear : onClick}
const CustomInput = React.forwardRef<Input, InputProps>((props, ref) => {
const {
icon,
isClearIconVisible,
label,
onClear,
onClick,
value,
...rest
} = props;

return (
<Form.Field>
{label && <label>{label}</label>}
<Input
data-testid="datepicker-input"
{...rest}
ref={ref}
icon={
<Icon
data-testid="datepicker-icon"
link
name={isClearIconVisible ? 'close' : icon}
onClick={isClearIconVisible ? onClear : onClick}
/>
}
onClick={onClick}
value={value}
/>
}
onClick={onClick}
value={value}
/>
);
</Form.Field>
);
});

CustomInput.defaultProps = {
icon: 'calendar',
Expand Down
67 changes: 57 additions & 10 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -93,6 +94,7 @@ class SemanticDatepicker extends React.Component<
};

el = React.createRef<HTMLDivElement>();
inputRef = React.createRef<SUIInput>();

componentDidUpdate(prevProps: SemanticDatepickerProps) {
const { locale, value } = this.props;
Expand Down Expand Up @@ -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);
}
}
};
Expand All @@ -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;
}

Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -272,7 +305,14 @@ class SemanticDatepicker extends React.Component<
this.setState({
isVisible: keepOpenOnSelect,
});

if (keepOpenOnSelect) {
this.focusOnInput();
} else if (!fromBlur) {
this.handleBlur(event);
}
}

return;
}

Expand All @@ -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 });
});
Expand All @@ -303,15 +349,15 @@ class SemanticDatepicker extends React.Component<
const areDatesValid = parsedValue.every(isValid);

if (areDatesValid) {
this.handleRangeInput(parsedValue, event);
this.handleRangeInput(parsedValue, event, true);
return;
}
} else {
const parsedValue = parseOnBlur(String(typedValue), format);
const isDateValid = isValid(parsedValue);

if (isDateValid) {
this.handleBasicInput(parsedValue, event);
this.handleBasicInput(parsedValue, event, true);
return;
}
}
Expand Down Expand Up @@ -381,13 +427,14 @@ class SemanticDatepicker extends React.Component<
<Input
{...this.inputProps}
isClearIconVisible={Boolean(clearable && selectedDateFormatted)}
onBlur={this.handleBlur}
onBlur={() => {}}
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 && (
<this.Component
Expand Down

0 comments on commit 3f50f9d

Please sign in to comment.