Skip to content

Commit

Permalink
feat: add chip selection support for ItemsRepeater
Browse files Browse the repository at this point in the history
  • Loading branch information
Xiaoy312 committed Jan 18, 2023
1 parent 2c250c9 commit dd169fc
Show file tree
Hide file tree
Showing 9 changed files with 371 additions and 55 deletions.
25 changes: 24 additions & 1 deletion doc/controls/ChipAndChipGroup.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
## Summary
`Chip` is a control that can be used for selection, filtering, or performing an action from a list.
`ChipGroup` is a container that can house a collection of `Chip`s.
Alternatively, `Chip` can also be used within the data-template of an `ItemsRepeater`.

## Chip
`Chip` is derived from `ToggleButton`, a control that a user can select (check) or clear (uncheck).
`Chip` is derived from `ToggleButton`, a control that a user can select (check) or deselect (uncheck).

### C#
```csharp
Expand Down Expand Up @@ -181,3 +182,25 @@ xmlns:utu="using:Uno.Toolkit.UI"
SelectionMode="Multiple"
Style="{StaticResource SuggestionChipGroupStyle}" />
```

## Chip with ItemsRepeater

`Chip` can also be used within the data-template of `ItemsRepeater`, as shown below:

```xml
xmlns:utu="using:Uno.Toolkit.UI"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"

<muxc:ItemsRepeater ItemsSource="{Binding Items}"
utu:ChipExtensions.ChipSelectionMode="Single">
<muxc:ItemsRepeater.ItemTemplate>
<DataTemplate>
<utu:Chip Content="{Binding Name}" />
</DataTemplate>
</muxc:ItemsRepeater.ItemTemplate>
</muxc:ItemsRepeater>
```

### Remarks
- For `ChipExtensions.ChipSelectionMode`, refer to the `ChipGroup.SelectionMode` property, as they function exactly in the same way. Except for the default value which is `SingleOrNone`.
- The `Chip` must be the root-level element of the data-template.
41 changes: 41 additions & 0 deletions src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Uno.Toolkit.RuntimeTests.Helpers
{
internal static class XamlHelper
{
/// <summary>
/// XamlReader.Load the xaml and type-check result.
/// </summary>
/// <param name="sanitizedXaml">Xaml with single or double quots</param>
/// <param name="defaultXmlns">The default xmlns to inject; use null to not inject one.</param>
public static T LoadXaml<T>(string sanitizedXaml, string? defaultXmlns = "http://schemas.microsoft.com/winfx/2006/xaml/presentation") where T : class =>
LoadXaml<T>(sanitizedXaml, new Dictionary<string, string?> { [string.Empty] = defaultXmlns });

/// <summary>
/// XamlReader.Load the xaml and type-check result.
/// </summary>
/// <param name="sanitizedXaml">Xaml with single or double quots</param>
/// <param name="xmlnses">Xmlns to inject; use string.Empty for the default xmlns' key</param>
public static T LoadXaml<T>(string xaml, Dictionary<string, string?> xmlnses) where T : class
{
var injection = " " + string.Join(" ", xmlnses
.Where(x => x.Value != null)
.Select(x => $"xmlns{(string.IsNullOrEmpty(x.Key) ? "" : $":{x.Key}")}=\"{x.Value}\"")
);

xaml = new Regex(@"(?=\\?>)").Replace(xaml, injection, 1);

var result = Windows.UI.Xaml.Markup.XamlReader.Load(xaml);
Assert.IsNotNull(result, "XamlReader.Load returned null");
Assert.IsInstanceOfType(result, typeof(T), "XamlReader.Load did not return the expected type");

return (T)result;
}
}
}
174 changes: 174 additions & 0 deletions src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.UI.Xaml.Controls;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Uno.Extensions;
using Uno.Toolkit.RuntimeTests.Extensions;
using Uno.Toolkit.RuntimeTests.Helpers;
using Uno.Toolkit.UI;
using Uno.UI.RuntimeTests;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Markup;
using ChipControl = Uno.Toolkit.UI.Chip; // ios/macos: to avoid collision with `global::Chip` namespace...


namespace Uno.Toolkit.RuntimeTests.Tests;

