diff --git a/change/react-native-windows-52161979-b14c-4bbe-b376-b292bbd03a4a.json b/change/react-native-windows-52161979-b14c-4bbe-b376-b292bbd03a4a.json new file mode 100644 index 00000000000..0fcfae8520b --- /dev/null +++ b/change/react-native-windows-52161979-b14c-4bbe-b376-b292bbd03a4a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "overflow: hidden should prevent hittesting", + "packageName": "react-native-windows", + "email": "30809111+acoates-ms@users.noreply.github.com", + "dependentChangeType": "patch" +} \ No newline at end of file diff --git a/change/react-native-windows-c4c264df-7619-4559-b43f-9761d5d68d63.json b/change/react-native-windows-c4c264df-7619-4559-b43f-9761d5d68d63.json index a8131090345..c5921ba4196 100644 --- a/change/react-native-windows-c4c264df-7619-4559-b43f-9761d5d68d63.json +++ b/change/react-native-windows-c4c264df-7619-4559-b43f-9761d5d68d63.json @@ -1,7 +1,7 @@ { - "type": "none", + "type": "patch", "comment": "Implement button property", "packageName": "react-native-windows", "email": "hmalothu@microsoft.com", "dependentChangeType": "none" -} +} \ No newline at end of file diff --git a/change/react-native-windows-edabe4dc-3296-42b9-952c-b3e3f7dc08d0.json b/change/react-native-windows-edabe4dc-3296-42b9-952c-b3e3f7dc08d0.json new file mode 100644 index 00000000000..d6ba87986e9 --- /dev/null +++ b/change/react-native-windows-edabe4dc-3296-42b9-952c-b3e3f7dc08d0.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Only show focus visuals when using keyboard to move focus", + "packageName": "react-native-windows", + "email": "30809111+acoates-ms@users.noreply.github.com", + "dependentChangeType": "patch" +} \ No newline at end of file diff --git a/packages/@react-native-windows/tester/src/js/examples-win/HitTest/HitTestExample.js b/packages/@react-native-windows/tester/src/js/examples-win/HitTest/HitTestExample.js new file mode 100644 index 00000000000..c4550934b32 --- /dev/null +++ b/packages/@react-native-windows/tester/src/js/examples-win/HitTest/HitTestExample.js @@ -0,0 +1,105 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +'use strict'; + +import React from 'react'; +import {View, Text, Pressable} from 'react-native'; + +function HitTestWithOverflowVisibile() { + const [bgColor, setBgColor] = React.useState('red'); + + return ( + + + Clicking the pressable should work even if it is outside the bounds of + its parent. + + + + { + setBgColor(bgColor === 'red' ? 'green' : 'red'); + }}> + Press me + + + + + ); +} + +function HitTestWithOverflowHidden() { + const [bgColor, setBgColor] = React.useState('red'); + return ( + + + Clicking within the visible view will trigger the pressable. Clicking + outside the bounds, where the pressable extends but is clipped by its + parent overflow:hidden, should not trigger the pressable. + + + + { + setBgColor(bgColor === 'red' ? 'green' : 'red'); + }}> + Press me + + + + + ); +} + +exports.displayName = 'HitTestExample'; +exports.title = 'Hit Testing'; +exports.category = 'Basic'; +exports.description = 'Test that overflow hidden affect hit testing'; +exports.examples = [ + { + title: 'overflow visible affects hit testing\n', + render: function () { + return ; + }, + }, + { + title: 'overflow hidden affects hit testing\n', + render: function () { + return ; + }, + }, +]; diff --git a/packages/@react-native-windows/tester/src/js/utils/RNTesterList.windows.js b/packages/@react-native-windows/tester/src/js/utils/RNTesterList.windows.js index f581f42e88a..a0c915a3887 100644 --- a/packages/@react-native-windows/tester/src/js/utils/RNTesterList.windows.js +++ b/packages/@react-native-windows/tester/src/js/utils/RNTesterList.windows.js @@ -213,6 +213,11 @@ const Components: Array = [ key: 'LegacyTextHitTestTest', module: require('../examples-win/LegacyTests/TextHitTestPage'), }, + { + key: 'HitTestExample', + category: 'UI', + module: require('../examples-win/HitTest/HitTestExample'), + }, { key: 'PerformanceComparisonExample', category: 'Basic', diff --git a/packages/e2e-test-app-fabric/test/HitTest.test.ts b/packages/e2e-test-app-fabric/test/HitTest.test.ts new file mode 100644 index 00000000000..042943091be --- /dev/null +++ b/packages/e2e-test-app-fabric/test/HitTest.test.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import {app} from '@react-native-windows/automation'; +import {dumpVisualTree} from '@react-native-windows/automation-commands'; +import {goToComponentExample} from './RNTesterNavigation'; +import {verifyNoErrorLogs} from './Helpers'; + +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('Hit Testing'); +}); + +afterEach(async () => { + await verifyNoErrorLogs(); +}); + +async function verifyElementAccessibiltyValue(element: string, value: string) { + const dump = await dumpVisualTree(element); + expect(dump!['Automation Tree']['ValuePattern.Value']).toBe(value); +} + +describe('Hit Testing', () => { + test('Hit testing child outside the bounds of parents', async () => { + const target = await app.findElementByTestID('visible-overflow-element'); + + // View starts in red state + await verifyElementAccessibiltyValue('visible-overflow-element', 'red'); + + // The webdriverio package computes the offsets from the center point of the target. + // This is within the bounds of the child and the parent, so should hitTest even with overflow:visible + await target.click({x: -50, y: -50}); + + await verifyElementAccessibiltyValue('visible-overflow-element', 'green'); + + // The webdriverio package computes the offsets from the center point of the target. + // This is within the bounds of the child, but outside the parents bounds + await target.click({x: 0, y: 0}); + + // View should still be red, since the click should hit the pressable + await verifyElementAccessibiltyValue('visible-overflow-element', 'red'); + }); + + test('Overflow hidden prevents hittesting child', async () => { + const target = await app.findElementByTestID('hidden-overflow-element'); + + // View starts in red state + await verifyElementAccessibiltyValue('hidden-overflow-element', 'red'); + + // This is within the bounds of the child and the parent, so should hitTest even with overflow:hidden + await target.click({x: -50, y: -50}); + + await verifyElementAccessibiltyValue('hidden-overflow-element', 'green'); + + // This is within the bounds of the child, but shouldn't hit test, since the parent is overflow:hidden + await target.click({x: 0, y: 0}); + + // View should still be green, since the click shouldn't hit the pressable + await verifyElementAccessibiltyValue('hidden-overflow-element', 'green'); + }); +}); diff --git a/packages/e2e-test-app-fabric/test/__snapshots__/HomeUIADump.test.ts.snap b/packages/e2e-test-app-fabric/test/__snapshots__/HomeUIADump.test.ts.snap index 9b7af269a2c..8215378d4ca 100644 --- a/packages/e2e-test-app-fabric/test/__snapshots__/HomeUIADump.test.ts.snap +++ b/packages/e2e-test-app-fabric/test/__snapshots__/HomeUIADump.test.ts.snap @@ -2522,6 +2522,87 @@ exports[`Home UIA Tree Dump Glyph UWP 1`] = ` } `; +exports[`Home UIA Tree Dump Hit Testing 1`] = ` +{ + "Automation Tree": { + "AutomationId": "Hit Testing", + "ControlType": 50026, + "IsKeyboardFocusable": true, + "LocalizedControlType": "group", + "Name": "Hit Testing Test that overflow hidden affect hit testing", + "__Children": [ + { + "AutomationId": "", + "ControlType": 50020, + "LocalizedControlType": "text", + "Name": "Hit Testing", + "TextRangePattern.GetText": "Hit Testing", + }, + { + "AutomationId": "", + "ControlType": 50020, + "LocalizedControlType": "text", + "Name": "Test that overflow hidden affect hit testing", + "TextRangePattern.GetText": "Test that overflow hidden affect hit testing", + }, + ], + }, + "Component Tree": { + "Type": "Microsoft.ReactNative.Composition.ViewComponentView", + "_Props": { + "AccessibilityLabel": "Hit Testing Test that overflow hidden affect hit testing", + "TestId": "Hit Testing", + }, + "__Children": [ + { + "Type": "Microsoft.ReactNative.Composition.ParagraphComponentView", + "_Props": {}, + }, + { + "Type": "Microsoft.ReactNative.Composition.ParagraphComponentView", + "_Props": {}, + }, + ], + }, + "Visual Tree": { + "Brush": { + "Brush Type": "ColorBrush", + "Color": "rgba(255, 255, 255, 255)", + }, + "Comment": "Hit Testing", + "Offset": "0, 0, 0", + "Size": "966, 77", + "Visual Type": "SpriteVisual", + "__Children": [ + { + "Offset": "16, 16, 0", + "Size": "85, 25", + "Visual Type": "SpriteVisual", + "__Children": [ + { + "Offset": "0, 0, 0", + "Size": "85, 25", + "Visual Type": "SpriteVisual", + }, + ], + }, + { + "Offset": "16, 45, 0", + "Size": "934, 17", + "Visual Type": "SpriteVisual", + "__Children": [ + { + "Offset": "0, 0, 0", + "Size": "934, 17", + "Visual Type": "SpriteVisual", + }, + ], + }, + ], + }, +} +`; + exports[`Home UIA Tree Dump Image 1`] = ` { "Automation Tree": { @@ -2571,7 +2652,7 @@ exports[`Home UIA Tree Dump Image 1`] = ` }, "Comment": "Image", "Offset": "0, 0, 0", - "Size": "966, 77", + "Size": "966, 78", "Visual Type": "SpriteVisual", "__Children": [ { @@ -3398,12 +3479,12 @@ exports[`Home UIA Tree Dump LegacyLoginTest 1`] = ` }, { "Offset": "16, 45, 0", - "Size": "934, 17", + "Size": "934, 16", "Visual Type": "SpriteVisual", "__Children": [ { "Offset": "0, 0, 0", - "Size": "934, 17", + "Size": "934, 16", "Visual Type": "SpriteVisual", }, ], @@ -3479,12 +3560,12 @@ exports[`Home UIA Tree Dump LegacySelectableTextTest 1`] = ` }, { "Offset": "16, 45, 0", - "Size": "934, 16", + "Size": "934, 17", "Visual Type": "SpriteVisual", "__Children": [ { "Offset": "0, 0, 0", - "Size": "934, 16", + "Size": "934, 17", "Visual Type": "SpriteVisual", }, ], @@ -4029,7 +4110,7 @@ exports[`Home UIA Tree Dump Moving Light Example 1`] = ` }, "Comment": "Moving Light Example", "Offset": "0, 0, 0", - "Size": "966, 78", + "Size": "966, 77", "Visual Type": "SpriteVisual", "__Children": [ { @@ -4191,7 +4272,7 @@ exports[`Home UIA Tree Dump New App Screen 1`] = ` }, "Comment": "New App Screen", "Offset": "0, 0, 0", - "Size": "966, 77", + "Size": "966, 78", "Visual Type": "SpriteVisual", "__Children": [ { @@ -5018,12 +5099,12 @@ exports[`Home UIA Tree Dump ScrollView 1`] = ` }, { "Offset": "16, 45, 0", - "Size": "934, 17", + "Size": "934, 16", "Visual Type": "SpriteVisual", "__Children": [ { "Offset": "0, 0, 0", - "Size": "934, 17", + "Size": "934, 16", "Visual Type": "SpriteVisual", }, ], @@ -5099,12 +5180,12 @@ exports[`Home UIA Tree Dump ScrollViewAnimated 1`] = ` }, { "Offset": "16, 45, 0", - "Size": "934, 16", + "Size": "934, 17", "Visual Type": "SpriteVisual", "__Children": [ { "Offset": "0, 0, 0", - "Size": "934, 16", + "Size": "934, 17", "Visual Type": "SpriteVisual", }, ], @@ -5678,7 +5759,7 @@ exports[`Home UIA Tree Dump Text 1`] = ` }, "Comment": "Text", "Offset": "0, 0, 0", - "Size": "966, 78", + "Size": "966, 77", "Visual Type": "SpriteVisual", "__Children": [ { @@ -5759,7 +5840,7 @@ exports[`Home UIA Tree Dump TextInput 1`] = ` }, "Comment": "TextInput", "Offset": "0, 0, 0", - "Size": "966, 77", + "Size": "966, 78", "Visual Type": "SpriteVisual", "__Children": [ { @@ -6250,12 +6331,12 @@ exports[`Home UIA Tree Dump TransparentHitTestExample 1`] = ` "__Children": [ { "Offset": "16, 16, 0", - "Size": "214, 25", + "Size": "214, 24", "Visual Type": "SpriteVisual", "__Children": [ { "Offset": "0, 0, 0", - "Size": "214, 25", + "Size": "214, 24", "Visual Type": "SpriteVisual", }, ], @@ -6493,12 +6574,12 @@ exports[`Home UIA Tree Dump View 1`] = ` "__Children": [ { "Offset": "16, 16, 0", - "Size": "38, 24", + "Size": "38, 25", "Visual Type": "SpriteVisual", "__Children": [ { "Offset": "0, 0, 0", - "Size": "38, 24", + "Size": "38, 25", "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 f26b6db5f4e..e1c08116c4c 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 @@ -25908,6 +25908,174 @@ exports[`snapshotAllPages Glyph UWP 1`] = ` `; +exports[`snapshotAllPages Hit Testing 1`] = ` + + + Clicking the pressable should work even if it is outside the bounds of its parent. + + + + + + Press me + + + + + +`; + +exports[`snapshotAllPages Hit Testing 2`] = ` + + + Clicking within the visible view will trigger the pressable. Clicking outside the bounds, where the pressable extends but is clipped by its parent overflow:hidden, should not trigger the pressable. + + + + + + Press me + + + + + +`; + exports[`snapshotAllPages Keyboard 1`] = ` KeyDown; diff --git a/vnext/Microsoft.ReactNative/Fabric/ComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/ComponentView.cpp index 4ac8beda5d5..f47bc05e67e 100644 --- a/vnext/Microsoft.ReactNative/Fabric/ComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/ComponentView.cpp @@ -283,6 +283,7 @@ void ComponentView::parent(const winrt::Microsoft::ReactNative::ComponentView &p oldRootView->TrySetFocusedComponent( oldParent, winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Programmatic, true /*forceNoSelectionIfCannotMove*/); } } @@ -431,9 +432,10 @@ void ComponentView::GotFocus(winrt::event_token const &token) noexcept { m_gotFocusEvent.remove(token); } -bool ComponentView::TryFocus() noexcept { +bool ComponentView::TryFocus(winrt::Microsoft::ReactNative::FocusState focusState) noexcept { if (auto root = rootComponentView()) { - return root->TrySetFocusedComponent(*get_strong(), winrt::Microsoft::ReactNative::FocusNavigationDirection::None); + return root->TrySetFocusedComponent( + *get_strong(), winrt::Microsoft::ReactNative::FocusNavigationDirection::None, focusState); } return false; diff --git a/vnext/Microsoft.ReactNative/Fabric/ComponentView.h b/vnext/Microsoft.ReactNative/Fabric/ComponentView.h index 13eb1f8f322..25c62c81e8c 100644 --- a/vnext/Microsoft.ReactNative/Fabric/ComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/ComponentView.h @@ -201,7 +201,7 @@ struct ComponentView LayoutMetrics LayoutMetrics() const noexcept; - bool TryFocus() noexcept; + bool TryFocus(winrt::Microsoft::ReactNative::FocusState focusState) noexcept; virtual bool focusable() const noexcept; virtual facebook::react::SharedViewEventEmitter eventEmitterAtPoint(facebook::react::Point pt) noexcept; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp index 96f640af06f..80b5362c3e5 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp @@ -614,6 +614,8 @@ int64_t CompositionEventHandler::SendMessage(HWND hwnd, uint32_t msg, uint64_t w void CompositionEventHandler::onKeyDown( const winrt::Microsoft::ReactNative::Composition::Input::KeyRoutedEventArgs &args) noexcept { + RootComponentView().UseKeyboardForProgrammaticFocus(true); + if (auto focusedComponent = RootComponentView().GetFocusedComponent()) { winrt::get_self(focusedComponent)->OnKeyDown(args); @@ -637,7 +639,7 @@ void CompositionEventHandler::onKeyDown( } if (!fCtrl && args.Key() == winrt::Windows::System::VirtualKey::Tab) { - if (RootComponentView().TryMoveFocus(!fShift)) { + if (RootComponentView().TryMoveFocus(!fShift, winrt::Microsoft::ReactNative::FocusState::Keyboard)) { args.Handled(true); } @@ -647,6 +649,8 @@ void CompositionEventHandler::onKeyDown( void CompositionEventHandler::onKeyUp( const winrt::Microsoft::ReactNative::Composition::Input::KeyRoutedEventArgs &args) noexcept { + RootComponentView().UseKeyboardForProgrammaticFocus(true); + if (auto focusedComponent = RootComponentView().GetFocusedComponent()) { winrt::get_self(focusedComponent)->OnKeyUp(args); @@ -1179,6 +1183,8 @@ void CompositionEventHandler::onPointerPressed( winrt::Windows::System::VirtualKeyModifiers keyModifiers) noexcept { namespace Composition = winrt::Microsoft::ReactNative::Composition; + RootComponentView().UseKeyboardForProgrammaticFocus(false); + // Clears any active text selection when left pointer is pressed if (pointerPoint.Properties().PointerUpdateKind() != Composition::Input::PointerUpdateKind::RightButtonPressed) { RootComponentView().ClearCurrentTextSelection(); @@ -1275,6 +1281,8 @@ void CompositionEventHandler::onPointerReleased( winrt::Windows::System::VirtualKeyModifiers keyModifiers) noexcept { int pointerId = pointerPoint.PointerId(); + RootComponentView().UseKeyboardForProgrammaticFocus(false); + auto activeTouch = std::find_if(m_activeTouches.begin(), m_activeTouches.end(), [pointerId](const auto &pair) { return pair.second.touch.identifier == pointerId; }); diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp index 16169eac4ac..9b2ddbfbd22 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp @@ -375,7 +375,8 @@ void ComponentView::onGotFocus( const winrt::Microsoft::ReactNative::Composition::Input::RoutedEventArgs &args) noexcept { if (args.OriginalSource() == Tag()) { m_eventEmitter->onFocus(); - if (viewProps()->enableFocusRing) { + if (viewProps()->enableFocusRing && + rootComponentView()->focusState() == winrt::Microsoft::ReactNative::FocusState::Keyboard) { facebook::react::Rect focusRect = m_layoutMetrics.frame; focusRect.origin.x -= (FOCUS_VISUAL_WIDTH * 2); focusRect.origin.y -= (FOCUS_VISUAL_WIDTH * 2); @@ -428,15 +429,20 @@ void ComponentView::HandleCommand(const winrt::Microsoft::ReactNative::HandleCom auto commandName = args.CommandName(); if (commandName == L"focus") { if (auto root = rootComponentView()) { - root->TrySetFocusedComponent(*get_strong(), winrt::Microsoft::ReactNative::FocusNavigationDirection::None); + root->TrySetFocusedComponent( + *get_strong(), + winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Programmatic); } return; } if (commandName == L"blur") { if (auto root = rootComponentView()) { root->TrySetFocusedComponent( - nullptr, winrt::Microsoft::ReactNative::FocusNavigationDirection::None); // Todo store this component as - // previously focused element + nullptr, + winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Programmatic); // Todo store this component as + // previously focused element } return; } @@ -1176,15 +1182,17 @@ facebook::react::Tag ViewComponentView::hitTest( facebook::react::Tag targetTag = -1; + bool isPointInside = ptLocal.x >= 0 && ptLocal.x <= m_layoutMetrics.frame.size.width && ptLocal.y >= 0 && + ptLocal.y <= m_layoutMetrics.frame.size.height; + if ((ignorePointerEvents || m_props->pointerEvents == facebook::react::PointerEventsMode::Auto || m_props->pointerEvents == facebook::react::PointerEventsMode::BoxNone) && - anyHitTestHelper(targetTag, ptLocal, localPt)) + (isPointInside || !viewProps()->getClipsContentToBounds()) && anyHitTestHelper(targetTag, ptLocal, localPt)) return targetTag; if ((ignorePointerEvents || m_props->pointerEvents == facebook::react::PointerEventsMode::Auto || m_props->pointerEvents == facebook::react::PointerEventsMode::BoxOnly) && - ptLocal.x >= 0 && ptLocal.x <= m_layoutMetrics.frame.size.width && ptLocal.y >= 0 && - ptLocal.y <= m_layoutMetrics.frame.size.height) { + isPointInside) { localPt = ptLocal; return Tag(); } diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.cpp index 47a4f1e43c7..5ff37988bce 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.cpp @@ -83,7 +83,7 @@ void ContentIslandComponentView::ConnectInternal() noexcept { m_navigationHost.DepartFocusRequested([wkThis = get_weak()](const auto &, const auto &args) { if (auto strongThis = wkThis.get()) { const bool next = (args.Request().Reason() != winrt::Microsoft::UI::Input::FocusNavigationReason::Last); - strongThis->rootComponentView()->TryMoveFocus(next); + strongThis->rootComponentView()->TryMoveFocus(next, winrt::Microsoft::ReactNative::FocusState::Programmatic); args.Result(winrt::Microsoft::UI::Input::FocusNavigationResult::Moved); } }); diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ParagraphComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ParagraphComponentView.cpp index 625ef17e26c..e20ff718107 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ParagraphComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ParagraphComponentView.cpp @@ -613,7 +613,10 @@ void ParagraphComponentView::OnPointerPressed( // Focuses so we receive onLostFocus when clicking elsewhere if (auto root = rootComponentView()) { - root->TrySetFocusedComponent(*get_strong(), winrt::Microsoft::ReactNative::FocusNavigationDirection::None); + root->TrySetFocusedComponent( + *get_strong(), + winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Pointer); } args.Handled(true); diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.cpp index d666530456b..b2a6b887ee3 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.cpp @@ -82,7 +82,8 @@ winrt::Microsoft::ReactNative::ComponentView RootComponentView::GetFocusedCompon void RootComponentView::SetFocusedComponent( const winrt::Microsoft::ReactNative::ComponentView &value, - winrt::Microsoft::ReactNative::FocusNavigationDirection direction) noexcept { + winrt::Microsoft::ReactNative::FocusNavigationDirection direction, + winrt::Microsoft::ReactNative::FocusState focusState) noexcept { if (m_focusedComponent == value) return; @@ -97,11 +98,26 @@ void RootComponentView::SetFocusedComponent( winrt::get_self(rootView)->TrySetFocus(); } m_focusedComponent = value; + if (focusState == winrt::Microsoft::ReactNative::FocusState::Programmatic) { + focusState = + (!m_useKeyboardForProgrammaticFocus || m_focusState == winrt::Microsoft::ReactNative::FocusState::Pointer) + ? winrt::Microsoft::ReactNative::FocusState::Pointer + : winrt::Microsoft::ReactNative::FocusState::Keyboard; + } + m_focusState = focusState; auto args = winrt::make(value, direction); winrt::get_self(value)->onGotFocus(args); } } +winrt::Microsoft::ReactNative::FocusState RootComponentView::focusState() const noexcept { + return m_focusState; +} + +void RootComponentView::UseKeyboardForProgrammaticFocus(bool value) noexcept { + m_useKeyboardForProgrammaticFocus = value; +} + bool RootComponentView::NavigateFocus(const winrt::Microsoft::ReactNative::FocusNavigationRequest &request) noexcept { if (request.Reason() == winrt::Microsoft::ReactNative::FocusNavigationReason::Restore) { if (m_focusedComponent) @@ -116,7 +132,8 @@ bool RootComponentView::NavigateFocus(const winrt::Microsoft::ReactNative::Focus view, request.Reason() == winrt::Microsoft::ReactNative::FocusNavigationReason::First ? winrt::Microsoft::ReactNative::FocusNavigationDirection::First - : winrt::Microsoft::ReactNative::FocusNavigationDirection::Last); + : winrt::Microsoft::ReactNative::FocusNavigationDirection::Last, + winrt::Microsoft::ReactNative::FocusState::Programmatic); } return view != nullptr; } @@ -124,6 +141,7 @@ bool RootComponentView::NavigateFocus(const winrt::Microsoft::ReactNative::Focus bool RootComponentView::TrySetFocusedComponent( const winrt::Microsoft::ReactNative::ComponentView &view, winrt::Microsoft::ReactNative::FocusNavigationDirection direction, + winrt::Microsoft::ReactNative::FocusState focusState, bool forceNoSelectionIfCannotMove /*= false*/) noexcept { auto target = view; auto selfView = winrt::get_self(target); @@ -157,15 +175,15 @@ bool RootComponentView::TrySetFocusedComponent( winrt::get_self(losingFocusArgs.NewFocusedComponent()) ->rootComponentView() - ->SetFocusedComponent(gettingFocusArgs.NewFocusedComponent(), direction); + ->SetFocusedComponent(gettingFocusArgs.NewFocusedComponent(), direction, focusState); } else { - SetFocusedComponent(nullptr, direction); + SetFocusedComponent(nullptr, direction, focusState); } return true; } -bool RootComponentView::TryMoveFocus(bool next) noexcept { +bool RootComponentView::TryMoveFocus(bool next, winrt::Microsoft::ReactNative::FocusState focusState) noexcept { if (!m_focusedComponent) { return NavigateFocus(winrt::Microsoft::ReactNative::FocusNavigationRequest( next ? winrt::Microsoft::ReactNative::FocusNavigationReason::First @@ -173,7 +191,8 @@ bool RootComponentView::TryMoveFocus(bool next) noexcept { } Mso::Functor fn = - [currentlyFocused = m_focusedComponent, next](const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + [currentlyFocused = m_focusedComponent, next, focusState]( + const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { if (view == currentlyFocused) return false; auto selfView = winrt::get_self(view); @@ -185,7 +204,8 @@ bool RootComponentView::TryMoveFocus(bool next) noexcept { ->TrySetFocusedComponent( view, next ? winrt::Microsoft::ReactNative::FocusNavigationDirection::Next - : winrt::Microsoft::ReactNative::FocusNavigationDirection::Previous); + : winrt::Microsoft::ReactNative::FocusNavigationDirection::Previous, + focusState); }; if (winrt::Microsoft::ReactNative::implementation::walkTree(m_focusedComponent, next, fn)) { @@ -249,7 +269,10 @@ void RootComponentView::start(const winrt::Microsoft::ReactNative::ReactNativeIs } void RootComponentView::stop() noexcept { - SetFocusedComponent(nullptr, winrt::Microsoft::ReactNative::FocusNavigationDirection::None); + SetFocusedComponent( + nullptr, + winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Programmatic); if (m_visualAddedToIsland) { if (auto rootView = m_wkRootView.get()) { winrt::get_self(rootView)->RemoveRenderedVisual( diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.h index 58b6f1ae2d4..977eb8394b1 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.h @@ -30,15 +30,17 @@ struct RootComponentView : RootComponentViewT m_wkRootView{nullptr}; winrt::weak_ref m_wkPortal{nullptr}; bool m_visualAddedToIsland{false}; + bool m_useKeyboardForProgrammaticFocus{true}; ::Microsoft::ReactNative::ReactTaggedView m_viewWithTextSelection{ winrt::Microsoft::ReactNative::ComponentView{nullptr}}; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/SwitchComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/SwitchComponentView.cpp index 6adffd46183..78412a4cb2d 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/SwitchComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/SwitchComponentView.cpp @@ -271,7 +271,10 @@ void SwitchComponentView::OnPointerPressed( m_supressAnimationForNextFrame = true; if (auto root = rootComponentView()) { - root->TrySetFocusedComponent(*get_strong(), winrt::Microsoft::ReactNative::FocusNavigationDirection::None); + root->TrySetFocusedComponent( + *get_strong(), + winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Pointer); } updateVisuals(); diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp index 843c42e8d2f..59cbe03aa61 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp @@ -233,7 +233,9 @@ struct CompTextHost : public winrt::implements { winrt::check_hresult( m_outer->QueryInterface(winrt::guid_of(), winrt::put_abi(view))); m_outer->rootComponentView()->TrySetFocusedComponent( - view, winrt::Microsoft::ReactNative::FocusNavigationDirection::None); + view, + winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Programmatic); // assert(false); // TODO focus } @@ -1469,7 +1471,10 @@ void WindowsTextInputComponentView::onMounted() noexcept { // Handle autoFocus property - focus the component when mounted if autoFocus is true if (windowsTextInputProps().autoFocus) { if (auto root = rootComponentView()) { - root->TrySetFocusedComponent(*get_strong(), winrt::Microsoft::ReactNative::FocusNavigationDirection::None); + root->TrySetFocusedComponent( + *get_strong(), + winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Programmatic); } } } diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp index 6cbb13c4443..cd2084a18ed 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp @@ -141,7 +141,10 @@ HRESULT UiaSetFocusHelper(::Microsoft::ReactNative::ReactTaggedView &view) noexc if (rootCV == nullptr) return UIA_E_ELEMENTNOTAVAILABLE; - return rootCV->TrySetFocusedComponent(strongView, winrt::Microsoft::ReactNative::FocusNavigationDirection::None) + return rootCV->TrySetFocusedComponent( + strongView, + winrt::Microsoft::ReactNative::FocusNavigationDirection::None, + winrt::Microsoft::ReactNative::FocusState::Programmatic) ? S_OK : E_FAIL; }