Skip to content

Commit 3f50f9d

Browse files
authored
fix(datepicker): fire onBlur callback properly (#241)
* 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
1 parent 2cb8091 commit 3f50f9d

File tree

3 files changed

+121
-48
lines changed

3 files changed

+121
-48
lines changed

src/__tests__/datepicker.test.tsx

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ const setup = (props?: Partial<SemanticDatepickerProps>) => {
2424
.firstChild as HTMLInputElement,
2525
};
2626
};
27+
const onBlur = jest.fn();
2728
let spy: jest.SpyInstance;
2829

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

3334
afterEach(() => {
35+
onBlur.mockRestore();
3436
spy.mockRestore();
3537
});
3638

@@ -41,26 +43,23 @@ describe('Basic datepicker', () => {
4143

4244
describe('reacts to keyboard events', () => {
4345
it('closes datepicker on Esc', async () => {
44-
const { getByText, openDatePicker, queryByText } = setup();
46+
const { getByText, openDatePicker, queryByText } = setup({ onBlur });
4547
openDatePicker();
4648

4749
expect(getByText('Today')).toBeDefined();
48-
4950
fireEvent.keyDown(getByText('Today'), { keyCode: 27 });
50-
5151
expect(queryByText('Today')).toBeNull();
52+
expect(onBlur).toHaveBeenCalledTimes(1);
5253
});
5354

5455
it('ignore keys different from Enter', async () => {
55-
const onBlur = jest.fn();
5656
const { datePickerInput } = setup({ onBlur });
5757
fireEvent.keyDown(datePickerInput);
5858

5959
expect(onBlur).not.toHaveBeenCalled();
6060
});
6161

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

@@ -69,7 +68,6 @@ describe('Basic datepicker', () => {
6968
});
7069

7170
it('accepts valid input followed by Enter key', async () => {
72-
const onBlur = jest.fn();
7371
const { datePickerInput } = setup({ onBlur });
7472
fireEvent.input(datePickerInput, { target: { value: '2020-02-02' } });
7573
fireEvent.keyDown(datePickerInput, { keyCode: 13 });
@@ -80,7 +78,6 @@ describe('Basic datepicker', () => {
8078
});
8179

8280
it("doesn't accept invalid input followed by Enter key", async () => {
83-
const onBlur = jest.fn();
8481
const { datePickerInput } = setup({ onBlur });
8582
fireEvent.input(datePickerInput, { target: { value: '2020-02' } });
8683
fireEvent.keyDown(datePickerInput, { keyCode: 13 });
@@ -243,32 +240,30 @@ describe('Basic datepicker', () => {
243240
it('reset its state when prop is true', () => {
244241
const { datePickerInput, getByText, openDatePicker } = setup({
245242
keepOpenOnSelect: true,
243+
onBlur,
246244
});
247245

248246
openDatePicker();
249247
fireEvent.click(getByText('Today'));
250-
251248
expect(datePickerInput.value).not.toBe('');
252-
253249
fireEvent.click(getByText('Today'));
254-
255250
expect(datePickerInput.value).toBe('');
251+
expect(onBlur).toHaveBeenCalledTimes(1);
256252
});
257253

258254
it("doesn't reset its state when prop is false", () => {
259255
const { datePickerInput, getByText, openDatePicker } = setup({
260256
clearOnSameDateClick: false,
261257
keepOpenOnSelect: true,
258+
onBlur,
262259
});
263260

264261
openDatePicker();
265262
fireEvent.click(getByText('Today'));
266-
267263
expect(datePickerInput.value).not.toBe('');
268-
269264
fireEvent.click(getByText('Today'));
270-
271265
expect(datePickerInput.value).not.toBe('');
266+
expect(onBlur).not.toHaveBeenCalled();
272267
});
273268
});
274269
});
@@ -280,7 +275,6 @@ describe('Range datepicker', () => {
280275

281276
describe('reacts to keyboard events', () => {
282277
it('accepts valid input followed by Enter key', async () => {
283-
const onBlur = jest.fn();
284278
const { datePickerInput } = setup({ onBlur, type: 'range' });
285279
fireEvent.input(datePickerInput, { target: { value: '2020-02-02' } });
286280
fireEvent.keyDown(datePickerInput, { keyCode: 13 });
@@ -291,7 +285,6 @@ describe('Range datepicker', () => {
291285
});
292286

293287
it("doesn't accept invalid input followed by Enter key", async () => {
294-
const onBlur = jest.fn();
295288
const { datePickerInput } = setup({ onBlur, type: 'range' });
296289
fireEvent.input(datePickerInput, { target: { value: '2020-02' } });
297290
fireEvent.keyDown(datePickerInput, { keyCode: 13 });
@@ -302,6 +295,30 @@ describe('Range datepicker', () => {
302295
});
303296
});
304297

298+
it('fires onBlur prop when selecting both dates', async () => {
299+
const onChange = jest.fn();
300+
const now = new Date();
301+
const today = getShortDate(now) as string;
302+
const tomorrow = getShortDate(
303+
new Date(now.setDate(now.getDate() + 1))
304+
) as string;
305+
const { getByTestId, openDatePicker } = setup({
306+
onBlur,
307+
onChange,
308+
type: 'range',
309+
});
310+
311+
openDatePicker();
312+
const todayCell = getByTestId(RegExp(today));
313+
const tomorrowCell = getByTestId(RegExp(tomorrow));
314+
315+
fireEvent.click(todayCell);
316+
expect(onBlur).toHaveBeenCalledTimes(0);
317+
318+
fireEvent.click(tomorrowCell);
319+
expect(onBlur).toHaveBeenCalledTimes(1);
320+
});
321+
305322
it('updates the locale if the prop changes', async () => {
306323
const { getByTestId, openDatePicker, rerender } = setup({ type: 'range' });
307324

src/components/input.tsx

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,42 @@
11
import React from 'react';
2-
import { Form, Icon, FormInputProps } from 'semantic-ui-react';
2+
import { Form, Icon, Input, FormInputProps } from 'semantic-ui-react';
33

44
type InputProps = FormInputProps & {
55
isClearIconVisible: boolean;
66
};
77

8-
const CustomInput = ({
9-
icon,
10-
isClearIconVisible,
11-
onClear,
12-
onClick,
13-
value,
14-
...rest
15-
}: InputProps) => (
16-
<Form.Input
17-
data-testid="datepicker-input"
18-
{...rest}
19-
icon={
20-
<Icon
21-
data-testid="datepicker-icon"
22-
link
23-
name={isClearIconVisible ? 'close' : icon}
24-
onClick={isClearIconVisible ? onClear : onClick}
8+
const CustomInput = React.forwardRef<Input, InputProps>((props, ref) => {
9+
const {
10+
icon,
11+
isClearIconVisible,
12+
label,
13+
onClear,
14+
onClick,
15+
value,
16+
...rest
17+
} = props;
18+
19+
return (
20+
<Form.Field>
21+
{label && <label>{label}</label>}
22+
<Input
23+
data-testid="datepicker-input"
24+
{...rest}
25+
ref={ref}
26+
icon={
27+
<Icon
28+
data-testid="datepicker-icon"
29+
link
30+
name={isClearIconVisible ? 'close' : icon}
31+
onClick={isClearIconVisible ? onClear : onClick}
32+
/>
33+
}
34+
onClick={onClick}
35+
value={value}
2536
/>
26-
}
27-
onClick={onClick}
28-
value={value}
29-
/>
30-
);
37+
</Form.Field>
38+
);
39+
});
3140

3241
CustomInput.defaultProps = {
3342
icon: 'calendar',

src/index.tsx

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import isValid from 'date-fns/isValid';
22
import formatStringByPattern from 'format-string-by-pattern';
33
import React from 'react';
44
import isEqual from 'react-fast-compare';
5+
import { Input as SUIInput } from 'semantic-ui-react';
56
import {
67
formatSelectedDate,
78
moveElementsByN,
@@ -93,6 +94,7 @@ class SemanticDatepicker extends React.Component<
9394
};
9495

9596
el = React.createRef<HTMLDivElement>();
97+
inputRef = React.createRef<SUIInput>();
9698

9799
componentDidUpdate(prevProps: SemanticDatepickerProps) {
98100
const { locale, value } = this.props;
@@ -189,12 +191,17 @@ class SemanticDatepicker extends React.Component<
189191
});
190192
};
191193

194+
clearInput = (event) => {
195+
this.resetState(event);
196+
this.handleBlur(event);
197+
};
198+
192199
mousedownCb = (mousedownEvent) => {
193200
const { isVisible } = this.state;
194201

195202
if (isVisible && this.el) {
196203
if (this.el.current && !this.el.current.contains(mousedownEvent.target)) {
197-
this.close();
204+
this.close(mousedownEvent);
198205
}
199206
}
200207
};
@@ -203,35 +210,47 @@ class SemanticDatepicker extends React.Component<
203210
const { isVisible } = this.state;
204211
if (keydownEvent.keyCode === 27 && isVisible) {
205212
// Escape
206-
this.close();
213+
this.close(keydownEvent);
207214
}
208215
};
209216

210-
close = () => {
217+
close = (event) => {
211218
window.removeEventListener('keydown', this.keydownCb);
212219
window.removeEventListener('mousedown', this.mousedownCb);
213220

221+
this.handleBlur(event);
214222
this.setState({
215223
isVisible: false,
216224
});
217225
};
218226

227+
focusOnInput = () => {
228+
if (this.inputRef?.current?.focus) {
229+
this.inputRef.current.focus();
230+
}
231+
};
232+
219233
showCalendar = (event) => {
220234
event.preventDefault();
221235
window.addEventListener('mousedown', this.mousedownCb);
222236
window.addEventListener('keydown', this.keydownCb);
223237

238+
this.focusOnInput();
224239
this.setState({
225240
isVisible: true,
226241
});
227242
};
228243

229-
handleRangeInput = (newDates, event) => {
244+
handleRangeInput = (newDates, event, fromBlur = false) => {
230245
const { format, keepOpenOnSelect, onChange } = this.props;
231246

232247
if (!newDates || !newDates.length) {
233248
this.resetState(event);
234249

250+
if (!fromBlur) {
251+
this.handleBlur(event);
252+
}
253+
235254
return;
236255
}
237256

@@ -246,11 +265,21 @@ class SemanticDatepicker extends React.Component<
246265

247266
if (newDates.length === 2) {
248267
this.setState({ isVisible: keepOpenOnSelect });
268+
269+
if (keepOpenOnSelect) {
270+
this.focusOnInput();
271+
} else if (!fromBlur) {
272+
this.handleBlur(event);
273+
}
274+
} else if (newDates.length === 1) {
275+
this.focusOnInput();
276+
} else if (!fromBlur) {
277+
this.handleBlur(event);
249278
}
250279
});
251280
};
252281

253-
handleBasicInput = (newDate, event) => {
282+
handleBasicInput = (newDate, event, fromBlur = false) => {
254283
const {
255284
format,
256285
keepOpenOnSelect,
@@ -264,6 +293,10 @@ class SemanticDatepicker extends React.Component<
264293
// behavior, without a specific prop.
265294
if (clearOnSameDateClick) {
266295
this.resetState(event);
296+
297+
if (!fromBlur) {
298+
this.handleBlur(event);
299+
}
267300
} else {
268301
// Don't reset the state. Instead, close or keep open the
269302
// datepicker according to the value of keepOpenOnSelect.
@@ -272,7 +305,14 @@ class SemanticDatepicker extends React.Component<
272305
this.setState({
273306
isVisible: keepOpenOnSelect,
274307
});
308+
309+
if (keepOpenOnSelect) {
310+
this.focusOnInput();
311+
} else if (!fromBlur) {
312+
this.handleBlur(event);
313+
}
275314
}
315+
276316
return;
277317
}
278318

@@ -283,6 +323,12 @@ class SemanticDatepicker extends React.Component<
283323
typedValue: null,
284324
};
285325

326+
if (keepOpenOnSelect) {
327+
this.focusOnInput();
328+
} else if (!fromBlur) {
329+
this.handleBlur(event);
330+
}
331+
286332
this.setState(newState, () => {
287333
onChange(event, { ...this.props, value: newDate });
288334
});
@@ -303,15 +349,15 @@ class SemanticDatepicker extends React.Component<
303349
const areDatesValid = parsedValue.every(isValid);
304350

305351
if (areDatesValid) {
306-
this.handleRangeInput(parsedValue, event);
352+
this.handleRangeInput(parsedValue, event, true);
307353
return;
308354
}
309355
} else {
310356
const parsedValue = parseOnBlur(String(typedValue), format);
311357
const isDateValid = isValid(parsedValue);
312358

313359
if (isDateValid) {
314-
this.handleBasicInput(parsedValue, event);
360+
this.handleBasicInput(parsedValue, event, true);
315361
return;
316362
}
317363
}
@@ -381,13 +427,14 @@ class SemanticDatepicker extends React.Component<
381427
<Input
382428
{...this.inputProps}
383429
isClearIconVisible={Boolean(clearable && selectedDateFormatted)}
384-
onBlur={this.handleBlur}
430+
onBlur={() => {}}
385431
onChange={this.handleChange}
386-
onClear={this.resetState}
432+
onClear={this.clearInput}
387433
onClick={readOnly ? null : this.showCalendar}
388434
onKeyDown={this.handleKeyDown}
389-
value={typedValue || selectedDateFormatted}
390435
readOnly={readOnly || datePickerOnly}
436+
ref={this.inputRef}
437+
value={typedValue || selectedDateFormatted}
391438
/>
392439
{isVisible && (
393440
<this.Component

0 commit comments

Comments
 (0)