Skip to content

Commit e819f77

Browse files
authored
fix(user-event): include selection in TextInput change event (#1919)
1 parent e5e8e37 commit e819f77

9 files changed

Lines changed: 148 additions & 8 deletions

File tree

src/event-builder/__tests__/text.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import {
77
buildTextSelectionChangeEvent,
88
} from '../text';
99

10-
test('buildTextChangeEvent returns event with text', () => {
11-
const event = buildTextChangeEvent('Hello');
10+
test('buildTextChangeEvent returns event with text and selection', () => {
11+
const event = buildTextChangeEvent('Hello', { start: 5, end: 5 });
1212

1313
expect(event.nativeEvent).toEqual({
1414
text: 'Hello',
1515
target: 0,
1616
eventCount: 0,
17+
selection: { start: 5, end: 5 },
1718
});
1819
});
1920

src/event-builder/text.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { baseSyntheticEvent } from './base';
66
* - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}`
77
* - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}`
88
*/
9-
export function buildTextChangeEvent(text: string) {
9+
export function buildTextChangeEvent(text: string, { start, end }: TextRange) {
1010
return {
1111
...baseSyntheticEvent(),
12-
nativeEvent: { text, target: 0, eventCount: 0 },
12+
nativeEvent: { text, target: 0, eventCount: 0, selection: { start, end } },
1313
};
1414
}
1515

src/user-event/__tests__/__snapshots__/clear.test.tsx.snap

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ exports[`clear() supports basic case: value: "Hello! 1`] = `
6565
"isPropagationStopped": [Function],
6666
"nativeEvent": {
6767
"eventCount": 0,
68+
"selection": {
69+
"end": 0,
70+
"start": 0,
71+
},
6872
"target": 0,
6973
"text": "",
7074
},
@@ -202,6 +206,10 @@ exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`]
202206
"isPropagationStopped": [Function],
203207
"nativeEvent": {
204208
"eventCount": 0,
209+
"selection": {
210+
"end": 0,
211+
"start": 0,
212+
},
205213
"target": 0,
206214
"text": "",
207215
},
@@ -339,6 +347,10 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil
339347
"isPropagationStopped": [Function],
340348
"nativeEvent": {
341349
"eventCount": 0,
350+
"selection": {
351+
"end": 0,
352+
"start": 0,
353+
},
342354
"target": 0,
343355
"text": "",
344356
},

src/user-event/__tests__/__snapshots__/paste.test.tsx.snap

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ exports[`paste() paste on empty text input 1`] = `
4848
"isPropagationStopped": [Function],
4949
"nativeEvent": {
5050
"eventCount": 0,
51+
"selection": {
52+
"end": 3,
53+
"start": 3,
54+
},
5155
"target": 0,
5256
"text": "Hi!",
5357
},
@@ -168,6 +172,10 @@ exports[`paste() paste on filled text input 1`] = `
168172
"isPropagationStopped": [Function],
169173
"nativeEvent": {
170174
"eventCount": 0,
175+
"selection": {
176+
"end": 3,
177+
"start": 3,
178+
},
171179
"target": 0,
172180
"text": "Hi!",
173181
},
@@ -288,6 +296,10 @@ exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`]
288296
"isPropagationStopped": [Function],
289297
"nativeEvent": {
290298
"eventCount": 0,
299+
"selection": {
300+
"end": 3,
301+
"start": 3,
302+
},
291303
"target": 0,
292304
"text": "Hi!",
293305
},
@@ -408,6 +420,10 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil
408420
"isPropagationStopped": [Function],
409421
"nativeEvent": {
410422
"eventCount": 0,
423+
"selection": {
424+
"end": 3,
425+
"start": 3,
426+
},
411427
"target": 0,
412428
"text": "Hi!",
413429
},

src/user-event/paste.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ export async function paste(
4242

4343
// 3. Paste the text
4444
nativeState.valueForInstance.set(instance, text);
45-
await dispatchEvent(instance, 'change', buildTextChangeEvent(text));
46-
await dispatchEvent(instance, 'changeText', text);
4745

4846
const rangeAfter = { start: text.length, end: text.length };
47+
await dispatchEvent(instance, 'change', buildTextChangeEvent(text, rangeAfter));
48+
await dispatchEvent(instance, 'changeText', text);
4949
await dispatchEvent(instance, 'selectionChange', buildTextSelectionChangeEvent(rangeAfter));
5050

5151
// According to the docs only multiline TextInput emits contentSizeChange event

src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
9999
"isPropagationStopped": [Function],
100100
"nativeEvent": {
101101
"eventCount": 0,
102+
"selection": {
103+
"end": 1,
104+
"start": 1,
105+
},
102106
"target": 0,
103107
"text": "W",
104108
},
@@ -159,6 +163,10 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
159163
"isPropagationStopped": [Function],
160164
"nativeEvent": {
161165
"eventCount": 0,
166+
"selection": {
167+
"end": 2,
168+
"start": 2,
169+
},
162170
"target": 0,
163171
"text": "Wo",
164172
},
@@ -219,6 +227,10 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = `
219227
"isPropagationStopped": [Function],
220228
"nativeEvent": {
221229
"eventCount": 0,
230+
"selection": {
231+
"end": 3,
232+
"start": 3,
233+
},
222234
"target": 0,
223235
"text": "Wow",
224236
},
@@ -390,6 +402,10 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
390402
"isPropagationStopped": [Function],
391403
"nativeEvent": {
392404
"eventCount": 0,
405+
"selection": {
406+
"end": 4,
407+
"start": 4,
408+
},
393409
"target": 0,
394410
"text": "XXXA",
395411
},
@@ -450,6 +466,10 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
450466
"isPropagationStopped": [Function],
451467
"nativeEvent": {
452468
"eventCount": 0,
469+
"selection": {
470+
"end": 4,
471+
"start": 4,
472+
},
453473
"target": 0,
454474
"text": "XXXB",
455475
},
@@ -510,6 +530,10 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC"
510530
"isPropagationStopped": [Function],
511531
"nativeEvent": {
512532
"eventCount": 0,
533+
"selection": {
534+
"end": 4,
535+
"start": 4,
536+
},
513537
"target": 0,
514538
"text": "XXXC",
515539
},

src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
9999
"isPropagationStopped": [Function],
100100
"nativeEvent": {
101101
"eventCount": 0,
102+
"selection": {
103+
"end": 2,
104+
"start": 2,
105+
},
102106
"target": 0,
103107
"text": "xx",
104108
},
@@ -159,6 +163,10 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1
159163
"isPropagationStopped": [Function],
160164
"nativeEvent": {
161165
"eventCount": 0,
166+
"selection": {
167+
"end": 3,
168+
"start": 3,
169+
},
162170
"target": 0,
163171
"text": "xxa",
164172
},
@@ -330,6 +338,10 @@ exports[`type() supports basic case: input: "abc" 1`] = `
330338
"isPropagationStopped": [Function],
331339
"nativeEvent": {
332340
"eventCount": 0,
341+
"selection": {
342+
"end": 1,
343+
"start": 1,
344+
},
333345
"target": 0,
334346
"text": "a",
335347
},
@@ -390,6 +402,10 @@ exports[`type() supports basic case: input: "abc" 1`] = `
390402
"isPropagationStopped": [Function],
391403
"nativeEvent": {
392404
"eventCount": 0,
405+
"selection": {
406+
"end": 2,
407+
"start": 2,
408+
},
393409
"target": 0,
394410
"text": "ab",
395411
},
@@ -450,6 +466,10 @@ exports[`type() supports basic case: input: "abc" 1`] = `
450466
"isPropagationStopped": [Function],
451467
"nativeEvent": {
452468
"eventCount": 0,
469+
"selection": {
470+
"end": 3,
471+
"start": 3,
472+
},
453473
"target": 0,
454474
"text": "abc",
455475
},
@@ -621,6 +641,10 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
621641
"isPropagationStopped": [Function],
622642
"nativeEvent": {
623643
"eventCount": 0,
644+
"selection": {
645+
"end": 4,
646+
"start": 4,
647+
},
624648
"target": 0,
625649
"text": "xxxa",
626650
},
@@ -681,6 +705,10 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`]
681705
"isPropagationStopped": [Function],
682706
"nativeEvent": {
683707
"eventCount": 0,
708+
"selection": {
709+
"end": 5,
710+
"start": 5,
711+
},
684712
"target": 0,
685713
"text": "xxxab",
686714
},
@@ -852,6 +880,10 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
852880
"isPropagationStopped": [Function],
853881
"nativeEvent": {
854882
"eventCount": 0,
883+
"selection": {
884+
"end": 1,
885+
"start": 1,
886+
},
855887
"target": 0,
856888
"text": "
857889
",
@@ -935,6 +967,10 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = `
935967
"isPropagationStopped": [Function],
936968
"nativeEvent": {
937969
"eventCount": 0,
970+
"selection": {
971+
"end": 2,
972+
"start": 2,
973+
},
938974
"target": 0,
939975
"text": "
940976

src/user-event/type/__tests__/type.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,56 @@ describe('type()', () => {
6767
expect(events).toMatchSnapshot('input: "abc"');
6868
});
6969

70+
it('includes caret selection in the change event nativeEvent', async () => {
71+
const { events } = await renderTextInputWithToolkit();
72+
73+
const user = userEvent.setup();
74+
await user.type(screen.getByTestId('input'), 'Hello');
75+
76+
const changeEvents = events.filter((event) => event.name === 'change');
77+
const lastChangeEvent = changeEvents[changeEvents.length - 1];
78+
79+
// React Native (>=0.85) reports the caret position via `onChange`'s
80+
// `nativeEvent.selection` on all platforms.
81+
expect(lastChangeEvent.payload.nativeEvent.selection).toEqual({
82+
start: 'Hello'.length,
83+
end: 'Hello'.length,
84+
});
85+
});
86+
87+
it('drives components that read the caret from change event (regression)', async () => {
88+
const onChange = jest.fn();
89+
90+
function MaskedInput() {
91+
const [value, setValue] = React.useState('');
92+
return (
93+
<TextInput
94+
testID="masked-input"
95+
value={value}
96+
onChange={(event) => {
97+
// RN (>=0.85) reports the caret position via `nativeEvent.selection`
98+
// on all platforms; the bundled RN types may not declare it yet.
99+
const { selection, text } = event.nativeEvent as typeof event.nativeEvent & {
100+
selection: { start: number; end: number };
101+
};
102+
onChange(selection);
103+
// Mimic an input-mask library that relies on the caret position
104+
// reported inside the `change` event to update the value.
105+
setValue(text.slice(0, selection.end));
106+
}}
107+
/>
108+
);
109+
}
110+
111+
await render(<MaskedInput />);
112+
113+
const user = userEvent.setup();
114+
await user.type(screen.getByTestId('masked-input'), 'abc');
115+
116+
expect(onChange).toHaveBeenLastCalledWith({ start: 3, end: 3 });
117+
expect(screen.getByTestId('masked-input').props.value).toBe('abc');
118+
});
119+
70120
it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => {
71121
jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' });
72122
const { events } = await renderTextInputWithToolkit();

src/user-event/type/type.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,14 @@ export async function emitTypingEvents(
108108
}
109109

110110
nativeState.valueForInstance.set(instance, text);
111-
await dispatchEvent(instance, 'change', buildTextChangeEvent(text));
112-
await dispatchEvent(instance, 'changeText', text);
113111

114112
const selectionRange = {
115113
start: text.length,
116114
end: text.length,
117115
};
116+
117+
await dispatchEvent(instance, 'change', buildTextChangeEvent(text, selectionRange));
118+
await dispatchEvent(instance, 'changeText', text);
118119
await dispatchEvent(instance, 'selectionChange', buildTextSelectionChangeEvent(selectionRange));
119120

120121
// According to the docs only multiline TextInput emits contentSizeChange event

0 commit comments

Comments
 (0)