[TestClass]
[RunsOnUIThread]
internal class ItemsRepeaterChipTests_Asd
{
// note: the default state of Chip.IsChecked (inherited from ToggleButton) is false, since we don't use IsThreeState

#region Selection via Toggle

[TestMethod]
[DataRow(ChipSelectionMode.None, new[] { 1 }, null)]
[DataRow(ChipSelectionMode.SingleOrNone, new[] { 1 }, 1)]
[DataRow(ChipSelectionMode.SingleOrNone, new[] { 1, 1 }, null)] // deselection
[DataRow(ChipSelectionMode.SingleOrNone, new[] { 1, 2 }, 2)] // reselection
[DataRow(ChipSelectionMode.Single, new int[0], 0)] // selection enforced by 'Single'
[DataRow(ChipSelectionMode.Single, new[] { 1 }, 1)]
[DataRow(ChipSelectionMode.Single, new[] { 1, 1 }, 1)] // deselection denied
[DataRow(ChipSelectionMode.Single, new[] { 1, 2 }, 2)] // reselection
[DataRow(ChipSelectionMode.Multiple, new[] { 1 }, new object[] { 1 })]
[DataRow(ChipSelectionMode.Multiple, new[] { 1, 2 }, new object[] { 1, 2 })] // multi-select@1,2
[DataRow(ChipSelectionMode.Multiple, new[] { 1, 2, 2 }, new object[] { 1 })] // multi-select@1,2, deselection@2
public async Task VariousMode_TapSelection(ChipSelectionMode mode, int[] selectionSequence, object expectation)
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, mode);
bool?[] expected, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
expected = mode is ChipSelectionMode.Single
? new bool?[] { true, false, false }
: new bool?[] { false, false, false };
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);

foreach (var i in selectionSequence)
{
((ChipControl)SUT.TryGetElement(i)).Toggle();
}
expected = (expectation switch
{
object[] array => source.Select(x => array.Contains(x)),
int i => source.Select(x => x == i),
null => Enumerable.Repeat(false, 3),

_ => throw new ArgumentOutOfRangeException(nameof(expectation)),
}).Cast<bool?>().ToArray();
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

[TestMethod]
public async Task SingleMode_Selection()
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, ChipSelectionMode.SingleOrNone);
bool?[] expected = new bool?[] { false, true, false }, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
actual = GetChipsSelectionState(SUT);
Assert.IsTrue(actual.All(x => x == false));

((ChipControl)SUT.TryGetElement(1)).Toggle();
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

#endregion

#region Changing SelectionMode

[TestMethod]
public async Task SingleOrNoneToSingle_NoneSelected_ShouldAutoSelect()
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, ChipSelectionMode.SingleOrNone);
bool?[] expected = new bool?[] { true, false, false }, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
actual = GetChipsSelectionState(SUT);
Assert.IsTrue(actual.All(x => x == false));

ChipExtensions.SetChipSelectionMode(SUT, ChipSelectionMode.Single);
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

[TestMethod]
public async Task SingleOrNoneToSingle_Selected_ShouldPreserveSelection()
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, ChipSelectionMode.SingleOrNone);
bool?[] expected = new bool?[] { false, false, true }, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
((ChipControl)SUT.TryGetElement(2)).Toggle();
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);

ChipExtensions.SetChipSelectionMode(SUT, ChipSelectionMode.Single);
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

[TestMethod]
public async Task MultiToSingle_Selected_ShouldPreserveFirstSelection()
{
var source = Enumerable.Range(0, 3).ToArray();
var SUT = SetupItemsRepeater(source, ChipSelectionMode.Multiple);
bool?[] expected = new bool?[] { false, true, true }, actual;

await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
((ChipControl)SUT.TryGetElement(1)).Toggle();
((ChipControl)SUT.TryGetElement(2)).Toggle();
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);

ChipExtensions.SetChipSelectionMode(SUT, ChipSelectionMode.Single);
expected = new bool?[] { false, true, false };
actual = GetChipsSelectionState(SUT);
CollectionAssert.AreEqual(expected, actual);
}

#endregion

private static ItemsRepeater SetupItemsRepeater(object source, ChipSelectionMode mode)
{
var SUT = new ItemsRepeater
{
ItemsSource = source,
ItemTemplate = XamlHelper.LoadXaml<DataTemplate>(@"
<DataTemplate>
<Chip xmlns='using:Uno.Toolkit.UI' />
</DataTemplate>
"),
};
ChipExtensions.SetChipSelectionMode(SUT, mode);

return SUT;
}

private static bool? IsChipSelectedAt(ItemsRepeater ir, int index)
{
return (ir.TryGetElement(index) as ChipControl)?.IsChecked;
}

// since we are not using IsThreeState=True, the values can only be true/false.
// if any of them is null, that means there other problem and should be thrown.
// therefore, only == check should be used in assertion.
private static bool?[] GetChipsSelectionState(ItemsRepeater ir)
{
return (ir.ItemsSource as IEnumerable).Cast<object>()
.Select((_, i) => (ir.TryGetElement(i) as ChipControl)?.IsChecked)
.ToArray();
}
}
104 changes: 104 additions & 0 deletions src/Uno.Toolkit.UI/Behaviors/ChipExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.UI.Xaml.Controls;

