Skip to content

Commit 013419e

Browse files
committed
re-design
This should work better under bad network conditions
1 parent b52cff9 commit 013419e

File tree

2 files changed

+110
-66
lines changed

2 files changed

+110
-66
lines changed

src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.cs

Lines changed: 75 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
// Maintainer: Argo Zhang([email protected]) Website: https://www.blazor.zone
55

66
using Microsoft.Extensions.Localization;
7-
using System.Threading;
87

98
namespace BootstrapBlazor.Components;
109

@@ -90,6 +89,16 @@ public partial class AutoComplete
9089

9190
private List<string>? _filterItems;
9291

92+
/// <summary>
93+
/// Tracks the current user input to prevent it from being overwritten
94+
/// </summary>
95+
private string _currentUserInput = string.Empty;
96+
97+
/// <summary>
98+
/// Flag to track whether we're handling debounced filtering
99+
/// </summary>
100+
private bool _isFiltering = false;
101+
93102
/// <summary>
94103
/// <inheritdoc/>
95104
/// </summary>
@@ -113,14 +122,23 @@ protected override void OnParametersSet()
113122
LoadingIcon ??= IconTheme.GetIconByKey(ComponentIcons.LoadingIcon);
114123

115124
Items ??= [];
125+
126+
// Initialize _currentUserInput with current value if it hasn't been set yet
127+
if (string.IsNullOrEmpty(_currentUserInput) && !string.IsNullOrEmpty(CurrentValueAsString))
128+
{
129+
_currentUserInput = CurrentValueAsString;
130+
}
116131
}
117132

118133
/// <summary>
119134
/// Callback method when a candidate item is clicked
120135
/// </summary>
121136
private async Task OnClickItem(string val)
122137
{
138+
// Update both the CurrentValue and _currentUserInput when an item is clicked
139+
_currentUserInput = val;
123140
CurrentValue = val;
141+
124142
if (OnSelectedItemChanged != null)
125143
{
126144
await OnSelectedItemChanged(val);
@@ -129,73 +147,53 @@ private async Task OnClickItem(string val)
129147

130148
private List<string> Rows => _filterItems ?? [.. Items];
131149

132-
// Thread-safe tracking using SemaphoreSlim for async compatibility
133-
private string _userCurrentInput = string.Empty;
134-
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
135-
136150
/// <summary>
137151
/// TriggerFilter method
138152
/// </summary>
139153
/// <param name="val"></param>
140154
[JSInvokable]
141155
public override async Task TriggerFilter(string val)
142156
{
143-
// Thread-safe update using SemaphoreSlim
144-
await _semaphore.WaitAsync();
145157
try
146158
{
147-
_userCurrentInput = val;
148-
}
149-
finally
150-
{
151-
_semaphore.Release();
152-
}
159+
_isFiltering = true;
160+
// Update our tracking variable
161+
_currentUserInput = val;
153162

154-
// Process filtering
155-
if (OnCustomFilter != null)
156-
{
157-
var items = await OnCustomFilter(val);
158-
_filterItems = [.. items];
159-
}
160-
else if (string.IsNullOrEmpty(val))
161-
{
162-
_filterItems = [.. Items];
163-
}
164-
else
165-
{
166-
var comparison = IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
167-
var items = IsLikeMatch
168-
? Items.Where(s => s.Contains(val, comparison))
169-
: Items.Where(s => s.StartsWith(val, comparison));
170-
_filterItems = [.. items];
171-
}
163+
// Filter items as usual
164+
if (OnCustomFilter != null)
165+
{
166+
var items = await OnCustomFilter(val);
167+
_filterItems = [.. items];
168+
}
169+
else if (string.IsNullOrEmpty(val))
170+
{
171+
_filterItems = [.. Items];
172+
}
173+
else
174+
{
175+
var comparison = IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
176+
var items = IsLikeMatch
177+
? Items.Where(s => s.Contains(val, comparison))
178+
: Items.Where(s => s.StartsWith(val, comparison));
179+
_filterItems = [.. items];
180+
}
172181

173-
if (DisplayCount != null)
174-
{
175-
_filterItems = [.. _filterItems.Take(DisplayCount.Value)];
176-
}
182+
if (DisplayCount != null)
183+
{
184+
_filterItems = [.. _filterItems.Take(DisplayCount.Value)];
185+
}
177186

178-
// Thread-safe read using SemaphoreSlim
179-
await _semaphore.WaitAsync();
180-
string latestInput;
181-
try
182-
{
183-
latestInput = _userCurrentInput;
187+
// Update the bound value to match the user input, triggering proper value change notifications
188+
// This ensures OnValueChanged is triggered while preventing visual disruption
189+
CurrentValue = val;
190+
191+
// Refresh UI
192+
StateHasChanged();
184193
}
185194
finally
186195
{
187-
_semaphore.Release();
188-
}
189-
190-
// Only update CurrentValue if this is still the latest input
191-
if (latestInput == val)
192-
{
193-
CurrentValue = val;
194-
195-
if (!ValueChanged.HasDelegate)
196-
{
197-
StateHasChanged();
198-
}
196+
_isFiltering = false;
199197
}
200198
}
201199

@@ -206,8 +204,10 @@ public override async Task TriggerFilter(string val)
206204
[JSInvokable]
207205
public override Task TriggerChange(string val)
208206
{
209-
// Only update CurrentValue if the value has actually changed
210-
// This prevents overwriting the user's input
207+
// Update our tracking variable
208+
_currentUserInput = val;
209+
210+
// Update component value and trigger change notifications
211211
if (CurrentValue != val)
212212
{
213213
CurrentValue = val;
@@ -218,4 +218,24 @@ public override Task TriggerChange(string val)
218218
}
219219
return Task.CompletedTask;
220220
}
221+
222+
/// <summary>
223+
/// Override CurrentValueAsString to return the current user input
224+
/// </summary>
225+
protected override string? FormatValueAsString(string? value)
226+
{
227+
// During filtering operations, use what the user is actually typing
228+
if (_isFiltering)
229+
{
230+
return _currentUserInput;
231+
}
232+
233+
// In non-filtering scenarios, sync our tracked value with the component value
234+
if (!string.IsNullOrEmpty(value) && _currentUserInput != value)
235+
{
236+
_currentUserInput = value;
237+
}
238+
239+
return base.FormatValueAsString(value);
240+
}
221241
}

src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.js

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,37 @@ export function init(id, invoke) {
1717
ac.popover = Popover.init(el, { toggleClass: '[data-bs-toggle="bb.dropdown"]' });
1818
}
1919

20+
// Track the current user input to prevent it from being overwritten
21+
ac.currentUserInput = input.value;
22+
23+
// Save original input value
24+
const updateCurrentInput = (e) => {
25+
if (e && e.target) {
26+
ac.currentUserInput = e.target.value;
27+
}
28+
};
29+
30+
// Add an input event listener to track user typing in real-time
31+
EventHandler.on(input, 'input', updateCurrentInput);
32+
2033
// debounce
2134
const duration = parseInt(input.getAttribute('data-bb-debounce') || '0');
2235
if (duration > 0) {
2336
ac.debounce = true
2437
EventHandler.on(input, 'keyup', debounce(e => {
38+
// Don't let the debounce overwrite what the user is currently typing
39+
if (input.value !== ac.currentUserInput) {
40+
input.value = ac.currentUserInput;
41+
}
2542
handlerKeyup(ac, e);
2643
}, duration, e => {
2744
return ['ArrowUp', 'ArrowDown', 'Escape', 'Enter', 'NumpadEnter'].indexOf(e.key) > -1
2845
}))
2946
}
3047
else {
3148
EventHandler.on(input, 'keyup', e => {
49+
// Make sure we're using the most current input value
50+
updateCurrentInput(e);
3251
handlerKeyup(ac, e);
3352
})
3453
}
@@ -55,6 +74,7 @@ export function init(id, invoke) {
5574
});
5675

