diff --git a/doc/controls/ChipAndChipGroup.md b/doc/controls/ChipAndChipGroup.md index b2534dd79..5af87d7cd 100644 --- a/doc/controls/ChipAndChipGroup.md +++ b/doc/controls/ChipAndChipGroup.md @@ -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 @@ -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" + + + + + + + + +``` + +### 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. diff --git a/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs new file mode 100644 index 000000000..a7133c5a6 --- /dev/null +++ b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs @@ -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 + { + /// + /// XamlReader.Load the xaml and type-check result. + /// + /// Xaml with single or double quots + /// The default xmlns to inject; use null to not inject one. + public static T LoadXaml(string sanitizedXaml, string? defaultXmlns = "http://schemas.microsoft.com/winfx/2006/xaml/presentation") where T : class => + LoadXaml(sanitizedXaml, new Dictionary { [string.Empty] = defaultXmlns }); + + /// + /// XamlReader.Load the xaml and type-check result. + /// + /// Xaml with single or double quots + /// Xmlns to inject; use string.Empty for the default xmlns' key + public static T LoadXaml(string xaml, Dictionary 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; + } + } +} diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs new file mode 100644 index 000000000..ce905180d --- /dev/null +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs @@ -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().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(@" + + + + "), + }; + 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() + .Select((_, i) => (ir.TryGetElement(i) as ChipControl)?.IsChecked) + .ToArray(); + } +} diff --git a/src/Uno.Toolkit.UI/Behaviors/ChipExtensions.cs b/src/Uno.Toolkit.UI/Behaviors/ChipExtensions.cs new file mode 100644 index 000000000..c071864e2 --- /dev/null +++ b/src/Uno.Toolkit.UI/Behaviors/ChipExtensions.cs @@ -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()) + { + 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().FirstOrDefault() is { } first) + { + first.SetIsCheckedSilently(true); + } + } + + private static void EnforceItemsRepeaterChipSingleSelection(ItemsRepeater ir) + { + var chips = ir.GetChildren().OfType().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); + } + } + } + } +} diff --git a/src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs b/src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs index 0d17d8004..e0814b9d4 100644 --- a/src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs +++ b/src/Uno.Toolkit.UI/Controls/Chips/Chip.Members.cs @@ -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))); - - /// - /// Gets or sets whether the chip can be checked. Used to prevent showing selection state. - /// - /// - /// When nested under the , this property will be overwritten by . - /// - public bool IsCheckable - { - get => (bool)GetValue(IsCheckableProperty); - set => SetValue(IsCheckableProperty, value); - } - - #endregion - #region DependencyProperty: RemovedCommand public static DependencyProperty RemovedCommandProperty { get; } = DependencyProperty.Register( diff --git a/src/Uno.Toolkit.UI/Controls/Chips/Chip.cs b/src/Uno.Toolkit.UI/Controls/Chips/Chip.cs index d3d18d073..00fe38b9f 100644 --- a/src/Uno.Toolkit.UI/Controls/Chips/Chip.cs +++ b/src/Uno.Toolkit.UI/Controls/Chips/Chip.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Windows.Input; @@ -11,6 +12,7 @@ using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Controls.Primitives; +using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater; #endif namespace Uno.Toolkit.UI @@ -20,7 +22,7 @@ public partial class Chip : ToggleButton { private const string RemoveButtonName = "PART_RemoveButton"; - private bool _isMuted; + private bool _shouldRaiseIsCheckedChanged; private ChipGroup? ChipGroup => ItemsControl.ItemsControlFromItemContainer(this) as ChipGroup; @@ -40,9 +42,9 @@ protected override void OnApplyTemplate() } } - private void OnIsCheckableChanged(DependencyPropertyChangedEventArgs e) + internal void OnChipSelectionModeChanged(DependencyPropertyChangedEventArgs e) { - if (!IsCheckable) + if (e.NewValue is ChipSelectionMode.None) { IsChecked = false; } @@ -50,7 +52,7 @@ private void OnIsCheckableChanged(DependencyPropertyChangedEventArgs e) private void RaiseIsCheckedChanged(object sender, RoutedEventArgs e) { - if (!_isMuted) + if (!_shouldRaiseIsCheckedChanged) { IsCheckedChanged?.Invoke(sender, e); } @@ -83,24 +85,42 @@ internal void SetIsCheckedSilently(bool? value) { try { - _isMuted = true; + _shouldRaiseIsCheckedChanged = true; IsChecked = value; } finally { - _isMuted = false; + _shouldRaiseIsCheckedChanged = false; } } protected override void OnToggle() { - if (!IsCheckable) return; - if (IsChecked == true && ChipGroup?.SelectionMode == ChipSelectionMode.Single) + var mode = GetSelectionMode(); + if (mode is ChipSelectionMode.None || + (mode is ChipSelectionMode.Single && IsChecked == true)) { return; } + // for Single && SingleOrNone mode, we need to deselect others on new selection. + // for ChipGroup setup, it is handled by ChipGroup; with ItemsRepeater setup, it is handled here: + if (IsChecked == false && // the value would be still false, until base.OnToggle() + Parent is ItemsRepeater ir && + mode is ChipSelectionMode.Single or ChipSelectionMode.SingleOrNone) + { + foreach(var chip in ir.GetChildren().OfType()) + { + chip.SetIsCheckedSilently(false); + } + } + base.OnToggle(); } + + private ChipSelectionMode GetSelectionMode() => + ChipGroup?.SelectionMode ?? + (Parent is ItemsRepeater ir ? ChipExtensions.GetChipSelectionMode(ir) : default(ChipSelectionMode?)) ?? + ChipExtensions.GetChipSelectionMode(this); } } diff --git a/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs b/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs index 3471f604d..18d9fad39 100644 --- a/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs +++ b/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs @@ -293,8 +293,6 @@ private void EnforceSelectionMode() var selected = default(Chip); foreach (var container in GetItemContainers()) { - container.IsCheckable = SelectionMode != ChipSelectionMode.None; - if (IsSingleSelection && container.IsChecked == true) { // preserve first existing selection and clear the rest diff --git a/src/library/Uno.Toolkit.Material/Styles/Controls/v1/Chip.xaml b/src/library/Uno.Toolkit.Material/Styles/Controls/v1/Chip.xaml index febcdfa59..3390640d1 100644 --- a/src/library/Uno.Toolkit.Material/Styles/Controls/v1/Chip.xaml +++ b/src/library/Uno.Toolkit.Material/Styles/Controls/v1/Chip.xaml @@ -357,8 +357,6 @@ - @@ -382,8 +380,6 @@ Value="{StaticResource MaterialOnSurfaceMediumBrush}" /> - @@ -596,8 +592,6 @@ BasedOn="{StaticResource BaseMaterialFilledChipStyle}"> - @@ -712,8 +706,6 @@ BasedOn="{StaticResource BaseMaterialOutlinedChipStyle}"> - diff --git a/src/library/Uno.Toolkit.Material/Styles/Controls/v2/Chip.xaml b/src/library/Uno.Toolkit.Material/Styles/Controls/v2/Chip.xaml index 07bae05ca..6aff1dd33 100644 --- a/src/library/Uno.Toolkit.Material/Styles/Controls/v2/Chip.xaml +++ b/src/library/Uno.Toolkit.Material/Styles/Controls/v2/Chip.xaml @@ -304,8 +304,6 @@ Value="{StaticResource OutlineBrush}" /> - @@ -319,8 +317,6 @@ Value="{StaticResource MaterialChipElevation}" /> - @@ -334,8 +330,6 @@ Value="1" /> - @@ -376,8 +366,6 @@ Value="1" /> -