diff --git a/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs b/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs index 26d9193ff92d..2a9fa6f3d875 100644 --- a/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs +++ b/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs @@ -31,9 +31,7 @@ protected override void OnSelectedChanged() return; } - VisualStateManager.GoToState(View, IsSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + View.IsItemSelected = IsSelected; } public void Recycle(ItemsView itemsView) diff --git a/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs b/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs index a19d0bdf7e3f..83cf767a7380 100644 --- a/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs +++ b/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs @@ -187,9 +187,7 @@ internal void UpdateIsSelected(bool isSelected) if (formsElement == null) return; - VisualStateManager.GoToState(formsElement, isSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + formsElement.IsItemSelected = isSelected; } void OnViewMeasureInvalidated(object sender, EventArgs e) diff --git a/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs b/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs index 119c3cc2b474..aaed48a54f9c 100644 --- a/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs +++ b/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs @@ -295,9 +295,7 @@ void UpdateVisualStates() if (element != null) { - VisualStateManager.GoToState(element, Selected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + element.IsItemSelected = Selected; } } } diff --git a/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs b/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs index d8dd7c43dff6..6d31743daff2 100644 --- a/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs +++ b/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs @@ -107,10 +107,7 @@ void UpdateVisualState() { if (BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - if (baseShellItem.IsChecked) - VisualStateManager.GoToState(View, "Selected"); - else - VisualStateManager.GoToState(View, "Normal"); + View.IsItemSelected = baseShellItem.IsChecked; } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs index 558f99d4c436..16acb52466c7 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs @@ -308,10 +308,7 @@ void UpdateVisualState() { if (Element is BaseShellItem baseShellItem && baseShellItem != null) { - if (baseShellItem.IsChecked) - VisualStateManager.GoToState(View, "Selected"); - else - VisualStateManager.GoToState(View, "Normal"); + View.IsItemSelected = baseShellItem.IsChecked; } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs index b93e55b34e83..b827357f96ee 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs @@ -131,10 +131,7 @@ void UpdateVisualState() { if (BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - if (baseShellItem.IsChecked) - VisualStateManager.GoToState(View, "Selected"); - else - VisualStateManager.GoToState(View, "Normal"); + View.IsItemSelected = baseShellItem.IsChecked; } } diff --git a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs index 184d53455b63..78e45ec2dbd3 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs @@ -29,9 +29,7 @@ protected override void OnSelectedChanged() return; } - VisualStateManager.GoToState(View, IsSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + View.IsItemSelected = IsSelected; } public void Recycle(ItemsView itemsView) diff --git a/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs b/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs index 82e7a05f34ab..e1b75635e944 100644 --- a/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs +++ b/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs @@ -80,12 +80,12 @@ public override void UpdateViewState(NView view, ViewHolderState state) formsView.SetValue(VisualElement.IsFocusedPropertyKey, true); break; case ViewHolderState.Normal: - VisualStateManager.GoToState(formsView, VisualStateManager.CommonStates.Normal); + formsView.IsItemSelected = false; formsView.SetValue(VisualElement.IsFocusedPropertyKey, false); break; case ViewHolderState.Selected: if (IsSelectable) - VisualStateManager.GoToState(formsView, VisualStateManager.CommonStates.Selected); + formsView.IsItemSelected = true; break; } } diff --git a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs index f0ef75743234..9467843bf5fe 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs @@ -342,9 +342,7 @@ void UpdateVisualStates() { if (PlatformHandler?.VirtualView is VisualElement element) { - VisualStateManager.GoToState(element, Selected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + element.IsItemSelected = Selected; } } diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index b8dad3ef4324..6d9379ead92d 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -267,7 +267,7 @@ void BindVirtualView(View virtualView, object bindingContext, ItemsView itemsVie virtualView.BindingContext = bindingContext; itemsView.AddLogicalChild(virtualView); - + if (this.Selected) { UpdateVisualStates(); @@ -369,9 +369,7 @@ void UpdateVisualStates() { if (PlatformHandler?.VirtualView is VisualElement element) { - VisualStateManager.GoToState(element, Selected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + element.IsItemSelected = Selected; } } diff --git a/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs b/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs index b61e4525b862..9ee09d4d10c5 100644 --- a/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs +++ b/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs @@ -141,10 +141,7 @@ void UpdateVisualState() { if (_content?.BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - if (baseShellItem.IsChecked) - VisualStateManager.GoToState(_content, "Selected"); - else - VisualStateManager.GoToState(_content, "Normal"); + _content.IsItemSelected = baseShellItem.IsChecked; } } diff --git a/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs b/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs index 9e1ee609a23e..80bbe27eeb27 100644 --- a/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs +++ b/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs @@ -135,9 +135,7 @@ void ResetIndicatorStylesNonBatch() : GetColorOrDefault(_indicatorView.IndicatorColor, Colors.Silver); - VisualStateManager.GoToState(visualElement, isSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + visualElement.IsItemSelected = isSelected; } diff --git a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs index 12505d7839b6..19865b7e3a08 100644 --- a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs +++ b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs @@ -261,9 +261,7 @@ internal void UpdateIsSelected(bool isSelected) if (formsElement == null) return; - VisualStateManager.GoToState(formsElement, isSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + formsElement.IsItemSelected = isSelected; } void OnViewMeasureInvalidated(object sender, EventArgs e) diff --git a/src/Controls/src/Core/VisualElement/VisualElement.cs b/src/Controls/src/Core/VisualElement/VisualElement.cs index 27503d9fdd0d..b94f6549048a 100644 --- a/src/Controls/src/Core/VisualElement/VisualElement.cs +++ b/src/Controls/src/Core/VisualElement/VisualElement.cs @@ -1653,6 +1653,29 @@ void PropagateBindingContextToStateTriggers() internal void ChangeVisualStateInternal() => ChangeVisualState(); bool _isPointerOver; + bool _isItemSelected; + + /// + /// Gets or sets whether this element is in the Selected visual state. + /// Platform handlers (CollectionView, Shell flyout, IndicatorView) use this property + /// to select/deselect items. The setter includes an equality guard to avoid redundant + /// state recomputation and routes through so that + /// IsEnabled and other state priorities (Disabled, PointerOver, Normal) are respected. + /// + internal bool IsItemSelected + { + get => _isItemSelected; + set + { + if (_isItemSelected == value) + { + return; + } + + _isItemSelected = value; + ChangeVisualState(); + } + } internal bool IsPointerOver { diff --git a/src/Controls/src/Core/VisualStateManager.cs b/src/Controls/src/Core/VisualStateManager.cs index 6c74249d2959..1e745a84c065 100644 --- a/src/Controls/src/Core/VisualStateManager.cs +++ b/src/Controls/src/Core/VisualStateManager.cs @@ -816,16 +816,7 @@ internal static bool HasVisualState(this VisualElement element, string name) internal static bool IsElementInSelectedState(this VisualElement element) { - var groups = VisualStateManager.GetVisualStateGroups(element); - foreach (var group in groups) - { - if (group.CurrentState?.Name == VisualStateManager.CommonStates.Selected) - { - return true; - } - } - - return false; + return element.IsItemSelected; } } diff --git a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs index 09e74cdb3c7c..0f3a357a1528 100644 --- a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs +++ b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs @@ -646,5 +646,138 @@ public void CustomImplicitStyleVSMStateDoesNotOverrideLocalValue() VisualStateManager.GoToState(button, customStateName); Assert.Equal(localColor, button.BackgroundColor); } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void SelectHoverDeselectRestoresPointerOverState() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // 1. Select the item (simulates CollectionView selection) + element.IsItemSelected = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // 2. Simulate pointer hover while selected — Selected takes priority + SetIsPointerOver(element, true); + element.ChangeVisualState(); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // 3. Deselect while pointer is still hovering — should restore to PointerOver + element.IsItemSelected = false; + Assert.Equal(VisualStateManager.CommonStates.PointerOver, groups[0].CurrentState.Name); + } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void SelectedStatePreservedAcrossMouseHover() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // Select the item (simulates Shell flyout item selection) + element.IsItemSelected = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // Pointer enters — Selected should be preserved + SetIsPointerOver(element, true); + element.ChangeVisualState(); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // Pointer exits — Selected should still be preserved + SetIsPointerOver(element, false); + element.ChangeVisualState(); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void IsEnabledToggleWhileSelectedPreservesState() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // Select the item + element.IsItemSelected = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // Disable — should go to Disabled + element.IsEnabled = false; + Assert.Equal(DisabledStateName, groups[0].CurrentState.Name); + Assert.True(element.IsItemSelected); // selection flag preserved + + // Re-enable — should restore to Selected since IsItemSelected is still true + element.IsEnabled = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void SelectingWhileDisabledStaysDisabledUntilReEnabled() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // Disable first + element.IsEnabled = false; + Assert.Equal(DisabledStateName, groups[0].CurrentState.Name); + + // Select while disabled — visual state should remain Disabled + element.IsItemSelected = true; + Assert.Equal(DisabledStateName, groups[0].CurrentState.Name); + + // Re-enable — now it should go to Selected + element.IsEnabled = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void AssigningSameIsItemSelectedValueIsNoOp() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // Select the item + element.IsItemSelected = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // Manually set to Normal to detect if ChangeVisualState fires again + VisualStateManager.GoToState(element, VisualStateManager.CommonStates.Normal); + Assert.Equal(VisualStateManager.CommonStates.Normal, groups[0].CurrentState.Name); + + // Assign same value — should be a no-op (equality guard) + element.IsItemSelected = true; + Assert.Equal(VisualStateManager.CommonStates.Normal, groups[0].CurrentState.Name); + } + + static VisualStateGroupList CreateStateGroupsWithSelectedAndPointerOver() + { + return new VisualStateGroupList + { + new VisualStateGroup + { + Name = CommonStatesGroupName, + States = + { + new VisualState { Name = NormalStateName }, + new VisualState { Name = VisualStateManager.CommonStates.Selected }, + new VisualState { Name = VisualStateManager.CommonStates.PointerOver }, + new VisualState { Name = DisabledStateName }, + } + } + }; + } + + static void SetIsPointerOver(VisualElement element, bool value) + { + var field = typeof(VisualElement).GetField("_isPointerOver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + field!.SetValue(element, value); + } } } diff --git a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/VisualStateManager/VSMCollectionViewPage/VisualStateManagerCollectionViewPage.xaml.cs b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/VisualStateManager/VSMCollectionViewPage/VisualStateManagerCollectionViewPage.xaml.cs index 673992f896d3..4a421b88905f 100644 --- a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/VisualStateManager/VSMCollectionViewPage/VisualStateManagerCollectionViewPage.xaml.cs +++ b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/VisualStateManager/VSMCollectionViewPage/VisualStateManagerCollectionViewPage.xaml.cs @@ -73,7 +73,7 @@ void OnItemPointerExited(object sender, PointerEventArgs e) if (sender is VisualElement ve) { var bc = ve.BindingContext; - var isSelected = IsItemSelected(bc); + var isSelected = IsItemCurrentlySelected(bc); VisualStateManager.GoToState(ve, isSelected ? "Selected" : "Normal"); CVState.Text = GetSelectedCount() > 0 ? $"State: Selected ({GetSelectedCount()})" : "State: Normal"; } @@ -86,7 +86,7 @@ int GetSelectedCount() return MyCollectionView.SelectedItem != null ? 1 : 0; } - bool IsItemSelected(object item) + bool IsItemCurrentlySelected(object item) { if (item is null) return false; @@ -123,7 +123,7 @@ void OnSelectItem(object sender, EventArgs e) void OnSetNormal(object sender, EventArgs e) { - if(!MyCollectionView.IsEnabled) + if (!MyCollectionView.IsEnabled) { CVState.Text = "State: Disabled"; return; diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue35399.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue35399.cs new file mode 100644 index 000000000000..d994eebb8fae --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue35399.cs @@ -0,0 +1,129 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 35399, "VisualElement.ChangeVisualState() gets stuck in Selected state", PlatformAffected.All)] +public class Issue35399 : TestContentPage +{ + const string SelectButtonId = "SelectButton"; + const string DeselectButtonId = "DeselectButton"; + const string StateLabelId = "StateLabel"; + + SelectableBox _box = null!; + Label _stateLabel = null!; + + protected override void Init() + { + _stateLabel = new Label + { + AutomationId = StateLabelId, + HorizontalOptions = LayoutOptions.Center, + FontSize = 14 + }; + + _box = new SelectableBox + { + HeightRequest = 100, + WidthRequest = 200, + HorizontalOptions = LayoutOptions.Center + }; + + Content = new VerticalStackLayout + { + Padding = 24, + Spacing = 16, + Children = + { + _box, + _stateLabel, + new HorizontalStackLayout + { + HorizontalOptions = LayoutOptions.Center, + Spacing = 12, + Children = + { + new Button { Text = "Select", AutomationId = SelectButtonId, Command = new Command(OnSelect) }, + new Button { Text = "Deselect", AutomationId = DeselectButtonId, Command = new Command(OnDeselect) } + } + } + } + }; + } + + void OnSelect() + { + _box.IsSelected = true; + UpdateStateLabel(); + } + + void OnDeselect() + { + _box.IsSelected = false; + UpdateStateLabel(); + } + + void UpdateStateLabel() + { + var groups = VisualStateManager.GetVisualStateGroups(_box); + _stateLabel.Text = groups.Count > 0 ? (groups[0].CurrentState?.Name ?? "None") : "None"; + } + + // Reproduces the ChangeVisualState() pattern from the issue report: + // when IsSelected goes false, base.ChangeVisualState() is called while the VSM + // group's CurrentState is still "Selected", causing the base to re-apply it. + class SelectableBox : ContentView + { + public static readonly BindableProperty IsSelectedProperty = + BindableProperty.Create( + nameof(IsSelected), + typeof(bool), + typeof(SelectableBox), + defaultValue: false, + propertyChanged: (b, _, _) => ((SelectableBox)b).ChangeVisualState()); + + public bool IsSelected + { + get => (bool)GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + public SelectableBox() + { + VisualStateManager.SetVisualStateGroups(this, new VisualStateGroupList + { + new VisualStateGroup + { + Name = "CommonStates", + States = + { + new VisualState + { + Name = VisualStateManager.CommonStates.Normal, + Setters = { new Setter { Property = BackgroundColorProperty, Value = Colors.LightGray } } + }, + new VisualState + { + Name = VisualStateManager.CommonStates.Selected, + Setters = { new Setter { Property = BackgroundColorProperty, Value = Colors.MediumSeaGreen } } + }, + new VisualState + { + Name = VisualStateManager.CommonStates.Disabled, + Setters = { new Setter { Property = BackgroundColorProperty, Value = Colors.DarkGray } } + } + } + } + }); + } + + protected internal override void ChangeVisualState() + { + if (IsSelected && IsEnabled) + { + VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Selected); + } + else + { + base.ChangeVisualState(); + } + } + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35399.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35399.cs new file mode 100644 index 000000000000..7b7d72215079 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35399.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue35399 : _IssuesUITest +{ + public Issue35399(TestDevice device) : base(device) { } + + public override string Issue => "VisualElement.ChangeVisualState() gets stuck in Selected state"; + + [Test] + [Category(UITestCategories.VisualStateManager)] + public void ChangeVisualStateShouldExitSelectedStateAfterDeselect() + { + App.WaitForElement(SelectButtonId); + + // Transition to Selected state + App.Tap(SelectButtonId); + var stateAfterSelect = App.FindElement(StateLabelId).GetText(); + Assert.That(stateAfterSelect, Is.EqualTo("Selected"), + "After selecting, the element should be in the Selected visual state."); + + // Deselect: base.ChangeVisualState() should transition back to Normal. + // Regression: on .NET 10, IsElementInSelectedState() still reads "Selected" from + // the VSM group's CurrentState, causing base to re-apply Selected even though + // IsSelected is now false. + App.Tap(DeselectButtonId); + var stateAfterDeselect = App.FindElement(StateLabelId).GetText(); + Assert.That(stateAfterDeselect, Is.EqualTo("Normal"), + "After deselecting, base.ChangeVisualState() must transition the element to Normal, not remain stuck in Selected."); + } + + const string SelectButtonId = "SelectButton"; + const string DeselectButtonId = "DeselectButton"; + const string StateLabelId = "StateLabel"; +}