Skip to content

Commit 96df6fa

Browse files
authored
Mimic native input in DateInput and PhoneNumberInput components (#2751)
1 parent 4b92be5 commit 96df6fa

15 files changed

+419
-167
lines changed

.changeset/two-snakes-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sumup-oss/circuit-ui": major
3+
---
4+
5+
Updated the PhoneNumberInput component to accept `value` and `defaultValue` props. The `onChange` callback is now called with an `Event` object instead of a string to mimic a native input.

packages/circuit-ui/components/ColorInput/ColorInput.spec.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
fireEvent,
2424
userEvent,
2525
} from '../../util/test-utils.js';
26-
import type { InputElement } from '../Input/index.js';
2726

2827
import { ColorInput } from './ColorInput.js';
2928

@@ -39,7 +38,7 @@ describe('ColorInput', () => {
3938
});
4039

4140
it('should forward a ref', () => {
42-
const ref = createRef<InputElement>();
41+
const ref = createRef<HTMLInputElement>();
4342
render(<ColorInput {...baseProps} ref={ref} />);
4443
const [input] = screen.getAllByLabelText(baseProps.label);
4544
expect(ref.current).toBe(input);

packages/circuit-ui/components/ColorInput/ColorInput.tsx

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from 'react';
2525

2626
import { classes as inputClasses } from '../Input/index.js';
27-
import type { InputElement, InputProps } from '../Input/index.js';
27+
import type { InputProps } from '../Input/index.js';
2828
import { clsx } from '../../styles/clsx.js';
2929
import {
3030
FieldLabelText,
@@ -33,6 +33,7 @@ import {
3333
FieldValidationHint,
3434
} from '../Field/index.js';
3535
import { applyMultipleRefs } from '../../util/refs.js';
36+
import { changeInputValue } from '../../util/input-value.js';
3637

3738
import classes from './ColorInput.module.css';
3839

@@ -65,7 +66,7 @@ export interface ColorInputProps
6566
defaultValue?: string;
6667
}
6768

68-
export const ColorInput = forwardRef<InputElement, ColorInputProps>(
69+
export const ColorInput = forwardRef<HTMLInputElement, ColorInputProps>(
6970
(
7071
{
7172
'aria-describedby': descriptionId,
@@ -91,23 +92,23 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(
9192
},
9293
ref,
9394
) => {
94-
const colorPickerRef = useRef<InputElement>(null);
95-
const colorInputRef = useRef<InputElement>(null);
95+
const colorPickerRef = useRef<HTMLInputElement>(null);
96+
const colorInputRef = useRef<HTMLInputElement>(null);
9697

9798
const labelId = useId();
9899
const pickerId = useId();
99100
const validationHintId = useId();
100101

101102
const descriptionIds = clsx(validationHintId, descriptionId);
102103

103-
const handlePaste: ClipboardEventHandler<InputElement> = (e) => {
104+
const handlePaste: ClipboardEventHandler<HTMLInputElement> = (event) => {
104105
if (!colorPickerRef.current || !colorInputRef.current || readOnly) {
105106
return;
106107
}
107108

108-
e.preventDefault();
109+
event.preventDefault();
109110

110-
const pastedText = e.clipboardData.getData('text/plain').trim();
111+
const pastedText = event.clipboardData.getData('text/plain').trim();
111112

112113
if (!pastedText || !/^#?[0-9A-F]{6}$/i.test(pastedText)) {
113114
return;
@@ -118,42 +119,34 @@ export const ColorInput = forwardRef<InputElement, ColorInputProps>(
118119
: `#${pastedText}`;
119120

120121
colorPickerRef.current.value = pastedColor;
121-
122-
// React overwrites the input.value setter. In order to be able to trigger
123-
// a 'change' event on the input, we need to use the native setter.
124-
// Adapted from https://stackoverflow.com/a/46012210/4620154
125-
Object.getOwnPropertyDescriptor(
126-
HTMLInputElement.prototype,
127-
'value',
128-
)?.set?.call(colorInputRef.current, pastedColor.replace('#', ''));
129-
130-
colorInputRef.current.dispatchEvent(
131-
new Event('change', { bubbles: true }),
132-
);
133122
colorPickerRef.current.dispatchEvent(
134123
new Event('change', { bubbles: true }),
135124
);
125+
126+
changeInputValue(colorInputRef.current, pastedColor.replace('#', ''));
136127
};
137128

138-
const onPickerColorChange: ChangeEventHandler<InputElement> = (e) => {
129+
const onPickerColorChange: ChangeEventHandler<HTMLInputElement> = (
130+
event,
131+
) => {
139132
if (colorInputRef.current) {
140-
colorInputRef.current.value = e.target.value.replace('#', '');
133+
colorInputRef.current.value = event.target.value.replace('#', '');
141134
}
142135
if (onChange) {
143-
onChange(e);
136+
onChange(event);
144137
}
145138
};
146139

147-
const onInputChange: ChangeEventHandler<InputElement> = (e) => {
140+
const onInputChange: ChangeEventHandler<HTMLInputElement> = (event) => {
148141
if (colorPickerRef.current) {
149-
colorPickerRef.current.value = `#${e.target.value}`;
142+
colorPickerRef.current.value = `#${event.target.value}`;
150143
}
151144
if (onChange) {
152145
onChange({
153-
...e,
146+
...event,
154147
target: {
155-
...e.target,
156-
value: `#${e.target.value}`,
148+
...event.target,
149+
value: `#${event.target.value}`,
157150
},
158151
});
159152
}

packages/circuit-ui/components/DateInput/DateInput.module.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
display: flex;
33
}
44

5+
.hidden {
6+
display: none;
7+
}
8+
59
.segments {
610
position: relative;
711
z-index: var(--cui-z-index-absolute);
@@ -64,7 +68,7 @@
6468
color: var(--cui-fg-warning);
6569
}
6670

67-
:global([data-disabled="true"]) .input {
71+
:global([data-disabled="true"]) .wrapper {
6872
color: var(--cui-fg-normal-disabled);
6973
background-color: var(--cui-bg-normal-disabled);
7074
border-color: var(--cui-border-normal-disabled);

packages/circuit-ui/components/DateInput/DateInput.spec.tsx

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ describe('DateInput', () => {
4545
});
4646

4747
it('should forward a ref', () => {
48-
const ref = createRef<HTMLDivElement>();
48+
const ref = createRef<HTMLInputElement>();
4949
const { container } = render(<DateInput {...props} ref={ref} />);
5050
// eslint-disable-next-line testing-library/no-container
51-
const wrapper = container.querySelectorAll('div')[0];
52-
expect(ref.current).toBe(wrapper);
51+
const input = container.querySelector('input[type="date"]');
52+
expect(ref.current).toBe(input);
5353
});
5454

5555
it('should merge a custom class name with the default ones', () => {
@@ -222,34 +222,44 @@ describe('DateInput', () => {
222222

223223
describe('state', () => {
224224
it('should display a default value', () => {
225-
render(<DateInput {...props} defaultValue="2000-01-12" />);
225+
const ref = createRef<HTMLInputElement>();
226+
render(<DateInput {...props} ref={ref} defaultValue="2000-01-12" />);
226227

228+
expect(ref.current).toHaveValue('2000-01-12');
227229
expect(screen.getByLabelText(/day/i)).toHaveValue('12');
228230
expect(screen.getByLabelText(/month/i)).toHaveValue('1');
229231
expect(screen.getByLabelText(/year/i)).toHaveValue('2000');
230232
});
231233

232234
it('should display an initial value', () => {
233-
render(<DateInput {...props} value="2000-01-12" />);
235+
const ref = createRef<HTMLInputElement>();
236+
render(<DateInput {...props} ref={ref} value="2000-01-12" />);
234237

238+
expect(ref.current).toHaveValue('2000-01-12');
235239
expect(screen.getByLabelText(/day/i)).toHaveValue('12');
236240
expect(screen.getByLabelText(/month/i)).toHaveValue('1');
237241
expect(screen.getByLabelText(/year/i)).toHaveValue('2000');
238242
});
239243

240244
it('should ignore an invalid value', () => {
241-
render(<DateInput {...props} value="2000-13-54" />);
245+
const ref = createRef<HTMLInputElement>();
246+
render(<DateInput {...props} ref={ref} value="2000-13-54" />);
242247

248+
expect(ref.current).toHaveValue('');
243249
expect(screen.getByLabelText(/day/i)).toHaveValue('');
244250
expect(screen.getByLabelText(/month/i)).toHaveValue('');
245251
expect(screen.getByLabelText(/year/i)).toHaveValue('');
246252
});
247253

248254
it('should update the displayed value', () => {
249-
const { rerender } = render(<DateInput {...props} value="2000-01-12" />);
255+
const ref = createRef<HTMLInputElement>();
256+
const { rerender } = render(
257+
<DateInput {...props} ref={ref} value="2000-01-12" />,
258+
);
250259

251-
rerender(<DateInput {...props} value="2000-01-15" />);
260+
rerender(<DateInput {...props} ref={ref} value="2000-01-15" />);
252261

262+
expect(ref.current).toHaveValue('2000-01-15');
253263
expect(screen.getByLabelText(/day/i)).toHaveValue('15');
254264
expect(screen.getByLabelText(/month/i)).toHaveValue('1');
255265
expect(screen.getByLabelText(/year/i)).toHaveValue('2000');
@@ -266,15 +276,17 @@ describe('DateInput', () => {
266276
});
267277

268278
it('should allow users to type a date', async () => {
279+
const ref = createRef<HTMLInputElement>();
269280
const onChange = vi.fn();
270281

271-
render(<DateInput {...props} onChange={onChange} />);
282+
render(<DateInput {...props} ref={ref} onChange={onChange} />);
272283

273284
await userEvent.type(screen.getByLabelText('Year'), '2017');
274285
await userEvent.type(screen.getByLabelText('Month'), '8');
275286
await userEvent.type(screen.getByLabelText('Day'), '28');
276287

277-
expect(onChange).toHaveBeenCalledWith('2017-08-28');
288+
expect(onChange).toHaveBeenCalled();
289+
expect(ref.current).toHaveValue('2017-08-28');
278290
});
279291

280292
it('should update the minimum and maximum input values as the user types', async () => {
@@ -304,26 +316,34 @@ describe('DateInput', () => {
304316
});
305317

306318
it('should allow users to delete the date', async () => {
319+
const ref = createRef<HTMLInputElement>();
307320
const onChange = vi.fn();
308321

309322
render(
310-
<DateInput {...props} defaultValue="2000-01-12" onChange={onChange} />,
323+
<DateInput
324+
{...props}
325+
ref={ref}
326+
defaultValue="2000-01-12"
327+
onChange={onChange}
328+
/>,
311329
);
312330

313331
await userEvent.click(screen.getByLabelText(/year/i));
314332
await userEvent.keyboard(Array(9).fill('{backspace}').join(''));
315333

334+
expect(ref.current).toHaveValue('');
316335
expect(screen.getByLabelText(/day/i)).toHaveValue('');
317336
expect(screen.getByLabelText(/month/i)).toHaveValue('');
318337
expect(screen.getByLabelText(/year/i)).toHaveValue('');
319338

320-
expect(onChange).toHaveBeenCalledWith('');
339+
expect(onChange).toHaveBeenCalled();
321340
});
322341

323342
it('should allow users to select a date on a calendar', async () => {
343+
const ref = createRef<HTMLInputElement>();
324344
const onChange = vi.fn();
325345

326-
render(<DateInput {...props} onChange={onChange} />);
346+
render(<DateInput {...props} ref={ref} onChange={onChange} />);
327347

328348
const openCalendarButton = screen.getByRole('button', {
329349
name: /change date/i,
@@ -336,14 +356,21 @@ describe('DateInput', () => {
336356
const dateButton = screen.getByRole('button', { name: /12/ });
337357
await userEvent.click(dateButton);
338358

339-
expect(onChange).toHaveBeenCalledWith('2000-01-12');
359+
expect(ref.current).toHaveValue('2000-01-12');
360+
expect(onChange).toHaveBeenCalled();
340361
});
341362

342363
it('should allow users to clear the date', async () => {
364+
const ref = createRef<HTMLInputElement>();
343365
const onChange = vi.fn();
344366

345367
render(
346-
<DateInput {...props} defaultValue="2000-01-12" onChange={onChange} />,
368+
<DateInput
369+
{...props}
370+
ref={ref}
371+
defaultValue="2000-01-12"
372+
onChange={onChange}
373+
/>,
347374
);
348375

349376
const openCalendarButton = screen.getByRole('button', {
@@ -357,7 +384,8 @@ describe('DateInput', () => {
357384
const clearButton = screen.getByRole('button', { name: /clear date/i });
358385
await userEvent.click(clearButton);
359386

360-
expect(onChange).toHaveBeenCalledWith('');
387+
expect(ref.current).toHaveValue('');
388+
expect(onChange).toHaveBeenCalled();
361389
});
362390

363391
describe('on narrow viewports', () => {
@@ -367,9 +395,10 @@ describe('DateInput', () => {
367395

368396
it('should allow users to select a date on a calendar', async () => {
369397
(useMedia as Mock).mockReturnValue(true);
398+
const ref = createRef<HTMLInputElement>();
370399
const onChange = vi.fn();
371400

372-
render(<DateInput {...props} onChange={onChange} />);
401+
render(<DateInput {...props} ref={ref} onChange={onChange} />);
373402

374403
const openCalendarButton = screen.getByRole('button', {
375404
name: /change date/i,
@@ -387,15 +416,18 @@ describe('DateInput', () => {
387416
const applyButton = screen.getByRole('button', { name: /apply/i });
388417
await userEvent.click(applyButton);
389418

390-
expect(onChange).toHaveBeenCalledWith('2000-01-12');
419+
expect(ref.current).toHaveValue('2000-01-12');
420+
expect(onChange).toHaveBeenCalled();
391421
});
392422

393423
it('should allow users to clear the date', async () => {
424+
const ref = createRef<HTMLInputElement>();
394425
const onChange = vi.fn();
395426

396427
render(
397428
<DateInput
398429
{...props}
430+
ref={ref}
399431
defaultValue="2000-01-12"
400432
onChange={onChange}
401433
/>,
@@ -412,15 +444,18 @@ describe('DateInput', () => {
412444
const clearButton = screen.getByRole('button', { name: /clear date/i });
413445
await userEvent.click(clearButton);
414446

415-
expect(onChange).toHaveBeenCalledWith('');
447+
expect(ref.current).toHaveValue('');
448+
expect(onChange).toHaveBeenCalled();
416449
});
417450

418451
it('should allow users to close the calendar dialog without selecting a date', async () => {
452+
const ref = createRef<HTMLInputElement>();
419453
const onChange = vi.fn();
420454

421455
render(
422456
<DateInput
423457
{...props}
458+
ref={ref}
424459
defaultValue="2000-01-12"
425460
onChange={onChange}
426461
/>,
@@ -438,6 +473,7 @@ describe('DateInput', () => {
438473
await userEvent.click(closeButton);
439474

440475
expect(calendarDialog).not.toBeVisible();
476+
expect(ref.current).toHaveValue('2000-01-12');
441477
expect(onChange).not.toHaveBeenCalled();
442478
});
443479
});

packages/circuit-ui/components/DateInput/DateInput.stories.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ const baseArgs = {
5050

5151
export const Base = (args: DateInputProps) => {
5252
const [value, setValue] = useState(args.defaultValue || args.value || '');
53-
return <DateInput {...args} value={value} onChange={setValue} />;
53+
return (
54+
<DateInput
55+
{...args}
56+
value={value}
57+
onChange={(event) => setValue(event.target.value)}
58+
/>
59+
);
5460
};
5561

5662
Base.args = baseArgs;

0 commit comments

Comments
 (0)