using Connected.Utilities; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; namespace Connected.Components; public partial class Autocomplete : InputBase, IDisposable { private Func? _toStringFunc; private Task _currentSearchTask; private CancellationTokenSource _cancellationTokenSrc; private bool _isOpen; private Timer _timer; private T[] _items; private int _selectedListItemIndex = 0; private IList _enabledItemIndices = new List(); private int _itemsReturned; //the number of items returned by the search function int _elementKey = 0; /// /// This boolean will keep track if the clear function is called too keep the set text function to be called. /// private bool _isCleared; private Input _elementReference; /// /// We need a random id for the year items in the year list so we can scroll to the item safely in every DatePicker. /// private readonly string _componentId = Guid.NewGuid().ToString(); public Autocomplete() { Adornment = Adornment.End; IconSize = Size.Medium; } [Inject] IScrollManager ScrollManager { get; set; } /// /// User class names for the popover, separated by space /// [Parameter] public string PopoverClass { get; set; } /// /// Set the anchor origin point to determen where the popover will open from. /// [Parameter] public Origin AnchorOrigin { get; set; } = Origin.BottomCenter; /// /// Sets the transform origin point for the popover. /// [Parameter] public Origin TransformOrigin { get; set; } = Origin.TopCenter; /// /// If true, compact vertical padding will be applied to all Autocomplete items. /// [Parameter] public bool Dense { get; set; } /// /// The Open Autocomplete Icon /// [Parameter] public string OpenIcon { get; set; } = Icons.Material.Filled.ArrowDropDown; /// /// The Close Autocomplete Icon /// [Parameter] public string CloseIcon { get; set; } = Icons.Material.Filled.ArrowDropUp; /// /// The maximum height of the Autocomplete when it is open. /// [Parameter] public int MaxHeight { get; set; } = 300; /// /// Defines how values are displayed in the drop-down list /// [Parameter] public Func? ToStringFunc { get => _toStringFunc; set { if (_toStringFunc == value) return; _toStringFunc = value; SetConverter(new LambdaConverter(_toStringFunc ?? (x => x?.ToString()), null)); } } /// /// Whether to show the progress indicator. /// [Parameter] public bool ShowProgressIndicator { get; set; } = false; /// /// The color of the progress indicator. /// [Parameter] public ThemeColor ProgressIndicatorColor { get; set; } = ThemeColor.Default; private bool IsLoading => _currentSearchTask is not null && !_currentSearchTask.IsCompleted; /// /// Func that returns a list of items matching the typed text. Provides a cancellation token that /// is marked as cancelled when the user changes the search text or selects a value from the list. /// This can be used to cancel expensive asynchronous work occuring within the SearchFunc itself. /// [Parameter] public Func>> SearchFuncWithCancel { get; set; } /// /// The SearchFunc returns a list of items matching the typed text /// [Parameter] public Func>> SearchFunc { get; set; } /// /// Maximum items to display, defaults to 10. /// A null value will display all items. /// [Parameter] public int? MaxItems { get; set; } = 10; /// /// Minimum characters to initiate a search /// [Parameter] public int MinCharacters { get; set; } = 0; /// /// Reset value if user deletes the text /// [Parameter] public bool ResetValueOnEmptyText { get; set; } = false; /// /// If true, clicking the text field will select (highlight) its contents. /// [Parameter] public bool SelectOnClick { get; set; } = true; /// /// Debounce interval in milliseconds. /// [Parameter] public int DebounceInterval { get; set; } = 100; /// /// Optional presentation template for unselected items /// [Parameter] public RenderFragment ItemTemplate { get; set; } /// /// Optional presentation template for the selected item /// [Parameter] public RenderFragment ItemSelectedTemplate { get; set; } /// /// Optional presentation template for disabled item /// [Parameter] public RenderFragment ItemDisabledTemplate { get; set; } /// /// Optional presentation template for when more items were returned from the Search function than the MaxItems limit /// [Parameter] public RenderFragment MoreItemsTemplate { get; set; } /// /// Optional presentation template for when no items were returned from the Search function /// [Parameter] public RenderFragment NoItemsTemplate { get; set; } /// /// Optional template for progress indicator /// [Parameter] public RenderFragment ProgressIndicatorTemplate { get; set; } /// /// Optional template for showing progress indicator inside the popover /// [Parameter] public RenderFragment ProgressIndicatorInPopoverTemplate { get; set; } /// /// On drop-down close override Text with selected Value. This makes it clear to the user /// which list value is currently selected and disallows incomplete values in Text. /// [Parameter] public bool CoerceText { get; set; } = true; /// /// If user input is not found by the search func and CoerceValue is set to true the user input /// will be applied to the Value which allows to validate it and display an error message. /// [Parameter] public bool CoerceValue { get; set; } /// /// Function to be invoked when checking whether an item should be disabled or not /// [Parameter] public Func ItemDisabledFunc { get; set; } /// /// Returns the open state of the drop-down. /// public bool IsOpen { get => _isOpen; // Note: the setter is protected because it was needed by a user who derived his own autocomplete from this class. // Note: setting IsOpen will not open or close it. Use ToggleMenu() for that. protected set { if (value == _isOpen) return; _isOpen = value; IsOpenChanged.InvokeAsync(_isOpen).AndForget(); } } /// /// An event triggered when the state of IsOpen has changed /// [Parameter] public EventCallback IsOpenChanged { get; set; } /// /// If true, the currently selected item from the drop-down (if it is open) is selected. /// [Parameter] public bool SelectValueOnTab { get; set; } = false; /// /// Show clear button. /// [Parameter] public bool Clearable { get; set; } = false; /// /// Button click event for clear button. Called after text and value has been cleared. /// [Parameter] public EventCallback OnClearButtonClick { get; set; } private string CurrentIcon => !string.IsNullOrWhiteSpace(AdornmentIcon) ? AdornmentIcon : _isOpen ? CloseIcon : OpenIcon; protected string ClassList() { return new CssBuilder("select") .AddClass(Class) .Build(); } protected string AutocompleteClassList() { return new CssBuilder("select") .AddClass("autocomplete") .AddClass("width-full", FullWidth) .AddClass("autocomplete--with-progress", ShowProgressIndicator && IsLoading) .Build(); } protected string CircularProgressClassList() { return new CssBuilder("progress-indicator-circular") .AddClass("progress-indicator-circular--with-adornment", Adornment == Adornment.End) .Build(); } public async Task SelectOption(T value) { await SetValueAsync(value); if (_items is not null) _selectedListItemIndex = Array.IndexOf(_items, value); var optionText = GetItemString(value); if (!_isCleared) await SetTextAsync(optionText, false); _timer?.Dispose(); IsOpen = false; BeginValidate(); if (!_isCleared) _elementReference?.SetText(optionText); _elementReference?.FocusAsync().AndForget(); StateHasChanged(); } /// /// Toggle the menu (if not disabled or not readonly, and is opened). /// public async Task ToggleMenu() { if ((Disabled || ReadOnly) && !IsOpen) return; await ChangeMenu(!IsOpen); } private async Task ChangeMenu(bool open) { if (open) { if (SelectOnClick) await _elementReference.SelectAsync(); await OnSearchAsync(); } else { _timer?.Dispose(); RestoreScrollPosition(); await CoerceTextToValue(); IsOpen = false; StateHasChanged(); } } protected override void OnInitialized() { var text = GetItemString(Value); if (!string.IsNullOrWhiteSpace(text)) Text = text; } protected override void OnAfterRender(bool firstRender) { _isCleared = false; base.OnAfterRender(firstRender); } protected override Task UpdateTextPropertyAsync(bool updateValue) { _timer?.Dispose(); // This keeps the text from being set when clear() was called if (_isCleared) return Task.CompletedTask; return base.UpdateTextPropertyAsync(updateValue); } protected override async Task UpdateValuePropertyAsync(bool updateText) { _timer?.Dispose(); if (ResetValueOnEmptyText && string.IsNullOrWhiteSpace(Text)) await SetValueAsync(default, updateText); if (DebounceInterval <= 0) await OnSearchAsync(); else _timer = new Timer(OnTimerComplete, null, DebounceInterval, Timeout.Infinite); } private void OnTimerComplete(object stateInfo) { InvokeAsync(OnSearchAsync); } private void CancelToken() { try { _cancellationTokenSrc?.Cancel(); } catch { } _cancellationTokenSrc = new CancellationTokenSource(); } /// /// This async method needs to return a task and be awaited in order for /// unit tests that trigger this method to work correctly. /// private async Task OnSearchAsync() { if (MinCharacters > 0 && (string.IsNullOrWhiteSpace(Text) || Text.Length < MinCharacters)) { IsOpen = false; StateHasChanged(); return; } IEnumerable searchedItems = Array.Empty(); CancelToken(); try { if (ProgressIndicatorInPopoverTemplate is not null) IsOpen = true; var searchTask = SearchFuncWithCancel is not null ? SearchFuncWithCancel(Text, _cancellationTokenSrc.Token) : SearchFunc(Text); _currentSearchTask = searchTask; StateHasChanged(); searchedItems = await searchTask ?? Array.Empty(); } catch (TaskCanceledException) { } catch (OperationCanceledException) { } catch (Exception e) { Console.WriteLine($"The search function failed to return results: {e.Message}"); } _itemsReturned = searchedItems.Count(); if (MaxItems.HasValue) searchedItems = searchedItems.Take(MaxItems.Value); _items = searchedItems.ToArray(); _enabledItemIndices = _items.Select((item, idx) => (item, idx)).Where(tuple => ItemDisabledFunc?.Invoke(tuple.item) != true).Select(tuple => tuple.idx).ToList(); _selectedListItemIndex = _enabledItemIndices.Any() ? _enabledItemIndices.First() : -1; IsOpen = true; if (_items?.Length == 0) { await CoerceValueToText(); StateHasChanged(); return; } StateHasChanged(); } /// /// Clears the autocomplete's text /// public async Task Clear() { _isCleared = true; IsOpen = false; await SetTextAsync(string.Empty, updateValue: false); await CoerceValueToText(); if (_elementReference is not null) await _elementReference.SetText(string.Empty); _timer?.Dispose(); StateHasChanged(); } protected override async void ResetValue() { await Clear(); base.ResetValue(); } private string GetItemString(T item) { if (item is null) return string.Empty; try { return Converter.Convert(item); } catch (NullReferenceException) { } return "null"; } internal virtual async Task OnInputKeyDown(KeyboardEventArgs args) { switch (args.Key) { case "Tab": // NOTE: We need to catch Tab in Keydown because a tab will move focus to the next element and thus // in OnInputKeyUp we'd never get the tab key if (!IsOpen) return; if (SelectValueOnTab) await OnEnterKey(); else IsOpen = false; break; } } internal virtual async Task OnInputKeyUp(KeyboardEventArgs args) { switch (args.Key) { case "Enter": case "NumpadEnter": if (!IsOpen) await ToggleMenu(); else await OnEnterKey(); break; case "ArrowDown": if (!IsOpen) await ToggleMenu(); else { var increment = _enabledItemIndices.ElementAtOrDefault(_enabledItemIndices.IndexOf(_selectedListItemIndex) + 1) - _selectedListItemIndex; await SelectNextItem(increment < 0 ? 1 : increment); } break; case "ArrowUp": if (args.AltKey) await ChangeMenu(false); else if (!IsOpen) await ToggleMenu(); else { var decrement = _selectedListItemIndex - _enabledItemIndices.ElementAtOrDefault(_enabledItemIndices.IndexOf(_selectedListItemIndex) - 1); await SelectNextItem(-(decrement < 0 ? 1 : decrement)); } break; case "Escape": await ChangeMenu(false); break; case "Tab": await Task.Delay(1); if (!IsOpen) return; if (SelectValueOnTab) await OnEnterKey(); else await ToggleMenu(); break; case "Backspace": if (args.CtrlKey && args.ShiftKey) Reset(); break; } base.InvokeKeyUp(args); } private ValueTask SelectNextItem(int increment) { if (increment == 0 || _items is null || !_items.Any() || !_enabledItemIndices.Any()) return ValueTask.CompletedTask; // if we are at the end, or the beginning we just do an rollover _selectedListItemIndex = Math.Clamp(value: (10 * _items.Length + _selectedListItemIndex + increment) % _items.Length, min: 0, max: _items.Length - 1); return ScrollToListItem(_selectedListItemIndex); } /// /// Scroll to a specific item index in the Autocomplete list of items. /// /// the index to scroll to public ValueTask ScrollToListItem(int index) { var id = GetListItemId(index); //id of the scrolled element return ScrollManager.ScrollToListItemAsync(id); } /* * This restores the scroll position after closing the menu and element being 0 */ private void RestoreScrollPosition() { if (_selectedListItemIndex != 0) return; ScrollManager.ScrollToListItemAsync(GetListItemId(0)); } private string GetListItemId(in int index) { return $"{_componentId}_item{index}"; } internal Task OnEnterKey() { if (!IsOpen) return Task.CompletedTask; if (_items is null || !_items.Any()) return Task.CompletedTask; if (_selectedListItemIndex >= 0 && _selectedListItemIndex < _items.Length) return SelectOption(_items[_selectedListItemIndex]); return Task.CompletedTask; } private Task OnInputBlurred(FocusEventArgs args) { OnBlur.InvokeAsync(args); return Task.CompletedTask; // we should not validate on blur in autocomplete, because the user needs to click out of the input to select a value, // resulting in a premature validation. thus, don't call base //base.OnBlurred(args); } private Task CoerceTextToValue() { if (!CoerceText) return Task.CompletedTask; _timer?.Dispose(); var text = Value is null ? null : GetItemString(Value); /* * Don't update the value to prevent the popover from opening again after coercion */ if (text != Text) return SetTextAsync(text, updateValue: false); return Task.CompletedTask; } private Task CoerceValueToText() { if (!CoerceValue) return Task.CompletedTask; _timer?.Dispose(); var value = Converter.ConvertBack(Text); return SetValueAsync(value, updateText: false); } protected override void Dispose(bool disposing) { _timer?.Dispose(); if (_cancellationTokenSrc is not null) { try { _cancellationTokenSrc.Dispose(); } catch { } } base.Dispose(disposing); } /// /// Focus the input in the Autocomplete component. /// public override ValueTask FocusAsync() { return _elementReference.FocusAsync(); } /// /// Blur from the input in the Autocomplete component. /// public override ValueTask BlurAsync() { return _elementReference.BlurAsync(); } /// /// Select all text within the Autocomplete input. /// public override ValueTask SelectAsync() { return _elementReference.SelectAsync(); } /// /// Select all text within the Autocomplete input and aligns its start and end points to the text content of the current input. /// public override ValueTask SelectRangeAsync(int pos1, int pos2) { return _elementReference.SelectRangeAsync(pos1, pos2); } private async Task OnTextChanged(string text) { await TextChanged.InvokeAsync(); if (text is null) return; await SetTextAsync(text, true); } private async Task ListItemOnClick(T item) { await SelectOption(item); } }