Skip to content

Commit 0b0f2e9

Browse files
Merge branch 'master' into copilot/fix-6
2 parents 56d0004 + 8057e81 commit 0b0f2e9

File tree

7 files changed

+369
-10
lines changed

7 files changed

+369
-10
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<IsTestProject>true</IsTestProject>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
13+
<PackageReference Include="xunit" Version="2.9.3" />
14+
<PackageReference Include="coverlet.collector" Version="6.0.4">
15+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
16+
<PrivateAssets>all</PrivateAssets>
17+
</PackageReference>
18+
<PackageReference Include="bUnit" Version="1.40.0" />
19+
<PackageReference Include="bUnit.web" Version="1.40.0" />
20+
<PackageReference Include="FluentAssertions" Version="8.6.0" />
21+
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.19" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<ProjectReference Include="..\src\SimpleBlazorMultiselect\SimpleBlazorMultiselect.csproj" />
26+
</ItemGroup>
27+
28+
</Project>
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
using AngleSharp.Dom;
2+
using AngleSharp.Html.Dom;
3+
using Bunit;
4+
using FluentAssertions;
5+
using Microsoft.AspNetCore.Components;
6+
using Microsoft.JSInterop;
7+
using Xunit;
8+
9+
namespace SimpleBlazorMultiselect.Tests;
10+
11+
public class SimpleMultiselectTests : TestContext
12+
{
13+
private readonly List<string> _testOptions = new() { "Apple", "Banana", "Cherry", "Date", "Elderberry" };
14+
15+
public SimpleMultiselectTests()
16+
{
17+
JSInterop.SetupModule("./_content/SimpleBlazorMultiselect/js/simpleMultiselect.js")
18+
.SetupModule("register", invocation => invocation.Arguments.Count == 2)
19+
.SetupVoid("dispose");
20+
}
21+
22+
[Fact]
23+
public void Component_RendersWithDefaultText_WhenNoOptionsSelected()
24+
{
25+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
26+
.Add(p => p.Options, _testOptions)
27+
.Add(p => p.DefaultText, "Choose items"));
28+
29+
var button = component.Find("button");
30+
button.TextContent.Should().Contain("Choose items");
31+
}
32+
33+
[Fact]
34+
public void Component_RendersSelectedOptions_WhenOptionsAreSelected()
35+
{
36+
var selectedOptions = new HashSet<string> { "Apple", "Banana" };
37+
38+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
39+
.Add(p => p.Options, _testOptions)
40+
.Add(p => p.SelectedOptions, selectedOptions));
41+
42+
var button = component.Find("button");
43+
button.TextContent.Should().Contain("Apple, Banana");
44+
}
45+
46+
[Fact]
47+
public void Component_TogglesDropdown_WhenButtonClicked()
48+
{
49+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
50+
.Add(p => p.Options, _testOptions));
51+
52+
var button = component.Find("button");
53+
button.Click();
54+
55+
var dropdown = component.Find(".dropdown-menu.show");
56+
dropdown.Should().NotBeNull();
57+
}
58+
59+
[Fact]
60+
public void Component_ShowsAllOptions_WhenDropdownIsOpen()
61+
{
62+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
63+
.Add(p => p.Options, _testOptions));
64+
65+
var button = component.Find("button");
66+
button.Click();
67+
68+
var dropdownItems = component.FindAll(".dropdown-item");
69+
dropdownItems.Should().HaveCount(_testOptions.Count);
70+
71+
foreach (var option in _testOptions)
72+
{
73+
component.Markup.Should().Contain(option);
74+
}
75+
}
76+
77+
[Fact]
78+
public void Component_SelectsOption_WhenOptionClicked()
79+
{
80+
var selectedOptions = new HashSet<string>();
81+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
82+
.Add(p => p.Options, _testOptions)
83+
.Add(p => p.SelectedOptions, selectedOptions)
84+
.Add(p => p.SelectedOptionsChanged, EventCallback.Factory.Create<HashSet<string>>(this, newSelection =>
85+
{
86+
selectedOptions = newSelection;
87+
})));
88+
89+
var button = component.Find("button");
90+
button.Click();
91+
92+
var firstOption = component.FindAll(".dropdown-item")[0];
93+
firstOption.Click();
94+
95+
firstOption = component.FindAll(".dropdown-item")[0];
96+
var checkbox = firstOption.QuerySelector<IHtmlInputElement>("input[type='checkbox']");
97+
checkbox.Should().NotBeNull();
98+
checkbox.IsChecked.Should().BeTrue();
99+
selectedOptions.Should().Contain("Apple");
100+
}
101+
102+
[Fact]
103+
public void Component_DeselectsOption_WhenSelectedOptionClicked()
104+
{
105+
var selectedOptions = new HashSet<string> { "Apple" };
106+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
107+
.Add(p => p.Options, _testOptions)
108+
.Add(p => p.SelectedOptions, selectedOptions)
109+
.Add(p => p.SelectedOptionsChanged, EventCallback.Factory.Create<HashSet<string>>(this, newSelection =>
110+
{
111+
selectedOptions = newSelection;
112+
})));
113+
114+
var button = component.Find("button");
115+
button.Click();
116+
117+
var firstOption = component.FindAll(".dropdown-item")[0];
118+
firstOption.Click();
119+
120+
selectedOptions.Should().NotContain("Apple");
121+
foreach(var option in component.FindAll(".dropdown-item"))
122+
{
123+
var cb = option.QuerySelector<IHtmlInputElement>("input[type='checkbox']");
124+
cb!.IsChecked.Should().BeFalse();
125+
}
126+
}
127+
128+
[Fact]
129+
public void Component_ShowsFilterInput_WhenCanFilterIsTrue()
130+
{
131+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
132+
.Add(p => p.Options, _testOptions)
133+
.Add(p => p.CanFilter, true));
134+
135+
var button = component.Find("button");
136+
button.Click();
137+
138+
var filterInput = component.Find(".simple-filter-input");
139+
filterInput.Should().NotBeNull();
140+
filterInput.GetAttribute("placeholder").Should().Be("Filter...");
141+
}
142+
143+
[Fact]
144+
public void Component_FiltersOptions_WhenFilterTextEntered()
145+
{
146+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
147+
.Add(p => p.Options, _testOptions)
148+
.Add(p => p.CanFilter, true));
149+
150+
var button = component.Find("button");
151+
button.Click();
152+
153+
var filterInput = component.Find(".simple-filter-input");
154+
filterInput.Input("App");
155+
156+
var dropdownItems = component.FindAll(".dropdown-item");
157+
dropdownItems.Should().HaveCount(1);
158+
dropdownItems[0].TextContent.Should().Contain("Apple");
159+
}
160+
161+
[Fact]
162+
public void Component_UsesCustomStringSelector_WhenProvided()
163+
{
164+
var complexOptions = new List<TestItem>
165+
{
166+
new("1", "Apple"),
167+
new("2", "Banana")
168+
};
169+
170+
var component = RenderComponent<SimpleMultiselect<TestItem>>(parameters => parameters
171+
.Add(p => p.Options, complexOptions)
172+
.Add(p => p.StringSelector, item => item.Name));
173+
174+
var button = component.Find("button");
175+
button.Click();
176+
177+
component.Markup.Should().Contain("Apple");
178+
component.Markup.Should().Contain("Banana");
179+
}
180+
181+
[Fact]
182+
public void Component_UsesCustomFilterPredicate_WhenProvided()
183+
{
184+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
185+
.Add(p => p.Options, _testOptions)
186+
.Add(p => p.CanFilter, true)
187+
.Add(p => p.FilterPredicate, (item, filter) => item.StartsWith(filter, StringComparison.OrdinalIgnoreCase)));
188+
189+
var button = component.Find("button");
190+
button.Click();
191+
192+
var filterInput = component.Find(".simple-filter-input");
193+
filterInput.Input("B");
194+
195+
var dropdownItems = component.FindAll(".dropdown-item");
196+
dropdownItems.Should().HaveCount(1);
197+
dropdownItems[0].TextContent.Should().Contain("Banana");
198+
}
199+
200+
[Fact]
201+
public void Component_HandlesEmptyOptions_Gracefully()
202+
{
203+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
204+
.Add(p => p.Options, new List<string>()));
205+
206+
var button = component.Find("button");
207+
button.Click();
208+
209+
var dropdownItems = component.FindAll(".dropdown-item");
210+
dropdownItems.Should().BeEmpty();
211+
}
212+
213+
[Fact]
214+
public void Component_SingleSelectMode_SelectsOnlyOneOption()
215+
{
216+
var selectedOptions = new HashSet<string>();
217+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
218+
.Add(p => p.Options, _testOptions)
219+
.Add(p => p.SelectedOptions, selectedOptions)
220+
.Add(p => p.IsMultiSelect, false)
221+
.Add(p => p.SelectedOptionsChanged, EventCallback.Factory.Create<HashSet<string>>(this, newSelection =>
222+
{
223+
selectedOptions = newSelection;
224+
})));
225+
226+
var button = component.Find("button");
227+
button.Click();
228+
229+
var firstOption = component.FindAll(".dropdown-item")[0];
230+
firstOption.Click();
231+
component.Render();
232+
233+
JSInterop.VerifyInvoke("dispose");
234+
component.Instance.IsDropdownOpen.Should().BeFalse();
235+
236+
button.Click();
237+
var secondOption = component.FindAll(".dropdown-item")[1];
238+
secondOption.Click();
239+
240+
selectedOptions.Should().HaveCount(1);
241+
selectedOptions.Should().Contain("Banana");
242+
selectedOptions.Should().NotContain("Apple");
243+
}
244+
245+
[Fact]
246+
public void Component_AppliesCustomCssClasses_WhenProvided()
247+
{
248+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
249+
.Add(p => p.Options, _testOptions)
250+
.Add(p => p.Class, "custom-class"));
251+
252+
var container = component.Find(".simple-dropdown");
253+
container.ClassList.Should().Contain("custom-class");
254+
}
255+
256+
[Fact]
257+
public void Component_AppliesCustomStyles_WhenProvided()
258+
{
259+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
260+
.Add(p => p.Options, _testOptions)
261+
.Add(p => p.Style, "width: 300px;"));
262+
263+
var container = component.Find(".simple-dropdown");
264+
container.GetAttribute("style").Should().Contain("width: 300px;");
265+
}
266+
267+
[Fact]
268+
public void Component_SetsButtonId_WhenProvided()
269+
{
270+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
271+
.Add(p => p.Options, _testOptions)
272+
.Add(p => p.Id, "test-multiselect"));
273+
274+
var button = component.Find("button");
275+
button.Id.Should().Be("test-multiselect");
276+
}
277+
278+
[Fact]
279+
public void Component_ShowsStandaloneStyles_WhenStandaloneIsTrue()
280+
{
281+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
282+
.Add(p => p.Options, _testOptions)
283+
.Add(p => p.Standalone, true));
284+
285+
var container = component.Find(".simple-dropdown");
286+
container.ClassList.Should().Contain("simple-bs-compat");
287+
}
288+
289+
[Fact]
290+
public void Component_CachesFilteredOptions_ForPerformance()
291+
{
292+
var component = RenderComponent<SimpleMultiselect<string>>(parameters => parameters
293+
.Add(p => p.Options, _testOptions)
294+
.Add(p => p.CanFilter, true));
295+
296+
var button = component.Find("button");
297+
button.Click();
298+
299+
var filterInput = component.Find(".simple-filter-input");
300+
filterInput.Input("Appl");
301+
302+
// Trigger multiple renders without changing filter
303+
component.Render();
304+
component.Render();
305+
306+
var dropdownItems = component.FindAll(".dropdown-item");
307+
dropdownItems.Should().HaveCount(1);
308+
dropdownItems[0].TextContent.Should().Contain("Apple");
309+
}
310+
311+
private record TestItem(string Id, string Name);
312+
}

