diff --git a/change/react-native-windows-edbf9be7-245f-42c4-9644-96e44ed5d3d5.json b/change/react-native-windows-edbf9be7-245f-42c4-9644-96e44ed5d3d5.json new file mode 100644 index 00000000000..77ddf766794 --- /dev/null +++ b/change/react-native-windows-edbf9be7-245f-42c4-9644-96e44ed5d3d5.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "add fabric onKeyPress to textinput", + "packageName": "react-native-windows", + "email": "tatianakapos@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/@react-native-windows/tester/src/js/examples-win/LegacyTests/TextInputTestPage.tsx b/packages/@react-native-windows/tester/src/js/examples-win/LegacyTests/TextInputTestPage.tsx index 7875884689e..739f3f49458 100644 --- a/packages/@react-native-windows/tester/src/js/examples-win/LegacyTests/TextInputTestPage.tsx +++ b/packages/@react-native-windows/tester/src/js/examples-win/LegacyTests/TextInputTestPage.tsx @@ -69,7 +69,9 @@ export class TextInputTestPage extends React.Component< placeholder="autoCapitalize" autoCapitalize="characters" /> - {this.state.log} + + {this.state.log} + ); } diff --git a/packages/e2e-test-app-fabric/test/LegacyTextInputTest.test.ts b/packages/e2e-test-app-fabric/test/LegacyTextInputTest.test.ts new file mode 100644 index 00000000000..da49f5c3b10 --- /dev/null +++ b/packages/e2e-test-app-fabric/test/LegacyTextInputTest.test.ts @@ -0,0 +1,170 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ +import {app} from '@react-native-windows/automation'; +import {goToComponentExample} from './RNTesterNavigation'; +import {verifyNoErrorLogs} from './Helpers'; +import {dumpVisualTree} from '@react-native-windows/automation-commands'; + +beforeAll(async () => { + // If window is partially offscreen, tests will fail to click on certain elements + await app.setWindowPosition(0, 0); + await app.setWindowSize(1000, 1250); + await goToComponentExample('LegacyTextInputTest'); +}); + +afterEach(async () => { + await verifyNoErrorLogs(); +}); + +describe('LegacyTextInputTest', () => { + test('Click on TextInput to focus', async () => { + const textInput = await textInputField(); + await textInput.click(); + await assertLogContains('onFocus'); + }); + + test('Click on multiline TextInput to move focus away from single line TextInput', async () => { + const textInput = await multiLineTextInputField(); + await textInput.click(); + await assertLogContains('onBlur'); + }); + test('Type abc on TextInput', async () => { + const textInput = await textInputField(); + await textInput.setValue('abc'); + expect(await textInput.getText()).toBe('abc'); + await assertLogContains('onKeyPress key: c'); + }); + test('Type def on TextInput', async () => { + const textInput = await textInputField(); + await app.waitUntil( + async () => { + await textInput.setValue('def'); + return (await textInput.getText()) === 'def'; + }, + { + interval: 1500, + timeout: 5000, + timeoutMsg: `Unable to enter correct text.`, + }, + ); + expect(await textInput.getText()).toBe('def'); + }); + /* Issue to enable these tests: #12778 + test('Type hello world on autoCap TextInput', async () => { + const textInput = await autoCapsTextInputField(); + await textInput.setValue('def'); + expect(await textInput.getText()).toBe('DEF'); + + await textInput.setValue('hello world'); + expect(await textInput.getText()).toBe('HELLO WORLD'); + }); + */ + test('Type abc on multiline TextInput then press Enter key', async () => { + const textInput = await textInputField(); + await textInput.setValue('abc'); + await textInput.addValue('Enter'); + + await assertLogContains('onSubmitEditing text: abc'); + }); + test('Type abc on multiline TextInput', async () => { + const textInput = await multiLineTextInputField(); + await textInput.setValue('abc'); + + expect(await textInput.getText()).toBe('abc'); + }); + + test('Enter key then type def on multiline TextInput', async () => { + const textInput = await multiLineTextInputField(); + + await textInput.addValue('End'); + await textInput.addValue('Enter'); + await textInput.addValue('def'); + + expect(await textInput.getText()).toBe('abc\rdef'); + }); + + test('TextInput onChange before onSelectionChange', async () => { + const textInput = await textInputField(); + await textInput.setValue('a'); + await assertLogContains('onChange text: a\nonSelectionChange range: 1,1'); + /* Issue to enable these tests: #12778 + await assertLogContainsInOrder([ + 'onChange text: a', + 'onSelectionChange range: 1,1', + ]); + */ + }); +}); + +async function textInputField() { + const component = await app.findElementByTestID('textinput-field'); + await component.waitForDisplayed({timeout: 5000}); + return component; +} + +/* Issue to enable these tests: #12778 +async function autoCapsTextInputField() { + const component = await app.findElementByTestID('auto-caps-textinput-field'); + await component.waitForDisplayed({timeout: 5000}); + return component; +} +*/ + +async function multiLineTextInputField() { + const component = await app.findElementByTestID('multi-line-textinput-field'); + await component.waitForDisplayed({timeout: 5000}); + return component; +} + +async function assertLogContains(_text: string) { + const textLogComponent = await app.findElementByTestID('textinput-log'); + await textLogComponent.waitForDisplayed({timeout: 5000}); + + const dump = await dumpVisualTree('textinput-log'); + expect(dump).toMatchSnapshot(); + /* + await app.waitUntil( + async () => { + const loggedText = await textLogComponent.getText(); + return loggedText.split('\n').includes(text); + }, + { + timeoutMsg: `"${await textLogComponent.getValue()}" "${await textLogComponent.getText()}" did not contain "${text}"`, + }, + ); + */ +} + +/* +async function assertLogContainsInOrder(expectedLines: string[]) { + const textLogComponent = await app.findElementByTestID('textinput-log'); + await textLogComponent.waitForDisplayed({timeout: 5000}); + + await app.waitUntil( + async () => { + const loggedText = await textLogComponent.getText(); + const actualLines = loggedText.split('\n'); + let previousIndex = Number.MAX_VALUE; + for (const line of expectedLines) { + const index = actualLines.findIndex(l => l === line); + if (index === -1 || index > previousIndex) { + return false; + } + + previousIndex = index; + } + + return true; + }, + { + timeoutMsg: `"${await textLogComponent.getText()}" did not contain lines "${expectedLines.join( + ', ', + )}"`, + }, + ); +} +*/ diff --git a/packages/e2e-test-app-fabric/test/__snapshots__/LegacyTextInputTest.test.ts.snap b/packages/e2e-test-app-fabric/test/__snapshots__/LegacyTextInputTest.test.ts.snap new file mode 100644 index 00000000000..74bd9c140f5 --- /dev/null +++ b/packages/e2e-test-app-fabric/test/__snapshots__/LegacyTextInputTest.test.ts.snap @@ -0,0 +1,242 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LegacyTextInputTest Click on TextInput to focus 1`] = ` +{ + "Automation Tree": { + "AutomationId": "textinput-log", + "ControlType": 50020, + "HelpText": "", + "IsEnabled": true, + "IsKeyboardFocusable": false, + "LocalizedControlType": "text", + "Name": "onFocus +", + }, + "Visual Tree": { + "Comment": "textinput-log", + "Offset": [ + 0, + 0, + 0, + ], + "Opacity": 1, + "Size": [ + 998, + 38, + ], + "Visual Type": "SpriteVisual", + }, +} +`; + +exports[`LegacyTextInputTest Click on multiline TextInput to move focus away from single line TextInput 1`] = ` +{ + "Automation Tree": { + "AutomationId": "textinput-log", + "ControlType": 50020, + "HelpText": "", + "IsEnabled": true, + "IsKeyboardFocusable": false, + "LocalizedControlType": "text", + "Name": "onBlur +onFocus +", + }, + "Visual Tree": { + "Comment": "textinput-log", + "Offset": [ + 0, + 0, + 0, + ], + "Opacity": 1, + "Size": [ + 998, + 57, + ], + "Visual Type": "SpriteVisual", + }, +} +`; + +exports[`LegacyTextInputTest TextInput onChange before onSelectionChange 1`] = ` +{ + "Automation Tree": { + "AutomationId": "textinput-log", + "ControlType": 50020, + "HelpText": "", + "IsEnabled": true, + "IsKeyboardFocusable": false, + "LocalizedControlType": "text", + "Name": "onChange text: a +onSelectionChange range: 1,1 +onKeyPress key: a +onChange text: +onSelectionChange range: 0,0 +onFocus +onBlur +onSubmitEditing text: abc +onChange text: abc +onSelectionChange range: 3,3 +onKeyPress key: c +onChange text: ab +onSelectionChange range: 2,2 +onKeyPress key: b +onChange text: a +onSelectionChange range: 1,1 +onKeyPress key: a +onChange text: +onSelectionChange range: 0,0 +onChange text: def +onSelectionChange range: 3,3 +onKeyPress key: f +onChange text: de +onSelectionChange range: 2,2 +onKeyPress key: e +onChange text: d +onSelectionChange range: 1,1 +onKeyPress key: d +onChange text: +onSelectionChange range: 0,0 +onChange text: abc +onSelectionChange range: 3,3 +onKeyPress key: c +onChange text: ab +onSelectionChange range: 2,2 +onKeyPress key: b +onSelectionChange range: 1,1 +onChange text: a +onSelectionChange range: 0,0 +onChange text: a +onSelectionChange range: 1,1 +onKeyPress key: a +onFocus +onBlur +onFocus +", + }, + "Visual Tree": { + "Comment": "textinput-log", + "Offset": [ + 0, + 0, + 0, + ], + "Opacity": 1, + "Size": [ + 998, + 858, + ], + "Visual Type": "SpriteVisual", + }, +} +`; + +exports[`LegacyTextInputTest Type abc on TextInput 1`] = ` +{ + "Automation Tree": { + "AutomationId": "textinput-log", + "ControlType": 50020, + "HelpText": "", + "IsEnabled": true, + "IsKeyboardFocusable": false, + "LocalizedControlType": "text", + "Name": "onChange text: abc +onSelectionChange range: 3,3 +onKeyPress key: c +onChange text: ab +onSelectionChange range: 2,2 +onKeyPress key: b +onSelectionChange range: 1,1 +onChange text: a +onSelectionChange range: 0,0 +onChange text: a +onSelectionChange range: 1,1 +onKeyPress key: a +onFocus +onBlur +onFocus +", + }, + "Visual Tree": { + "Comment": "textinput-log", + "Offset": [ + 0, + 0, + 0, + ], + "Opacity": 1, + "Size": [ + 998, + 299, + ], + "Visual Type": "SpriteVisual", + }, +} +`; + +exports[`LegacyTextInputTest Type abc on multiline TextInput then press Enter key 1`] = ` +{ + "Automation Tree": { + "AutomationId": "textinput-log", + "ControlType": 50020, + "HelpText": "", + "IsEnabled": true, + "IsKeyboardFocusable": false, + "LocalizedControlType": "text", + "Name": "onSubmitEditing text: abc +onChange text: abc +onSelectionChange range: 3,3 +onKeyPress key: c +onChange text: ab +onSelectionChange range: 2,2 +onKeyPress key: b +onChange text: a +onSelectionChange range: 1,1 +onKeyPress key: a +onChange text: +onSelectionChange range: 0,0 +onChange text: def +onSelectionChange range: 3,3 +onKeyPress key: f +onChange text: de +onSelectionChange range: 2,2 +onKeyPress key: e +onChange text: d +onSelectionChange range: 1,1 +onKeyPress key: d +onChange text: +onSelectionChange range: 0,0 +onChange text: abc +onSelectionChange range: 3,3 +onKeyPress key: c +onChange text: ab +onSelectionChange range: 2,2 +onKeyPress key: b +onSelectionChange range: 1,1 +onChange text: a +onSelectionChange range: 0,0 +onChange text: a +onSelectionChange range: 1,1 +onKeyPress key: a +onFocus +onBlur +onFocus +", + }, + "Visual Tree": { + "Comment": "textinput-log", + "Offset": [ + 0, + 0, + 0, + ], + "Opacity": 1, + "Size": [ + 998, + 727, + ], + "Visual Type": "SpriteVisual", + }, +} +`; diff --git a/packages/e2e-test-app-fabric/test/__snapshots__/snapshotPages.test.js.snap b/packages/e2e-test-app-fabric/test/__snapshots__/snapshotPages.test.js.snap index 7965ea5627d..d2ee275e9c7 100644 --- a/packages/e2e-test-app-fabric/test/__snapshots__/snapshotPages.test.js.snap +++ b/packages/e2e-test-app-fabric/test/__snapshots__/snapshotPages.test.js.snap @@ -29713,6 +29713,7 @@ exports[`snapshotAllPages LegacyTextInputTest 1`] = ` testID="auto-caps-textinput-field" /> <Log Start> diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp index 4af9d1fcee8..2454fe78f5f 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp @@ -20,6 +20,8 @@ #include "WindowsTextInputState.h" #include "guid/msoGuid.h" +#include + // convert a BSTR to a std::string. std::string &BstrToStdString(const BSTR bstr, std::string &dst, int cp = CP_UTF8) { if (!bstr) { @@ -865,6 +867,22 @@ void WindowsTextInputComponentView::OnCharacterReceived( return; } + // convert keyCode to std::string + wchar_t key[2] = L" "; + key[0] = static_cast(args.KeyCode()); + std::string keyString = ::Microsoft::Common::Unicode::Utf16ToUtf8(key, 1); + // Call onKeyPress event + auto emitter = std::static_pointer_cast(m_eventEmitter); + facebook::react::WindowsTextInputEventEmitter::OnKeyPress onKeyPressArgs; + if (keyString.compare("\r") == 0) { + onKeyPressArgs.key = "Enter"; + } else if (keyString.compare("\b") == 0) { + onKeyPressArgs.key = "Backspace"; + } else { + onKeyPressArgs.key = keyString; + } + emitter->onKeyPress(onKeyPressArgs); + WPARAM wParam = static_cast(args.KeyCode()); LPARAM lParam = 0; lParam = args.KeyStatus().RepeatCount; // bits 0-15 @@ -1186,6 +1204,7 @@ void WindowsTextInputComponentView::OnTextUpdated() noexcept { m_state->updateState(std::move(data)); if (m_eventEmitter && !m_comingFromJS) { + // call onChange event auto emitter = std::static_pointer_cast(m_eventEmitter); facebook::react::WindowsTextInputEventEmitter::OnChange onChangeArgs; onChangeArgs.text = GetTextFromRichEdit(); diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputEventEmitter.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputEventEmitter.cpp index 628e8fe4b79..edd0d55ab0b 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputEventEmitter.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputEventEmitter.cpp @@ -38,4 +38,12 @@ void WindowsTextInputEventEmitter::onSubmitEditing(OnSubmitEditing event) const }); } +void WindowsTextInputEventEmitter::onKeyPress(OnKeyPress event) const { + dispatchEvent("textInputKeyPress", [event = std::move(event)](jsi::Runtime &runtime) { + auto payload = jsi::Object(runtime); + payload.setProperty(runtime, "key", event.key); + return payload; + }); +} + } // namespace facebook::react diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputEventEmitter.h b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputEventEmitter.h index e35a4ab47ea..900fe45dd3f 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputEventEmitter.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputEventEmitter.h @@ -32,9 +32,14 @@ class WindowsTextInputEventEmitter : public ViewEventEmitter { std::string text; }; + struct OnKeyPress { + std::string key; + }; + void onChange(OnChange value) const; void onSelectionChange(const OnSelectionChange &value) const; void onSubmitEditing(OnSubmitEditing value) const; + void onKeyPress(OnKeyPress value) const; }; } // namespace facebook::react