diff --git a/doc/controls-styles.md b/doc/controls-styles.md
index 6fca397a6..468a9220f 100644
--- a/doc/controls-styles.md
+++ b/doc/controls-styles.md
@@ -16,6 +16,7 @@ The `Uno.Toolkit.UI` library adds the following helper classes:
- [`AncestorBinding` and `ItemsControlBinding`](helpers\ancestor-itemscontrol-binding.md): These markup extensions provides relative binding based on ancestor type. If you are familiar with WPF, they are very similar to `{RelativeSource Mode=FindAncestor}`.
- [`CommandExtensions`](helpers\command-extensions.md): Provides Command/CommandParameter attached properties for common scenarios.
- [`InputExtensions`](helpers\input-extensions.md): Provides various attached properties for _input controls_, such as `TextBox` and `PasswordBox`.
+- [`ItemsRepeaterExtensions`](helpers\itemsrepeater-extensions.md): Provides selection support for ItemsRepeater.
- [`StatusBar`](helpers\StatusBar-extensions.md): Provides two attached properties on `Page` to controls the visual of the status bar on mobile platforms.
- [`TabBarItemExtensions`](helpers\TabBarItem-extensions.md): Provides additional features for `TabBarItem`.
- [`VisualStateManagerExtensions`](helpers\VisualStateManager-extensions.md): Provides a way of manipulating the visual states of Control with attached property.
diff --git a/doc/controls/ChipAndChipGroup.md b/doc/controls/ChipAndChipGroup.md
index b2534dd79..21e7cd51e 100644
--- a/doc/controls/ChipAndChipGroup.md
+++ b/doc/controls/ChipAndChipGroup.md
@@ -8,7 +8,7 @@
`ChipGroup` is a container that can house a collection of `Chip`s.
## 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 an user can select (check) or deselect (uncheck).
### C#
```csharp
@@ -38,7 +38,6 @@ CanRemove|bool|Gets or sets whether the remove button is visible.
Elevation|double|Gets or sets the elevation of the `Chip`.
Icon|object|Gets or sets the icon of the `Chip`.
IconTemplate|DataTemplate|Gets or sets the data template that is used to display the icon of the `Chip`.
-IsCheckable|bool|Gets or sets whether the chip can be checked. Used to prevent showing selection state. note: When nested under the `ChipGroup`, this property will be overwritten by `ChipGroup.SelectionMode`.
RemovedCommand|ICommand|Gets or sets the command to invoke when the remove button is pressed.
RemovedCommandParameter|object|Gets or sets the parameter to pass to the RemovedCommand property.
diff --git a/doc/helpers/itemsrepeater-extensions.md b/doc/helpers/itemsrepeater-extensions.md
new file mode 100644
index 000000000..42084ba8f
--- /dev/null
+++ b/doc/helpers/itemsrepeater-extensions.md
@@ -0,0 +1,45 @@
+# ItemsRepeaterExtensions Attached Properties
+Provides selection support for `ItemsRepeater`.
+
+## Properties
+Property|Type|Description
+-|-|-
+SelectedItem|object|Two-ways bindable property for the current/first(in Multiple mode) selected item.\*
+SelectedIndex|int|Two-ways bindable property for the current/first(in Multiple mode) selected index.\*
+SelectedItems|IList\|Two-ways bindable property for the current selected items.\*
+SelectedIndexes|IList\|Two-ways bindable property for the current selected indexes.\*
+SelectionMode|ItemsSelectionMode|Gets or sets the selection behavior: `None`, `SingleOrNone`, `Single`, `Multiple` note: Changing this value will cause the `Selected-`properties to be re-coerced.
+
+### Remarks
+- `Selected-`properties only takes effect when `SelectionMode` is set to a valid value that is not `None`.
+- `ItemsSelectionMode`: Defines constants that specify the selection behavior.
+ > Different numbers of selected items are guaranteed: None=0, SingleOrNone=0 or 1, Single=1, Multiple=0 or many.
+ - `None`: Selection is disabled.
+ - `SingleOrNone`: Up to one item can be selected at a time. The current item can be deselected.
+ - `Single`: One item is selected at any time. The current item cannot be deselected.
+ - `Multiple`: The current item cannot be deselected.
+
+## Usage
+```xml
+xmlns:utu="using:Uno.Toolkit.UI"
+xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
+...
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Remarks
+- The selection feature from this extensions support ItemTemplate whose the root element is a `SelectorItem` or `ToggleButton`(which includes `Chip`).
+- `RadioButton`: Multiple mode is not supported due to control limitation.
diff --git a/doc/toc.yml b/doc/toc.yml
index e988a8545..fa8f14bec 100644
--- a/doc/toc.yml
+++ b/doc/toc.yml
@@ -39,6 +39,8 @@
href: helpers/control-extensions.md
- name: InputExtensions attached properties
href: helpers/input-extensions.md
+ - name: ItemsRepeaterExtensions Attached Properties
+ href: helpers/itemsrepeater-extensions.md
- name: StatusBar attached properties
href: helpers/StatusBar-extensions.md
- name: TabBarItem attached properties
diff --git a/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs
new file mode 100644
index 000000000..dcce720cd
--- /dev/null
+++ b/src/Uno.Toolkit.RuntimeTests/Helpers/XamlHelper.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+#if IS_WINUI
+using Microsoft.UI.Xaml.Markup;
+#else
+using Windows.UI.Xaml.Markup;
+#endif
+
+namespace Uno.Toolkit.RuntimeTests.Helpers
+{
+ internal static class XamlHelper
+ {
+ ///
+ /// Matches right before the > or \> tail of any tag.
+ ///
+ ///
+ /// It will match an opening or closing or self-closing tag.
+ ///
+ private static readonly Regex EndOfTagRegex = new Regex(@"(?=( ?/)?>)");
+
+ ///
+ /// Matches any tag without xmlns prefix.
+ ///
+ private static readonly Regex NonXmlnsTagRegex = new Regex(@"<\w+[ />]");
+
+ private static readonly IReadOnlyDictionary KnownXmlnses = new Dictionary
+ {
+ [string.Empty] = "http://schemas.microsoft.com/winfx/2006/xaml/presentation",
+ ["x"] = "http://schemas.microsoft.com/winfx/2006/xaml",
+ ["toolkit"] = "using:Uno.UI.Toolkit", // uno utilities
+ ["utu"] = "using:Uno.Toolkit.UI", // this library
+ ["muxc"] = "using:Microsoft.UI.Xaml.Controls",
+ };
+
+ ///
+ /// XamlReader.Load the xaml and type-check result.
+ ///
+ /// Xaml with single or double quotes
+ /// Toggle automatic detection of xmlns required and inject to the xaml
+ public static T LoadXaml(string xaml, bool autoInjectXmlns = true) where T : class
+ {
+ var xmlnses = new Dictionary();
+
+ if (autoInjectXmlns)
+ {
+ foreach (var xmlns in KnownXmlnses)
+ {
+ var match = xmlns.Key == string.Empty
+ ? NonXmlnsTagRegex.IsMatch(xaml)
+ : xaml.Contains($"<{xmlns.Key}:");
+ if (match)
+ {
+ xmlnses.Add(xmlns.Key, xmlns.Value);
+ }
+ }
+ }
+
+ return LoadXaml(xaml, xmlnses);
+ }
+
+ ///
+ /// XamlReader.Load the xaml and type-check result.
+ ///
+ /// Xaml with single or double quotes
+ /// 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
+ .Select(x => $"xmlns{(string.IsNullOrEmpty(x.Key) ? "" : $":{x.Key}")}=\"{x.Value}\"")
+ );
+
+ xaml = EndOfTagRegex.Replace(xaml, injection.TrimEnd(), 1);
+
+ var result = 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..6338eb7e0
--- /dev/null
+++ b/src/Uno.Toolkit.RuntimeTests/Tests/ItemsRepeaterChipTests.cs
@@ -0,0 +1,197 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+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 ChipControl = Uno.Toolkit.UI.Chip; // ios/macos: to avoid collision with `global::Chip` namespace...
+using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater;
+
+#if IS_WINUI
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls.Primitives;
+#else
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls.Primitives;
+#endif
+
+namespace Uno.Toolkit.RuntimeTests.Tests;
+
+[TestClass]
+[RunsOnUIThread]
+internal class ItemsRepeaterChipTests
+{
+ // note: the default state of Chip.IsChecked (inherited from ToggleButton) is false, since we don't use IsThreeState
+
+ #region Selection via Toggle
+
+ [TestMethod]
+ [DataRow(ItemsSelectionMode.None, new[] { 1 }, null)]
+ [DataRow(ItemsSelectionMode.SingleOrNone, new[] { 1 }, 1)]
+ [DataRow(ItemsSelectionMode.SingleOrNone, new[] { 1, 1 }, null)] // deselection
+ [DataRow(ItemsSelectionMode.SingleOrNone, new[] { 1, 2 }, 2)] // reselection
+ [DataRow(ItemsSelectionMode.Single, new int[0], 0)] // selection enforced by 'Single'
+ [DataRow(ItemsSelectionMode.Single, new[] { 1 }, 1)]
+ [DataRow(ItemsSelectionMode.Single, new[] { 1, 1 }, 1)] // deselection denied
+ [DataRow(ItemsSelectionMode.Single, new[] { 1, 2 }, 2)] // reselection
+ [DataRow(ItemsSelectionMode.Multiple, new[] { 1 }, new object[] { 1 })]
+ [DataRow(ItemsSelectionMode.Multiple, new[] { 1, 2 }, new object[] { 1, 2 })] // multi-select@1,2
+ [DataRow(ItemsSelectionMode.Multiple, new[] { 1, 2, 2 }, new object[] { 1 })] // multi-select@1,2, deselection@2
+ public async Task VariousMode_TapSelection(ItemsSelectionMode 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 ItemsSelectionMode.Single
+ ? new bool?[] { true, false, false }
+ : new bool?[] { false, false, false };
+ actual = GetChipsSelectionState(SUT);
+ CollectionAssert.AreEqual(expected, actual);
+
+ foreach (var i in selectionSequence)
+ {
+ FakeTapItemAt(SUT, i);
+ }
+ 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, ItemsSelectionMode.SingleOrNone);
+ bool?[] expected = new bool?[] { false, true, false }, actual;
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+ actual = GetChipsSelectionState(SUT);
+ Assert.IsTrue(actual.All(x => x == false));
+
+ FakeTapItemAt(SUT, 1);
+ 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, ItemsSelectionMode.SingleOrNone);
+ bool?[] expected = new bool?[] { true, false, false }, actual;
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+ actual = GetChipsSelectionState(SUT);
+ Assert.IsTrue(actual.All(x => x == false));
+
+ ItemsRepeaterExtensions.SetSelectionMode(SUT, ItemsSelectionMode.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, ItemsSelectionMode.SingleOrNone);
+ bool?[] expected = new bool?[] { false, false, true }, actual;
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+ FakeTapItemAt(SUT, 2);
+ actual = GetChipsSelectionState(SUT);
+ CollectionAssert.AreEqual(expected, actual);
+
+ ItemsRepeaterExtensions.SetSelectionMode(SUT, ItemsSelectionMode.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, ItemsSelectionMode.Multiple);
+ bool?[] expected = new bool?[] { false, true, true }, actual;
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+ FakeTapItemAt(SUT, 1);
+ FakeTapItemAt(SUT, 2);
+ actual = GetChipsSelectionState(SUT);
+ CollectionAssert.AreEqual(expected, actual);
+
+ ItemsRepeaterExtensions.SetSelectionMode(SUT, ItemsSelectionMode.Single);
+ expected = new bool?[] { false, true, false };
+ actual = GetChipsSelectionState(SUT);
+ CollectionAssert.AreEqual(expected, actual);
+ }
+
+ #endregion
+
+ private static ItemsRepeater SetupItemsRepeater(object source, ItemsSelectionMode mode)
+ {
+ var SUT = new ItemsRepeater
+ {
+ ItemsSource = source,
+ ItemTemplate = XamlHelper.LoadXaml("""
+
+
+
+ """),
+ };
+ ItemsRepeaterExtensions.SetSelectionMode(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 is another problem and should be thrown.
+ // therefore, only == check should be used in an assertion.
+ private static bool?[] GetChipsSelectionState(ItemsRepeater ir)
+ {
+ return (ir.ItemsSource as IEnumerable)?.Cast()
+ .Select((_, i) => (ir.TryGetElement(i) as ChipControl)?.IsChecked)
+ .ToArray() ?? new bool?[0];
+ }
+
+ private static void FakeTapItemAt(ItemsRepeater ir, int index)
+ {
+ if (ir.TryGetElement(index) is { } element)
+ {
+ // Fake local tap handler on ToggleButton level.
+ // For SelectorItem, nothing will happen on tap unless nested under a Selector, which isnt the case here.
+ (element as ToggleButton)?.Toggle();
+
+ // This is whats called in ItemsRepeater::Tapped handler.
+ // Note that the handler will not trigger from a "fake tap" like the line above, so we have to manually invoke here.
+ ItemsRepeaterExtensions.ToggleItemSelectionAtCoerced(ir, index);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Element at index={index} is not yet materialized or out of range.");
+ }
+ }
+}
diff --git a/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs b/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs
index 3f065a7da..64fc18596 100644
--- a/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs
+++ b/src/Uno.Toolkit.UI/Behaviors/ItemsRepeaterExtensions.cs
@@ -1,66 +1,558 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Windows.Input;
+using Microsoft.Extensions.Logging;
+using Uno.Disposables;
+using Uno.Extensions;
+using Uno.Logging;
#if IS_WINUI
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
#else
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Input;
using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater;
#endif
-namespace Uno.Toolkit.UI
+namespace Uno.Toolkit.UI;
+
+public static partial class ItemsRepeaterExtensions
{
- public static partial class ItemsRepeaterExtensions
+ private static ILogger _logger { get; } = typeof(CommandExtensions).Log();
+
+ #region DependencyProperty: IsSynchronizingSelection
+
+ private static DependencyProperty IsSynchronizingSelectionProperty { [DynamicDependency(nameof(GetIsSynchronizingSelection))] get; } = DependencyProperty.RegisterAttached(
+ "IsSynchronizingSelection",
+ typeof(bool),
+ typeof(ItemsRepeaterExtensions),
+ new PropertyMetadata(default(bool)));
+
+ [DynamicDependency(nameof(SetIsSynchronizingSelection))]
+ private static bool GetIsSynchronizingSelection(ItemsRepeater obj) => (bool)obj.GetValue(IsSynchronizingSelectionProperty);
+ [DynamicDependency(nameof(GetIsSynchronizingSelection))]
+ private static void SetIsSynchronizingSelection(ItemsRepeater obj, bool value) => obj.SetValue(IsSynchronizingSelectionProperty, value);
+
+ #endregion
+ #region DependencyProperty: SelectedItem
+
+ public static DependencyProperty SelectedItemProperty { [DynamicDependency(nameof(GetSelectedItem))] get; } = DependencyProperty.RegisterAttached(
+ "SelectedItem",
+ typeof(object),
+ typeof(ItemsRepeaterExtensions),
+ new PropertyMetadata(default(object), OnSelectedItemChanged));
+
+ [DynamicDependency(nameof(SetSelectedItem))]
+ public static object? GetSelectedItem(ItemsRepeater obj) => obj.GetValue(SelectedItemProperty);
+ [DynamicDependency(nameof(GetSelectedItem))]
+ public static void SetSelectedItem(ItemsRepeater obj, object? value) => obj.SetValue(SelectedItemProperty, value);
+
+ #endregion
+ #region DependencyProperty: SelectedItems
+
+ public static DependencyProperty SelectedItemsProperty { [DynamicDependency(nameof(GetSelectedItems))] get; } = DependencyProperty.RegisterAttached(
+ "SelectedItems",
+ typeof(IList),
+ typeof(ItemsRepeaterExtensions),
+ new PropertyMetadata(default(IList), OnSelectedItemsChanged));
+
+ [DynamicDependency(nameof(SetSelectedItems))]
+ public static IList GetSelectedItems(ItemsRepeater obj) => (IList)obj.GetValue(SelectedItemsProperty);
+ [DynamicDependency(nameof(GetSelectedItems))]
+ public static void SetSelectedItems(ItemsRepeater obj, IList value) => obj.SetValue(SelectedItemsProperty, value);
+
+ #endregion
+ #region DependencyProperty: SelectedIndex = -1
+
+ public static DependencyProperty SelectedIndexProperty { [DynamicDependency(nameof(GetSelectedIndex))] get; } = DependencyProperty.RegisterAttached(
+ "SelectedIndex",
+ typeof(int),
+ typeof(ItemsRepeaterExtensions),
+ new PropertyMetadata(-1, OnSelectedIndexChanged));
+
+ [DynamicDependency(nameof(SetSelectedIndex))]
+ public static int GetSelectedIndex(ItemsRepeater obj) => (int)obj.GetValue(SelectedIndexProperty);
+ [DynamicDependency(nameof(GetSelectedIndex))]
+ public static void SetSelectedIndex(ItemsRepeater obj, int value) => obj.SetValue(SelectedIndexProperty, value);
+
+ #endregion
+ #region DependencyProperty: SelectedIndexes
+
+ public static DependencyProperty SelectedIndexesProperty { [DynamicDependency(nameof(GetSelectedIndexes))] get; } = DependencyProperty.RegisterAttached(
+ "SelectedIndexes",
+ typeof(IList),
+ typeof(ItemsRepeaterExtensions),
+ new PropertyMetadata(default(IList), OnSelectedIndexesChanged));
+
+ [DynamicDependency(nameof(SetSelectedIndexes))]
+ public static IList GetSelectedIndexes(ItemsRepeater obj) => (IList)obj.GetValue(SelectedIndexesProperty);
+ [DynamicDependency(nameof(GetSelectedIndexes))]
+ public static void SetSelectedIndexes(ItemsRepeater obj, IList value) => obj.SetValue(SelectedIndexesProperty, value);
+
+ #endregion
+ #region DependencyProperty: SelectionMode = (ItemsSelectionMode)0
+
+ public static DependencyProperty SelectionModeProperty { [DynamicDependency(nameof(GetSelectionMode))] get; } = DependencyProperty.RegisterAttached(
+ "SelectionMode",
+ typeof(ItemsSelectionMode),
+ typeof(ItemsRepeaterExtensions),
+ new PropertyMetadata((ItemsSelectionMode)0, OnSelectionModeChanged));
+
+ [DynamicDependency(nameof(SetSelectionMode))]
+ public static ItemsSelectionMode GetSelectionMode(ItemsRepeater obj) => (ItemsSelectionMode)obj.GetValue(SelectionModeProperty);
+ [DynamicDependency(nameof(GetSelectionMode))]
+ public static void SetSelectionMode(ItemsRepeater obj, ItemsSelectionMode value) => obj.SetValue(SelectionModeProperty, value);
+
+ #endregion
+ #region DependencyProperty: SelectionSubscription
+
+ private static DependencyProperty SelectionSubscriptionProperty { [DynamicDependency(nameof(GetSelectionSubscription))] get; } = DependencyProperty.RegisterAttached(
+ "SelectionSubscription",
+ typeof(IDisposable),
+ typeof(ItemsRepeaterExtensions),
+ new PropertyMetadata(default(IDisposable)));
+
+ [DynamicDependency(nameof(SetSelectionSubscription))]
+ private static IDisposable GetSelectionSubscription(ItemsRepeater obj) => (IDisposable)obj.GetValue(SelectionSubscriptionProperty);
+ [DynamicDependency(nameof(GetSelectionSubscription))]
+ private static void SetSelectionSubscription(ItemsRepeater obj, IDisposable value) => obj.SetValue(SelectionSubscriptionProperty, value);
+
+ #endregion
+
+ #region ItemCommand Impl
+ internal static void OnItemCommandChanged(ItemsRepeater sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (e.OldValue is ICommand)
+ {
+ if (e.NewValue is not ICommand) // tear down
+ {
+ sender.Tapped -= OnItemsRepeaterCommandTapped;
+ }
+ else
+ {
+ // When transitioning from one command to another, there is no need to rewire the event.
+ // Since the handler is setup to invoke the command in the DP.
+ }
+ }
+ else if (e.NewValue is ICommand command)
+ {
+ sender.Tapped += OnItemsRepeaterCommandTapped;
+ }
+ }
+
+ private static void OnItemsRepeaterCommandTapped(object sender, TappedRoutedEventArgs e)
+ {
+ // ItemsRepeater is more closely related to Panel than ItemsControl, and it cannot be templated.
+ // It is safe to assume all direct children of IR are materialized item template,
+ // and there can't be header/footer or wrapper (ItemContainer) among them.
+
+ if (sender is not ItemsRepeater ir) return;
+ if (e.OriginalSource is ItemsRepeater) return;
+ if (e.OriginalSource is DependencyObject source)
+ {
+ if (ir.FindRootElementOf(source) is FrameworkElement root)
+ {
+ CommandExtensions.TryInvokeCommand(ir, CommandExtensions.GetCommandParameter(root) ?? root.DataContext);
+ }
+ }
+ }
+ #endregion
+
+ // ItemsRepeater is more closely related to Panel than ItemsControl, and it cannot be templated.
+ // It is safe to assume all direct children of IR are materialized item template,
+ // and there can't be header/footer or wrapper (ItemContainer) among them.
+
+ // ItemsRepeater.ItemsSource can contain UIElement, although it is not possible to define directly in xaml like you can with ListView+LVI.
+ // In the above case, the UIElement will be nested directly under the IR, unless an IR.ItemTemplate has been defined.
+
+ // ItemsRepeater's children contains only materialized element; materialization and de-materialization can be track with
+ // ElementPrepared and ElementClearing events. Recycled elements are reused based on FIFO-rule, resulting in index desync.
+ // Selection state saved on the element (LVI.IsSelect, Chip.IsChecked) will also desync when it happens.
+ // !!! So it is important to save the selection state into a dp, and validate against that on element materialization and correct when necessary.
+
+ // Unlike ToggleButton (or Chip which derives from), SelectorItem is not normally selectable on click, unless nested under a Selector.
+
+ private static void OnSelectedItemChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => OnSelectionStateChanged(sender, e);
+ private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => OnSelectionStateChanged(sender, e);
+ private static void OnSelectedIndexChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => OnSelectionStateChanged(sender, e);
+ private static void OnSelectedIndexesChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => OnSelectionStateChanged(sender, e);
+
+ private static void OnSelectionModeChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (sender is not ItemsRepeater ir) return;
+
+ if (e.NewValue is ItemsSelectionMode value && value != 0)
+ {
+ // Skip events setup, when we are just switching between valid modes.
+ if (e.OldValue is ItemsSelectionMode oldValue && oldValue == 0)
+ {
+ ir.Tapped += OnItemsRepeaterTapped;
+ ir.ElementPrepared += OnItemsRepeaterElementPrepared;
+
+ SetSelectionSubscription(ir, new CompositeDisposable(
+ Disposable.Create(() =>
+ {
+ ir.Tapped -= OnItemsRepeaterTapped;
+ ir.ElementPrepared -= OnItemsRepeaterElementPrepared;
+ }),
+ ir.RegisterDisposablePropertyChangedCallback(ItemsRepeater.ItemsSourceProperty, OnItemsRepeaterItemsSourceChanged)
+ ));
+ }
+
+ if (ir.ItemsSourceView is { Count: > 0 })
+ {
+ try
+ {
+ SetIsSynchronizingSelection(ir, true);
+
+ TrySynchronizeDefaultSelection(ir);
+ SynchronizeMaterializedElementsSelection(ir);
+ }
+ finally
+ {
+ SetIsSynchronizingSelection(ir, false);
+ }
+ }
+ }
+ else
+ {
+ GetSelectionSubscription(ir)?.Dispose();
+ }
+ }
+ private static void OnSelectionStateChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (sender is not ItemsRepeater ir) return;
+ if (GetIsSynchronizingSelection(ir)) return;
+
+ try
+ {
+ SetIsSynchronizingSelection(ir, true);
+
+ if (ir.ItemsSourceView is { Count: > 0 })
+ {
+ var indexes = MapStateToIndexes();
+ if (indexes.All(x => 0 <= x && x < ir.ItemsSourceView.Count))
+ {
+ SetSelectionStates(ir, indexes);
+ }
+ else
+ {
+ ir.SetValue(e.Property, e.OldValue);
+ }
+ }
+ else
+ {
+ SetEmptySelection(ir);
+ }
+ }
+ finally
+ {
+ SetIsSynchronizingSelection(ir, false);
+ }
+
+ int[] MapStateToIndexes()
+ {
+ if (e.Property == SelectedItemProperty || e.Property == SelectedItemsProperty)
+ {
+ if (e.NewValue == null) return Array.Empty();
+
+ var selectedItems = e.Property == SelectedItemsProperty
+ ? new[] { e.NewValue }
+ : (IList)e.NewValue;
+
+#if HAS_UNO || true // ItemsSourceView::IndexOf is not defined
+ return Enumerable.Join(
+ Enumerable.Range(0, ir.ItemsSourceView.Count), // all valid indexes
+ selectedItems,
+ ir.ItemsSourceView.GetAt,
+ x => x,
+ (index, item) => index
+ ).ToArray();
+#else
+ return items.Select(ir.ItemsSourceView.IndexOf).ToArray();
+#endif
+ }
+ else if (e.Property == SelectedIndexProperty)
+ {
+ if (e.NewValue is int index && index != -1)
+ {
+ return new int[] { index };
+ }
+ else
+ {
+ return Array.Empty();
+ }
+ }
+ else if (e.Property == SelectedIndexesProperty)
+ {
+ // Out-of-bounds values will be handled by the outside block
+ return (e.NewValue as IList)?.ToArray() ?? Array.Empty();
+ }
+ else
+ {
+ throw new InvalidOperationException($"Unknown selection state property: {e.Property}");
+ }
+ }
+ }
+
+ private static void OnItemsRepeaterElementPrepared(ItemsRepeater sender, Microsoft.UI.Xaml.Controls.ItemsRepeaterElementPreparedEventArgs args)
+ {
+ // When we reach here, it should be guaranteed that default selection state has been applied,
+ // and we can rely on it to synchronize the selection on the view-level.
+ var selected = GetSelectedIndexes(sender)?.Contains(args.Index) ?? false;
+
+ SetItemSelection(args.Element, selected);
+ }
+ private static void OnItemsRepeaterItemsSourceChanged(DependencyObject sender, DependencyProperty dp)
+ {
+ // When we reach here, ItemsSourceView is already updated.
+ if (sender is not ItemsRepeater ir) return;
+ if (ir.ItemsSourceView is { Count: > 0 })
+ {
+ try
+ {
+ SetIsSynchronizingSelection(ir, true);
+
+ TrySynchronizeDefaultSelection(ir, shouldBaseFromSelectionState: false);
+ // Unlike in OnSelectionModeChanged, ir.GetChildren() still holds old materialized elements from previous ItemsSource.
+ // There is nothing to do here; We can just let OnItemsRepeaterElementPrepared to handle the rest.
+ }
+ finally
+ {
+ SetIsSynchronizingSelection(ir, false);
+ }
+ }
+ }
+ private static void OnItemsRepeaterTapped(object sender, TappedRoutedEventArgs e)
{
- internal static void OnItemCommandChanged(ItemsRepeater sender, DependencyPropertyChangedEventArgs e)
+ // By the time we are here, ToggleButton.IsChecked would have already been toggled.
+
+ if (sender is not ItemsRepeater ir) return;
+ if (e.OriginalSource is ItemsRepeater) return;
+ if (e.OriginalSource is DependencyObject source)
{
- if (e.OldValue is ICommand)
+ if (ir.FindRootElementOf(source) is { } element)
{
- if (e.NewValue is not ICommand) // tear down
+ ToggleItemSelectionAtCoerced(ir, ir.GetElementIndex(element));
+ }
+ }
+ }
+
+ // note: TryCoerceSelectionFromXyz methods should never accept empty-selection as result, so that the next non-empty property can be evaluated.
+ // Even if all 4 properties fail for being empty, TrySetDefaultSelection or SetEmptySelection can pick from there.
+ private static bool TrySynchronizeDefaultSelection(ItemsRepeater ir, bool shouldBaseFromSelectionState = true)
+ {
+ if (ir.ItemsSourceView is not { Count: > 0 }) return false;
+ if (GetSelectionMode(ir) is var mode && mode != 0)
+ {
+ if (mode is ItemsSelectionMode.None)
+ {
+ return SetEmptySelection(ir);
+ }
+ else if (mode is ItemsSelectionMode.Multiple)
+ {
+ if (shouldBaseFromSelectionState)
{
- sender.Tapped -= OnItemsRepeaterTapped;
+ return TryCoerceSelectionFromIndexes(ir)
+ || TryCoerceSelectionFromItems(ir)
+ || TryCoerceSelectionFromIndex(ir)
+ || TryCoerceSelectionFromItem(ir)
+ || SetEmptySelection(ir);
}
else
{
- // When transitioning from one command to another, there is no need to rewire the event.
- // Since the handler is setup to invoke the command in the DP.
+ return SetEmptySelection(ir);
+
}
+
}
- else if (e.NewValue is ICommand command)
+ else if (mode is ItemsSelectionMode.Single or ItemsSelectionMode.SingleOrNone)
{
- sender.Tapped += OnItemsRepeaterTapped;
+ if (shouldBaseFromSelectionState)
+ {
+ return TryCoerceSelectionFromIndex(ir)
+ || TryCoerceSelectionFromItem(ir)
+ || TryCoerceSelectionFromIndexes(ir)
+ || TryCoerceSelectionFromItems(ir)
+ || TrySetDefaultSelection(ir)
+ || SetEmptySelection(ir);
+ }
+ else
+ {
+ return TrySetDefaultSelection(ir)
+ || SetEmptySelection(ir);
+ }
+ }
+ else
+ {
+ throw new ArgumentOutOfRangeException($"ItemsSelectionMode: {mode}");
}
}
- private static void OnItemsRepeaterTapped(object sender, TappedRoutedEventArgs e)
+ return false;
+ }
+ private static bool TryCoerceSelectionFromIndex(ItemsRepeater ir)
+ {
+ if (GetSelectedIndex(ir) is { } index &&
+ 0 <= index && index < ir.ItemsSourceView.Count)
{
- // ItemsRepeater is more closely related to Panel than ItemsControl, and it cannot be templated.
- // It is safe to assume all direct children of IR are materialized item template,
- // and there can't be header/footer or wrapper (ItemContainer) among them.
+ return SetSelectionStates(ir, index);
+ }
- if (sender is not ItemsRepeater owner) return;
- if (e.OriginalSource is ItemsRepeater) return;
- if (e.OriginalSource is DependencyObject source)
+ return false;
+ }
+ private static bool TryCoerceSelectionFromItem(ItemsRepeater ir)
+ {
+ if (GetSelectedItem(ir) is { } item &&
+ ir.ItemsSourceView.IndexOf(item) is { } index && index != -1)
+ {
+ return SetSelectionStates(ir, index);
+ }
+
+ return false;
+ }
+ private static bool TryCoerceSelectionFromIndexes(ItemsRepeater ir)
+ {
+ // note: Do not accept partially matched indexes/items.
+ if (GetSelectedIndexes(ir) is { Count: > 0 } indexes &&
+ indexes.All(x => 0 <= x && x <= ir.ItemsSourceView.Count))
+ {
+ return SetSelectionStates(ir, indexes.ToArray());
+ }
+
+ return false;
+ }
+ private static bool TryCoerceSelectionFromItems(ItemsRepeater ir)
+ {
+ // note: Do not accept partially matched indexes/items.
+ if (GetSelectedItems(ir) is { Count: > 0 } items &&
+ items.Select(ir.ItemsSourceView.IndexOf).ToArray() is { } indexes &&
+ indexes.All(x => 0 <= x && x <= ir.ItemsSourceView.Count))
+ {
+ return SetSelectionStates(ir, indexes.ToArray());
+ }
+
+ return false;
+ }
+ private static bool TrySetDefaultSelection(ItemsRepeater ir)
+ {
+ if (GetSelectionMode(ir) is ItemsSelectionMode.Single)
+ {
+ if (ir.ItemsSourceView is { Count: > 0 } isv)
+ {
+ return SetSelectionStates(ir, indexes: 0);
+ }
+ }
+
+ return false;
+ }
+ private static bool SetEmptySelection(ItemsRepeater ir)
+ {
+ return SetSelectionStates(ir, indexes: null);
+ }
+ private static bool SetSelectionStates(ItemsRepeater ir, params int[]? indexes)
+ {
+ if (indexes is { Length: > 0 })
+ {
+ var items = indexes.Select(ir.ItemsSourceView.GetAt).ToArray();
+
+ SetSelectedIndex(ir, indexes[0]);
+ SetSelectedItem(ir, items[0]);
+ SetSelectedIndexes(ir, indexes);
+ SetSelectedItems(ir, items);
+ }
+ else
+ {
+ SetSelectedIndex(ir, -1);
+ SetSelectedItem(ir, null);
+ SetSelectedIndexes(ir, Array.Empty());
+ SetSelectedItems(ir, Array.Empty());
+ }
+
+ return true;
+ }
+
+ private static void SynchronizeMaterializedElementsSelection(ItemsRepeater ir)
+ {
+ if (GetSelectedIndexes(ir) is not { } indexes) throw new InvalidOperationException("Selection state is not ready.");
+
+ foreach (var element in ir.GetChildren())
+ {
+ if (element is UIElement uie &&
+ ir.GetElementIndex(uie) is var index && index != -1)
+ {
+ SetItemSelection(uie, indexes.Contains(index));
+ }
+ }
+ }
+ internal static void ToggleItemSelectionAtCoerced(ItemsRepeater ir, int index)
+ {
+ if (GetIsSynchronizingSelection(ir))
+ {
+ if (_logger.IsEnabled(LogLevel.Warning))
{
- // e.OriginalSource is the top-most element under the cursor.
- // In order to find the materialized item template, we have to walk up the visual-tree, to the first element right below IR:
- // ItemsRepeater > (item template root) > (layer0...n) > (tapped element)
- var element = source.GetAncestors(includeCurrent: true)
- .ZipSkipOne()
- .FirstOrDefault(x => x.Current is ItemsRepeater)
- .Previous;
- if (element is FrameworkElement fe)
+ _logger.Warn($"ToggleItemSelectionAtCoerced is invoked during selection synchronization.");
+ }
+ return;
+ }
+
+ try
+ {
+ SetIsSynchronizingSelection(ir, true);
+
+ var selection = GetSelectedIndexes(ir) ?? Array.Empty();
+ var updated = ItemsSelectionHelper.ToggleSelectionAtCoerced(
+ GetSelectionMode(ir),
+ ir.ItemsSourceView.Count,
+ selection,
+ index
+ );
+ var diffIndexes = new[] { index } // even if selection at index remains unchanged, the element is out of sync already from ToggleButton impl.
+ .Concat(updated.Except(selection))
+ .Concat(selection.Except(updated))
+ .Distinct()
+ .ToArray();
+
+ SetSelectionStates(ir, updated);
+ foreach (var diffIndex in diffIndexes)
+ {
+ if (ir.TryGetElement(diffIndex) is { } materialized)
{
- CommandExtensions.TryInvokeCommand(owner, CommandExtensions.GetCommandParameter(fe) ?? fe.DataContext);
+ SetItemSelection(materialized, updated.Contains(diffIndex));
+ }
+ else
+ {
+ // non-materialized element will be handled by OnItemsRepeaterElementPrepared when its is materializing
}
}
+
+ }
+ finally
+ {
+ SetIsSynchronizingSelection(ir, false);
+ }
+ }
+ internal static void SetItemSelection(DependencyObject x, bool value)
+ {
+ if (x is SelectorItem si)
+ {
+ si.IsSelected = value;
+ }
+ else if (x is ToggleButton toggle)
+ {
+ toggle.IsChecked = value;
+ }
+ else
+ {
+ // todo: generic item is not supported
}
}
}
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 DataTemplate? IconTemplate
#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..820702b55 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,19 +85,24 @@ 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 = ChipGroup?.SelectionMode;
+ if (mode is ChipSelectionMode.None)
+ {
+ SetIsCheckedSilently(false);
+ return;
+ }
+ if (mode is ChipSelectionMode.Single && IsChecked == true)
{
return;
}
diff --git a/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs b/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs
index 3471f604d..a257b3281 100644
--- a/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs
+++ b/src/Uno.Toolkit.UI/Controls/Chips/ChipGroup.cs
@@ -3,7 +3,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
-using Uno.Extensions.Specialized;
#if IS_WINUI
using Microsoft.UI.Xaml;
@@ -121,7 +120,7 @@ private void OnSelectedItemChanged()
Chip? GetCoercedSelection() =>
FindContainer(SelectedItem) ??
(SelectionMode is ChipSelectionMode.Single ? GetFallbackSelection() : default);
- Chip? GetFallbackSelection () =>
+ Chip? GetFallbackSelection() =>
GetItemContainers().FirstOrDefault(x => x.IsChecked == true) ??
GetItemContainers().FirstOrDefault();
}
@@ -293,8 +292,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
@@ -393,7 +390,7 @@ bool ShouldClearSelection(Chip container)
private bool IsReady => _isLoaded && HasItems && HasContainers;
- private bool HasItems => GetItems().Any();
+ private bool HasItems => GetItems().OfType().Any();
private bool HasContainers => GetItemContainers().Any();
@@ -416,7 +413,7 @@ ItemsSource as IEnumerable ??
// Because of this, we retrieve the container using the index instead.
if (item is Enum)
{
- var index = GetItems().IndexOf(item);
+ var index = GetItems().OfType().ToList().IndexOf(item);
if (index != -1)
{
return ContainerFromIndex(index) as Chip;
@@ -435,6 +432,5 @@ item as Chip ??
private IEnumerable GetItemContainers() =>
ItemsPanelRoot?.Children.OfType() ??
Enumerable.Empty();
-
}
}
diff --git a/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs b/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs
new file mode 100644
index 000000000..a6b95ded5
--- /dev/null
+++ b/src/Uno.Toolkit.UI/Helpers/ItemsSelectionHelper.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+#if IS_WINUI
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+#else
+using Windows.UI.Xaml;
+using ItemsSourceView = Microsoft.UI.Xaml.Controls.ItemsSourceView;
+using ItemsRepeater = Microsoft.UI.Xaml.Controls.ItemsRepeater;
+#endif
+
+namespace Uno.Toolkit.UI;
+
+public enum ItemsSelectionMode
+{
+ None = 1,
+ SingleOrNone,
+ Single,
+ Multiple,
+}
+
+internal static class ItemsSelectionHelper
+{
+#if HAS_UNO
+ public static int IndexOf(this ItemsSourceView isv, object item)
+ {
+ return Enumerable.Range(0, isv.Count)
+ .Cast()
+ .FirstOrDefault(x => isv.GetAt((int)x!) == item) ?? -1;
+ }
+#endif
+
+ public static int[] ToggleSelectionAtCoerced(ItemsSelectionMode mode, int length, IList selection, int index)
+ {
+ if (length < 0) throw new ArgumentOutOfRangeException(nameof(length));
+ if (0 > index || index >= length) throw new ArgumentOutOfRangeException(nameof(index));
+
+ if (mode is ItemsSelectionMode.None)
+ {
+ return Array.Empty();
+ }
+ else if (mode is ItemsSelectionMode.Single or ItemsSelectionMode.SingleOrNone)
+ {
+ var wasSelected = selection.Contains(index);
+ if (wasSelected && mode is ItemsSelectionMode.Single) // prevent deselection
+ {
+ return new int[] { index };
+ }
+
+ return wasSelected ? Array.Empty() : new int[] { index };
+ }
+ else if (mode is ItemsSelectionMode.Multiple)
+ {
+ var wasSelected = selection.Contains(index);
+ var newSelection = wasSelected
+ ? selection.Where(x => x != index)
+ : selection.Append(index);
+
+ return newSelection.ToArray();
+ }
+ else
+ {
+ throw new ArgumentOutOfRangeException(nameof(mode));
+ }
+ }
+
+ public static UIElement? FindRootElementOf(this ItemsRepeater ir, DependencyObject node)
+ {
+ // e.OriginalSource is the top-most element under the cursor.
+ // In order to find the materialized element, we have to walk up the visual-tree, to the first element right below IR:
+ // ItemsRepeater > (item template root) > (layer0...n) > (tapped element)
+ return node.GetAncestors(includeCurrent: true)
+ .ZipSkipOne()
+ .FirstOrDefault(x => x.Current is ItemsRepeater)
+ .Previous as UIElement;
+ }
+}
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" />
-
-