SimpleBlazorMultiselect.sln

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
2-
Microsoft Visual Studio Solution File, Format Version 12.00
1+
Microsoft Visual Studio Solution File, Format Version 12.00
32
# Visual Studio Version 17
43
VisualStudioVersion = 17.9.34728.123
54
MinimumVisualStudioVersion = 10.0.40219.1
@@ -9,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleBlazorMultiselect", "
98
EndProject
109
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleBlazorMultiselect.Demo", "src\SimpleBlazorMultiselect.Demo\SimpleBlazorMultiselect.Demo.csproj", "{025632E8-CB51-4E49-B47C-B9C75D35A874}"
1110
EndProject
11+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleBlazorMultiselect.Tests", "SimpleBlazorMultiselect.Tests\SimpleBlazorMultiselect.Tests.csproj", "{B4C7E1D8-9F4A-4B2B-8A1C-6D5E7F8A9B0C}"
12+
EndProject
1213
Global
1314
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1415
Debug|Any CPU = Debug|Any CPU
@@ -25,6 +26,10 @@ Global
2526
{025632E8-CB51-4E49-B47C-B9C75D35A874}.Debug|Any CPU.Build.0 = Debug|Any CPU
2627
{025632E8-CB51-4E49-B47C-B9C75D35A874}.Release|Any CPU.ActiveCfg = Release|Any CPU
2728
{025632E8-CB51-4E49-B47C-B9C75D35A874}.Release|Any CPU.Build.0 = Release|Any CPU
29+
{B4C7E1D8-9F4A-4B2B-8A1C-6D5E7F8A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
30+
{B4C7E1D8-9F4A-4B2B-8A1C-6D5E7F8A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
31+
{B4C7E1D8-9F4A-4B2B-8A1C-6D5E7F8A9B0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
32+
{B4C7E1D8-9F4A-4B2B-8A1C-6D5E7F8A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
2833
EndGlobalSection
2934
GlobalSection(SolutionProperties) = preSolution
3035
HideSolutionNode = FALSE

src/SimpleBlazorMultiselect.Demo/Pages/BasicDropdown.razor

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,16 @@
55
<div class="col-4">
66
<SimpleMultiselect
77
Options="@Globals.EuropeanCapitals"
8-
@bind-SelectedOptions="_selectedItems"/>
8+
@bind-SelectedOptions="_selectedItems">
9+
<SelectedOptionsRenderer Context="options">
10+
@foreach (var option in options)
11+
{
12+
<span class="badge bg-primary" style="padding: 6px; margin-right: 10px;">
13+
@option
14+
</span>
15+
}
16+
</SelectedOptionsRenderer>
17+
</SimpleMultiselect>
918
</div>
1019
<div class="col-4">
1120
You have selected the following items:

src/SimpleBlazorMultiselect.Demo/wwwroot/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<link rel="stylesheet" href="css/app.css"/>
1010
<link rel="icon" type="image/png" href="favicon.png"/>
1111
<link href="SimpleBlazorMultiselect.Demo.styles.css" rel="stylesheet"/>
12+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
1213
</head>
1314

1415
<body>

0 commit comments

Comments
 (0)