Skip to content

Commit 933a1c6

Browse files
Merge pull request #4163 from CommunityToolkit/shweaver/token-selection-mode
Added support for limiting max tokens in TokenizingTextBox
2 parents bd71319 + ccefe38 commit 933a1c6

File tree

7 files changed

+177
-16
lines changed

7 files changed

+177
-16
lines changed

Microsoft.Toolkit.Uwp.SampleApp/SamplePages/TokenizingTextBox/TokenizingTextBoxXaml.bind

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,20 @@
3030
<RowDefinition/>
3131
</Grid.RowDefinitions>
3232
<StackPanel>
33-
<TextBlock FontSize="32" Text="Select Actions"
34-
Margin="0,0,0,4"/>
33+
<TextBlock FontSize="32" Margin="0,0,0,4">
34+
<Run Text="Select up to" />
35+
<Run Text="{Binding MaximumTokens, ElementName=TokenBox, Mode=OneWay}" />
36+
<Run Text="actions" />
37+
</TextBlock>
3538
<controls:TokenizingTextBox
3639
x:Name="TokenBox"
3740
PlaceholderText="Add Actions"
3841
QueryIcon="{ui:SymbolIconSource Symbol=Setting}"
3942
MaxHeight="104"
4043
HorizontalAlignment="Stretch"
4144
TextMemberPath="Text"
42-
TokenDelimiter=",">
45+
TokenDelimiter=","
46+
MaximumTokens="3">
4347
<controls:TokenizingTextBox.SuggestedItemTemplate>
4448
<DataTemplate>
4549
<StackPanel Orientation="Horizontal">

Microsoft.Toolkit.Uwp.UI.Controls.Input/TokenizingTextBox/TokenizingTextBox.Properties.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,37 @@ private static void TextPropertyChanged(DependencyObject d, DependencyPropertyCh
157157
typeof(TokenizingTextBox),
158158
new PropertyMetadata(false));
159159

160+
/// <summary>
161+
/// Identifies the <see cref="MaximumTokens"/> property.
162+
/// </summary>
163+
public static readonly DependencyProperty MaximumTokensProperty = DependencyProperty.Register(
164+
nameof(MaximumTokens),
165+
typeof(int),
166+
typeof(TokenizingTextBox),
167+
new PropertyMetadata(null, OnMaximumTokensChanged));
168+
169+
private static void OnMaximumTokensChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
170+
{
171+
if (d is TokenizingTextBox ttb && ttb.ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && e.NewValue is int newMaxTokens)
172+
{
173+
var tokenCount = ttb._innerItemsSource.ItemsSource.Count;
174+
if (tokenCount > 0 && tokenCount > newMaxTokens)
175+
{
176+
int tokensToRemove = tokenCount - Math.Max(newMaxTokens, 0);
177+
178+
// Start at the end, remove any extra tokens.
179+
for (var i = tokenCount; i > tokenCount - tokensToRemove; --i)
180+
{
181+
var token = ttb._innerItemsSource.ItemsSource[i - 1];
182+
183+
// Force remove the items. No warning and no option to cancel.
184+
ttb._innerItemsSource.Remove(token);
185+
ttb.TokenItemRemoved?.Invoke(ttb, token);
186+
}
187+
}
188+
}
189+
}
190+
160191
/// <summary>
161192
/// Gets or sets the Style for the contained AutoSuggestBox template part.
162193
/// </summary>
@@ -303,5 +334,14 @@ public string SelectedTokenText
303334
return PrepareSelectionForClipboard();
304335
}
305336
}
337+
338+
/// <summary>
339+
/// Gets or sets the maximum number of token results allowed at a time.
340+
/// </summary>
341+
public int MaximumTokens
342+
{
343+
get => (int)GetValue(MaximumTokensProperty);
344+
set => SetValue(MaximumTokensProperty, value);
345+
}
306346
}
307-
}
347+
}

