Skip to content

Commit 6894d96

Browse files
authored
fix: TimeInput not triggering onChange on incomplete values (#1711)
Update TimeInput to trigger onChange on incomplete input values, missing chars filled in with zeros. Update internal/displayed value on blur to match the last onChange. fixes #1710
1 parent 43d40bd commit 6894d96

2 files changed

Lines changed: 155 additions & 15 deletions

File tree

packages/components/src/TimeInput.test.tsx

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ describe('selection', () => {
114114

115115
input.focus();
116116
input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
117-
user.type(input, '{Shift}', {
117+
await user.type(input, '{Shift}', {
118118
skipClick: true,
119119
initialSelectionStart: selectionStart,
120120
initialSelectionEnd: selectionEnd,
@@ -150,14 +150,14 @@ describe('selection', () => {
150150
});
151151

152152
describe('select and type', () => {
153-
async function testSelectAndType(
153+
async function selectAndType(
154154
user: ReturnType<typeof userEvent.setup>,
155155
cursorPosition: number,
156-
str: string,
157-
expectedResult: string
156+
str: string
158157
) {
159158
const elementRef = React.createRef<TimeInputElement>();
160-
const { unmount } = makeTimeInput({ ref: elementRef });
159+
const onChange = jest.fn();
160+
const { unmount } = makeTimeInput({ ref: elementRef, onChange });
161161
const input: HTMLInputElement = screen.getByRole('textbox');
162162

163163
input.focus();
@@ -169,10 +169,36 @@ describe('select and type', () => {
169169
initialSelectionEnd: cursorPosition,
170170
});
171171
await user.keyboard(str);
172+
return { input, onChange, unmount };
173+
}
174+
// Test internal/displayed value, but not the onChange callback
175+
async function testSelectAndType(
176+
user: ReturnType<typeof userEvent.setup>,
177+
cursorPosition: number,
178+
str: string,
179+
expectedResult: string
180+
) {
181+
const { input, unmount } = await selectAndType(user, cursorPosition, str);
172182

173183
expect(input.value).toEqual(expectedResult);
174184
unmount();
175185
}
186+
// Test the value in onChange callback
187+
async function testSelectAndTypeOnChange(
188+
user: ReturnType<typeof userEvent.setup>,
189+
cursorPosition: number,
190+
str: string,
191+
expectedResult: string
192+
) {
193+
const { onChange, unmount } = await selectAndType(
194+
user,
195+
cursorPosition,
196+
str
197+
);
198+
expect(onChange).lastCalledWith(TimeUtils.parseTime(expectedResult));
199+
unmount();
200+
}
201+
176202
it('handles typing after autoselecting a segment', async () => {
177203
const user = userEvent.setup();
178204
await testSelectAndType(user, 0, '0', '02:34:56');
@@ -247,6 +273,58 @@ describe('select and type', () => {
247273
unmount();
248274
});
249275

276+
it('fills in missing chars and triggers onChange', async () => {
277+
const user = userEvent.setup();
278+
await testSelectAndTypeOnChange(user, 1, '{backspace}', '00:34:56');
279+
await testSelectAndTypeOnChange(user, 3, '{backspace}', '12:00:56');
280+
await testSelectAndTypeOnChange(user, 6, '{backspace}', '12:34:00');
281+
await testSelectAndTypeOnChange(
282+
user,
283+
8,
284+
// First backspace clears the whole section
285+
'{backspace}{backspace}{backspace}{backspace}',
286+
'10:00:00'
287+
);
288+
});
289+
290+
it('updates the displayed value on blur', async () => {
291+
const user = userEvent.setup();
292+
const onChange = jest.fn();
293+
const { unmount } = makeTimeInput({ onChange });
294+
const input: HTMLInputElement = screen.getByRole('textbox');
295+
input.focus();
296+
await user.type(input, '{shift}{backspace}{backspace}', {
297+
initialSelectionStart: 6,
298+
initialSelectionEnd: 6,
299+
});
300+
expect(onChange).toBeCalledTimes(2);
301+
expect(onChange).lastCalledWith(TimeUtils.parseTime('12:30:00'));
302+
expect(input.value).toEqual('12:3');
303+
304+
input.blur();
305+
306+
// Blur should update the internal value to match the last onChange
307+
// but not trigger another onChange
308+
expect(onChange).toBeCalledTimes(2);
309+
310+
expect(input.value).toEqual('12:30:00');
311+
312+
// Fill in missing chars in the middle
313+
input.focus();
314+
await user.type(input, '{shift}{backspace}', {
315+
skipClick: true,
316+
initialSelectionStart: 3,
317+
initialSelectionEnd: 3,
318+
});
319+
expect(input.value).toEqual(
320+
`12:${FIXED_WIDTH_SPACE}${FIXED_WIDTH_SPACE}:00`
321+
);
322+
input.blur();
323+
expect(input.value).toEqual('12:00:00');
324+
325+
unmount();
326+
});
327+
250328
it('existing edge cases', async () => {
251329
const user = userEvent.setup();
252330
// Ideally it should change the first section to 20, i.e. '20:34:56'
@@ -440,3 +518,37 @@ it('updates properly when the value prop is updated', () => {
440518

441519
expect(textbox.value).toEqual('00:00:00');
442520
});
521+
522+
it('ignores value prop changes matching displayed value', async () => {
523+
const user = userEvent.setup();
524+
const onChange = jest.fn();
525+
const { rerender } = makeTimeInput({ value: 1, onChange });
526+
527+
const textbox: HTMLInputElement = screen.getByRole('textbox');
528+
expect(textbox.value).toEqual('00:00:01');
529+
530+
textbox.focus();
531+
await user.type(textbox, '{backspace}', {
532+
skipClick: true,
533+
initialSelectionStart: 8,
534+
initialSelectionEnd: 8,
535+
});
536+
537+
expect(textbox.value).toEqual('00:00:0');
538+
expect(onChange).toBeCalledWith(0);
539+
540+
// Ignore prop update matching internal state
541+
rerender(<TimeInput value={0} onChange={onChange} />);
542+
expect(textbox.value).toEqual('00:00:0');
543+
expect(onChange).toBeCalledTimes(1);
544+
545+
// Update internal value
546+
rerender(<TimeInput value={1} onChange={onChange} />);
547+
expect(textbox.value).toEqual('00:00:01');
548+
expect(onChange).toBeCalledTimes(1);
549+
550+
// Update internal value
551+
rerender(<TimeInput value={0} onChange={onChange} />);
552+
expect(textbox.value).toEqual('00:00:00');
553+
expect(onChange).toBeCalledTimes(1);
554+
});

packages/components/src/TimeInput.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export type TimeInputElement = {
3333
setSelection: (newSelection: SelectionSegment) => void;
3434
};
3535

36+
function fixIncompleteValue(value: string): string {
37+
// If value is not a complete HH:mm:ss time, fill missing parts with 0
38+
if (value != null) {
39+
return `${value
40+
.substring(0, 8)
41+
.replace(/\u2007/g, '0')}${`00:00:00`.substring(value.length)}`;
42+
}
43+
return value;
44+
}
45+
3646
// Forward ref causes a false positive for display-name in eslint:
3747
// https://github.com/yannickcr/eslint-plugin-react/issues/2269
3848
// eslint-disable-next-line react/display-name
@@ -49,6 +59,7 @@ const TimeInput = React.forwardRef<TimeInputElement, TimeInputProps>(
4959
'data-testid': dataTestId,
5060
} = props;
5161
const [value, setValue] = useState(TimeUtils.formatTime(propsValue));
62+
const parsedValueRef = useRef<number>(propsValue);
5263
const [selection, setSelection] = useState<SelectionSegment>();
5364
const inputRef = useRef<HTMLInputElement>(null);
5465

@@ -68,9 +79,14 @@ const TimeInput = React.forwardRef<TimeInputElement, TimeInputProps>(
6879

6980
useEffect(
7081
function setFormattedTime() {
71-
setValue(TimeUtils.formatTime(propsValue));
82+
// Ignore value prop update if it matches the displayed value
83+
// to preserve the displayed value while typing
84+
if (parsedValueRef.current !== propsValue) {
85+
setValue(TimeUtils.formatTime(propsValue));
86+
parsedValueRef.current = propsValue;
87+
}
7288
},
73-
[propsValue]
89+
[parsedValueRef, propsValue]
7490
);
7591

7692
function getNextSegmentValue(
@@ -115,15 +131,27 @@ const TimeInput = React.forwardRef<TimeInputElement, TimeInputProps>(
115131
);
116132
}
117133

118-
function handleChange(newValue: string): void {
119-
log.debug('handleChange', newValue);
120-
setValue(newValue);
134+
const handleChange = useCallback(
135+
(newValue: string): void => {
136+
log.debug('handleChange', newValue);
137+
setValue(newValue);
138+
parsedValueRef.current = TimeUtils.parseTime(
139+
fixIncompleteValue(newValue)
140+
);
141+
onChange(parsedValueRef.current);
142+
},
143+
[onChange]
144+
);
121145

122-
// Only send a change if the value is actually valid
123-
if (TimeUtils.isTimeString(newValue)) {
124-
onChange(TimeUtils.parseTime(newValue));
146+
const handleBlur = useCallback((): void => {
147+
const fixedValue = fixIncompleteValue(value);
148+
// Update the value displayed in the input
149+
// onChange with the fixed value already triggered in handleChange
150+
if (fixedValue !== value) {
151+
setValue(fixedValue);
125152
}
126-
}
153+
onBlur();
154+
}, [value, onBlur]);
127155

128156
const handleSelect = useCallback(
129157
(newSelection: SelectionSegment) => {
@@ -146,7 +174,7 @@ const TimeInput = React.forwardRef<TimeInputElement, TimeInputProps>(
146174
selection={selection}
147175
value={value}
148176
onFocus={onFocus}
149-
onBlur={onBlur}
177+
onBlur={handleBlur}
150178
data-testid={dataTestId}
151179
/>
152180
);

0 commit comments

Comments
 (0)