Skip to content

Commit 473dcf6

Browse files
SplitButton leveraging PopupBox (#3382)
* POC of SplitButton leveraging PopupBox * Fixing drop-shadow issue and Click event issue * Fix indentation * Removed the need for RightButtonMargin DP * Add style variations for SplitButton Styles added and showcased in the demo app * Added SplitButton to the MD3 demo app * Reset launchSettings.json back to its original values * Some minor cleanup and starting on UI Tests * Add SplitButton.SplitContent and its relatives This allows for controlling the content of the right-side button of the SplitButton from a consumer perspective. * Revert cleanup as this introduces bugs Ripple does not work correctly with the cleanup, and the Click event fires when the Popup opens which is also undesirable. These two things are prevented with the "workaround". * Map SplitButton.Command and others into the nested Button * Add delay to allow the LeftClick to be registered before the assertion * Avoid double opacity on PopupBox content when SplitButton is disabled * Add UI test for command binding * Adding UI test for CanExecute=False on the Command binding * Revert launchSettings.json changes * Revert TestBase changes * Revert changes in TestBase * Add slight delays to make test run green in the pipeline * Minor clean up on tests --------- Co-authored-by: Kevin Bost <[email protected]>
1 parent dc94343 commit 473dcf6

16 files changed

+1261
-20
lines changed

MainDemo.Wpf/Buttons.xaml

Lines changed: 286 additions & 4 deletions
Large diffs are not rendered by default.

MaterialDesign3.Demo.Wpf/Buttons.xaml

Lines changed: 282 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<UserControl x:Class="MaterialDesignThemes.UITests.Samples.SplitButton.SplitButtonWithCommandBinding"
2+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
5+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
6+
xmlns:local="clr-namespace:MaterialDesignThemes.UITests.Samples.SplitButton"
7+
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
8+
mc:Ignorable="d"
9+
d:DesignHeight="450" d:DesignWidth="800"
10+
d:DataContext="{d:DesignInstance Type={x:Type local:SplitButtonWithCommandBindingViewModel}, IsDesignTimeCreatable= False}">
11+
<StackPanel>
12+
<materialDesign:SplitButton Command="{Binding LeftSideButtonClickedCommand}"
13+
Content="Split Button" />
14+
</StackPanel>
15+
</UserControl>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using CommunityToolkit.Mvvm.ComponentModel;
2+
using CommunityToolkit.Mvvm.Input;
3+
4+
namespace MaterialDesignThemes.UITests.Samples.SplitButton;
5+
6+
/// <summary>
7+
/// Interaction logic for SplitButtonWithCommandBinding.xaml
8+
/// </summary>
9+
public partial class SplitButtonWithCommandBinding
10+
{
11+
private SplitButtonWithCommandBindingViewModel ViewModel { get; }
12+
13+
public bool CommandInvoked => ViewModel.CommandInvoked;
14+
15+
public bool CommandCanExecute
16+
{
17+
get => ViewModel.CommandCanExecute;
18+
set => ViewModel.CommandCanExecute = value;
19+
}
20+
21+
public SplitButtonWithCommandBinding()
22+
{
23+
DataContext = ViewModel = new SplitButtonWithCommandBindingViewModel();
24+
InitializeComponent();
25+
}
26+
}
27+
28+
public partial class SplitButtonWithCommandBindingViewModel : ObservableObject
29+
{
30+
public bool CommandInvoked { get; private set; }
31+
32+
[ObservableProperty]
33+
[NotifyCanExecuteChangedFor(nameof(LeftSideButtonClickedCommand))]
34+
private bool _commandCanExecute = true;
35+
36+
[RelayCommand(CanExecute = nameof(CommandCanExecute))]
37+
private void LeftSideButtonClicked() => CommandInvoked = true;
38+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<Window x:Class="MaterialDesignThemes.UITests.Samples.SplitButton.SplitButtonWithCommandBindingWindow"
2+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
5+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
6+
xmlns:local="clr-namespace:MaterialDesignThemes.UITests.Samples.SplitButton"
7+
mc:Ignorable="d"
8+
Title="SplitButtonWithCommandBindingWindow" Height="450" Width="800">
9+
<local:SplitButtonWithCommandBinding />
10+
</Window>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace MaterialDesignThemes.UITests.Samples.SplitButton;
2+
3+
/// <summary>
4+
/// Interaction logic for SplitButtonWithCommandBindingWindow.xaml
5+
/// </summary>
6+
public partial class SplitButtonWithCommandBindingWindow
7+
{
8+
public SplitButtonWithCommandBindingWindow()
9+
{
10+
InitializeComponent();
11+
}
12+
}

MaterialDesignThemes.UITests/TestBase.cs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,27 @@
1-
using System.Diagnostics;
21
using System.Diagnostics.CodeAnalysis;
32
using System.Windows.Media;
43

54
[assembly: CollectionBehavior(DisableTestParallelization = true)]
6-
[assembly: GenerateHelpers(typeof(SmartHint))]
7-
[assembly: GenerateHelpers(typeof(TimePicker))]
8-
[assembly: GenerateHelpers(typeof(DrawerHost))]
5+
[assembly: GenerateHelpers(typeof(AutoSuggestBox))]
96
[assembly: GenerateHelpers(typeof(ColorPicker))]
107
[assembly: GenerateHelpers(typeof(DialogHost))]
11-
[assembly: GenerateHelpers(typeof(AutoSuggestBox))]
8+
[assembly: GenerateHelpers(typeof(DrawerHost))]
9+
[assembly: GenerateHelpers(typeof(PopupBox))]
10+
[assembly: GenerateHelpers(typeof(SmartHint))]
11+
[assembly: GenerateHelpers(typeof(TimePicker))]
1212
[assembly: GenerateHelpers(typeof(TreeListView))]
1313
[assembly: GenerateHelpers(typeof(TreeListViewItem))]
1414

1515
namespace MaterialDesignThemes.UITests;
1616

17-
public abstract class TestBase : IAsyncLifetime
17+
public abstract class TestBase(ITestOutputHelper output) : IAsyncLifetime
1818
{
1919
protected bool AttachedDebuggerToRemoteProcess { get; set; } = true;
20-
protected ITestOutputHelper Output { get; }
20+
protected ITestOutputHelper Output { get; } = output ?? throw new ArgumentNullException(nameof(output));
2121

2222
[NotNull]
2323
protected IApp? App { get; set; }
2424

25-
public TestBase(ITestOutputHelper output)
26-
=> Output = output ?? throw new ArgumentNullException(nameof(output));
27-
2825
protected async Task<Color> GetThemeColor(string name)
2926
{
3027
IResource resource = await App.GetResource(name);
@@ -51,7 +48,7 @@ public async Task InitializeAsync() =>
5148
App = await XamlTest.App.StartRemote(new AppOptions
5249
{
5350
#if !DEBUG
54-
MinimizeOtherWindows = !Debugger.IsAttached,
51+
MinimizeOtherWindows = !System.Diagnostics.Debugger.IsAttached,
5552
#endif
5653
AllowVisualStudioDebuggerAttach = AttachedDebuggerToRemoteProcess,
5754
LogMessage = Output.WriteLine

MaterialDesignThemes.UITests/WPF/PopupBox/PopupBoxTests.cs renamed to MaterialDesignThemes.UITests/WPF/PopupBoxes/PopupBoxTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
using System.Windows.Media;
33
using MaterialDesignThemes.UITests.Samples.PopupBox;
44

5-
namespace MaterialDesignThemes.UITests.WPF.PopupBox;
5+
namespace MaterialDesignThemes.UITests.WPF.PopupBoxes;
66

77
public class PopupBoxTests : TestBase
88
{
@@ -20,7 +20,7 @@ public async Task PopupBox_WithElevation_AppliesElevationToNestedCard(Elevation
2020
await using var recorder = new TestRecorder(App);
2121

2222
//Arrange
23-
IVisualElement<Wpf.PopupBox> popupBox = await LoadXaml<Wpf.PopupBox>($@"
23+
IVisualElement<PopupBox> popupBox = await LoadXaml<PopupBox>($@"
2424
<materialDesign:PopupBox VerticalAlignment=""Top""
2525
PopupElevation=""{elevation}"">
2626
<StackPanel>
@@ -46,7 +46,7 @@ public async Task PopupBox_WithContentTemplateSelector_ChangesContent()
4646
IVisualElement grid = (await LoadUserControl<PopupBoxWithTemplateSelector>());
4747

4848
IVisualElement<Button> button = await grid.GetElement<Button>();
49-
IVisualElement<Wpf.PopupBox> popupBox = await grid.GetElement<Wpf.PopupBox>();
49+
IVisualElement<PopupBox> popupBox = await grid.GetElement<PopupBox>();
5050

5151

5252
// Assert
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using MaterialDesignThemes.UITests.Samples.SplitButton;
2+
3+
[assembly:GenerateHelpers(typeof(SplitButtonWithCommandBinding))]
4+
5+
namespace MaterialDesignThemes.UITests.WPF.SplitButtons;
6+
7+
8+
public class SplitButtonTests : TestBase
9+
{
10+
public SplitButtonTests(ITestOutputHelper output)
11+
: base(output)
12+
{
13+
}
14+
15+
[Fact]
16+
public async Task SplitButton_ClickingSplitButton_ShowsPopup()
17+
{
18+
await using var recorder = new TestRecorder(App);
19+
20+
//Arrange
21+
IVisualElement<SplitButton> splitButton = await LoadXaml<SplitButton>("""
22+
<materialDesign:SplitButton Content="Split Button" VerticalAlignment="Center" HorizontalAlignment="Center">
23+
<materialDesign:SplitButton.PopupContent>
24+
<TextBlock x:Name="PopupContent" Text="Popup Content"/>
25+
</materialDesign:SplitButton.PopupContent>
26+
</materialDesign:SplitButton>
27+
""");
28+
29+
IVisualElement<PopupBox> popupBox = await splitButton.GetElement<PopupBox>();
30+
IVisualElement<TextBlock> popupContent = await popupBox.GetElement<TextBlock>("PopupContent");
31+
32+
//Act
33+
await popupBox.LeftClick();
34+
35+
// Assert
36+
await Wait.For(async () => await popupBox.GetIsPopupOpen());
37+
await Wait.For(async () => await popupContent.GetIsVisible());
38+
39+
recorder.Success();
40+
}
41+
42+
[Fact]
43+
public async Task SplitButton_RegisterForClick_RaisesEvent()
44+
{
45+
await using var recorder = new TestRecorder(App);
46+
47+
//Arrange
48+
IVisualElement<SplitButton> splitButton = await LoadXaml<SplitButton>("""
49+
<materialDesign:SplitButton Content="Split Button" VerticalAlignment="Center" HorizontalAlignment="Center">
50+
<materialDesign:SplitButton.PopupContent>
51+
<TextBlock x:Name="PopupContent" Text="Popup Content"/>
52+
</materialDesign:SplitButton.PopupContent>
53+
</materialDesign:SplitButton>
54+
""");
55+
56+
IEventRegistration clickEvent = await splitButton.RegisterForEvent(ButtonBase.ClickEvent.Name);
57+
58+
IVisualElement<Button> leftButton = await splitButton.GetElement<Button>("PART_LeftButton");
59+
IVisualElement<PopupBox> popupBox = await splitButton.GetElement<PopupBox>();
60+
61+
//Act
62+
await leftButton.LeftClick();
63+
await Task.Delay(50);
64+
int leftButtonCount = (await clickEvent.GetInvocations()).Count;
65+
await popupBox.LeftClick();
66+
await Task.Delay(50);
67+
int rightButtonCount = (await clickEvent.GetInvocations()).Count;
68+
69+
// Assert
70+
Assert.Equal(1, leftButtonCount);
71+
//NB: The popup box button should only show the popup not trigger the click event
72+
Assert.Equal(1, rightButtonCount);
73+
74+
recorder.Success();
75+
}
76+
77+
[Fact]
78+
public async Task SplitButton_WithButtonInPopup_CanBeInvoked()
79+
{
80+
await using var recorder = new TestRecorder(App);
81+
82+
//Arrange
83+
IVisualElement<SplitButton> splitButton = await LoadXaml<SplitButton>("""
84+
<materialDesign:SplitButton Content="Split Button" VerticalAlignment="Center" HorizontalAlignment="Center">
85+
<materialDesign:SplitButton.PopupContent>
86+
<Button x:Name="PopupContent" Content="Popup Content" />
87+
</materialDesign:SplitButton.PopupContent>
88+
</materialDesign:SplitButton>
89+
""");
90+
91+
IVisualElement<Button> popupContent = await splitButton.GetElement<Button>("PopupContent");
92+
IVisualElement<PopupBox> popupBox = await splitButton.GetElement<PopupBox>();
93+
94+
IEventRegistration clickEvent = await popupContent.RegisterForEvent(ButtonBase.ClickEvent.Name);
95+
96+
//Act
97+
await popupBox.LeftClick();
98+
//NB: give the popup some time to show
99+
await Wait.For(async () => await popupContent.GetIsVisible());
100+
await Wait.For(async () => await popupContent.GetActualHeight() > 10);
101+
await popupContent.LeftClick();
102+
await Task.Delay(50);
103+
104+
// Assert
105+
var invocations = await clickEvent.GetInvocations();
106+
Assert.Single(invocations);
107+
108+
recorder.Success();
109+
}
110+
111+
[Fact]
112+
public async Task SplitButton_RegisterCommandBinding_InvokesCommand()
113+
{
114+
await using var recorder = new TestRecorder(App);
115+
116+
//Arrange
117+
await App.InitializeWithMaterialDesign();
118+
IWindow window = await App.CreateWindow<SplitButtonWithCommandBindingWindow>();
119+
IVisualElement<SplitButtonWithCommandBinding> userControl = await window.GetElement<SplitButtonWithCommandBinding>();
120+
IVisualElement<SplitButton> splitButton = await userControl.GetElement<SplitButton>();
121+
122+
Assert.False(await userControl.GetCommandInvoked());
123+
124+
//Act
125+
await splitButton.LeftClick();
126+
await Task.Delay(50);
127+
128+
// Assert
129+
Assert.True(await userControl.GetCommandInvoked());
130+
131+
recorder.Success();
132+
}
133+
134+
[Fact]
135+
public async Task SplitButton_CommandCanExecuteFalse_DisablesButton()
136+
{
137+
await using var recorder = new TestRecorder(App);
138+
139+
//Arrange
140+
await App.InitializeWithMaterialDesign();
141+
IWindow window = await App.CreateWindow<SplitButtonWithCommandBindingWindow>();
142+
IVisualElement<SplitButtonWithCommandBinding> userControl = await window.GetElement<SplitButtonWithCommandBinding>();
143+
IVisualElement<SplitButton> splitButton = await userControl.GetElement<SplitButton>();
144+
145+
Assert.True(await splitButton.GetIsEnabled());
146+
147+
//Act
148+
await userControl.SetProperty(nameof(SplitButtonWithCommandBinding.CommandCanExecute), false);
149+
150+
// Assert
151+
Assert.False(await splitButton.GetIsEnabled());
152+
153+
recorder.Success();
154+
}
155+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System.Globalization;
2+
using System.Windows.Data;
3+
4+
namespace MaterialDesignThemes.Wpf.Converters;
5+
6+
public class CornerRadiusCloneConverter : IValueConverter
7+
{
8+
public double? FixedTopLeft { get; set; }
9+
public double? FixedTopRight { get; set; }
10+
public double? FixedBottomLeft { get; set; }
11+
public double? FixedBottomRight { get; set; }
12+
13+
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
14+
{
15+
if (value is CornerRadius cornerRadius)
16+
{
17+
return new CornerRadius(
18+
FixedTopLeft ?? cornerRadius.TopLeft,
19+
FixedTopRight ?? cornerRadius.TopRight,
20+
FixedBottomRight ?? cornerRadius.BottomRight,
21+
FixedBottomLeft ?? cornerRadius.BottomLeft);
22+
}
23+
return new CornerRadius();
24+
}
25+
26+
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
27+
=> throw new NotImplementedException();
28+
}

0 commit comments

Comments
 (0)