5776
EventHandler.on(input, 'change', e => {
77+
updateCurrentInput(e);
5878
invoke.invokeMethodAsync('TriggerChange', e.target.value);
5979
});
6080

@@ -63,26 +83,29 @@ export function init(id, invoke) {
6383
filterDuration = 200;
6484
}
6585
const filterCallback = debounce(async v => {
66-
// Check if the input value is still the same
67-
// If not, this is an old operation that should be ignored
68-
if (input.dataset.lastValue === v) {
69-
await invoke.invokeMethodAsync('TriggerFilter', v);
70-
el.classList.remove('is-loading');
86+
// Keep track of what was filtered vs what might be currently typed
87+
const currentTypedValue = input.value;
88+
89+
await invoke.invokeMethodAsync('TriggerFilter', v);
90+
91+
// Only reset input value if the user hasn't typed something new
92+
// during the async operation
93+
if (input.value === v) {
94+
input.value = ac.currentUserInput;
7195
}
96+
97+
el.classList.remove('is-loading');
7298
}, filterDuration);
7399

74100
Input.composition(input, v => {
101+
// Update our tracked input value
102+
ac.currentUserInput = v;
103+
75104
if (isPopover === false) {
76105
el.classList.add('show');
77106
}
78107

79108
el.classList.add('is-loading');
80-
81-
// Store the current input value on the element
82-
// This helps track the latest user input
83-
input.dataset.lastValue = v;
84-
85-
// Modify the filterCallback to check if the input value has changed
86109
filterCallback(v);
87110
});
88111

@@ -176,6 +199,7 @@ export function dispose(id) {
176199
}
177200
EventHandler.off(input, 'change');
178201
EventHandler.off(input, 'keyup');
202+
EventHandler.off(input, 'input'); // Remove the input event listener we added
179203
EventHandler.off(menu, 'click');
180204
Input.dispose(input);
181205

0 commit comments

Comments
 (0)