diff --git a/Directory.Build.props b/Directory.Build.props index 6ac5fe32..2ccb9d2b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,6 +9,7 @@ $(RepositoryDirectory)\components\Effects\src\CommunityToolkit.WinUI.Effects.csproj $(RepositoryDirectory)\components\Behaviors\src\CommunityToolkit.WinUI.Behaviors.csproj $(RepositoryDirectory)\components\Animations\src\CommunityToolkit.WinUI.Animations.csproj + $(RepositoryDirectory)\components\Primitives\src\CommunityToolkit.WinUI.Controls.Primitives.csproj diff --git a/components/TokenizingTextBox/OpenSolution.bat b/components/TokenizingTextBox/OpenSolution.bat new file mode 100644 index 00000000..814a56d4 --- /dev/null +++ b/components/TokenizingTextBox/OpenSolution.bat @@ -0,0 +1,3 @@ +@ECHO OFF + +powershell ..\..\tooling\ProjectHeads\GenerateSingleSampleHeads.ps1 -componentPath %CD% %* \ No newline at end of file diff --git a/components/TokenizingTextBox/samples/Assets/TokenizingTextBox.png b/components/TokenizingTextBox/samples/Assets/TokenizingTextBox.png new file mode 100644 index 00000000..91dd78f9 Binary files /dev/null and b/components/TokenizingTextBox/samples/Assets/TokenizingTextBox.png differ diff --git a/components/TokenizingTextBox/samples/Dependencies.props b/components/TokenizingTextBox/samples/Dependencies.props new file mode 100644 index 00000000..e622e1df --- /dev/null +++ b/components/TokenizingTextBox/samples/Dependencies.props @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/samples/SampleDataType.cs b/components/TokenizingTextBox/samples/SampleDataType.cs new file mode 100644 index 00000000..832741b0 --- /dev/null +++ b/components/TokenizingTextBox/samples/SampleDataType.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace TokenizingTextBoxExperiment.Samples; + +/// +/// Sample of strongly-typed data for . +/// +public class SampleDataType +{ + /// + /// Gets or sets symbol to display. + /// + public Symbol Icon { get; set; } + + /// + /// Gets or sets text to display. + /// + public string? Text { get; set; } + + public override string ToString() + { + return Text!; + } +} diff --git a/components/TokenizingTextBox/samples/TokenizingTextBox.Samples.csproj b/components/TokenizingTextBox/samples/TokenizingTextBox.Samples.csproj new file mode 100644 index 00000000..7f375002 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBox.Samples.csproj @@ -0,0 +1,8 @@ + + + TokenizingTextBox + + + + + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBox.md b/components/TokenizingTextBox/samples/TokenizingTextBox.md new file mode 100644 index 00000000..65b9a34f --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBox.md @@ -0,0 +1,19 @@ +--- +title: TokenizingTextBox +author: michael-hawker +description: A text input control that auto-suggests and displays token items. +keywords: TokenizingTextBox, control, tokens +dev_langs: + - csharp +category: Controls +subcategory: Input +discussion-id: 0 +issue-id: 0 +icon: Assets/TokenizingTextBox.png +--- + +# TokenizingTextBox + +The [TokenizingTextBox](/dotnet/api/microsoft.toolkit.uwp.ui.controls.tokenizingtextbox) is an advanced [AutoSuggestBox](/uwp/api/Windows.UI.Xaml.Controls.AutoSuggestBox) which will display selected items as tokens within the textbox. A user can easily see the picked items or remove them easily. + +> [!Sample TokenizingTextBoxSample] diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml new file mode 100644 index 00000000..54081441 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs new file mode 100644 index 00000000..738818c0 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Controls; + +namespace TokenizingTextBoxExperiment.Samples; + +[ToolkitSample(id: nameof(TokenizingTextBoxSample), "Basic sample", description: $"A sample for showing how to create and use a {nameof(TokenizingTextBox)}.")] +public sealed partial class TokenizingTextBoxSample : Page +{ + public readonly List _samples = new List() + { + new SampleDataType() { Text = "Account", Icon = Symbol.Account }, + new SampleDataType() { Text = "Add friend", Icon = Symbol.AddFriend }, + new SampleDataType() { Text = "Attach", Icon = Symbol.Attach }, + new SampleDataType() { Text = "Attach camera", Icon = Symbol.AttachCamera }, + new SampleDataType() { Text = "Audio", Icon = Symbol.Audio }, + new SampleDataType() { Text = "Block contact", Icon = Symbol.BlockContact }, + new SampleDataType() { Text = "Calculator", Icon = Symbol.Calculator }, + new SampleDataType() { Text = "Calendar", Icon = Symbol.Calendar }, + new SampleDataType() { Text = "Camera", Icon = Symbol.Camera }, + new SampleDataType() { Text = "Contact", Icon = Symbol.Contact }, + new SampleDataType() { Text = "Favorite", Icon = Symbol.Favorite }, + new SampleDataType() { Text = "Link", Icon = Symbol.Link }, + new SampleDataType() { Text = "Mail", Icon = Symbol.Mail }, + new SampleDataType() { Text = "Map", Icon = Symbol.Map }, + new SampleDataType() { Text = "Phone", Icon = Symbol.Phone }, + new SampleDataType() { Text = "Pin", Icon = Symbol.Pin }, + new SampleDataType() { Text = "Rotate", Icon = Symbol.Rotate }, + new SampleDataType() { Text = "Rotate camera", Icon = Symbol.RotateCamera }, + new SampleDataType() { Text = "Send", Icon = Symbol.Send }, + new SampleDataType() { Text = "Tags", Icon = Symbol.Tag }, + new SampleDataType() { Text = "UnFavorite", Icon = Symbol.UnFavorite }, + new SampleDataType() { Text = "UnPin", Icon = Symbol.UnPin }, + new SampleDataType() { Text = "Zoom", Icon = Symbol.Zoom }, + new SampleDataType() { Text = "ZoomIn", Icon = Symbol.ZoomIn }, + new SampleDataType() { Text = "ZoomOut", Icon = Symbol.ZoomOut }, + }; + + public ObservableCollection SelectedTokens { get; set; } + + public TokenizingTextBoxSample() + { + this.InitializeComponent(); + SelectedTokens = new() + { + _samples[0], + _samples[1] + }; + + } + + private void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + currentEdit.Text = TokenBox.Text; + SetSelectedTokenText(); + } + + private void SetSelectedTokenText() + { + selectedItemsString.Text = TokenBox.SelectedTokenText; + } + + private void TokenItemCreating(object sender, TokenItemAddingEventArgs e) + { + // Take the user's text and convert it to our data type (if we have a matching one). +#if !HAS_UNO + e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText, StringComparison.CurrentCultureIgnoreCase)); +#else + e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText)); +#endif + // Otherwise, create a new version of our data type + if (e.Item == null) + { + e.Item = new SampleDataType() + { + Text = e.TokenText, + Icon = Symbol.OutlineStar + }; + } + } + + private void TokenBox_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is SampleDataType selectedItem) + { + clickedItem.Text = selectedItem.Text!; + } + } + + private void TokenBox_Loaded(object sender, RoutedEventArgs e) + { + SetSelectedTokenText(); + } +} diff --git a/components/TokenizingTextBox/src/AdditionalAssemblyInfo.cs b/components/TokenizingTextBox/src/AdditionalAssemblyInfo.cs new file mode 100644 index 00000000..cfffc6c1 --- /dev/null +++ b/components/TokenizingTextBox/src/AdditionalAssemblyInfo.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +// These `InternalsVisibleTo` calls are intended to make it easier for +// for any internal code to be testable in all the different test projects +// used with the Labs infrastructure. +[assembly: InternalsVisibleTo("TokenizingTextBox.Tests.Uwp")] +[assembly: InternalsVisibleTo("TokenizingTextBox.Tests.WinAppSdk")] +[assembly: InternalsVisibleTo("CommunityToolkit.Tests.Uwp")] +[assembly: InternalsVisibleTo("CommunityToolkit.Tests.WinAppSdk")] diff --git a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj new file mode 100644 index 00000000..292dfbb9 --- /dev/null +++ b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj @@ -0,0 +1,23 @@ + + + TokenizingTextBox + This package contains TokenizingTextBox. + 0.0.1 + + + CommunityToolkit.WinUI.Controls.TokenizingTextBoxRns + + + + + + + + + + + + + $(PackageIdPrefix).$(PackageIdVariant).Controls.$(ToolkitComponentName) + + diff --git a/components/TokenizingTextBox/src/Dependencies.props b/components/TokenizingTextBox/src/Dependencies.props new file mode 100644 index 00000000..e622e1df --- /dev/null +++ b/components/TokenizingTextBox/src/Dependencies.props @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/src/ITokenStringContainer.cs b/components/TokenizingTextBox/src/ITokenStringContainer.cs new file mode 100644 index 00000000..2c846849 --- /dev/null +++ b/components/TokenizingTextBox/src/ITokenStringContainer.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// Provides access to unresolved token string values within the tokenizing text box control +/// +public interface ITokenStringContainer +{ + /// + /// Gets or sets the string text for this unresolved token + /// + string Text { get; set; } + + /// + /// Gets a value indicating whether this is the last text based token in the collection as it will always remain at the end. + /// + bool IsLast { get; } +} diff --git a/components/TokenizingTextBox/src/InterspersedObservableCollection.cs b/components/TokenizingTextBox/src/InterspersedObservableCollection.cs new file mode 100644 index 00000000..74a3b81c --- /dev/null +++ b/components/TokenizingTextBox/src/InterspersedObservableCollection.cs @@ -0,0 +1,433 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections; +using System.Collections.Specialized; +using CommunityToolkit.WinUI.Helpers; + +namespace CommunityToolkit.WinUI.Controls; + +//// We need to implement the IList interface here for ListViewBase to listen to changes - https://github.com/microsoft/microsoft-ui-xaml/issues/1809 + +#pragma warning disable CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member (possibly because of nullability attributes). +#pragma warning disable CS8622 +#pragma warning disable CS8603 +#pragma warning disable CS8714 +internal class InterspersedObservableCollection : IList, IEnumerable, INotifyCollectionChanged +{ + public IList ItemsSource { get; private set; } + + public bool IsFixedSize => false; + + public bool IsReadOnly => false; + + public int Count => ItemsSource.Count + _interspersedObjects.Count; + + public bool IsSynchronized => false; + + public object SyncRoot => new object(); + + public object this[int index] + { + get + { + if (_interspersedObjects.TryGetValue(index, out var value)) + { + return value; + } + else + { + // Find out the number of elements in our dictionary with keys below ours. + return ItemsSource[ToInnerIndex(index)]; + } + } + set => throw new NotImplementedException(); + } + + private Dictionary _interspersedObjects = new Dictionary(); + private bool _isInsertingOriginal = false; + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public InterspersedObservableCollection(object itemsSource) + { + if (!(itemsSource is IList list)) + { + ThrowArgumentException(); + } + + ItemsSource = list; + + if (ItemsSource is INotifyCollectionChanged notifier) + { + var weakPropertyChangedListener = new WeakEventListener(this) + { + OnEventAction = static (instance, source, eventArgs) => instance.ItemsSource_CollectionChanged(source, eventArgs), + OnDetachAction = (weakEventListener) => notifier.CollectionChanged -= weakEventListener.OnEvent // Use Local Reference Only + }; + notifier.CollectionChanged += weakPropertyChangedListener.OnEvent; + } + + static void ThrowArgumentException() => throw new ArgumentNullException("The input items source must be assignable to the System.Collections.IList type."); + } + + private void ItemsSource_CollectionChanged(object source, NotifyCollectionChangedEventArgs eventArgs) + { + switch (eventArgs.Action) + { + case NotifyCollectionChangedAction.Add: + // Shift any existing interspersed items after the inserted item + var count = eventArgs.NewItems!.Count; + + if (count > 0) + { + if (!_isInsertingOriginal) + { + MoveKeysForward(eventArgs.NewStartingIndex, count); + } + + _isInsertingOriginal = false; + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, + eventArgs.NewItems, + ToOuterIndex(eventArgs.NewStartingIndex))); + } + + break; + case NotifyCollectionChangedAction.Remove: + count = eventArgs.OldItems!.Count; + + if (count > 0) + { + var outerIndex = ToOuterIndexAfterRemoval(eventArgs.OldStartingIndex); + + MoveKeysBackward(outerIndex, count); + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Remove, + eventArgs.OldItems, + outerIndex)); + } + + break; + case NotifyCollectionChangedAction.Reset: + + ReadjustKeys(); + + // TODO: ListView doesn't like this notification and throws a visual tree duplication exception... + // Not sure what to do with that yet... + CollectionChanged?.Invoke(this, eventArgs); + break; + } + } + + /// + /// Moves our interspersed keys at or past the given index forward by the amount. + /// + /// index of added item + /// by how many + private void MoveKeysForward(int pivot, int amount) + { + // Sort in reverse order to work from highest to lowest + var keys = _interspersedObjects.Keys.OrderByDescending(v => v).ToArray(); + foreach (var key in keys) + { + if (key < pivot) //// If it's the last item in the collection, we still want to move our last key, otherwise we'd use <= + { + break; + } + + _interspersedObjects[key + amount] = _interspersedObjects[key]; + _interspersedObjects.Remove(key); + } + } + + /// + /// Moves our interspersed keys at or past the given index backward by the amount. + /// + /// index of removed item + /// by how many + private void MoveKeysBackward(int pivot, int amount) + { + // Sort in regular order to work from the earliest indices onwards + var keys = _interspersedObjects.Keys.OrderBy(v => v).ToArray(); + foreach (var key in keys) + { + // Skip elements before the pivot point + if (key <= pivot) //// Include pivot point as that's the point where we start modifying beyond + { + continue; + } + + _interspersedObjects[key - amount] = _interspersedObjects[key]; + _interspersedObjects.Remove(key); + } + } + + /// + /// Condenses our interspersed keys around any remaining items, mainly for when the original collection is reset. + /// + private void ReadjustKeys() + { + var count = ItemsSource.Count; + var existing = 0; + + var keys = _interspersedObjects.Keys.OrderBy(v => v).ToArray(); + foreach (var key in keys) + { + if (key <= count) + { + existing++; + continue; + } + + _interspersedObjects[count + existing++] = _interspersedObjects[key]; + _interspersedObjects.Remove(key); + } + } + + /// + /// Takes an index from the entire collection and maps it to the inner collection index. Assumes, mapping is valid. + /// + /// Index into the entire collection. + /// Inner ItemsSource Index. + private int ToInnerIndex(int outerIndex) + { + if ((uint)outerIndex >= Count) + { + ThrowArgumentOutOfRangeException(); + } + + if (_interspersedObjects.ContainsKey(outerIndex)) + { + ThrowArgumentException(); + } + + return outerIndex - _interspersedObjects.Keys.Count(key => key!.Value <= outerIndex); + + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(outerIndex)); + static void ThrowArgumentException() => throw new ArgumentException("The outer index can't be inserted as a key to the original collection."); + } + + /// + /// Takes an index from the inner collection and maps it to an index for this entire collection. + /// + /// Index into the ItemsSource. + /// Index into the entire collection. + private int ToOuterIndex(int innerIndex) + { + if ((uint)innerIndex >= ItemsSource.Count) + { + ThrowArgumentOutOfRangeException(); + } + + var keys = _interspersedObjects.OrderBy(v => v.Key); + + foreach (var key in keys) + { + if (innerIndex >= key.Key) + { + innerIndex++; + } + else + { + break; + } + } + + return innerIndex; + + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(innerIndex)); + } + + /// + /// Takes an index from the inner collection and maps it to an index for this entire collection, projects as if an element from the provided index was still in the collection. + /// + /// Previous index from ItemsSource + /// Projected index in the entire collection. + private int ToOuterIndexAfterRemoval(int innerIndexToProject) + { + if ((uint)innerIndexToProject >= ItemsSource.Count + 1) + { + ThrowArgumentOutOfRangeException(); + } + + //// TODO: Deal with bounds (0 / Count)? Or is it the same? + + var keys = _interspersedObjects.OrderBy(v => v.Key); + + foreach (var key in keys) + { + if (innerIndexToProject >= key.Key) + { + innerIndexToProject++; + } + else + { + break; + } + } + + return innerIndexToProject; + + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(innerIndexToProject)); + } + + /// + /// Inserts an item to intersperse with the underlying collection, but not be part of the underlying collection itself. + /// + /// Position to insert the item at. + /// Item to intersperse + public void Insert(int index, object obj) + { + MoveKeysForward(index, 1); // Move existing keys at index over to make room for new item + + _interspersedObjects[index] = obj; + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, obj, index)); + } + + /// + /// Inserts an item into the underlying collection and moves interspersed items such that the provide item will appear at the provided index as part of the whole collection. + /// + /// Position to insert the item at. + /// Item to place in wrapped collection. + public void InsertAt(int outerIndex, object obj) + { + // Find out our closest index based on interspersed keys + var index = outerIndex - _interspersedObjects.Keys.Count(key => key!.Value < outerIndex); // Note: we exclude the = from ToInnerIndex here + + // If we're inserting where we would normally, then just do that, otherwise we need extra room to not move other keys + if (index != outerIndex) + { + MoveKeysForward(outerIndex, 1); // Skip over until the current spot unlike normal + + _isInsertingOriginal = true; // Prevent Collection callback from moving keys forward on insert + } + + // Insert into original collection + ItemsSource.Insert(index, obj); + + // TODO: handle manipulation/notification if not observable + } + + public IEnumerator GetEnumerator() + { + int i = 0; // Index of our current 'virtual' position + int count = 0; + int realized = 0; + + foreach (var element in ItemsSource) + { + while (_interspersedObjects.TryGetValue(i++, out var obj)) + { + realized++; // Track interspersed items used + + yield return obj; + } + + count++; // Track original items used + + yield return element; + } + + // Add any remaining items in our interspersed collection past the index we reached in the original collection + if (realized < _interspersedObjects.Count) + { + // Only select items past our current index, but make sure we've sorted them by their index as well. + foreach (var keyValue in _interspersedObjects.Where(kvp => kvp.Key >= i).OrderBy(kvp => kvp.Key)) + { + yield return keyValue.Value; + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public int Add(object value) + { + var index = ItemsSource.Add(value); //// TODO: If the collection isn't observable, we should do manipulations/notifications here...? + return ToOuterIndex(index); + } + + public void Clear() + { + ItemsSource.Clear(); + _interspersedObjects.Clear(); + } + + public bool Contains(object value) + { + return _interspersedObjects.ContainsValue(value) || ItemsSource.Contains(value); + } + + /// + /// Looks up an item's key in the _interspersedObject dictionary by its value. Handles nulls. + /// + /// Search value + /// KeyValuePair or default KeyValuePair + private KeyValuePair ItemKeySearch(object value) + { + if (value == null) + { + return _interspersedObjects.FirstOrDefault(kvp => kvp.Value == null); + } + + return _interspersedObjects.FirstOrDefault(kvp => kvp.Value?.Equals(value) == true); + } + + public int IndexOf(object value) + { + var item = ItemKeySearch(value); + + if (item.Key != null) + { + return item.Key.Value; + } + else + { + int index = ItemsSource.IndexOf(value); + + // Find out the number of elements in our dictionary with keys below ours. + return index == -1 ? -1 : ToOuterIndex(index); + } + } + + public void Remove(object value) + { + var item = ItemKeySearch(value); + + if (item.Key != null) + { + _interspersedObjects.Remove(item.Key); + + MoveKeysBackward(item.Key.Value, 1); // Move other interspersed items back + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item.Value, item.Key.Value)); + } + else + { + ItemsSource.Remove(value); // TODO: If not observable, update indices? + } + } + + public void RemoveAt(int index) + { + throw new NotImplementedException(); + } + + public void CopyTo(Array array, int index) + { + throw new NotImplementedException(); + } +} + +#pragma warning restore CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member (possibly because of nullability attributes). +#pragma warning disable CS8622 +#pragma warning disable CS8603 +#pragma warning disable CS8714 diff --git a/components/TokenizingTextBox/src/MultiTarget.props b/components/TokenizingTextBox/src/MultiTarget.props new file mode 100644 index 00000000..b11c1942 --- /dev/null +++ b/components/TokenizingTextBox/src/MultiTarget.props @@ -0,0 +1,9 @@ + + + + uwp;wasdk;wpf;wasm;linuxgtk;macos;ios;android; + + \ No newline at end of file diff --git a/components/TokenizingTextBox/src/PretokenStringContainer.cs b/components/TokenizingTextBox/src/PretokenStringContainer.cs new file mode 100644 index 00000000..d6b44931 --- /dev/null +++ b/components/TokenizingTextBox/src/PretokenStringContainer.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// support class +/// +internal partial class PretokenStringContainer : DependencyObject, ITokenStringContainer +{ + public string Text + { + get { return (string)GetValue(TextProperty); } + set { SetValue(TextProperty, value); } + } + + // Using a DependencyProperty as the backing store for Text. This enables animation, styling, binding, etc... + public static readonly DependencyProperty TextProperty = + DependencyProperty.Register(nameof(Text), typeof(string), typeof(PretokenStringContainer), new PropertyMetadata(string.Empty)); + + public bool IsLast { get; private set; } + + public PretokenStringContainer(bool isLast = false) + { + IsLast = isLast; + } + + public PretokenStringContainer(string text) + { + Text = text; + } + + /// + /// Override and provide the content of the container on ToString() so the calling app can access the token string + /// + /// The content of the string token + public override string ToString() + { + return Text; + } +} diff --git a/components/TokenizingTextBox/src/Themes/Generic.xaml b/components/TokenizingTextBox/src/Themes/Generic.xaml new file mode 100644 index 00000000..a07801ea --- /dev/null +++ b/components/TokenizingTextBox/src/Themes/Generic.xaml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs b/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs new file mode 100644 index 00000000..fa638ea5 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Common.Deferred; + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// Event arguments for event. +/// +public class TokenItemAddingEventArgs : DeferredCancelEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// User entered string. + public TokenItemAddingEventArgs(string token) + { + TokenText = token; + } + + /// + /// Gets token as typed by the user. + /// + public string TokenText { get; private set; } + + /// + /// Gets or sets the item to be added to the . If null, string will be added. + /// + public object? Item { get; set; } = null; +} diff --git a/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs b/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs new file mode 100644 index 00000000..78e7959e --- /dev/null +++ b/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Common.Deferred; + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// Event arguments for event. +/// +public class TokenItemRemovingEventArgs : DeferredCancelEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// Item being removed. + /// container being closed. + public TokenItemRemovingEventArgs(object item, TokenizingTextBoxItem token) + { + Item = item; + Token = token; + } + + /// + /// Gets the Item being closed. + /// + public object Item { get; private set; } + + /// + /// Gets the being removed. + /// + public TokenizingTextBoxItem Token { get; private set; } +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Events.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Events.cs new file mode 100644 index 00000000..4dfabe30 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Events.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// A text input control that auto-suggests and displays token items. +/// +public partial class TokenizingTextBox : ListViewBase +{ + /// + /// Event raised when the text input value has changed. + /// + public event TypedEventHandler TextChanged; + + /// + /// Event raised when a suggested item is chosen by the user. + /// + public event TypedEventHandler SuggestionChosen; + + /// + /// Event raised when the user submits the text query. + /// + public event TypedEventHandler QuerySubmitted; + + /// + /// Event raised before a new token item is created from a string, can be used to transform data type from text user entered. + /// + public event TypedEventHandler TokenItemAdding; + + /// + /// Event raised when a new token item has been added. + /// + public event TypedEventHandler TokenItemAdded; + + /// + /// Event raised when a token item is about to be removed. Can be canceled to prevent removal of a token. + /// + public event TypedEventHandler TokenItemRemoving; + + /// + /// Event raised after a token has been removed. + /// + public event TypedEventHandler TokenItemRemoved; +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs new file mode 100644 index 00000000..7c73057b --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs @@ -0,0 +1,351 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// A text input control that auto-suggests and displays token items. +/// +public partial class TokenizingTextBox : ListViewBase +{ + /// + /// Identifies the property. + /// + public static readonly DependencyProperty AutoSuggestBoxStyleProperty = DependencyProperty.Register( + nameof(AutoSuggestBoxStyle), + typeof(Style), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty AutoSuggestBoxTextBoxStyleProperty = DependencyProperty.Register( + nameof(AutoSuggestBoxTextBoxStyle), + typeof(Style), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TextMemberPathProperty = DependencyProperty.Register( + nameof(TextMemberPath), + typeof(string), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TokenItemTemplateProperty = DependencyProperty.Register( + nameof(TokenItemTemplate), + typeof(DataTemplate), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TokenItemTemplateSelectorProperty = DependencyProperty.Register( + nameof(TokenItemTemplateSelector), + typeof(DataTemplateSelector), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TokenDelimiterProperty = DependencyProperty.Register( + nameof(TokenDelimiter), + typeof(string), + typeof(TokenizingTextBox), + new PropertyMetadata(" ")); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TokenSpacingProperty = DependencyProperty.Register( + nameof(TokenSpacing), + typeof(double), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty PlaceholderTextProperty = DependencyProperty.Register( + nameof(PlaceholderText), + typeof(string), + typeof(TokenizingTextBox), + new PropertyMetadata(string.Empty)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty QueryIconProperty = DependencyProperty.Register( + nameof(QueryIcon), + typeof(IconSource), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TextProperty = DependencyProperty.Register( + nameof(Text), + typeof(string), + typeof(TokenizingTextBox), + new PropertyMetadata(string.Empty, TextPropertyChanged)); + + private static void TextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TokenizingTextBox ttb && ttb._currentTextEdit != null) + { + if (e.NewValue is string newValue) + { + ttb._currentTextEdit.Text = newValue; + + // Notify inner container of text change, see issue #4749 + var item = ttb.ContainerFromItem(ttb._currentTextEdit) as TokenizingTextBoxItem; + item?.UpdateText(ttb._currentTextEdit.Text); + } + } + } + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty SuggestedItemsSourceProperty = DependencyProperty.Register( + nameof(SuggestedItemsSource), + typeof(object), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty SuggestedItemTemplateProperty = DependencyProperty.Register( + nameof(SuggestedItemTemplate), + typeof(DataTemplate), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty SuggestedItemTemplateSelectorProperty = DependencyProperty.Register( + nameof(SuggestedItemTemplateSelector), + typeof(DataTemplateSelector), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty SuggestedItemContainerStyleProperty = DependencyProperty.Register( + nameof(SuggestedItemContainerStyle), + typeof(Style), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TabNavigateBackOnArrowProperty = DependencyProperty.Register( + nameof(TabNavigateBackOnArrow), + typeof(bool), + typeof(TokenizingTextBox), + new PropertyMetadata(false)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty MaximumTokensProperty = DependencyProperty.Register( + nameof(MaximumTokens), + typeof(int), + typeof(TokenizingTextBox), + new PropertyMetadata(null, OnMaximumTokensChanged)); + + private static void OnMaximumTokensChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TokenizingTextBox ttb && ttb.ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && e.NewValue is int newMaxTokens) + { + var tokenCount = ttb._innerItemsSource.ItemsSource.Count; + if (tokenCount > 0 && tokenCount > newMaxTokens) + { + int tokensToRemove = tokenCount - Math.Max(newMaxTokens, 0); + + // Start at the end, remove any extra tokens. + for (var i = tokenCount; i > tokenCount - tokensToRemove; --i) + { + var token = ttb._innerItemsSource.ItemsSource[i - 1]; + + if (token != null) + { + // Force remove the items. No warning and no option to cancel. + ttb._innerItemsSource.Remove(token!); + ttb.TokenItemRemoved?.Invoke(ttb, token); + } + } + } + } + } + + /// + /// Gets or sets the Style for the contained AutoSuggestBox template part. + /// + public Style AutoSuggestBoxStyle + { + get => (Style)GetValue(AutoSuggestBoxStyleProperty); + set => SetValue(AutoSuggestBoxStyleProperty, value); + } + + /// + /// Gets or sets the Style for the TextBox part of the AutoSuggestBox template part. + /// + public Style AutoSuggestBoxTextBoxStyle + { + get => (Style)GetValue(AutoSuggestBoxStyleProperty); + set => SetValue(AutoSuggestBoxStyleProperty, value); + } + + /// + /// Gets or sets the TextMemberPath of the AutoSuggestBox template part. + /// + public string TextMemberPath + { + get => (string)GetValue(TextMemberPathProperty); + set => SetValue(TextMemberPathProperty, value); + } + + /// + /// Gets or sets the template for token items. + /// + public DataTemplate TokenItemTemplate + { + get => (DataTemplate)GetValue(TokenItemTemplateProperty); + set => SetValue(TokenItemTemplateProperty, value); + } + + /// + /// Gets or sets the template selector for token items. + /// + public DataTemplateSelector TokenItemTemplateSelector + { + get => (DataTemplateSelector)GetValue(TokenItemTemplateSelectorProperty); + set => SetValue(TokenItemTemplateSelectorProperty, value); + } + + /// + /// Gets or sets delimiter used to determine when to process text input as a new token item. + /// + public string TokenDelimiter + { + get => (string)GetValue(TokenDelimiterProperty); + set => SetValue(TokenDelimiterProperty, value); + } + + /// + /// Gets or sets the spacing value used to separate token items. + /// + public double TokenSpacing + { + get => (double)GetValue(TokenSpacingProperty); + set => SetValue(TokenSpacingProperty, value); + } + + /// + /// Gets or sets the PlaceholderText for the AutoSuggestBox template part. + /// + public string PlaceholderText + { + get => (string)GetValue(PlaceholderTextProperty); + set => SetValue(PlaceholderTextProperty, value); + } + + /// + /// Gets or sets the icon to display in the AutoSuggestBox template part. + /// + public IconSource QueryIcon + { + get => (IconSource)GetValue(QueryIconProperty); + set => SetValue(QueryIconProperty, value); + } + + /// + /// Gets or sets the input text of the currently active text edit. + /// + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + /// + /// Gets or sets the items source for token suggestions. + /// + public object SuggestedItemsSource + { + get => GetValue(SuggestedItemsSourceProperty); + set => SetValue(SuggestedItemsSourceProperty, value); + } + + /// + /// Gets or sets the template for displaying suggested tokens. + /// + public DataTemplate SuggestedItemTemplate + { + get => (DataTemplate)GetValue(SuggestedItemTemplateProperty); + set => SetValue(SuggestedItemTemplateProperty, value); + } + + /// + /// Gets or sets the template selector for displaying suggested tokens. + /// + public DataTemplateSelector SuggestedItemTemplateSelector + { + get => (DataTemplateSelector)GetValue(SuggestedItemTemplateSelectorProperty); + set => SetValue(SuggestedItemTemplateSelectorProperty, value); + } + + /// + /// Gets or sets the item container style for displaying suggested tokens. + /// + public Style SuggestedItemContainerStyle + { + get => (Style)GetValue(SuggestedItemContainerStyleProperty); + set => SetValue(SuggestedItemContainerStyleProperty, value); + } + + /// + /// Gets or sets a value indicating whether the control will move focus to the previous + /// control when an arrow key is pressed and selection is at one of the limits in the control. + /// + public bool TabNavigateBackOnArrow + { + get => (bool)GetValue(TabNavigateBackOnArrowProperty); + set => SetValue(TabNavigateBackOnArrowProperty, value); + } + + /// + /// Gets the complete text value of any selection in the control. The result is the same text as would be copied to the clipboard. + /// + public string SelectedTokenText + { + get + { + return PrepareSelectionForClipboard(); + } + } + + /// + /// Gets or sets the maximum number of token results allowed at a time. + /// + public int MaximumTokens + { + get => (int)GetValue(MaximumTokensProperty); + set => SetValue(MaximumTokensProperty, value); + } +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs new file mode 100644 index 00000000..0b20a6fe --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs @@ -0,0 +1,387 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.ApplicationModel.DataTransfer; + +#if WINAPPSDK +using Microsoft.UI.Dispatching; +#else +using Windows.System; +#endif + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// Methods related to Selection of items in the . +/// + +#pragma warning disable CS8602 +public partial class TokenizingTextBox +{ + private enum MoveDirection + { + Next, + Previous + } + + /// + /// Adjust the selected item and range based on keyboard input. + /// This is used to override the listview behaviors for up/down arrow manipulation vs left/right for a horizontal control + /// + /// direction to move the selection + /// True if the focus was moved, false otherwise + private bool MoveFocusAndSelection(MoveDirection direction) + { + bool retVal = false; + var currentContainerItem = GetCurrentContainerItem(); + + if (currentContainerItem != null) + { + var currentItem = ItemFromContainer(currentContainerItem); + var previousIndex = Items.IndexOf(currentItem); + var index = previousIndex; + + if (direction == MoveDirection.Previous) + { + if (previousIndex > 0) + { + index -= 1; + } + else + { + if (TabNavigateBackOnArrow) + +#if WINAPPSDK +{ +FocusManager.TryMoveFocus(FocusNavigationDirection.Previous, new FindNextElementOptions + { + SearchRoot = XamlRoot.Content! + }); + } +#else + FocusManager.TryMoveFocus(FocusNavigationDirection.Previous); +#endif + + retVal = true; + } + } + else if (direction == MoveDirection.Next) + { + if (previousIndex < Items.Count - 1) + { + index += 1; + } + } + + // Only do stuff if the index is actually changing + if (index != previousIndex) + { + if (ContainerFromIndex(index) is TokenizingTextBoxItem newItem) + { + // Check for the new item being a text control. + // this must happen before focus is set to avoid seeing the caret + // jump in come cases + if (Items[index] is ITokenStringContainer && !IsShiftPressed) + { + newItem._autoSuggestTextBox.SelectionLength = 0; + newItem._autoSuggestTextBox.SelectionStart = direction == MoveDirection.Next + ? 0 + : newItem._autoSuggestTextBox.Text.Length; + } + + newItem.Focus(FocusState.Keyboard); + + // if no control keys are selected then the selection also becomes just this item + if (IsShiftPressed) + { + // What we do here depends on where the selection started + // if the previous item is between the start and new position then we add the new item to the selected range + // if the new item is between the start and the previous position then we remove the previous position + int newDistance = Math.Abs(SelectedIndex - index); + int oldDistance = Math.Abs(SelectedIndex - previousIndex); + + if (newDistance > oldDistance) + { + SelectedItems.Add(Items[index]); + } + else + { + SelectedItems.Remove(Items[previousIndex]); + } + } + else if (!IsControlPressed) + { + SelectedIndex = index; + + // This looks like a bug in the underlying ListViewBase control. + // Might need to be reviewed if the base behavior is fixed + // When two consecutive items are selected and the navigation moves between them, + // the first time that happens the old focused item is not unselected + if (SelectedItems.Count > 1) + { + SelectedItems.Clear(); + SelectedIndex = index; + } + } + + retVal = true; + } + } + } + + return retVal; + } + + private TokenizingTextBoxItem? GetCurrentContainerItem() + { + if (IsXamlRootAvailable && XamlRoot != null) + { + return FocusManager.GetFocusedElement(XamlRoot) as TokenizingTextBoxItem; + } + else + { + return FocusManager.GetFocusedElement() as TokenizingTextBoxItem; + } + } + + internal void SelectAllTokensAndText() + { + _ = _dispatcherQueue.EnqueueAsync(() => + { + this.SelectAllSafe(); + + // need to synchronize the select all and the focus behavior on the text box + // because there is no way to identify that the focus has been set from this point + // to avoid instantly clearing the selection of tokens + PauseTokenClearOnFocus = true; + + foreach (var item in Items) + { + if (item is ITokenStringContainer) + { + // grab any selected text + if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken) + { + pretoken._autoSuggestTextBox.SelectionStart = 0; + pretoken._autoSuggestTextBox.SelectionLength = pretoken._autoSuggestTextBox.Text.Length; + } + } + } + + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Programmatic); + } + }, DispatcherQueuePriority.Normal); + } + + internal void DeselectAllTokensAndText(TokenizingTextBoxItem? ignoreItem = null) + { + this.DeselectAll(); + ClearAllTextSelections(ignoreItem); + } + + private void ClearAllTextSelections(TokenizingTextBoxItem? ignoreItem) + { + // Clear any selection in the text box + foreach (var item in Items) + { + if (item is ITokenStringContainer) + { + if (ContainerFromItem(item) is TokenizingTextBoxItem container) + { + if (container != ignoreItem) + { + container._autoSuggestTextBox.SelectionLength = 0; + } + } + } + } + } + /// + /// Select the previous item in the list, if one is available. Called when moving from textbox to token. + /// + /// identifies the current item + /// a value indicating whether the previous item was successfully selected + internal bool SelectPreviousItem(TokenizingTextBoxItem item) + { + return SelectNewItem(item, -1, i => i > 0); + } + + /// + /// Select the next item in the list, if one is available. Called when moving from textbox to token. + /// + /// identifies the current item + /// a value indicating whether the next item was successfully selected, false if nothing was changed + internal bool SelectNextItem(TokenizingTextBoxItem item) + { + return SelectNewItem(item, 1, i => i < Items.Count - 1); + } + + private bool SelectNewItem(TokenizingTextBoxItem item, int increment, Func testFunc) + { + bool returnVal = false; + + // find the item in the list + var currentIndex = IndexFromContainer(item); + + // Select previous token item (if there is one). + if (testFunc(currentIndex)) + { + if (ContainerFromItem(Items[currentIndex + increment]) is ListViewItem newItem) + { + newItem.Focus(FocusState.Keyboard); + SelectedItems.Add(Items[currentIndex + increment]); + returnVal = true; + } + } + + return returnVal; + } + + private async void TokenizingTextBoxItem_ClearAllAction(TokenizingTextBoxItem sender, RoutedEventArgs args) + { + // find the first item selected + int newSelectedIndex = -1; + + if (SelectedRanges.Count > 0) + { + newSelectedIndex = SelectedRanges[0].FirstIndex - 1; + } + + await RemoveAllSelectedTokens(); + + SelectedIndex = newSelectedIndex; + + if (newSelectedIndex == -1) + { + newSelectedIndex = Items.Count - 1; + } + + // focus the item prior to the first selected item + if (ContainerFromIndex(newSelectedIndex) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Keyboard); + } + } + + private async void TokenizingTextBoxItem_ClearClicked(TokenizingTextBoxItem sender, RoutedEventArgs? args) + { + await RemoveTokenAsync(sender); + } + + /// + /// Remove any tokens that are in the selected list, except for the last text box or the currently selected item + /// + /// async task + internal async Task RemoveAllSelectedTokens() + { + var currentContainerItem = GetCurrentContainerItem(); + + while (SelectedItems.Count > 0) + { + if (ContainerFromItem(SelectedItems[0]) is TokenizingTextBoxItem container) + { + + if (IndexFromContainer(container) != Items.Count - 1) + { + // if its a text box, remove any selected text, and if its then empty remove the container, unless its focused + if (SelectedItems[0] is ITokenStringContainer) + { + var asb = container._autoSuggestTextBox; + + // grab any selected text + var tempStr = asb.SelectionStart == 0 + ? string.Empty + : asb.Text.Substring( + 0, + asb.SelectionStart); + tempStr += + asb.SelectionStart + + asb.SelectionLength < asb.Text.Length + ? asb.Text.Substring( + asb.SelectionStart + + asb.SelectionLength) + : string.Empty; + + if (tempStr.Length == 0) + { + // Need to be careful not to remove the last item in the list + await RemoveTokenAsync(container); + } + else + { + asb.Text = tempStr; + } + } + else + { + // if the item is a token just remove it. + await RemoveTokenAsync(container); + } + } + else + { + if (SelectedItems.Count == 1) + { + // at this point we have one selection and its the default textbox. + // stop the iteration here + break; + } + } + } + } + } + + private void CopySelectedToClipboard() + { + DataPackage dataPackage = new DataPackage(); + dataPackage.RequestedOperation = DataPackageOperation.Copy; + + var tokenString = PrepareSelectionForClipboard(); + + if (!string.IsNullOrEmpty(tokenString)) + { + dataPackage.SetText(tokenString); + Clipboard.SetContent(dataPackage); + } + } + + private string PrepareSelectionForClipboard() + { + string tokenString = string.Empty; + bool addSeparator = false; + + // Copy all items if none selected (and no text selected) + foreach (var item in SelectedItems.Count > 0 ? SelectedItems : Items) + { + if (addSeparator) + { + tokenString += TokenDelimiter; + } + else + { + addSeparator = true; + } + + if (item is ITokenStringContainer) + { + // grab any selected text + if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken && pretoken._autoSuggestTextBox != null) + { + tokenString += pretoken._autoSuggestTextBox.Text.Substring( + pretoken._autoSuggestTextBox.SelectionStart, + pretoken._autoSuggestTextBox.SelectionLength); + } + } + else + { + tokenString += item.ToString(); + } + } + + return tokenString; + } +} +#pragma warning restore CS8602 diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.cs b/components/TokenizingTextBox/src/TokenizingTextBox.cs new file mode 100644 index 00000000..d3972b4e --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.cs @@ -0,0 +1,627 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +#if WINAPPSDK +using CommunityToolkit.WinUI.Deferred; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Input; +using VirtualKey = Windows.System.VirtualKey; +using DispatcherQueuePriority = Microsoft.UI.Dispatching.DispatcherQueuePriority; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; +#else +using DispatcherQueuePriority = Windows.System.DispatcherQueuePriority; +using DispatcherQueue = Windows.System.DispatcherQueue; +using CommunityToolkit.WinUI.Deferred; +#endif +using Windows.System; +using Windows.UI.Core; +using Windows.Foundation.Metadata; +using CommunityToolkit.WinUI.Automation.Peers; +using CommunityToolkit.WinUI.Helpers; + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// A text input control that auto-suggests and displays token items. +/// +[global::System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1124:Do not use regions", Justification = "Organization")] +[TemplatePart(Name = PART_NormalState, Type = typeof(VisualState))] +[TemplatePart(Name = PART_PointerOverState, Type = typeof(VisualState))] +[TemplatePart(Name = PART_FocusedState, Type = typeof(VisualState))] +[TemplatePart(Name = PART_UnfocusedState, Type = typeof(VisualState))] +[TemplatePart(Name = PART_MaxReachedState, Type = typeof(VisualState))] +public partial class TokenizingTextBox : ListViewBase +{ + internal const string PART_NormalState = "Normal"; + internal const string PART_PointerOverState = "PointerOver"; + internal const string PART_FocusedState = "Focused"; + internal const string PART_UnfocusedState = "Unfocused"; + internal const string PART_MaxReachedState = "MaxReachedState"; + + /// + /// Gets a value indicating whether the shift key is currently in a pressed state + /// + +#if WINAPPSDK + internal static bool IsShiftPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); +#else + internal static bool IsShiftPressed => CoreWindow.GetForCurrentThread()!.GetKeyState(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); +#endif + /// + /// Gets a value indicating whether the control key is currently in a pressed state + /// + +#if WINAPPSDK + internal bool IsControlPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); +#else + internal bool IsControlPressed => CoreWindow.GetForCurrentThread()!.GetKeyState(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); +#endif + internal bool PauseTokenClearOnFocus { get; set; } + + internal bool IsClearingForClick { get; set; } + + private DispatcherQueue _dispatcherQueue; + private InterspersedObservableCollection _innerItemsSource; + private ITokenStringContainer _currentTextEdit; // Don't update this directly outside of initialization, use UpdateCurrentTextEdit Method - in future see https://github.com/dotnet/csharplang/issues/140#issuecomment-625012514 + private ITokenStringContainer _lastTextEdit; + + /// + /// Initializes a new instance of the class. + /// +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public TokenizingTextBox() +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + // Setup our base state of our collection + _innerItemsSource = new InterspersedObservableCollection(new ObservableCollection()); // TODO: Test this still will let us bind to ItemsSource in XAML? + _currentTextEdit = _lastTextEdit = new PretokenStringContainer(true); + _innerItemsSource.Insert(_innerItemsSource.Count, _currentTextEdit); + ItemsSource = _innerItemsSource; + //// TODO: Consolidate with callback below for ItemsSourceProperty changed? + + DefaultStyleKey = typeof(TokenizingTextBox); + + // TODO: Do we want to support ItemsSource better? Need to investigate how that works with adding... + RegisterPropertyChangedCallback(ItemsSourceProperty, ItemsSource_PropertyChanged); + PreviewKeyDown += TokenizingTextBox_PreviewKeyDown; + PreviewKeyUp += TokenizingTextBox_PreviewKeyUp; + CharacterReceived += TokenizingTextBox_CharacterReceived; + ItemClick += TokenizingTextBox_ItemClick; + +#if WINAPPSDK + _dispatcherQueue = DispatcherQueue; +#else + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); +#endif + } + + private void ItemsSource_PropertyChanged(DependencyObject sender, DependencyProperty dp) + { + // If we're given a different ItemsSource, we need to wrap that collection in our helper class. + if (ItemsSource != null && ItemsSource.GetType() != typeof(InterspersedObservableCollection)) + { + _innerItemsSource = new InterspersedObservableCollection(ItemsSource); + + if (ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && _innerItemsSource.ItemsSource.Count >= MaximumTokens) + { + // Reduce down to below the max as necessary. + var endCount = MaximumTokens > 0 ? MaximumTokens : 0; + for (var i = _innerItemsSource.ItemsSource.Count - 1; i >= endCount; --i) + { + _innerItemsSource.Remove(_innerItemsSource[i]); + } + } + + // Add our text box at the end of items and set its default value to our initial text, fix for #4749 + _currentTextEdit = _lastTextEdit = new PretokenStringContainer(true) { Text = Text }; + _innerItemsSource.Insert(_innerItemsSource.Count, _currentTextEdit); + ItemsSource = _innerItemsSource; + } + } + + private void TokenizingTextBox_ItemClick(object sender, ItemClickEventArgs e) + { + // If the user taps an item in the list, make sure to clear any text selection as required + // Note, token selection is cleared by the listview default behavior + if (!IsControlPressed) + { + // Set class state flag to prevent click item being immediately deselected + IsClearingForClick = true; + ClearAllTextSelections(null); + } + } + + private void TokenizingTextBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e) + { + TokenizingTextBox_PreviewKeyUp(e.Key); + } + + internal void TokenizingTextBox_PreviewKeyUp(VirtualKey key) + { + switch (key) + { + case VirtualKey.Escape: + { + // Clear any selection and place the focus back into the text box + DeselectAllTokensAndText(); + FocusPrimaryAutoSuggestBox(); + break; + } + } + } + + /// + /// Set the focus to the last item in the collection + /// + private void FocusPrimaryAutoSuggestBox() + { + if (Items?.Count > 0) + { + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Programmatic); + } + } + } + + private async void TokenizingTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + e.Handled = await TokenizingTextBox_PreviewKeyDown(e.Key); + } + + internal async Task TokenizingTextBox_PreviewKeyDown(VirtualKey key) + { + // Global handlers on control regardless if focused on item or in textbox. + switch (key) + { + case VirtualKey.C: + if (IsControlPressed) + { + CopySelectedToClipboard(); + return true; + } + + break; + + case VirtualKey.X: + if (IsControlPressed) + { + CopySelectedToClipboard(); + + // now clear all selected tokens and text, or all if none are selected + await RemoveAllSelectedTokens(); + } + + break; + + // For moving between tokens + case VirtualKey.Left: + return MoveFocusAndSelection(MoveDirection.Previous); + + case VirtualKey.Right: + return MoveFocusAndSelection(MoveDirection.Next); + + case VirtualKey.A: + // modify the select-all behavior to ensure the text in the edit box gets selected. + if (IsControlPressed) + { + this.SelectAllTokensAndText(); + return true; + } + + break; + } + + return false; + } + + /// + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + var selectAllMenuItem = new MenuFlyoutItem + { + // TO DO: "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("Microsoft.Toolkit.Uwp.UI.Controls.Input/Resources") + Text = "Select all" + }; + selectAllMenuItem.Click += (s, e) => this.SelectAllTokensAndText(); + var menuFlyout = new MenuFlyout(); + menuFlyout.Items.Add(selectAllMenuItem); + +#if !HAS_UNO + if (IsXamlRootAvailable && XamlRoot != null) + { + menuFlyout.XamlRoot = XamlRoot; + } +#endif + ContextFlyout = menuFlyout; + } + + internal void RaiseQuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + QuerySubmitted?.Invoke(sender, args); + } + + internal void RaiseSuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + SuggestionChosen?.Invoke(sender, args); + } + + internal void RaiseTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + TextChanged?.Invoke(sender, args); + } + + private async void TokenizingTextBox_CharacterReceived(UIElement sender, CharacterReceivedRoutedEventArgs args) + { + var container = ContainerFromItem(_currentTextEdit) as TokenizingTextBoxItem; + + if (container != null && !(GetFocusedElement().Equals(container._autoSuggestTextBox) || char.IsControl(args.Character))) + { + if (SelectedItems.Count > 0) + { + var index = _innerItemsSource.IndexOf(SelectedItems.First()); + + await RemoveAllSelectedTokens(); + + // Wait for removal of old items +#if WINAPPSDK + _ = DispatcherQueue.EnqueueAsync( +#else + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _ = dispatcherQueue.EnqueueAsync( +#endif + () => + { + // If we're before the last textbox and it's empty, redirect focus to that one instead + if (index == _innerItemsSource.Count - 1 && string.IsNullOrWhiteSpace(_lastTextEdit.Text)) + { + if (ContainerFromItem(_lastTextEdit) is TokenizingTextBoxItem lastContainer) + { + lastContainer.UseCharacterAsUser = true; // Make sure we trigger a refresh of suggested items. + + _lastTextEdit.Text = string.Empty + args.Character; + + UpdateCurrentTextEdit(_lastTextEdit); + + lastContainer._autoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted + + lastContainer._autoSuggestTextBox.Focus(FocusState.Keyboard); + } + } + else + { + //// Otherwise, create a new textbox for this text. + + UpdateCurrentTextEdit(new PretokenStringContainer((string.Empty + args.Character).Trim())); // Trim so that 'space' isn't inserted and can be used to insert a new box. + + _innerItemsSource.Insert(index, _currentTextEdit); + + // Need to wait for containerization +#if WINAPPSDK + _ = DispatcherQueue.EnqueueAsync( +#else + _ = dispatcherQueue.EnqueueAsync( +#endif + () => + { + if (ContainerFromIndex(index) is TokenizingTextBoxItem newContainer) // Should be our last text box + { + newContainer.UseCharacterAsUser = true; // Make sure we trigger a refresh of suggested items. + + void WaitForLoad(object s, RoutedEventArgs eargs) + { + if (newContainer._autoSuggestTextBox != null) + { + newContainer._autoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted + + newContainer._autoSuggestTextBox.Focus(FocusState.Keyboard); + } + + newContainer.Loaded -= WaitForLoad; + } + + newContainer.AutoSuggestTextBoxLoaded += WaitForLoad; + } + }, DispatcherQueuePriority.Normal); + } + }, DispatcherQueuePriority.Normal); + } + else + { + // If no items are selected, send input to the last active string container. + // This code is only fires during an edgecase where an item is in the process of being deleted and the user inputs a character before the focus has been redirected to a string container. + if (_innerItemsSource[_innerItemsSource.Count - 1] is ITokenStringContainer textToken) + { + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem last) // Should be our last text box + { + var text = last._autoSuggestTextBox.Text; + var selectionStart = last._autoSuggestTextBox.SelectionStart; + var position = selectionStart > text.Length ? text.Length : selectionStart; + textToken.Text = text.Substring(0, position) + args.Character + + text.Substring(position); + + last._autoSuggestTextBox.SelectionStart = position + 1; // Set position to after our new character inserted + + last._autoSuggestTextBox.Focus(FocusState.Keyboard); + } + } + } + } + } + + private object GetFocusedElement() + { + if (IsXamlRootAvailable && XamlRoot != null) + { + return FocusManager.GetFocusedElement(XamlRoot)!; + } + else + { + return FocusManager.GetFocusedElement()!; + } + } + + #region ItemsControl Container Methods + + /// + protected override DependencyObject GetContainerForItemOverride() => new TokenizingTextBoxItem(); + + /// + protected override bool IsItemItsOwnContainerOverride(object item) + { + return item is TokenizingTextBoxItem; + } + + /// + protected override void PrepareContainerForItemOverride(DependencyObject element, object item) + { + base.PrepareContainerForItemOverride(element, item); + + if (element is TokenizingTextBoxItem tokenitem) + { + tokenitem.Owner = this; + + tokenitem.ContentTemplateSelector = TokenItemTemplateSelector; + tokenitem.ContentTemplate = TokenItemTemplate; + + tokenitem.ClearClicked -= TokenizingTextBoxItem_ClearClicked; + tokenitem.ClearClicked += TokenizingTextBoxItem_ClearClicked; + + tokenitem.ClearAllAction -= TokenizingTextBoxItem_ClearAllAction; + tokenitem.ClearAllAction += TokenizingTextBoxItem_ClearAllAction; + + tokenitem.GotFocus -= TokenizingTextBoxItem_GotFocus; + tokenitem.GotFocus += TokenizingTextBoxItem_GotFocus; + + tokenitem.LostFocus -= TokenizingTextBoxItem_LostFocus; + tokenitem.LostFocus += TokenizingTextBoxItem_LostFocus; + + var menuFlyout = new MenuFlyout(); + + var removeMenuItem = new MenuFlyoutItem + { + // TO DO: Localize - "WCT_TokenizingTextBoxItem_MenuFlyout_Remove".GetLocalized("Microsoft.Toolkit.Uwp.UI.Controls.Input/Resources") + Text = "Remove" + }; + removeMenuItem.Click += (s, e) => TokenizingTextBoxItem_ClearClicked(tokenitem, null); + + menuFlyout.Items.Add(removeMenuItem); + +#if !HAS_UNO + if (IsXamlRootAvailable && XamlRoot != null) + { + menuFlyout.XamlRoot = XamlRoot; + } +#endif + var selectAllMenuItem = new MenuFlyoutItem + { + // TO DO: Localize - "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("Microsoft.Toolkit.Uwp.UI.Controls.Input/Resources") + Text = "Select all" + }; + selectAllMenuItem.Click += (s, e) => this.SelectAllTokensAndText(); + + menuFlyout.Items.Add(selectAllMenuItem); + + tokenitem.ContextFlyout = menuFlyout; + } + } + #endregion + + private void TokenizingTextBoxItem_GotFocus(object sender, RoutedEventArgs e) + { + // Keep track of our currently focused textbox + if (sender is TokenizingTextBoxItem ttbi && ttbi.Content is ITokenStringContainer text) + { + UpdateCurrentTextEdit(text); + } + } + + private void TokenizingTextBoxItem_LostFocus(object sender, RoutedEventArgs e) + { + // Keep track of our currently focused textbox + if (sender is TokenizingTextBoxItem ttbi && ttbi.Content is ITokenStringContainer text && + string.IsNullOrWhiteSpace(text.Text) && text != _lastTextEdit) + { + // We're leaving an inner textbox that's blank, so we'll remove it + _innerItemsSource.Remove(text); + + UpdateCurrentTextEdit(_lastTextEdit); + + GuardAgainstPlaceholderTextLayoutIssue(); + } + } + + /// + /// Adds the specified data item as a new token to the collection, will raise the event asynchronously still for confirmation. + /// + /// + /// The will automatically handle adding items for you, or you can add items to your backing collection. This method is provide for other cases where you may need to drive inserting a new token item to where the user is currently inserting text between tokens. + /// + /// Item to add as a token. + /// Flag to indicate if the item should be inserted in the last used textbox (inserted) or placed at end of the token list. + public void AddTokenItem(object data, bool atEnd = false) + { + _ = AddTokenAsync(data, atEnd); + } + + /// + /// Clears the whole collection, will raise the event asynchronously for each item. + /// + /// async task + public async Task ClearAsync() + { + while (_innerItemsSource.Count > 1) + { + if (ContainerFromItem(_innerItemsSource[0]) is TokenizingTextBoxItem container) + { + if (!await RemoveTokenAsync(container, _innerItemsSource[0])) + { + // if a removal operation fails then stop the clear process + break; + } + } + } + + // Clear the active pretoken string. + // Setting the text property directly avoids a delay when setting the text in the autosuggest box. + Text = string.Empty; + } + + internal async Task AddTokenAsync(object data, bool? atEnd = null) + { + if (ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && (MaximumTokens <= 0 || MaximumTokens <= _innerItemsSource.ItemsSource.Count)) + { + // No tokens for you + return; + } + + if (data is string str && TokenItemAdding != null) + { + var tiaea = new TokenItemAddingEventArgs(str); + await TokenItemAdding.InvokeAsync(this, tiaea); + + if (tiaea.Cancel) + { + return; + } + + if (tiaea.Item != null) + { + data = tiaea.Item; // Transformed by event implementor + } + } + + // If we've been typing in the last box, just add this to the end of our collection + if (atEnd == true || _currentTextEdit == _lastTextEdit) + { + _innerItemsSource.InsertAt(_innerItemsSource.Count - 1, data); + } + else + { + // Otherwise, we'll insert before our current box + var edit = _currentTextEdit; + var index = _innerItemsSource.IndexOf(edit); + + // Insert our new data item at the location of our textbox + _innerItemsSource.InsertAt(index, data); + + // Remove our textbox + _innerItemsSource.Remove(edit); + } + + // Focus back to our end box as Outlook does. + var last = ContainerFromItem(_lastTextEdit) as TokenizingTextBoxItem; + last?._autoSuggestTextBox.Focus(FocusState.Keyboard); + + TokenItemAdded?.Invoke(this, data); + + GuardAgainstPlaceholderTextLayoutIssue(); + } + + /// + /// Helper to change out the currently focused text element in the control. + /// + /// element which is now the main edited text. + protected void UpdateCurrentTextEdit(ITokenStringContainer edit) + { + _currentTextEdit = edit; + + Text = edit.Text; // Update our text property. + } + + /// + /// Creates AutomationPeer () + /// + /// An automation peer for this . + protected override AutomationPeer OnCreateAutomationPeer() + { + return new TokenizingTextBoxAutomationPeer(this); + } + + /// + /// Remove the specified token from the list. + /// + /// Item in the list to delete + /// data + /// + /// the data parameter is passed in optionally to support UX UTs. When running in the UT the Container items are not manifest. + /// + /// true if the item was removed successfully, false otherwise + private async Task RemoveTokenAsync(TokenizingTextBoxItem item, object? data = null) + { + if (data == null) + { + data = ItemFromContainer(item); + } + + if (TokenItemRemoving != null) + { + var tirea = new TokenItemRemovingEventArgs(data, item); + await TokenItemRemoving.InvokeAsync(this, tirea); + + if (tirea.Cancel) + { + return false; + } + } + + _innerItemsSource.Remove(data); + + TokenItemRemoved?.Invoke(this, data); + + GuardAgainstPlaceholderTextLayoutIssue(); + + return true; + } + + private void GuardAgainstPlaceholderTextLayoutIssue() + { + // If the *PlaceholderText is visible* on the last AutoSuggestBox, it can incorrectly layout itself + // when the *ASB has focus*. We think this is an optimization in the platform, but haven't been able to + // isolate a straight-reproduction of this issue outside of this control (though we have eliminated + // most Toolkit influences like ASB/TextBox Style, the InterspersedObservableCollection, etc...). + // The only Toolkit component involved here should be WrapPanel (which is a straight-forward Panel). + // We also know the ASB itself is adjusting it's size correctly, it's the inner component. + // + // To combat this issue: + // We toggle the visibility of the Placeholder ContentControl in order to force it's layout to update properly + var placeholder = ContainerFromItem(_lastTextEdit)?.FindDescendant("PlaceholderTextContentPresenter"); + + if (placeholder?.Visibility == Visibility.Visible) + { + placeholder.Visibility = Visibility.Collapsed; + + // After we ensure we've hid the control, make it visible again (this is imperceptible to the user). + _ = CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => + { + placeholder.Visibility = Visibility.Visible; + }); + } + } + + public static bool IsXamlRootAvailable { get; } = ApiInformation.IsPropertyPresent("Windows.UI.Xaml.UIElement", "XamlRoot"); +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.xaml b/components/TokenizingTextBox/src/TokenizingTextBox.xaml new file mode 100644 index 00000000..54a3c70c --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.xaml @@ -0,0 +1,180 @@ + + + + + + + + 3,2,3,2 + 0,0,6,0 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs b/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs new file mode 100644 index 00000000..b3c8b7df --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs @@ -0,0 +1,132 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Controls; +#if WINAPPSDK +using Microsoft.UI.Xaml.Automation.Provider; +#else +using Windows.UI.Xaml.Automation.Provider; +#endif + +namespace CommunityToolkit.WinUI.Automation.Peers; + +/// +/// Defines a framework element automation peer for the control. +/// +public class TokenizingTextBoxAutomationPeer : ListViewBaseAutomationPeer, IValueProvider +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The that is associated with this . + /// + public TokenizingTextBoxAutomationPeer(TokenizingTextBox owner) + : base(owner) + { + } + + /// Gets a value indicating whether the value of a control is read-only. + /// **true** if the value is read-only; **false** if it can be modified. + public bool IsReadOnly => !this.OwningTokenizingTextBox.IsEnabled; + + /// Gets the value of the control. + /// The value of the control. + public string Value => this.OwningTokenizingTextBox.Text; + + private TokenizingTextBox OwningTokenizingTextBox + { + get + { +#pragma warning disable CS8603 // Possible null reference return. + return Owner as TokenizingTextBox; +#pragma warning restore CS8603 // Possible null reference return. + } + } + + /// Sets the value of a control. + /// The value to set. The provider is responsible for converting the value to the appropriate data type. + /// Thrown if the control is in a read-only state. + public void SetValue(string value) + { + if (IsReadOnly) + { + throw new ElementNotEnabledException($"Could not set the value of the {nameof(TokenizingTextBox)} "); + } + + this.OwningTokenizingTextBox.Text = value; + } + + /// + /// Called by GetClassName that gets a human readable name that, in addition to AutomationControlType, + /// differentiates the control represented by this AutomationPeer. + /// + /// The string that contains the name. + protected override string GetClassNameCore() + { + return Owner.GetType().Name; + } + + /// + /// Called by GetName. + /// + /// + /// Returns the first of these that is not null or empty: + /// - Value returned by the base implementation + /// - Name of the owning TokenizingTextBox + /// - TokenizingTextBox class name + /// + protected override string GetNameCore() + { + string name = this.OwningTokenizingTextBox.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + return name; + } + + name = AutomationProperties.GetName(this.OwningTokenizingTextBox); + return !string.IsNullOrWhiteSpace(name) ? name : base.GetNameCore(); + } + + /// + /// Gets the control pattern that is associated with the specified Windows.UI.Xaml.Automation.Peers.PatternInterface. + /// + /// A value from the Windows.UI.Xaml.Automation.Peers.PatternInterface enumeration. + /// The object that supports the specified pattern, or null if unsupported. + protected override object GetPatternCore(PatternInterface patternInterface) + { + return patternInterface switch + { + PatternInterface.Value => this, + _ => base.GetPatternCore(patternInterface) + }; + } + + /// + /// Gets the collection of elements that are represented in the UI Automation tree as immediate + /// child elements of the automation peer. + /// + /// The children elements. + protected override IList GetChildrenCore() + { + TokenizingTextBox owner = this.OwningTokenizingTextBox; + + ItemCollection items = owner.Items; + if (items.Count <= 0) + { + return null!; + } + + List peers = new List(items.Count); + for (int i = 0; i < items.Count; i++) + { + if (owner.ContainerFromIndex(i) is TokenizingTextBoxItem element) + { + peers.Add(FromElement(element) ?? CreatePeerForElement(element)); + } + } + + return peers; + } +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs new file mode 100644 index 00000000..305d91cc --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs @@ -0,0 +1,436 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.System; + +#if WINAPPSDK +using Microsoft.UI; +#else +using Windows.UI; +#endif + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// A control that manages as the item logic for the control. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1124:Do not use regions", Justification = "Organization")] +[TemplatePart(Name = PART_AutoSuggestBox, Type = typeof(AutoSuggestBox))] //// String case +[TemplatePart(Name = PART_TokensCounter, Type = typeof(TextBlock))] +public partial class TokenizingTextBoxItem +{ + private const string PART_AutoSuggestBox = "PART_AutoSuggestBox"; + private const string PART_TokensCounter = "PART_TokensCounter"; + private const string QueryButton = "QueryButton"; + + private AutoSuggestBox _autoSuggestBox; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Tight Coupling with Parent for Selection control.")] + internal TextBox _autoSuggestTextBox; + + /// + /// Event raised when the 'Clear' Button is clicked. + /// + internal event TypedEventHandler AutoSuggestTextBoxLoaded; + + internal bool UseCharacterAsUser { get; set; } + + /// + /// Gets a value indicating whether the textbox caret is in the first position. False otherwise + /// + private bool IsCaretAtStart => _autoSuggestTextBox?.SelectionStart == 0; + + /// + /// Gets a value indicating whether the textbox caret is in the last position. False otherwise + /// + private bool IsCaretAtEnd => _autoSuggestTextBox?.SelectionStart == _autoSuggestTextBox?.Text.Length || + _autoSuggestTextBox?.SelectionStart + _autoSuggestTextBox?.SelectionLength == _autoSuggestTextBox?.Text.Length; + + /// + /// Gets a value indicating whether all text in the text box is currently selected. False otherwise. + /// + private bool IsAllSelected => _autoSuggestTextBox?.SelectedText == _autoSuggestTextBox?.Text && !string.IsNullOrEmpty(_autoSuggestTextBox?.Text); + + /// + /// Used to track if we're on the first character of the textbox while there is selected text + /// + private bool _isSelectedFocusOnFirstCharacter = false; + + /// + /// Used to track if we're on the last character of the textbox while there is selected text + /// + private bool _isSelectedFocusOnLastCharacter = false; + + /// Called from + private void OnApplyTemplateAutoSuggestBox(AutoSuggestBox auto) + { + if (_autoSuggestBox != null) + { + _autoSuggestBox.Loaded -= OnASBLoaded; + + _autoSuggestBox.QuerySubmitted -= AutoSuggestBox_QuerySubmitted; + _autoSuggestBox.SuggestionChosen -= AutoSuggestBox_SuggestionChosen; + _autoSuggestBox.TextChanged -= AutoSuggestBox_TextChanged; + _autoSuggestBox.PointerEntered -= AutoSuggestBox_PointerEntered; + _autoSuggestBox.PointerExited -= AutoSuggestBox_PointerExited; + _autoSuggestBox.PointerCanceled -= AutoSuggestBox_PointerExited; + _autoSuggestBox.PointerCaptureLost -= AutoSuggestBox_PointerExited; + _autoSuggestBox.GotFocus -= AutoSuggestBox_GotFocus; + _autoSuggestBox.LostFocus -= AutoSuggestBox_LostFocus; + + // Remove any previous QueryIcon + _autoSuggestBox.QueryIcon = null; + } + + _autoSuggestBox = auto; + + if (_autoSuggestBox != null) + { + _autoSuggestBox.Loaded += OnASBLoaded; + + _autoSuggestBox.QuerySubmitted += AutoSuggestBox_QuerySubmitted; + _autoSuggestBox.SuggestionChosen += AutoSuggestBox_SuggestionChosen; + _autoSuggestBox.TextChanged += AutoSuggestBox_TextChanged; + _autoSuggestBox.PointerEntered += AutoSuggestBox_PointerEntered; + _autoSuggestBox.PointerExited += AutoSuggestBox_PointerExited; + _autoSuggestBox.PointerCanceled += AutoSuggestBox_PointerExited; + _autoSuggestBox.PointerCaptureLost += AutoSuggestBox_PointerExited; + _autoSuggestBox.GotFocus += AutoSuggestBox_GotFocus; + _autoSuggestBox.LostFocus += AutoSuggestBox_LostFocus; + + // Setup a binding to the QueryIcon of the Parent if we're the last box. + if (Content is ITokenStringContainer str) + { + // We need to set our initial text in all cases. + _autoSuggestBox.Text = str.Text; + + // We only set/bind some properties on the last textbox to mimic the autosuggestbox look + if (str.IsLast) + { + // Workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/2568 + if (Owner.QueryIcon is FontIconSource fis && + fis.ReadLocalValue(FontIconSource.FontSizeProperty) == DependencyProperty.UnsetValue) + { + // This can be expensive, could we optimize? + // Also, this is changing the FontSize on the IconSource (which could be shared?) + fis.FontSize = Owner.TryFindResource("TokenizingTextBoxIconFontSize") as double? ?? 16; + } + + var iconBinding = new Binding() + { + Source = Owner, + Path = new PropertyPath(nameof(Owner.QueryIcon)), + RelativeSource = new RelativeSource() { Mode = RelativeSourceMode.TemplatedParent } + }; + + #if !HAS_UNO + var iconSourceElement = new IconSourceElement(); + iconSourceElement.SetBinding(IconSourceElement.IconSourceProperty, iconBinding); + _autoSuggestBox.QueryIcon = iconSourceElement; + #endif + } + } + } + } + + #region AutoSuggestBox + private async void AutoSuggestBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + Owner.RaiseQuerySubmitted(sender, args); + + object? chosenItem = null; + if (args.ChosenSuggestion != null) + { + chosenItem = args.ChosenSuggestion; + } + else if (!string.IsNullOrWhiteSpace(args.QueryText)) + { + chosenItem = args.QueryText; + } + + if (chosenItem != null) + { + await Owner.AddTokenAsync(chosenItem); // TODO: Need to pass index? + sender.Text = string.Empty; + Owner.Text = string.Empty; + sender.Focus(FocusState.Programmatic); + } + } + + private void AutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + Owner.RaiseSuggestionChosen(sender, args); + } + + // Called to update text by link:TokenizingTextBox.Properties.cs:TextPropertyChanged + internal void UpdateText(string text) + { + if (_autoSuggestBox != null) + { + _autoSuggestBox.Text = text; + } + else + { + void WaitForLoad(object s, RoutedEventArgs eargs) + { + if (_autoSuggestTextBox != null) + { + _autoSuggestTextBox.Text = text; + } + + AutoSuggestTextBoxLoaded -= WaitForLoad; + } + + AutoSuggestTextBoxLoaded += WaitForLoad; + } + } + + private void AutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (sender.Text == null) + { + return; + } + + if (!EqualityComparer.Default.Equals(sender.Text, Owner.Text)) + { + Owner.Text = sender.Text; // Update parent text property, if different + } + + // Override our programmatic manipulation as we're redirecting input for the user + if (UseCharacterAsUser) + { + UseCharacterAsUser = false; + + args.Reason = AutoSuggestionBoxTextChangeReason.UserInput; + } + + Owner.RaiseTextChanged(sender, args); + + var t = sender.Text?.Trim() ?? string.Empty; + + // Look for Token Delimiters to create new tokens when text changes. + if (!string.IsNullOrEmpty(Owner.TokenDelimiter) && t.Contains(Owner.TokenDelimiter)) + { + bool lastDelimited = t[t.Length - 1] == Owner.TokenDelimiter[0]; + +#if HAS_UNO + string[] tokens = t.Split(new[] { Owner.TokenDelimiter }, System.StringSplitOptions.RemoveEmptyEntries); +#else + string[] tokens = t.Split(Owner.TokenDelimiter); +#endif + int numberToProcess = lastDelimited ? tokens.Length : tokens.Length - 1; + for (int position = 0; position < numberToProcess; position++) + { + string token = tokens[position]; + token = token.Trim(); + if (token.Length > 0) + { + _ = Owner.AddTokenAsync(token); //// TODO: Pass Index? + } + } + + if (lastDelimited) + { + sender.Text = string.Empty; + } + else + { + sender.Text = tokens[tokens.Length - 1].Trim(); + } + } + } +#endregion + + #region Visual State Management for Parent + private void AutoSuggestBox_PointerEntered(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_PointerOverState, true); + } + + private void AutoSuggestBox_PointerExited(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_NormalState, true); + } + + private void AutoSuggestBox_LostFocus(object sender, RoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_UnfocusedState, true); + } + + private void AutoSuggestBox_GotFocus(object sender, RoutedEventArgs e) + { + // Verify if the usual behavior of clearing token selection is required + if (Owner.PauseTokenClearOnFocus == false && !TokenizingTextBox.IsShiftPressed) + { + // Clear any selected tokens + Owner.DeselectAll(); + } + + Owner.PauseTokenClearOnFocus = false; + + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_FocusedState, true); + } + #endregion + + #region Inner TextBox + private void OnASBLoaded(object sender, RoutedEventArgs e) + { + UpdateQueryIconVisibility(); + UpdateTokensCounter(this); + + // Local function for Selection changed + void AutoSuggestTextBox_SelectionChanged(object box, RoutedEventArgs args) + { + if (!(IsAllSelected || TokenizingTextBox.IsShiftPressed || Owner.IsClearingForClick)) + { + Owner.DeselectAllTokensAndText(this); + } + + // Ensure flag is always reset + Owner.IsClearingForClick = false; + } + + // local function for clearing selection on interaction with text box + async void AutoSuggestTextBox_TextChangingAsync(TextBox o, TextBoxTextChangingEventArgs args) + { + // remove any selected tokens. + if (Owner.SelectedItems.Count > 1) + { + await Owner.RemoveAllSelectedTokens(); + } + } + + if (_autoSuggestTextBox != null) + { + _autoSuggestTextBox.PreviewKeyDown -= this.AutoSuggestTextBox_PreviewKeyDown; + _autoSuggestTextBox.TextChanging -= AutoSuggestTextBox_TextChangingAsync; + _autoSuggestTextBox.SelectionChanged -= AutoSuggestTextBox_SelectionChanged; + _autoSuggestTextBox.SelectionChanging -= AutoSuggestTextBox_SelectionChanging; + } + + _autoSuggestTextBox = _autoSuggestBox.FindDescendant()!; + + if (_autoSuggestTextBox != null) + { + _autoSuggestTextBox.PreviewKeyDown += this.AutoSuggestTextBox_PreviewKeyDown; + _autoSuggestTextBox.TextChanging += AutoSuggestTextBox_TextChangingAsync; + _autoSuggestTextBox.SelectionChanged += AutoSuggestTextBox_SelectionChanged; + _autoSuggestTextBox.SelectionChanging += AutoSuggestTextBox_SelectionChanging; + + AutoSuggestTextBoxLoaded?.Invoke(this, e); + } + } + + private void AutoSuggestTextBox_SelectionChanging(TextBox sender, TextBoxSelectionChangingEventArgs args) + { +#if !HAS_UNO + _isSelectedFocusOnFirstCharacter = args.SelectionLength > 0 && args.SelectionStart == 0 && _autoSuggestTextBox.SelectionStart > 0; + _isSelectedFocusOnLastCharacter = + //// see if we are NOW on the last character. + //// test if the new selection includes the last character, and the current selection doesn't + (args.SelectionStart + args.SelectionLength == _autoSuggestTextBox.Text.Length) && + (_autoSuggestTextBox.SelectionStart + _autoSuggestTextBox.SelectionLength != _autoSuggestTextBox.Text.Length); +#endif + } + + private void AutoSuggestTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (IsCaretAtStart && + (e.Key == VirtualKey.Back || + e.Key == VirtualKey.Left)) + { + // if the back key is pressed and there is any selection in the text box then the text box can handle it + if ((e.Key == VirtualKey.Left && _isSelectedFocusOnFirstCharacter) || + _autoSuggestTextBox.SelectionLength == 0) + { + if (Owner.SelectPreviousItem(this)) + { + if (!TokenizingTextBox.IsShiftPressed) + { + // Clear any text box selection + _autoSuggestTextBox.SelectionLength = 0; + } + + e.Handled = true; + } + } + } + else if (IsCaretAtEnd && e.Key == VirtualKey.Right) + { + // if the back key is pressed and there is any selection in the text box then the text box can handle it + if (_isSelectedFocusOnLastCharacter || _autoSuggestTextBox.SelectionLength == 0) + { + if (Owner.SelectNextItem(this)) + { + if (!TokenizingTextBox.IsShiftPressed) + { + // Clear any text box selection + _autoSuggestTextBox.SelectionLength = 0; + } + + e.Handled = true; + } + } + } + else if (e.Key == VirtualKey.A && Owner.IsControlPressed) + { + // Need to provide this shortcut from the textbox only, as ListViewBase will do it for us on token. + Owner.SelectAllTokensAndText(); + } + } + + private void UpdateTokensCounter(TokenizingTextBoxItem ttbi) + { + if (_autoSuggestBox?.FindDescendant(PART_TokensCounter) is TextBlock maxTokensCounter) + { + void OnTokenCountChanged(TokenizingTextBox ttb, object? value = null) + { + if (ttb.ItemsSource is InterspersedObservableCollection itemsSource) + { + var currentTokens = itemsSource.ItemsSource.Count; + var maxTokens = ttb.MaximumTokens; + + maxTokensCounter.Text = $"{currentTokens}/{maxTokens}"; + maxTokensCounter.Visibility = Visibility.Visible; + + maxTokensCounter.Foreground = (currentTokens >= maxTokens) + ? (SolidColorBrush)Application.Current.Resources["SystemFillColorCriticalBrush"] + : (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"]; + } + } + + ttbi.Owner.TokenItemAdded -= OnTokenCountChanged; + ttbi.Owner.TokenItemRemoved -= OnTokenCountChanged; + + if (Content is ITokenStringContainer str && str.IsLast && ttbi?.Owner != null && ttbi.Owner.ReadLocalValue(TokenizingTextBox.MaximumTokensProperty) != DependencyProperty.UnsetValue) + { + ttbi.Owner.TokenItemAdded += OnTokenCountChanged; + ttbi.Owner.TokenItemRemoved += OnTokenCountChanged; + OnTokenCountChanged(ttbi.Owner); + } + else + { + maxTokensCounter.Visibility = Visibility.Collapsed; + maxTokensCounter.Text = string.Empty; + } + } + } + + internal void UpdateQueryIconVisibility() + { + if (_autoSuggestBox?.FindDescendant(QueryButton) is Button queryButton) + { + if (Owner.QueryIcon != null) + { + queryButton.Visibility = Visibility.Visible; + } + else + { + queryButton.Visibility = Visibility.Collapsed; + } + } + } +#endregion +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml new file mode 100644 index 00000000..62dd987f --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml @@ -0,0 +1,487 @@ + + + + 10,3,6,6 + + 10 + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml b/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml new file mode 100644 index 00000000..cadcb920 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml @@ -0,0 +1,469 @@ + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + 1 + + + + + 8,4,4,4 + 0,-2,0,1 + Center + Center + + + + + + + + diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs new file mode 100644 index 00000000..1bdaa519 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.System; + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// A control that manages as the item logic for the control. +/// +[TemplatePart(Name = PART_ClearButton, Type = typeof(ButtonBase))] //// Token case +public partial class TokenizingTextBoxItem : ListViewItem +{ + private const string PART_ClearButton = "PART_RemoveButton"; + + private Button _clearButton; + + /// + /// Event raised when the 'Clear' Button is clicked. + /// + public event TypedEventHandler ClearClicked; + + /// + /// Event raised when the delete key or a backspace is pressed. + /// + public event TypedEventHandler ClearAllAction; + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty ClearButtonStyleProperty = DependencyProperty.Register( + nameof(ClearButtonStyle), + typeof(Style), + typeof(TokenizingTextBoxItem), + new PropertyMetadata(Visibility.Collapsed)); + + /// + /// Gets or sets the Style for the 'Clear' Button + /// + public Style ClearButtonStyle + { + get => (Style)GetValue(ClearButtonStyleProperty); + set => SetValue(ClearButtonStyleProperty, value); + } + + internal TokenizingTextBox Owner + { + get { return (TokenizingTextBox)GetValue(OwnerProperty); } + set { SetValue(OwnerProperty, value); } + } + + // Using a DependencyProperty as the backing store for Owner. This enables animation, styling, binding, etc... + internal static readonly DependencyProperty OwnerProperty = + DependencyProperty.Register(nameof(Owner), typeof(TokenizingTextBox), typeof(TokenizingTextBoxItem), new PropertyMetadata(null)); + + /// + /// Initializes a new instance of the class. + /// +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public TokenizingTextBoxItem() +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + DefaultStyleKey = typeof(TokenizingTextBoxItem); + + // TODO: only add these if token? + RightTapped += TokenizingTextBoxItem_RightTapped; + KeyDown += TokenizingTextBoxItem_KeyDown; + } + + /// + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + if (GetTemplateChild(PART_AutoSuggestBox) is AutoSuggestBox suggestbox) + { + OnApplyTemplateAutoSuggestBox(suggestbox); + } + + if (_clearButton != null) + { + _clearButton.Click -= ClearButton_Click; + } + + _clearButton = (Button)GetTemplateChild(PART_ClearButton); + + if (_clearButton != null) + { + _clearButton.Click += ClearButton_Click; + } + } + + private void ClearButton_Click(object sender, RoutedEventArgs e) + { + ClearClicked?.Invoke(this, e); + } + + private void TokenizingTextBoxItem_RightTapped(object sender, RightTappedRoutedEventArgs e) + { + ContextFlyout.ShowAt(this); + } + + private void TokenizingTextBoxItem_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (!(Content is ITokenStringContainer)) + { + // We only want to 'remove' our token if we're not a textbox. + switch (e.Key) + { + case VirtualKey.Back: + case VirtualKey.Delete: + { + ClearAllAction?.Invoke(this, e); + break; + } + } + } + } +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxStyleSelector.cs b/components/TokenizingTextBox/src/TokenizingTextBoxStyleSelector.cs new file mode 100644 index 00000000..8d75fafa --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxStyleSelector.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// used by to choose the proper container style (text entry or token). +/// +public class TokenizingTextBoxStyleSelector : StyleSelector +{ + /// + /// Gets or sets the of a token item. + /// + public Style TokenStyle { get; set; } + + /// + /// Gets or sets the of a text entry item. + /// + public Style TextStyle { get; set; } + + /// + /// Initializes a new instance of the class. + /// +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public TokenizingTextBoxStyleSelector() +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + } + + /// + protected override Style SelectStyleCore(object item, DependencyObject container) + { + if (item is ITokenStringContainer) + { + return TextStyle; + } + + return TokenStyle; + } +} diff --git a/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs new file mode 100644 index 00000000..5b90b3fd --- /dev/null +++ b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Tests; +using CommunityToolkit.WinUI.Automation.Peers; +using CommunityToolkit.WinUI.Controls; + +namespace TokenizingTextBoxExperiment.Tests; + +[TestClass] +[TestCategory("Test_TokenizingTextBox")] +public class Test_TokenizingTextBox_AutomationPeer : VisualUITestBase +{ + [TestMethod] + public async Task ShouldConfigureTokenizingTextBoxAutomationPeerAsync() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + const string expectedAutomationName = "MyAutomationName"; + const string expectedName = "MyName"; + const string expectedValue = "Wor"; + + var items = new ObservableCollection { new() { Title = "Hello" }, new() { Title = "World" } }; + + var tokenizingTextBox = new TokenizingTextBox { ItemsSource = items }; + + await LoadTestContentAsync(tokenizingTextBox); + + var tokenizingTextBoxAutomationPeer = + FrameworkElementAutomationPeer.CreatePeerForElement(tokenizingTextBox) as TokenizingTextBoxAutomationPeer; + + Assert.IsNotNull(tokenizingTextBoxAutomationPeer, "Verify that the AutomationPeer is TokenizingTextBoxAutomationPeer."); + + // Asserts the automation peer name based on the Automation Property Name value. + tokenizingTextBox.SetValue(AutomationProperties.NameProperty, expectedAutomationName); + Assert.IsTrue(tokenizingTextBoxAutomationPeer.GetName().Contains(expectedAutomationName), "Verify that the UIA name contains the given AutomationProperties.Name of the TokenizingTextBox."); + + // Asserts the automation peer name based on the element Name property. + tokenizingTextBox.Name = expectedName; + Assert.IsTrue(tokenizingTextBoxAutomationPeer.GetName().Contains(expectedName), "Verify that the UIA name contains the given Name of the TokenizingTextBox."); + + tokenizingTextBoxAutomationPeer.SetValue(expectedValue); + Assert.IsTrue(tokenizingTextBoxAutomationPeer.Value.Equals(expectedValue), "Verify that the Value contains the given Text of the TokenizingTextBox."); + }); + } + + [TestMethod] + public async Task ShouldReturnTokensForTokenizingTextBoxAutomationPeerAsync() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var items = new ObservableCollection + { + new() { Title = "Hello" }, new() { Title = "World" } + }; + + var tokenizingTextBox = new TokenizingTextBox { ItemsSource = items }; + + await LoadTestContentAsync(tokenizingTextBox); + + tokenizingTextBox + .SelectAllTokensAndText(); // Will be 3 items due to the `AndText` that will select an empty text item. + var tokenizingTextBoxAutomationPeer = + FrameworkElementAutomationPeer.CreatePeerForElement(tokenizingTextBox) as + TokenizingTextBoxAutomationPeer; + + Assert.IsNotNull( + tokenizingTextBoxAutomationPeer, + "Verify that the AutomationPeer is TokenizingTextBoxAutomationPeer."); + + var selectedItems = tokenizingTextBoxAutomationPeer + .GetChildren() + .Cast() + .Select(peer => peer.Owner as TokenizingTextBoxItem) + .Select(item => item?.Content as TokenizingTextBoxTestItem) + .ToList(); + + Assert.AreEqual(3, selectedItems.Count); + Assert.AreEqual(items[0], selectedItems[0]); + Assert.AreEqual(items[1], selectedItems[1]); + Assert.IsNull(selectedItems[2]); // The 3rd item is the empty text item. + }); + } + + [TestMethod] + public async Task ShouldThrowElementNotEnabledExceptionIfValueSetWhenDisabled() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + const string expectedValue = "Wor"; + + var tokenizingTextBox = new TokenizingTextBox { IsEnabled = false }; + + await LoadTestContentAsync(tokenizingTextBox); + + var tokenizingTextBoxAutomationPeer = + FrameworkElementAutomationPeer.CreatePeerForElement(tokenizingTextBox) as TokenizingTextBoxAutomationPeer; + + Assert.ThrowsException(() => + { + tokenizingTextBoxAutomationPeer!.SetValue(expectedValue); + }); + }); + } + + public class TokenizingTextBoxTestItem + { + public string? Title { get; set; } + + public override string ToString() + { + return Title!; + } + } +} diff --git a/components/TokenizingTextBox/tests/Test_TokenizingTextBox_General.cs b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_General.cs new file mode 100644 index 00000000..724c9a13 --- /dev/null +++ b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_General.cs @@ -0,0 +1,363 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Tests; +using CommunityToolkit.WinUI.Controls; + +namespace TokenizingTextBoxExperiment.Tests; + + [TestClass] +public class Test_TokenizingTextBox_General : VisualUITestBase +{ + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_ClearTokens() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); + + // Add 4 items + tokenBox.AddTokenItem("TokenItem1"); + tokenBox.AddTokenItem("TokenItem2"); + tokenBox.AddTokenItem("TokenItem3"); + tokenBox.AddTokenItem("TokenItem4"); + + Assert.AreEqual(5, tokenBox.Items.Count, "Token Add count failed"); // 5th item is the textbox + + var count = 0; + + tokenBox.TokenItemRemoving += (sender, args) => { count++; }; + + // now test clear + await tokenBox.ClearAsync(); + + Assert.AreEqual(1, tokenBox.Items.Count, "Clear Failed to clear"); // Still expect textbox to remain + Assert.AreEqual(4, count, "Did not receive 4 removal events."); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_ClearTokenCancel() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); + + // test cancelled clear + tokenBox.AddTokenItem("TokenItem1"); + tokenBox.AddTokenItem("TokenItem2"); + tokenBox.AddTokenItem("TokenItem3"); + tokenBox.AddTokenItem("TokenItem4"); + + Assert.AreEqual(5, tokenBox.Items.Count, "Token Add count failed"); + + tokenBox.TokenItemRemoving += (sender, args) => { args.Cancel = true; }; + + await tokenBox.ClearAsync(); + + // Should have the same number of items left + Assert.AreEqual(5, tokenBox.Items.Count, "Cancelled Clear Failed "); + + // TODO: We should have test for individual removal as well. + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_MaximumTokens() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var maxTokens = 2; + + var treeRoot = XamlReader.Load( +$@" + + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + + // Items includes the text fields as well, so we can expect at least one item to exist initially, the input box. + // Use the starting count as an offset. + var startingItemsCount = tokenBox.Items.Count; + + // Add two items. + tokenBox.AddTokenItem("TokenItem1"); + tokenBox.AddTokenItem("TokenItem2"); + + // Make sure we have the appropriate amount of items and that they are in the appropriate order. + Assert.AreEqual(startingItemsCount + maxTokens, tokenBox.Items.Count, "Token Add failed"); + Assert.AreEqual("TokenItem1", tokenBox.Items[0]); + Assert.AreEqual("TokenItem2", tokenBox.Items[1]); + + // Attempt to add an additional item, beyond the maximum. + tokenBox.AddTokenItem("TokenItem3"); + + // Check that the number of items did not change, because the maximum number of items are already present. + Assert.AreEqual(startingItemsCount + maxTokens, tokenBox.Items.Count, "Token Add succeeded, where it should have failed."); + Assert.AreEqual("TokenItem1", tokenBox.Items[0]); + Assert.AreEqual("TokenItem2", tokenBox.Items[1]); + + // Reduce the maximum number of tokens. + tokenBox.MaximumTokens = 1; + + // The last token should be removed to account for the reduced maximum. + Assert.AreEqual(startingItemsCount + 1, tokenBox.Items.Count); + Assert.AreEqual("TokenItem1", tokenBox.Items[0]); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_SetInitialText() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); // AutoSuggestBox + + // Test initial value of property + Assert.AreEqual("Some Text", tokenBox.Text, "Token text not equal to starting value."); + + // Reach into AutoSuggestBox's text to check it was set properly + var autoSuggestBox = tokenBox.FindDescendant(); + + Assert.IsNotNull(autoSuggestBox, "Could not find inner autosuggestbox"); + Assert.AreEqual("Some Text", autoSuggestBox.Text, "Inner text not set based on initial value of TokenizingTextBox"); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_ChangeText() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); // AutoSuggestBox + + // Test initial value of property + Assert.AreEqual(string.Empty, tokenBox.Text, "Text should start as empty."); + + // Reach into AutoSuggestBox's text to check it was set properly + var autoSuggestBox = tokenBox.FindDescendant(); + + Assert.IsNotNull(autoSuggestBox, "Could not find inner autosuggestbox"); + Assert.AreEqual(string.Empty, autoSuggestBox.Text, "Inner text not set based on initial value of TokenizingTextBox"); + + // Change Text + tokenBox.Text = "New Text"; + + // Wait for update + await CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => { }); + + Assert.AreEqual("New Text", tokenBox.Text, "Text should be changed now."); + Assert.AreEqual("New Text", autoSuggestBox.Text, "Inner text not set based on value of TokenizingTextBox"); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_ClearText() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); // AutoSuggestBox + + // TODO: When in Labs, we should inject text via keyboard here vs. setting an initial value (more independent of SetInitialText test). + + // Test initial value of property + Assert.AreEqual("Some Text", tokenBox.Text, "Token text not equal to starting value."); + + // Reach into AutoSuggestBox's text to check it was set properly + var autoSuggestBox = tokenBox.FindDescendant(); + + Assert.IsNotNull(autoSuggestBox, "Could not find inner autosuggestbox"); + Assert.AreEqual("Some Text", autoSuggestBox.Text, "Inner text not set based on initial value of TokenizingTextBox"); + + await tokenBox.ClearAsync(); + + Assert.AreEqual(string.Empty, autoSuggestBox.Text, "Inner text was not cleared."); + Assert.AreEqual(string.Empty, tokenBox.Text, "TokenizingTextBox text was not cleared."); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_SetInitialTextWithDelimiter() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Tokens not created"); // AutoSuggestBox + + Assert.AreEqual("Token 1, Token 2, Token 3", tokenBox.Text, "Token text not equal to starting value."); + + await Task.Delay(500); // TODO: Wait for a loaded event? + + Assert.AreEqual(1 + 2, tokenBox.Items.Count, "Tokens not created"); + + // Test initial value of property + Assert.AreEqual("Token 3", tokenBox.Text, "Token text should be last value now."); + + Assert.AreEqual("Token 1", tokenBox.Items[0]); + Assert.AreEqual("Token 2", tokenBox.Items[1]); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_SetInitialTextWithDelimiterAll() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Tokens not created"); // AutoSuggestBox + + Assert.AreEqual("Token 1, Token 2, Token 3, ", tokenBox.Text, "Token text not equal to starting value."); + + await Task.Delay(500); // TODO: Wait for a loaded event? + + Assert.AreEqual(1 + 3, tokenBox.Items.Count, "Tokens not created"); + + // Test initial value of property + Assert.AreEqual(string.Empty, tokenBox.Text, "Token text should be blank now."); + + Assert.AreEqual("Token 1", tokenBox.Items[0]); + Assert.AreEqual("Token 2", tokenBox.Items[1]); + Assert.AreEqual("Token 3", tokenBox.Items[2]); + }); + } +} diff --git a/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.projitems b/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.projitems new file mode 100644 index 00000000..eacf9ba8 --- /dev/null +++ b/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.projitems @@ -0,0 +1,15 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 0EE1522F-BD8D-4BB1-9B44-303BAE40E457 + + + TokenizingTextBoxExperiment.Tests + + + + + + \ No newline at end of file diff --git a/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.shproj b/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.shproj new file mode 100644 index 00000000..92e0246d --- /dev/null +++ b/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.shproj @@ -0,0 +1,13 @@ + + + + 0EE1522F-BD8D-4BB1-9B44-303BAE40E457 + 14.0 + + + + + + + +