diff --git a/src/event-builder/__tests__/text.test.ts b/src/event-builder/__tests__/text.test.ts index 963c479ae..8970d4060 100644 --- a/src/event-builder/__tests__/text.test.ts +++ b/src/event-builder/__tests__/text.test.ts @@ -7,13 +7,14 @@ import { buildTextSelectionChangeEvent, } from '../text'; -test('buildTextChangeEvent returns event with text', () => { - const event = buildTextChangeEvent('Hello'); +test('buildTextChangeEvent returns event with text and selection', () => { + const event = buildTextChangeEvent('Hello', { start: 5, end: 5 }); expect(event.nativeEvent).toEqual({ text: 'Hello', target: 0, eventCount: 0, + selection: { start: 5, end: 5 }, }); }); diff --git a/src/event-builder/text.ts b/src/event-builder/text.ts index c8effcb74..0478f9817 100644 --- a/src/event-builder/text.ts +++ b/src/event-builder/text.ts @@ -6,10 +6,10 @@ import { baseSyntheticEvent } from './base'; * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` * - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}` */ -export function buildTextChangeEvent(text: string) { +export function buildTextChangeEvent(text: string, { start, end }: TextRange) { return { ...baseSyntheticEvent(), - nativeEvent: { text, target: 0, eventCount: 0 }, + nativeEvent: { text, target: 0, eventCount: 0, selection: { start, end } }, }; } diff --git a/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap b/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap index 5bf605ec3..3146bd80a 100644 --- a/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap +++ b/src/user-event/__tests__/__snapshots__/clear.test.tsx.snap @@ -65,6 +65,10 @@ exports[`clear() supports basic case: value: "Hello! 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 0, + "start": 0, + }, "target": 0, "text": "", }, @@ -202,6 +206,10 @@ exports[`clear() supports defaultValue prop: defaultValue: "Hello Default!" 1`] "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 0, + "start": 0, + }, "target": 0, "text": "", }, @@ -339,6 +347,10 @@ exports[`clear() supports multiline: value: "Hello World!\\nHow are you?" multil "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 0, + "start": 0, + }, "target": 0, "text": "", }, diff --git a/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap b/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap index aa62acb2e..619cfd17e 100644 --- a/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap +++ b/src/user-event/__tests__/__snapshots__/paste.test.tsx.snap @@ -48,6 +48,10 @@ exports[`paste() paste on empty text input 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 3, + "start": 3, + }, "target": 0, "text": "Hi!", }, @@ -168,6 +172,10 @@ exports[`paste() paste on filled text input 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 3, + "start": 3, + }, "target": 0, "text": "Hi!", }, @@ -288,6 +296,10 @@ exports[`paste() supports defaultValue prop: defaultValue: "Hello Default!" 1`] "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 3, + "start": 3, + }, "target": 0, "text": "Hi!", }, @@ -408,6 +420,10 @@ exports[`paste() supports multiline: value: "Hello World!\\nHow are you?" multil "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 3, + "start": 3, + }, "target": 0, "text": "Hi!", }, diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index 53f06cb45..ca888c87d 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -42,10 +42,10 @@ export async function paste( // 3. Paste the text nativeState.valueForInstance.set(instance, text); - await dispatchEvent(instance, 'change', buildTextChangeEvent(text)); - await dispatchEvent(instance, 'changeText', text); const rangeAfter = { start: text.length, end: text.length }; + await dispatchEvent(instance, 'change', buildTextChangeEvent(text, rangeAfter)); + await dispatchEvent(instance, 'changeText', text); await dispatchEvent(instance, 'selectionChange', buildTextSelectionChangeEvent(rangeAfter)); // According to the docs only multiline TextInput emits contentSizeChange event diff --git a/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap index 3d0383fc4..0e39d81ef 100644 --- a/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap +++ b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap @@ -99,6 +99,10 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 1, + "start": 1, + }, "target": 0, "text": "W", }, @@ -159,6 +163,10 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 2, + "start": 2, + }, "target": 0, "text": "Wo", }, @@ -219,6 +227,10 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 3, + "start": 3, + }, "target": 0, "text": "Wow", }, @@ -390,6 +402,10 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 4, + "start": 4, + }, "target": 0, "text": "XXXA", }, @@ -450,6 +466,10 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 4, + "start": 4, + }, "target": 0, "text": "XXXB", }, @@ -510,6 +530,10 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 4, + "start": 4, + }, "target": 0, "text": "XXXC", }, diff --git a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap index a7830825f..aee07c7ee 100644 --- a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap +++ b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap @@ -99,6 +99,10 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 2, + "start": 2, + }, "target": 0, "text": "xx", }, @@ -159,6 +163,10 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 3, + "start": 3, + }, "target": 0, "text": "xxa", }, @@ -330,6 +338,10 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 1, + "start": 1, + }, "target": 0, "text": "a", }, @@ -390,6 +402,10 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 2, + "start": 2, + }, "target": 0, "text": "ab", }, @@ -450,6 +466,10 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 3, + "start": 3, + }, "target": 0, "text": "abc", }, @@ -621,6 +641,10 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 4, + "start": 4, + }, "target": 0, "text": "xxxa", }, @@ -681,6 +705,10 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 5, + "start": 5, + }, "target": 0, "text": "xxxab", }, @@ -852,6 +880,10 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 1, + "start": 1, + }, "target": 0, "text": " ", @@ -935,6 +967,10 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` "isPropagationStopped": [Function], "nativeEvent": { "eventCount": 0, + "selection": { + "end": 2, + "start": 2, + }, "target": 0, "text": " diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index a96fb7512..23f0405df 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -67,6 +67,56 @@ describe('type()', () => { expect(events).toMatchSnapshot('input: "abc"'); }); + it('includes caret selection in the change event nativeEvent', async () => { + const { events } = await renderTextInputWithToolkit(); + + const user = userEvent.setup(); + await user.type(screen.getByTestId('input'), 'Hello'); + + const changeEvents = events.filter((event) => event.name === 'change'); + const lastChangeEvent = changeEvents[changeEvents.length - 1]; + + // React Native (>=0.85) reports the caret position via `onChange`'s + // `nativeEvent.selection` on all platforms. + expect(lastChangeEvent.payload.nativeEvent.selection).toEqual({ + start: 'Hello'.length, + end: 'Hello'.length, + }); + }); + + it('drives components that read the caret from change event (regression)', async () => { + const onChange = jest.fn(); + + function MaskedInput() { + const [value, setValue] = React.useState(''); + return ( + { + // RN (>=0.85) reports the caret position via `nativeEvent.selection` + // on all platforms; the bundled RN types may not declare it yet. + const { selection, text } = event.nativeEvent as typeof event.nativeEvent & { + selection: { start: number; end: number }; + }; + onChange(selection); + // Mimic an input-mask library that relies on the caret position + // reported inside the `change` event to update the value. + setValue(text.slice(0, selection.end)); + }} + /> + ); + } + + await render(); + + const user = userEvent.setup(); + await user.type(screen.getByTestId('masked-input'), 'abc'); + + expect(onChange).toHaveBeenLastCalledWith({ start: 3, end: 3 }); + expect(screen.getByTestId('masked-input').props.value).toBe('abc'); + }); + it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); const { events } = await renderTextInputWithToolkit(); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index f0e1a05c8..1672c9bf8 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -108,13 +108,14 @@ export async function emitTypingEvents( } nativeState.valueForInstance.set(instance, text); - await dispatchEvent(instance, 'change', buildTextChangeEvent(text)); - await dispatchEvent(instance, 'changeText', text); const selectionRange = { start: text.length, end: text.length, }; + + await dispatchEvent(instance, 'change', buildTextChangeEvent(text, selectionRange)); + await dispatchEvent(instance, 'changeText', text); await dispatchEvent(instance, 'selectionChange', buildTextSelectionChangeEvent(selectionRange)); // According to the docs only multiline TextInput emits contentSizeChange event