#if IS_WINUI
using Microsoft.UI.Xaml;
#else
using Windows.UI.Xaml;
#endif

namespace Uno.Toolkit.UI
{
public static class ChipExtensions
{
// note: unlike ChipGroup.SelectionMode which the default value is Single, here the default value is SingleOrNone.
// this is because we need a change of value to trigger OnChipSelectionModeChanged, to enforce coercion of initial and later selection.

#region DependencyProperty: ChipSelectionMode = SingleOrNone

public static DependencyProperty ChipSelectionModeProperty { get; } = DependencyProperty.RegisterAttached(
"ChipSelectionMode",
typeof(ChipSelectionMode),
typeof(ChipExtensions),
new PropertyMetadata(ChipSelectionMode.SingleOrNone, OnChipSelectionModeChanged));

public static ChipSelectionMode GetChipSelectionMode(DependencyObject obj) => (ChipSelectionMode)obj.GetValue(ChipSelectionModeProperty);
public static void SetChipSelectionMode(DependencyObject obj, ChipSelectionMode value) => obj.SetValue(ChipSelectionModeProperty, value);

#endregion

private static void OnChipSelectionModeChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
if (sender is ItemsRepeater ir)
{
ir.ElementPrepared -= OnItemsRepeaterElementPrepared;
ir.ElementPrepared += OnItemsRepeaterElementPrepared;

EnforceItemsRepeaterChipSelection(ir);
}
else if (sender is Chip chip)
{
chip.OnChipSelectionModeChanged(e);
}
}

private static void EnforceItemsRepeaterChipSelection(ItemsRepeater ir)
{
var mode = GetChipSelectionMode(ir);
var singleSelection = default(Chip);

foreach (var chip in ir.GetChildren().OfType<Chip>())
{
if (mode is ChipSelectionMode.None)
{
chip.SetIsCheckedSilently(false);
}
else if (mode is ChipSelectionMode.Single or ChipSelectionMode.SingleOrNone && chip.IsChecked == true)
{
if (singleSelection is { })
{
chip.SetIsCheckedSilently(false);
}
else
{
singleSelection = chip;
}
}
}
if (mode is ChipSelectionMode.Single && singleSelection is null
&& ir.GetChildren().OfType<Chip>().FirstOrDefault() is { } first)
{
first.SetIsCheckedSilently(true);
}
}

private static void EnforceItemsRepeaterChipSingleSelection(ItemsRepeater ir)
{
var chips = ir.GetChildren().OfType<Chip>().ToArray();
if (chips.All(x => x.IsChecked != true))
{
if (chips.FirstOrDefault() is { } first)
{
first.SetIsCheckedSilently(true);
}
}
}

private static void OnItemsRepeaterElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPreparedEventArgs args)
{
if (args.Element is Chip chip)
{
var mode = GetChipSelectionMode(sender);
SetChipSelectionMode(chip, mode);

if (mode is ChipSelectionMode.Single)
{
EnforceItemsRepeaterChipSingleSelection(sender);
}
}
}
}
}
22 changes: 0 additions & 22 deletions src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,28 +94,6 @@ public bool CanRemove

#endregion

#region DependencyProperty: IsCheckable = true

public static DependencyProperty IsCheckableProperty { get; } = DependencyProperty.Register(
nameof(IsCheckable),
typeof(bool),
typeof(Chip),
new PropertyMetadata(true, (s, e) => (s as Chip)?.OnIsCheckableChanged(e)));

/// <summary>
/// Gets or sets whether the chip can be checked. Used to prevent showing selection state.
/// </summary>
/// <remarks>
/// When nested under the <see cref="ChipGroup"/>, this property will be overwritten by <see cref="ChipGroup.SelectionMode"/>.
/// </remarks>
public bool IsCheckable
{
get => (bool)GetValue(IsCheckableProperty);
set => SetValue(IsCheckableProperty, value);
}

#endregion

#region DependencyProperty: RemovedCommand

public static DependencyProperty RemovedCommandProperty { get; } = DependencyProperty.Register(
Expand Down
Loading

0 comments on commit dd169fc

Please sign in to comment.