Microsoft.Toolkit.Uwp.UI.Controls.Input/TokenizingTextBox/TokenizingTextBox.cs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ private void ItemsSource_PropertyChanged(DependencyObject sender, DependencyProp
7878
if (ItemsSource != null && ItemsSource.GetType() != typeof(InterspersedObservableCollection))
7979
{
8080
_innerItemsSource = new InterspersedObservableCollection(ItemsSource);
81+
82+
if (ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && _innerItemsSource.ItemsSource.Count >= MaximumTokens)
83+
{
84+
// Reduce down to below the max as necessary.
85+
var endCount = MaximumTokens > 0 ? MaximumTokens : 0;
86+
for (var i = _innerItemsSource.ItemsSource.Count - 1; i >= endCount; --i)
87+
{
88+
_innerItemsSource.Remove(_innerItemsSource[i]);
89+
}
90+
}
91+
8192
_currentTextEdit = _lastTextEdit = new PretokenStringContainer(true);
8293
_innerItemsSource.Insert(_innerItemsSource.Count, _currentTextEdit);
8394
ItemsSource = _innerItemsSource;
@@ -279,18 +290,16 @@ void WaitForLoad(object s, RoutedEventArgs eargs)
279290
}
280291
else
281292
{
282-
// TODO: It looks like we're setting selection and focus together on items? Not sure if that's what we want...
283-
// If that's the case, don't think this code will ever be called?
284-
285-
//// TODO: Behavior question: if no items selected (just focus) does it just go to our last active textbox?
286-
//// Community voted that typing in the end box made sense
287-
293+
// If no items are selected, send input to the last active string container.
294+
// 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.
288295
if (_innerItemsSource[_innerItemsSource.Count - 1] is ITokenStringContainer textToken)
289296
{
290297
var last = ContainerFromIndex(Items.Count - 1) as TokenizingTextBoxItem; // Should be our last text box
291-
var position = last._autoSuggestTextBox.SelectionStart;
292-
textToken.Text = last._autoSuggestTextBox.Text.Substring(0, position) + args.Character +
293-
last._autoSuggestTextBox.Text.Substring(position);
298+
var text = last._autoSuggestTextBox.Text;
299+
var selectionStart = last._autoSuggestTextBox.SelectionStart;
300+
var position = selectionStart > text.Length ? text.Length : selectionStart;
301+
textToken.Text = text.Substring(0, position) + args.Character +
302+
text.Substring(position);
294303

295304
last._autoSuggestTextBox.SelectionStart = position + 1; // Set position to after our new character inserted
296305

@@ -433,6 +442,12 @@ public async Task ClearAsync()
433442

434443
internal async Task AddTokenAsync(object data, bool? atEnd = null)
435444
{
445+
if (ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && (MaximumTokens <= 0 || MaximumTokens <= _innerItemsSource.ItemsSource.Count))
446+
{
447+
// No tokens for you
448+
return;
449+
}
450+
436451
if (data is string str && TokenItemAdding != null)
437452
{
438453
var tiaea = new TokenItemAddingEventArgs(str);

Microsoft.Toolkit.Uwp.UI.Controls.Input/TokenizingTextBox/TokenizingTextBoxItem.AutoSuggestBox.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
using Windows.Foundation;
66
using Windows.System;
7+
using Windows.UI;
78
using Windows.UI.Xaml;
89
using Windows.UI.Xaml.Controls;
910
using Windows.UI.Xaml.Data;
1011
using Windows.UI.Xaml.Input;
12+
using Windows.UI.Xaml.Media;
1113

1214
namespace Microsoft.Toolkit.Uwp.UI.Controls
1315
{
@@ -16,9 +18,11 @@ namespace Microsoft.Toolkit.Uwp.UI.Controls
1618
/// </summary>
1719
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1124:Do not use regions", Justification = "Organization")]
1820
[TemplatePart(Name = PART_AutoSuggestBox, Type = typeof(AutoSuggestBox))] //// String case
21+
[TemplatePart(Name = PART_TokensCounter, Type = typeof(TextBlock))]
1922
public partial class TokenizingTextBoxItem
2023
{
2124
private const string PART_AutoSuggestBox = "PART_AutoSuggestBox";
25+
private const string PART_TokensCounter = "PART_TokensCounter";
2226

2327
private AutoSuggestBox _autoSuggestBox;
2428

@@ -231,6 +235,8 @@ private void AutoSuggestBox_GotFocus(object sender, RoutedEventArgs e)
231235
#region Inner TextBox
232236
private void OnASBLoaded(object sender, RoutedEventArgs e)
233237
{
238+
UpdateTokensCounter(this);
239+
234240
// Local function for Selection changed
235241
void AutoSuggestTextBox_SelectionChanged(object box, RoutedEventArgs args)
236242
{
@@ -329,6 +335,44 @@ private void AutoSuggestTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs
329335
Owner.SelectAllTokensAndText();
330336
}
331337
}
338+
339+
private void UpdateTokensCounter(TokenizingTextBoxItem ttbi)
340+
{
341+
var maxTokensCounter = (TextBlock)_autoSuggestBox?.FindDescendant(PART_TokensCounter);
342+
if (maxTokensCounter == null)
343+
{
344+
return;
345+
}
346+
347+
void OnTokenCountChanged(TokenizingTextBox ttb, object value = null)
348+
{
349+
var itemsSource = ttb.ItemsSource as InterspersedObservableCollection;
350+
var currentTokens = itemsSource.ItemsSource.Count;
351+
var maxTokens = ttb.MaximumTokens;
352+
353+
maxTokensCounter.Text = $"{currentTokens}/{maxTokens}";
354+
maxTokensCounter.Visibility = Visibility.Visible;
355+
356+
maxTokensCounter.Foreground = (currentTokens >= maxTokens)
357+
? new SolidColorBrush(Colors.Red)
358+
: _autoSuggestBox.Foreground;
359+
}
360+
361+
ttbi.Owner.TokenItemAdded -= OnTokenCountChanged;
362+
ttbi.Owner.TokenItemRemoved -= OnTokenCountChanged;
363+
364+
if (Content is ITokenStringContainer str && str.IsLast && ttbi?.Owner != null && ttbi.Owner.ReadLocalValue(TokenizingTextBox.MaximumTokensProperty) != DependencyProperty.UnsetValue)
365+
{
366+
ttbi.Owner.TokenItemAdded += OnTokenCountChanged;
367+
ttbi.Owner.TokenItemRemoved += OnTokenCountChanged;
368+
OnTokenCountChanged(ttbi.Owner);
369+
}
370+
else
371+
{
372+
maxTokensCounter.Visibility = Visibility.Collapsed;
373+
maxTokensCounter.Text = string.Empty;
374+
}
375+
}
332376
#endregion
333377
}
334378
}

Microsoft.Toolkit.Uwp.UI.Controls.Input/TokenizingTextBox/TokenizingTextBoxItem.AutoSuggestBox.xaml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
<ColumnDefinition Width="*" />
138138
<ColumnDefinition Width="Auto" />
139139
<ColumnDefinition Width="Auto" />
140+
<ColumnDefinition Width="Auto" />
140141
</Grid.ColumnDefinitions>
141142
<Grid.RowDefinitions>
142143
<RowDefinition Height="Auto" />
@@ -176,7 +177,7 @@
176177
ZoomMode="Disabled" />
177178
<ContentControl x:Name="PlaceholderTextContentPresenter"
178179
Grid.Row="1"
179-
Grid.ColumnSpan="3"
180+
Grid.ColumnSpan="2"
180181
Margin="{TemplateBinding BorderThickness}"
181182
Padding="{TemplateBinding Padding}"
182183
Content="{TemplateBinding PlaceholderText}"
@@ -194,9 +195,15 @@
194195
IsTabStop="False"
195196
Style="{StaticResource TokenizingTextBoxDeleteButtonStyle}"
196197
Visibility="Collapsed" />
198+
199+
<TextBlock Name="PART_TokensCounter"
200+
Grid.Row="1"
201+
Grid.Column="2"
202+
Margin="0,4,0,0" />
203+
197204
<Button x:Name="QueryButton"
198205
Grid.Row="1"
199-
Grid.Column="2"
206+
Grid.Column="3"
200207
Width="{TemplateBinding Height}"
201208
MinWidth="30"
202209
VerticalAlignment="Stretch"

Microsoft.Toolkit.Uwp.UI.Controls.Input/TokenizingTextBox/TokenizingTextBoxItem.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
using Windows.Foundation;
66
using Windows.System;
7-
using Windows.UI.Core;
87
using Windows.UI.Xaml;
98
using Windows.UI.Xaml.Controls;
109
using Windows.UI.Xaml.Controls.Primitives;

UnitTests/UnitTests.UWP/UI/Controls/Test_TokenizingTextBox_General.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,57 @@ public void Test_Clear()
6363

6464
Assert.AreEqual(tokenBox.Items.Count, 5, "Cancelled Clear Failed ");
6565
}
66+
67+
[TestCategory("Test_TokenizingTextBox_General")]
68+
[UITestMethod]
69+
public void Test_MaximumTokens()
70+
{
71+
var maxTokens = 2;
72+
73+
var treeRoot = XamlReader.Load(
74+
$@"<Page
75+
xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
76+
xmlns:x=""http://schemas.microsoft.com/winfx/2006/xaml""
77+
xmlns:controls=""using:Microsoft.Toolkit.Uwp.UI.Controls"">
78+
79+
<controls:TokenizingTextBox x:Name=""tokenboxname"" MaximumTokens=""{maxTokens}"">
80+
</controls:TokenizingTextBox>
81+
82+
</Page>") as FrameworkElement;
83+
84+
Assert.IsNotNull(treeRoot, "Could not load XAML tree.");
85+
86+
var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox;
87+
88+
Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree.");
89+
90+
// Items includes the text fields as well, so we can expect at least one item to exist initially, the input box.
91+
// Use the starting count as an offset.
92+
var startingItemsCount = tokenBox.Items.Count;
93+
94+
// Add two items.
95+
tokenBox.AddTokenItem("TokenItem1");
96+
tokenBox.AddTokenItem("TokenItem2");
97+
98+
// Make sure we have the appropriate amount of items and that they are in the appropriate order.
99+
Assert.AreEqual(startingItemsCount + maxTokens, tokenBox.Items.Count, "Token Add failed");
100+
Assert.AreEqual("TokenItem1", tokenBox.Items[0]);
101+
Assert.AreEqual("TokenItem2", tokenBox.Items[1]);
102+
103+
// Attempt to add an additional item, beyond the maximum.
104+
tokenBox.AddTokenItem("TokenItem3");
105+
106+
// Check that the number of items did not change, because the maximum number of items are already present.
107+
Assert.AreEqual(startingItemsCount + maxTokens, tokenBox.Items.Count, "Token Add succeeded, where it should have failed.");
108+
Assert.AreEqual("TokenItem1", tokenBox.Items[0]);
109+
Assert.AreEqual("TokenItem2", tokenBox.Items[1]);
110+
111+
// Reduce the maximum number of tokens.
112+
tokenBox.MaximumTokens = 1;
113+
114+
// The last token should be removed to account for the reduced maximum.
115+
Assert.AreEqual(startingItemsCount + 1, tokenBox.Items.Count);
116+
Assert.AreEqual("TokenItem1", tokenBox.Items[0]);
117+
}
66118
}
67119
}

0 commit comments

Comments
 (0)