diff --git a/Components/Autocomplete/Autocomplete.razor b/Components/Autocomplete/Autocomplete.razor index 67eeda7..c0dd9c6 100644 --- a/Components/Autocomplete/Autocomplete.razor +++ b/Components/Autocomplete/Autocomplete.razor @@ -5,14 +5,14 @@
+ Error="@HasError" ErrorText="@ErrorText" Disabled="@Disabled" @onclick="@ToggleMenu" Required="@Required" ForId="@FieldId"> : 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) + 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; + + Converter = 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; - _toStringFunc = value; - - Converter = new Converter - { - SetFunc = _toStringFunc ?? (x => x?.ToString()), - }; - } - } - /// - /// 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) + 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; + } - _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.Set(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; + IEnumerable searchedItems = Array.Empty(); + CancelToken(); - await SelectNextItem(increment < 0 ? 1 : increment); - } + 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}"; + } - break; - case "ArrowUp": - if (args.AltKey) - await ChangeMenu(false); - else if (!IsOpen) - await ToggleMenu(); - else + 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 { - var decrement = _selectedListItemIndex - _enabledItemIndices.ElementAtOrDefault(_enabledItemIndices.IndexOf(_selectedListItemIndex) - 1); - await SelectNextItem(-(decrement < 0 ? 1 : decrement)); + _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); + } - 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.Get(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); - } + private async Task ListItemOnClick(T item) + { + await SelectOption(item); + } } diff --git a/Components/BooleanInput.cs b/Components/BooleanInput.cs index d7d59f5..2a362c3 100644 --- a/Components/BooleanInput.cs +++ b/Components/BooleanInput.cs @@ -44,17 +44,17 @@ public class BooleanInput : FormComponent /// [Parameter] public EventCallback CheckedChanged { get; set; } - protected bool? BoolValue => Converter.Set(Checked); + protected bool? BoolValue => Converter.Convert(Checked); protected virtual Task OnChange(ChangeEventArgs args) { - Touched = true; + Modified = true; return SetBoolValueAsync((bool?)args.Value); } protected Task SetBoolValueAsync(bool? value) { - return SetCheckedAsync(Converter.Get(value)); + return SetCheckedAsync(Converter.ConvertBack(value)); } protected async Task SetCheckedAsync(T value) @@ -74,7 +74,7 @@ public class BooleanInput : FormComponent { var changed = base.SetConverter(value); if (changed) - SetBoolValueAsync(Converter.Set(Checked)).AndForget(); + SetBoolValueAsync(Converter.Convert(Checked)).AndForget(); return changed; } diff --git a/Components/CheckBox/CheckBox.razor.cs b/Components/CheckBox/CheckBox.razor.cs index ca80d3b..272677e 100644 --- a/Components/CheckBox/CheckBox.razor.cs +++ b/Components/CheckBox/CheckBox.razor.cs @@ -139,7 +139,7 @@ public partial class CheckBox : BooleanInput protected override Task OnChange(ChangeEventArgs args) { - Touched = true; + Modified = true; // Apply only when TriState parameter is set to true and T is bool? if (TriState && typeof(T) == typeof(bool?)) diff --git a/Components/DataGrid/DataGrid.razor.cs b/Components/DataGrid/DataGrid.razor.cs index d436468..bccd7aa 100644 --- a/Components/DataGrid/DataGrid.razor.cs +++ b/Components/DataGrid/DataGrid.razor.cs @@ -16,1313 +16,1309 @@ namespace Connected.Components; [RequiresUnreferencedCode(CodeMessage.SerializationUnreferencedCodeMessage)] public partial class DataGrid : UIComponent { - private int _currentPage = 0; - internal int? _rowsPerPage; - private bool _isFirstRendered = false; - private bool _filtersMenuVisible = false; - private bool _columnsPanelVisible = false; - private IEnumerable _items; - private T _selectedItem; - internal HashSet _groupExpansions = new HashSet(); - private List> _groups = new List>(); - internal HashSet _openHierarchies = new HashSet(); - private PropertyInfo[] _properties = typeof(T).GetProperties(); - - protected string _classname => - new CssBuilder("mud-table") - .AddClass("mud-data-grid") - .AddClass("mud-xs-table", Breakpoint == Breakpoint.Xs) - .AddClass("mud-sm-table", Breakpoint == Breakpoint.Sm) - .AddClass("mud-md-table", Breakpoint == Breakpoint.Md) - .AddClass("mud-lg-table", Breakpoint == Breakpoint.Lg || Breakpoint == Breakpoint.Always) - .AddClass("mud-xl-table", Breakpoint == Breakpoint.Xl || Breakpoint == Breakpoint.Always) - .AddClass("mud-table-dense", Dense) - .AddClass("mud-table-hover", Hover) - .AddClass("mud-table-bordered", Bordered) - .AddClass("mud-table-striped", Striped) - .AddClass("mud-table-outlined", Outlined) - .AddClass("mud-table-square", Square) - .AddClass("mud-table-sticky-header", FixedHeader) - .AddClass("mud-table-sticky-footer", FixedFooter) - .AddClass($"mud-elevation-{Elevation}", !Outlined) - .AddClass(Class) - .Build(); - - protected string _style => - new StyleBuilder() - .AddStyle("overflow-x", "auto", when: HorizontalScrollbar || ColumnResizeMode == ResizeMode.Container) - .AddStyle("position", "relative", when: hasStickyColumns) - .AddStyle(Style) - .Build(); - - protected string _tableStyle => - new StyleBuilder() - .AddStyle("height", Height, !string.IsNullOrWhiteSpace(Height)) - .AddStyle("width", "max-content", when: (HorizontalScrollbar || ColumnResizeMode == ResizeMode.Container) && !hasStickyColumns) - .AddStyle("display", "block", when: HorizontalScrollbar) - .Build(); - - protected string _tableClass => - new CssBuilder("mud-table-container") - .AddClass("cursor-col-resize", when: IsResizing) - .Build(); - - protected string _headClassname => new CssBuilder("mud-table-head") - .AddClass(HeaderClass).Build(); - - protected string _footClassname => new CssBuilder("mud-table-foot") - .AddClass(FooterClass).Build(); - - internal SortDirection GetColumnSortDirection(string columnName) - { - if (columnName == null) - { - return SortDirection.None; - } - else - { - SortDefinition sortDefinition = null; - var ok = SortDefinitions.TryGetValue(columnName, out sortDefinition); - - if (ok) - { - return sortDefinition.Descending ? SortDirection.Descending : SortDirection.Ascending; - } - else - { - return SortDirection.None; - } - } - } - - protected int numPages - { - get - { - if (ServerData != null) - return (int)Math.Ceiling(_server_data.TotalItems / (double)RowsPerPage); - - return (int)Math.Ceiling(FilteredItems.Count() / (double)RowsPerPage); - } - } - - public readonly List> RenderedColumns = new List>(); - internal T _editingItem; - - //internal int editingItemHash; - internal T editingSourceItem; - - internal T _previousEditingItem; - internal bool isEditFormOpen; - - // converters - private Converter _oppositeBoolConverter = new Converter - { - SetFunc = value => value ? false : true, - GetFunc = value => value.HasValue ? !value.Value : true, - }; - - #region Notify Children Delegates - - internal Action>, HashSet> SortChangedEvent { get; set; } - internal Action> SelectedItemsChangedEvent { get; set; } - internal Action SelectedAllItemsChangedEvent { get; set; } - internal Action StartedEditingItemEvent { get; set; } - internal Action EditingCancelledEvent { get; set; } - public Action PagerStateHasChangedEvent { get; set; } - - #endregion - - #region EventCallbacks - - /// - /// Callback is called when a row has been clicked and returns the selected item. - /// - [Parameter] public EventCallback SelectedItemChanged { get; set; } - - /// - /// Callback is called whenever items are selected or deselected in multi selection mode. - /// - [Parameter] public EventCallback> SelectedItemsChanged { get; set; } - - /// - /// Callback is called whenever a row is clicked. - /// - [Parameter] public EventCallback> RowClick { get; set; } - - /// - /// Callback is called when an item has begun to be edited. Returns the item being edited. - /// - [Parameter] public EventCallback StartedEditingItem { get; set; } - - /// - /// Callback is called when the process of editing an item has been cancelled. Returns the item which was previously in edit mode. - /// - [Parameter] public EventCallback CancelledEditingItem { get; set; } - - /// - /// Callback is called when the changes to an item are committed. Returns the item whose changes were committed. - /// - [Parameter] public EventCallback CommittedItemChanges { get; set; } - - /// - /// Callback is called when a field changes in the dialog MudForm. Only works in EditMode.Form - /// - [Parameter] public EventCallback FormFieldChanged { get; set; } - - #endregion - - #region Parameters - - /// - /// Controls whether data in the DataGrid can be sorted. This is overridable by each column. - /// - [Parameter] public SortMode SortMode { get; set; } = SortMode.Multiple; - - /// - /// Controls whether data in the DataGrid can be filtered. This is overridable by each column. - /// - [Parameter] public bool Filterable { get; set; } = false; - - /// - /// Controls whether columns in the DataGrid can be hidden. This is overridable by each column. - /// - [Parameter] public bool Hideable { get; set; } = false; - - /// - /// Controls whether to hide or show the column options. This is overridable by each column. - /// - [Parameter] public bool ShowColumnOptions { get; set; } = true; - - /// - /// At what breakpoint the table should switch to mobile layout. Takes None, Xs, Sm, Md, Lg and Xl the default behavior is breaking on Xs. - /// - [Parameter] public Breakpoint Breakpoint { get; set; } = Breakpoint.Xs; - - /// - /// The higher the number, the heavier the drop-shadow. 0 for no shadow. - /// - [Parameter] public int Elevation { set; get; } = 1; - - /// - /// Set true to disable rounded corners - /// - [Parameter] public bool Square { get; set; } - - /// - /// If true, table will be outlined. - /// - [Parameter] public bool Outlined { get; set; } - - /// - /// If true, table's cells will have left/right borders. - /// - [Parameter] public bool Bordered { get; set; } - - /// - /// Specifies a group of one or more columns in a table for formatting. - /// Ex: - /// table - /// colgroup - /// col span="2" style="background-color:red" - /// col style="background-color:yellow" - /// colgroup - /// header - /// body - /// table - /// - [Parameter] public RenderFragment ColGroup { get; set; } - - /// - /// Set true for rows with a narrow height - /// - [Parameter] public bool Dense { get; set; } - - /// - /// Set true to see rows hover on mouse-over. - /// - [Parameter] public bool Hover { get; set; } - - /// - /// If true, striped table rows will be used. - /// - [Parameter] public bool Striped { get; set; } - - /// - /// When true, the header will stay in place when the table is scrolled. Note: set Height to make the table scrollable. - /// - [Parameter] public bool FixedHeader { get; set; } - - /// - /// When true, the footer will be visible is not scrolled to the bottom. Note: set Height to make the table scrollable. - /// - [Parameter] public bool FixedFooter { get; set; } - - [Parameter] public bool ShowFilterIcons { get; set; } = true; - - [Parameter] public DataGridFilterMode FilterMode { get; set; } - - [Parameter] public DataGridFilterCaseSensitivity FilterCaseSensitivity { get; set; } - - [Parameter] public RenderFragment>> FilterTemplate { get; set; } - - /// - /// The list of FilterDefinitions that have been added to the data grid. FilterDefinitions are managed by the data - /// grid automatically when using the built in filter UI. You can also programmatically manage these definitions - /// through this collection. - /// - [Parameter] public List> FilterDefinitions { get; set; } = new List>(); - - /// - /// The list of SortDefinitions that have been added to the data grid. SortDefinitions are managed by the data - /// grid automatically when using the built in filter UI. You can also programmatically manage these definitions - /// through this collection. - /// - [Parameter] public Dictionary> SortDefinitions { get; set; } = new Dictionary>(); - - /// - /// If true, the results are displayed in a Virtualize component, allowing a boost in rendering speed. - /// - [Parameter] public bool Virtualize { get; set; } - - /// - /// Gets or sets a value that determines how many additional items will be rendered - /// before and after the visible region. This help to reduce the frequency of rendering - /// during scrolling. However, higher values mean that more elements will be present - /// in the page. - /// Only used for virtualization. - /// - [Parameter] public int OverscanCount { get; set; } = 3; - - /// - /// CSS class for the table rows. Note, many CSS settings are overridden by MudTd though - /// - [Parameter] public string RowClass { get; set; } - - /// - /// CSS styles for the table rows. Note, many CSS settings are overridden by MudTd though - /// - [Parameter] public string RowStyle { get; set; } - - /// - /// Returns the class that will get joined with RowClass. Takes the current item and row index. - /// - [Parameter] public Func RowClassFunc { get; set; } - - /// - /// Returns the class that will get joined with RowClass. Takes the current item and row index. - /// - [Parameter] public Func RowStyleFunc { get; set; } - - /// - /// Set to true to enable selection of multiple rows. - /// - [Parameter] public bool MultiSelection { get; set; } - - /// - /// When the grid is not read only, you can specify what type of editing mode to use. - /// - [Parameter] public DataGridEditMode? EditMode { get; set; } - - /// - /// Allows you to specify the action that will trigger an edit when the EditMode is Form. - /// - [Parameter] public DataGridEditTrigger? EditTrigger { get; set; } = DataGridEditTrigger.Manual; - - /// - /// Fine tune the edit dialog. - /// - [Parameter] public DialogOptions EditDialogOptions { get; set; } - - /// - /// The data to display in the table. MudTable will render one row per item - /// - /// - [Parameter] - public IEnumerable Items - { - get => _items; - set - { - if (_items == value) - return; - - _items = value; - - if (PagerStateHasChangedEvent != null) - InvokeAsync(PagerStateHasChangedEvent); - - // set initial grouping - if (Groupable) - { - GroupItems(); - } - - // Setup ObservableCollection functionality. - if (_items is INotifyCollectionChanged) - { - (_items as INotifyCollectionChanged).CollectionChanged += (s, e) => - { - if (Groupable) - GroupItems(); - }; - } - } - } - - /// - /// Show a loading animation, if true. - /// - [Parameter] public bool Loading { get; set; } - - /// - /// Define if Cancel button is present or not for inline editing. - /// - [Parameter] public bool CanCancelEdit { get; set; } = true; - - /// - /// The color of the loading progress if used. It supports the theme colors. - /// - [Parameter] public ThemeColor LoadingProgressColor { get; set; } = ThemeColor.Info; - - /// - /// Optional. Add any kind of toolbar to this render fragment. - /// - [Parameter] public RenderFragment ToolBarContent { get; set; } - - /// - /// Defines if the table has a horizontal scrollbar. - /// - [Parameter] public bool HorizontalScrollbar { get; set; } - - /// - /// Defines if columns of the grid can be resized. - /// - [Parameter] public ResizeMode ColumnResizeMode { get; set; } - - /// - /// Add a class to the thead tag - /// - [Parameter] public string HeaderClass { get; set; } - - /// - /// Setting a height will allow to scroll the table. If not set, it will try to grow in height. You can set this to any CSS value that the - /// attribute 'height' accepts, i.e. 500px. - /// - [Parameter] public string Height { get; set; } - - /// - /// Add a class to the tfoot tag - /// - [Parameter] public string FooterClass { get; set; } - - /// - /// A function that returns whether or not an item should be displayed in the table. You can use this to implement your own search function. - /// - [Parameter] public Func QuickFilter { get; set; } = null; - - /// - /// Allows adding a custom header beyond that specified in the Column component. Add HeaderCell - /// components to add a custom header. - /// - [Parameter] public RenderFragment Header { get; set; } - - /// - /// The Columns that make up the data grid. Add Column components to this RenderFragment. - /// - [Parameter] public RenderFragment Columns { get; set; } - - /// - /// The culture used to represent numeric columns and his filtering input fields. - /// Each column can override this DataGrid Culture. - /// - [Parameter] - public CultureInfo Culture { get; set; } - - /// - /// Row Child content of the component. - /// - [Parameter] public RenderFragment> ChildRowContent { get; set; } - - /// - /// Defines the table body content when there are no matching records found - /// - [Parameter] public RenderFragment NoRecordsContent { get; set; } - - /// - /// Defines the table body content the table has no rows and is loading - /// - [Parameter] public RenderFragment LoadingContent { get; set; } - - /// - /// Add MudTablePager here to enable breaking the rows in to multiple pages. - /// - [Parameter] public RenderFragment PagerContent { get; set; } - - /// - /// Supply an async function which (re)loads filtered, paginated and sorted data from server. - /// Table will await this func and update based on the returned TableData. - /// Used only with ServerData - /// - [Parameter] public Func, Task>> ServerData { get; set; } - - /// - /// If the table has more items than this number, it will break the rows into pages of said size. - /// Note: requires a MudTablePager in PagerContent. - /// - [Parameter] - public int RowsPerPage - { - get => _rowsPerPage ?? 10; - set - { - if (_rowsPerPage == null) - InvokeAsync(() => SetRowsPerPageAsync(value)); - } - } - - /// - /// The page index of the currently displayed page (Zero based). Usually called by MudTablePager. - /// Note: requires a MudTablePager in PagerContent. - /// - [Parameter] - public int CurrentPage - { - get => _currentPage; - set - { - if (_currentPage == value) - return; - _currentPage = value; - InvokeAsync(StateHasChanged); - - if (_isFirstRendered) - InvokeAsync(InvokeServerLoadFunc); - } - } - - /// - /// Locks Inline Edit mode, if true. - /// - [Parameter] public bool ReadOnly { get; set; } = true; - - /// - /// If MultiSelection is true, this returns the currently selected items. You can bind this property and the initial content of the HashSet you bind it to will cause these rows to be selected initially. - /// - [Parameter] - public HashSet SelectedItems - { - get - { - if (!MultiSelection) - if (_selectedItem is null) - return new HashSet(Array.Empty()); - else - return new HashSet(new T[] { _selectedItem }); - else - return Selection; - } - set - { - if (value == Selection) - return; - if (value == null) - { - if (Selection.Count == 0) - return; - Selection = new HashSet(); - } - else - Selection = value; - SelectedItemsChangedEvent?.Invoke(Selection); - SelectedItemsChanged.InvokeAsync(Selection); - InvokeAsync(StateHasChanged); - } - } - - /// - /// Returns the item which was last clicked on in single selection mode (that is, if MultiSelection is false) - /// - [Parameter] - public T SelectedItem - { - get => _selectedItem; - set - { - if (EqualityComparer.Default.Equals(SelectedItem, value)) - return; - _selectedItem = value; - SelectedItemChanged.InvokeAsync(value); - } - } - - /// - /// Determines whether grouping of columns is allowed in the data grid. - /// - [Parameter] - public bool Groupable - { - get { return _groupable; } - set - { - if (_groupable != value) - { - _groupable = value; - - if (!_groupable) - { - _groups.Clear(); - _groupExpansions.Clear(); - - foreach (var column in RenderedColumns) - column.RemoveGrouping(); - } - } - } - } - - private bool _groupable = false; - - /// - /// If set, a grouped column will be expanded by default. - /// - [Parameter] public bool GroupExpanded { get; set; } - - /// - /// CSS class for the groups. - /// - [Parameter] public string GroupClass { get; set; } - - /// - /// CSS styles for the groups. - /// - [Parameter] public string GroupStyle { get; set; } - - /// - /// Returns the class that will get joined with GroupClass. - /// - [Parameter] public Func, string> GroupClassFunc { get; set; } - - /// - /// Returns the class that will get joined with GroupStyle. - /// - [Parameter] public Func, string> GroupStyleFunc { get; set; } - - /// - /// When true, displays the built-in menu icon in the header of the data grid. - /// - [Parameter] public bool ShowMenuIcon { get; set; } = false; - - #endregion - - #region Properties - - internal IEnumerable CurrentPageItems - { - get - { - if (@PagerContent == null) - { - return FilteredItems; // we have no pagination - } - if (ServerData == null) - { - var filteredItemCount = GetFilteredItemsCount(); - int lastPageNo; - if (filteredItemCount == 0) - lastPageNo = 0; - else - lastPageNo = (filteredItemCount / RowsPerPage) - (filteredItemCount % RowsPerPage == 0 ? 1 : 0); - CurrentPage = lastPageNo < CurrentPage ? lastPageNo : CurrentPage; - } - - return GetItemsOfPage(CurrentPage, RowsPerPage); - } - } - - public HashSet Selection { get; set; } = new HashSet(); - public bool HasPager { get; set; } - private GridData _server_data = new GridData() { TotalItems = 0, Items = Array.Empty() }; - - public IEnumerable FilteredItems - { - get - { - var items = ServerData != null - ? _server_data.Items - : Items; - - // Quick filtering - if (QuickFilter != null) - { - items = items.Where(QuickFilter); - } - - foreach (var f in FilterDefinitions) - { - f.DataGrid = this; - var filterFunc = f.GenerateFilterFunction(); - items = items.Where(filterFunc); - } - - return Sort(items); - } - } - - public IForm Validator { get; set; } = new DataGridRowValidator(); - - internal Column GroupedColumn - { - get - { - return RenderedColumns.FirstOrDefault(x => x.grouping); - } - } - - #endregion - - #region Computed Properties - - private bool hasFooter - { - get - { - return RenderedColumns.Any(x => !x.Hidden && (x.FooterTemplate != null || x.AggregateDefinition != null)); - } - } - - private bool hasStickyColumns - { - get - { - return RenderedColumns.Any(x => x.StickyLeft || x.StickyRight); - } - } - - private bool hasHierarchyColumn - { - get - { - return RenderedColumns.Any(x => x.Tag?.ToString() == "hierarchy-column"); - } - } - - #endregion - - [UnconditionalSuppressMessage("Trimming", "IL2046: 'RequiresUnreferencedCodeAttribute' annotations must match across all interface implementations or overrides.", Justification = "Suppressing because we annotating the whole component with RequiresUnreferencedCodeAttribute for information that generic type must be preserved.")] - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - _isFirstRendered = true; - GroupItems(); - await InvokeServerLoadFunc(); - } - else - { - PagerStateHasChangedEvent?.Invoke(); - } - - await base.OnAfterRenderAsync(firstRender); - } - - [UnconditionalSuppressMessage("Trimming", "IL2046: 'RequiresUnreferencedCodeAttribute' annotations must match across all interface implementations or overrides.", Justification = "Suppressing because we annotating the whole component with RequiresUnreferencedCodeAttribute for information that generic type must be preserved.")] - public override async Task SetParametersAsync(ParameterView parameters) - { - var sortModeBefore = SortMode; - await base.SetParametersAsync(parameters); - - if (parameters.TryGetValue(nameof(SortMode), out SortMode sortMode) && sortMode != sortModeBefore) - await ClearCurrentSortings(); - } - - #region Methods - - protected IEnumerable GetItemsOfPage(int page, int pageSize) - { - if (page < 0 || pageSize <= 0) - return Array.Empty(); - - if (ServerData != null) - { - return QuickFilter != null - ? _server_data.Items.Where(QuickFilter) - : _server_data.Items; - } - - return FilteredItems.Skip(page * pageSize).Take(pageSize); - } - - internal async Task InvokeServerLoadFunc() - { - if (ServerData == null) - return; - - Loading = true; - StateHasChanged(); - - var state = new GridState - { - Page = CurrentPage, - PageSize = RowsPerPage, - SortDefinitions = SortDefinitions.Values.OrderBy(sd => sd.Index).ToList(), - // Additional ToList() here to decouple clients from internal list avoiding runtime issues - FilterDefinitions = FilterDefinitions.ToList() - }; - - _server_data = await ServerData(state); - - if (CurrentPage * RowsPerPage > _server_data.TotalItems) - CurrentPage = 0; - - Loading = false; - StateHasChanged(); - PagerStateHasChangedEvent?.Invoke(); - } - - internal void AddColumn(Column column) - { - if (column.Tag?.ToString() == "hierarchy-column") - { - RenderedColumns.Insert(0, column); - } - else if (column.Tag?.ToString() == "select-column") - { - // Position SelectColumn after HierarchyColumn if present - if (RenderedColumns.Select(x => x.Tag).Contains("hierarchy-column")) - { - RenderedColumns.Insert(1, column); - } - else - { - RenderedColumns.Insert(0, column); - } - } - else - { - RenderedColumns.Add(column); - } - } - - /// - /// Called by the DataGrid when the "Add Filter" button is pressed. - /// - internal void AddFilter() - { - var column = RenderedColumns.FirstOrDefault(x => x.filterable); - FilterDefinitions.Add(new FilterDefinition - { - Id = Guid.NewGuid(), - DataGrid = this, - Field = column?.Field, - Title = column?.Title, - FieldType = column?.FieldType - }); - _filtersMenuVisible = true; - StateHasChanged(); - } - - internal void ApplyFilters() - { - _filtersMenuVisible = false; - InvokeServerLoadFunc().AndForget(); - } - - internal void ClearFilters() - { - FilterDefinitions.Clear(); - } - - internal void AddFilter(Guid id, string field) - { - var column = RenderedColumns.FirstOrDefault(x => x.Field == field && x.filterable); - FilterDefinitions.Add(new FilterDefinition - { - Id = id, - DataGrid = this, - Field = field, - Title = column?.Title, - FieldType = column?.FieldType, - }); - _filtersMenuVisible = true; - StateHasChanged(); - } - - internal void RemoveFilter(Guid id) - { - FilterDefinitions.RemoveAll(x => x.Id == id); - GroupItems(); - } - - internal async Task SetSelectedItemAsync(bool value, T item) - { - if (value) - Selection.Add(item); - else - Selection.Remove(item); - - await InvokeAsync(() => SelectedItemsChangedEvent.Invoke(SelectedItems)); - await SelectedItemsChanged.InvokeAsync(SelectedItems); - await InvokeAsync(StateHasChanged); - } - - internal async Task SetSelectAllAsync(bool value) - { - if (value) - Selection = new HashSet(Items); - else - Selection.Clear(); - - SelectedItemsChangedEvent?.Invoke(SelectedItems); - SelectedAllItemsChangedEvent?.Invoke(value); - await SelectedItemsChanged.InvokeAsync(SelectedItems); - StateHasChanged(); - } - - internal IEnumerable Sort(IEnumerable items) - { - if (null == items || !items.Any()) - return items; - - if (null == SortDefinitions || 0 == SortDefinitions.Count) - return items; - - IOrderedEnumerable orderedEnumerable = null; - - foreach (var sortDefinition in SortDefinitions.Values.Where(sd => null != sd.SortFunc).OrderBy(sd => sd.Index)) - { - if (null == orderedEnumerable) - orderedEnumerable = sortDefinition.Descending ? items.OrderByDescending(item => sortDefinition.SortFunc(item)) - : items.OrderBy(item => sortDefinition.SortFunc(item)); - else - orderedEnumerable = sortDefinition.Descending ? orderedEnumerable.ThenByDescending(item => sortDefinition.SortFunc(item)) - : orderedEnumerable.ThenBy(item => sortDefinition.SortFunc(item)); - } - - return orderedEnumerable ?? items; - } - - internal void ClearEditingItem() - { - _editingItem = default; - editingSourceItem = default; - } - - /// - /// This method notifies the consumer that changes to the data have been committed - /// and what those changes are. This variation of the method is only used by the Cell - /// when the EditMode is set to cell. - /// - /// - /// - internal async Task CommitItemChangesAsync(T item) - { - // Here, we need to validate at the cellular level... - await CommittedItemChanges.InvokeAsync(item); - } - - /// - /// This method notifies the consumer that changes to the data have been committed - /// and what those changes are. This variation of the method is used when the EditMode - /// is anything but Cell since the _editingItem is used. - /// - /// - internal async Task CommitItemChangesAsync() - { - // Here, we need to validate at the cellular level... - - if (editingSourceItem != null) - { - foreach (var property in _properties) - { - if (property.CanWrite) - property.SetValue(editingSourceItem, property.GetValue(_editingItem)); - } - - await CommittedItemChanges.InvokeAsync(editingSourceItem); - ClearEditingItem(); - isEditFormOpen = false; - } - } - - internal async Task OnRowClickedAsync(MouseEventArgs args, T item, int rowIndex) - { - await RowClick.InvokeAsync(new DataGridRowClickEventArgs - { - MouseEventArgs = args, - Item = item, - RowIndex = rowIndex - }); - - if (EditMode != DataGridEditMode.Cell && EditTrigger == DataGridEditTrigger.OnRowClick) - await SetEditingItemAsync(item); - - await SetSelectedItemAsync(item); - } - - /// - /// Gets the total count of filtered items in the data grid. - /// - /// - public int GetFilteredItemsCount() - { - if (ServerData != null) - return _server_data.TotalItems; - return FilteredItems.Count(); - } - - /// - /// Navigates to a specific page when the data grid has an attached data pager. - /// - /// - public void NavigateTo(Page page) - { - switch (page) - { - case Page.First: - CurrentPage = 0; - break; - - case Page.Last: - CurrentPage = Math.Max(0, numPages - 1); - break; - - case Page.Next: - CurrentPage = Math.Min(numPages - 1, CurrentPage + 1); - break; - - case Page.Previous: - CurrentPage = Math.Max(0, CurrentPage - 1); - break; - } - - GroupItems(); - } - - /// - /// Sets the rows displayed per page when the data grid has an attached data pager. - /// - /// - public async Task SetRowsPerPageAsync(int size) - { - if (_rowsPerPage == size) - return; - - _rowsPerPage = size; - CurrentPage = 0; - StateHasChanged(); - - if (_isFirstRendered) - await InvokeAsync(InvokeServerLoadFunc); - } - - /// - /// Sets the sort on the data grid. - /// - /// The field. - /// The direction. - /// The sort function. - public async Task SetSortAsync(string field, SortDirection direction, Func sortFunc) - { - var removedSortDefinitions = new HashSet(SortDefinitions.Keys); - SortDefinitions.Clear(); - - var newDefinition = new SortDefinition(field, direction == SortDirection.Descending, 0, sortFunc); - SortDefinitions[field] = newDefinition; - - // In case sort is just updated make sure to not mark the field as removed - removedSortDefinitions.Remove(field); - - await InvokeSortUpdates(SortDefinitions, removedSortDefinitions); - } - - public async Task ExtendSortAsync(string field, SortDirection direction, Func sortFunc) - { - // If SortMode is not multiple, use the default set approach and don't extend. - if (SortMode != SortMode.Multiple) - { - await SetSortAsync(field, direction, sortFunc); - return; - } - - // in case it already exists, just update the current entry - if (SortDefinitions.TryGetValue(field, out var sortDefinition)) - SortDefinitions[field] = sortDefinition with { Descending = direction == SortDirection.Descending, SortFunc = sortFunc }; - else - { - var newDefinition = new SortDefinition(field, direction == SortDirection.Descending, SortDefinitions.Count, sortFunc); - SortDefinitions[field] = newDefinition; - } - - await InvokeSortUpdates(SortDefinitions, null); - } - - public async Task RemoveSortAsync(string field) - { - if (!string.IsNullOrWhiteSpace(field) && SortDefinitions.TryGetValue(field, out var definition)) - { - SortDefinitions.Remove(field); - foreach (var defToUpdate in SortDefinitions.Where(kvp => kvp.Value.Index > definition.Index).ToList()) - SortDefinitions[defToUpdate.Key] = defToUpdate.Value with { Index = defToUpdate.Value.Index - 1 }; - - await InvokeSortUpdates(SortDefinitions, new HashSet() { field }); - } - } - - private async Task ClearCurrentSortings() - { - var removedSortDefinitions = new HashSet(SortDefinitions.Keys); - SortDefinitions.Clear(); - await InvokeSortUpdates(SortDefinitions, removedSortDefinitions); - } - - private async Task InvokeSortUpdates(Dictionary> activeSortDefinitions, HashSet removedSortDefinitions) - { - SortChangedEvent?.Invoke(activeSortDefinitions, removedSortDefinitions); - await InvokeServerLoadFunc(); - StateHasChanged(); - } - - /// - /// Set the currently selected item in the data grid. - /// - /// - /// - public async Task SetSelectedItemAsync(T item) - { - if (MultiSelection) - { - if (Selection.Contains(item)) - { - Selection.Remove(item); - } - else - { - Selection.Add(item); - } - - SelectedItemsChangedEvent?.Invoke(SelectedItems); - await SelectedItemsChanged.InvokeAsync(SelectedItems); - } - - SelectedItem = item; - } - - /// - /// Set an item to be edited. - /// - /// - /// - [UnconditionalSuppressMessage("Trimming", "IL2026: Using member 'System.Text.Json.JsonSerializer.Deserialize(string, System.Text.Json.JsonSerializerOptions?)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.", Justification = "Suppressing because T is a type supplied by the user and it is unlikely that it is not referenced by their code.")] - public async Task SetEditingItemAsync(T item) - { - if (ReadOnly) return; - - editingSourceItem = item; - EditingCancelledEvent?.Invoke(); - _previousEditingItem = _editingItem; - _editingItem = JsonSerializer.Deserialize(JsonSerializer.Serialize(item)); - StartedEditingItemEvent?.Invoke(); - await StartedEditingItem.InvokeAsync(_editingItem); - isEditFormOpen = true; - } - - /// - /// Cancel editing an item. - /// - public async Task CancelEditingItemAsync() - { - EditingCancelledEvent?.Invoke(); - await CancelledEditingItem.InvokeAsync(_editingItem); - ClearEditingItem(); - isEditFormOpen = false; - } - - /// - /// Opens or closes the filter panel. - /// - public void ToggleFiltersMenu() - { - _filtersMenuVisible = !_filtersMenuVisible; - StateHasChanged(); - } - - /// - /// Call this to reload the server-filtered, -sorted and -paginated items - /// - public Task ReloadServerData() - { - return InvokeServerLoadFunc(); - } - - /// - /// Opens the filter panel. - /// - public void OpenFilters() - { - _filtersMenuVisible = true; - StateHasChanged(); - } - - internal async Task HideAllColumnsAsync() - { - foreach (var column in RenderedColumns) - { - if (column.Hideable ?? false) - await column.HideAsync(); - } - - StateHasChanged(); - } - - internal async Task ShowAllColumnsAsync() - { - foreach (var column in RenderedColumns) - { - if (column.Hideable ?? false) - await column.ShowAsync(); - } - - StateHasChanged(); - } - - public void ShowColumnsPanel() - { - _columnsPanelVisible = true; - StateHasChanged(); - } - - public void HideColumnsPanel() - { - _columnsPanelVisible = false; - StateHasChanged(); - } - - internal void ExternalStateHasChanged() - { - StateHasChanged(); - } - - public void GroupItems() - { - if (GroupedColumn == null) - { - _groups = new List>(); - StateHasChanged(); - return; - } - - var groupings = CurrentPageItems.GroupBy(GroupedColumn.groupBy); - - if (_groupExpansions.Count == 0) - { - if (GroupExpanded) - { - // We need to initially expand all groups. - foreach (var group in groupings) - { - _groupExpansions.Add(group.Key); - } - } - - _groupExpansions.Add("__initial__"); - } - - // construct the groups - _groups = groupings.Select(x => new GroupDefinition(x, - _groupExpansions.Contains(x.Key))).ToList(); - - StateHasChanged(); - } - - internal void ChangedGrouping(Column column) - { - foreach (var c in RenderedColumns) - { - if (c.Field != column.Field) - c.RemoveGrouping(); - } - - GroupItems(); - } - - internal void ToggleGroupExpansion(GroupDefinition g) - { - if (_groupExpansions.Contains(g.Grouping.Key)) - { - _groupExpansions.Remove(g.Grouping.Key); - } - else - { - _groupExpansions.Add(g.Grouping.Key); - } - - GroupItems(); - } - - public void ExpandAllGroups() - { - foreach (var group in _groups) - { - group.IsExpanded = true; - _groupExpansions.Add(group.Grouping.Key); - } - } - - public void CollapseAllGroups() - { - foreach (var group in _groups) - { - group.IsExpanded = false; - } - } - - #endregion - - internal async Task ToggleHierarchyVisibilityAsync(T item) - { - if (_openHierarchies.Contains(item)) - { - _openHierarchies.Remove(item); - } - else - { - _openHierarchies.Add(item); - } - - await InvokeAsync(StateHasChanged); - } - - #region Resize feature - - [Inject] private IEventListener EventListener { get; set; } - internal bool IsResizing { get; set; } - - private ElementReference _gridElement; - private DataGridColumnResizeService _resizeService; - - internal DataGridColumnResizeService ResizeService - { - get - { - if (null == _resizeService) - { - _resizeService = new DataGridColumnResizeService(this, EventListener); - } - - return _resizeService; - } - } - - internal async Task StartResizeColumn(HeaderCell headerCell, double clientX) - => await ResizeService.StartResizeColumn(headerCell, clientX, RenderedColumns, ColumnResizeMode); - - internal async Task GetActualHeight() - { - var gridRect = await _gridElement.MudGetBoundingClientRectAsync(); - var gridHeight = gridRect.Height; - return gridHeight; - } - - #endregion + private int _currentPage = 0; + internal int? _rowsPerPage; + private bool _isFirstRendered = false; + private bool _filtersMenuVisible = false; + private bool _columnsPanelVisible = false; + private IEnumerable _items; + private T _selectedItem; + internal HashSet _groupExpansions = new HashSet(); + private List> _groups = new List>(); + internal HashSet _openHierarchies = new HashSet(); + private PropertyInfo[] _properties = typeof(T).GetProperties(); + + protected string _classname => + new CssBuilder("mud-table") + .AddClass("mud-data-grid") + .AddClass("mud-xs-table", Breakpoint == Breakpoint.Xs) + .AddClass("mud-sm-table", Breakpoint == Breakpoint.Sm) + .AddClass("mud-md-table", Breakpoint == Breakpoint.Md) + .AddClass("mud-lg-table", Breakpoint == Breakpoint.Lg || Breakpoint == Breakpoint.Always) + .AddClass("mud-xl-table", Breakpoint == Breakpoint.Xl || Breakpoint == Breakpoint.Always) + .AddClass("mud-table-dense", Dense) + .AddClass("mud-table-hover", Hover) + .AddClass("mud-table-bordered", Bordered) + .AddClass("mud-table-striped", Striped) + .AddClass("mud-table-outlined", Outlined) + .AddClass("mud-table-square", Square) + .AddClass("mud-table-sticky-header", FixedHeader) + .AddClass("mud-table-sticky-footer", FixedFooter) + .AddClass($"mud-elevation-{Elevation}", !Outlined) + .AddClass(Class) + .Build(); + + protected string _style => + new StyleBuilder() + .AddStyle("overflow-x", "auto", when: HorizontalScrollbar || ColumnResizeMode == ResizeMode.Container) + .AddStyle("position", "relative", when: hasStickyColumns) + .AddStyle(Style) + .Build(); + + protected string _tableStyle => + new StyleBuilder() + .AddStyle("height", Height, !string.IsNullOrWhiteSpace(Height)) + .AddStyle("width", "max-content", when: (HorizontalScrollbar || ColumnResizeMode == ResizeMode.Container) && !hasStickyColumns) + .AddStyle("display", "block", when: HorizontalScrollbar) + .Build(); + + protected string _tableClass => + new CssBuilder("mud-table-container") + .AddClass("cursor-col-resize", when: IsResizing) + .Build(); + + protected string _headClassname => new CssBuilder("mud-table-head") + .AddClass(HeaderClass).Build(); + + protected string _footClassname => new CssBuilder("mud-table-foot") + .AddClass(FooterClass).Build(); + + internal SortDirection GetColumnSortDirection(string columnName) + { + if (columnName == null) + { + return SortDirection.None; + } + else + { + SortDefinition sortDefinition = null; + var ok = SortDefinitions.TryGetValue(columnName, out sortDefinition); + + if (ok) + { + return sortDefinition.Descending ? SortDirection.Descending : SortDirection.Ascending; + } + else + { + return SortDirection.None; + } + } + } + + protected int numPages + { + get + { + if (ServerData != null) + return (int)Math.Ceiling(_server_data.TotalItems / (double)RowsPerPage); + + return (int)Math.Ceiling(FilteredItems.Count() / (double)RowsPerPage); + } + } + + public readonly List> RenderedColumns = new List>(); + internal T _editingItem; + + //internal int editingItemHash; + internal T editingSourceItem; + + internal T _previousEditingItem; + internal bool isEditFormOpen; + + // converters + private Converter _oppositeBoolConverter = new LambdaConverter(value => value ? false : true, value => value.HasValue ? !value.Value : true); + + #region Notify Children Delegates + + internal Action>, HashSet> SortChangedEvent { get; set; } + internal Action> SelectedItemsChangedEvent { get; set; } + internal Action SelectedAllItemsChangedEvent { get; set; } + internal Action StartedEditingItemEvent { get; set; } + internal Action EditingCancelledEvent { get; set; } + public Action PagerStateHasChangedEvent { get; set; } + + #endregion + + #region EventCallbacks + + /// + /// Callback is called when a row has been clicked and returns the selected item. + /// + [Parameter] public EventCallback SelectedItemChanged { get; set; } + + /// + /// Callback is called whenever items are selected or deselected in multi selection mode. + /// + [Parameter] public EventCallback> SelectedItemsChanged { get; set; } + + /// + /// Callback is called whenever a row is clicked. + /// + [Parameter] public EventCallback> RowClick { get; set; } + + /// + /// Callback is called when an item has begun to be edited. Returns the item being edited. + /// + [Parameter] public EventCallback StartedEditingItem { get; set; } + + /// + /// Callback is called when the process of editing an item has been cancelled. Returns the item which was previously in edit mode. + /// + [Parameter] public EventCallback CancelledEditingItem { get; set; } + + /// + /// Callback is called when the changes to an item are committed. Returns the item whose changes were committed. + /// + [Parameter] public EventCallback CommittedItemChanges { get; set; } + + /// + /// Callback is called when a field changes in the dialog MudForm. Only works in EditMode.Form + /// + [Parameter] public EventCallback FormFieldChanged { get; set; } + + #endregion + + #region Parameters + + /// + /// Controls whether data in the DataGrid can be sorted. This is overridable by each column. + /// + [Parameter] public SortMode SortMode { get; set; } = SortMode.Multiple; + + /// + /// Controls whether data in the DataGrid can be filtered. This is overridable by each column. + /// + [Parameter] public bool Filterable { get; set; } = false; + + /// + /// Controls whether columns in the DataGrid can be hidden. This is overridable by each column. + /// + [Parameter] public bool Hideable { get; set; } = false; + + /// + /// Controls whether to hide or show the column options. This is overridable by each column. + /// + [Parameter] public bool ShowColumnOptions { get; set; } = true; + + /// + /// At what breakpoint the table should switch to mobile layout. Takes None, Xs, Sm, Md, Lg and Xl the default behavior is breaking on Xs. + /// + [Parameter] public Breakpoint Breakpoint { get; set; } = Breakpoint.Xs; + + /// + /// The higher the number, the heavier the drop-shadow. 0 for no shadow. + /// + [Parameter] public int Elevation { set; get; } = 1; + + /// + /// Set true to disable rounded corners + /// + [Parameter] public bool Square { get; set; } + + /// + /// If true, table will be outlined. + /// + [Parameter] public bool Outlined { get; set; } + + /// + /// If true, table's cells will have left/right borders. + /// + [Parameter] public bool Bordered { get; set; } + + /// + /// Specifies a group of one or more columns in a table for formatting. + /// Ex: + /// table + /// colgroup + /// col span="2" style="background-color:red" + /// col style="background-color:yellow" + /// colgroup + /// header + /// body + /// table + /// + [Parameter] public RenderFragment ColGroup { get; set; } + + /// + /// Set true for rows with a narrow height + /// + [Parameter] public bool Dense { get; set; } + + /// + /// Set true to see rows hover on mouse-over. + /// + [Parameter] public bool Hover { get; set; } + + /// + /// If true, striped table rows will be used. + /// + [Parameter] public bool Striped { get; set; } + + /// + /// When true, the header will stay in place when the table is scrolled. Note: set Height to make the table scrollable. + /// + [Parameter] public bool FixedHeader { get; set; } + + /// + /// When true, the footer will be visible is not scrolled to the bottom. Note: set Height to make the table scrollable. + /// + [Parameter] public bool FixedFooter { get; set; } + + [Parameter] public bool ShowFilterIcons { get; set; } = true; + + [Parameter] public DataGridFilterMode FilterMode { get; set; } + + [Parameter] public DataGridFilterCaseSensitivity FilterCaseSensitivity { get; set; } + + [Parameter] public RenderFragment>> FilterTemplate { get; set; } + + /// + /// The list of FilterDefinitions that have been added to the data grid. FilterDefinitions are managed by the data + /// grid automatically when using the built in filter UI. You can also programmatically manage these definitions + /// through this collection. + /// + [Parameter] public List> FilterDefinitions { get; set; } = new List>(); + + /// + /// The list of SortDefinitions that have been added to the data grid. SortDefinitions are managed by the data + /// grid automatically when using the built in filter UI. You can also programmatically manage these definitions + /// through this collection. + /// + [Parameter] public Dictionary> SortDefinitions { get; set; } = new Dictionary>(); + + /// + /// If true, the results are displayed in a Virtualize component, allowing a boost in rendering speed. + /// + [Parameter] public bool Virtualize { get; set; } + + /// + /// Gets or sets a value that determines how many additional items will be rendered + /// before and after the visible region. This help to reduce the frequency of rendering + /// during scrolling. However, higher values mean that more elements will be present + /// in the page. + /// Only used for virtualization. + /// + [Parameter] public int OverscanCount { get; set; } = 3; + + /// + /// CSS class for the table rows. Note, many CSS settings are overridden by MudTd though + /// + [Parameter] public string RowClass { get; set; } + + /// + /// CSS styles for the table rows. Note, many CSS settings are overridden by MudTd though + /// + [Parameter] public string RowStyle { get; set; } + + /// + /// Returns the class that will get joined with RowClass. Takes the current item and row index. + /// + [Parameter] public Func RowClassFunc { get; set; } + + /// + /// Returns the class that will get joined with RowClass. Takes the current item and row index. + /// + [Parameter] public Func RowStyleFunc { get; set; } + + /// + /// Set to true to enable selection of multiple rows. + /// + [Parameter] public bool MultiSelection { get; set; } + + /// + /// When the grid is not read only, you can specify what type of editing mode to use. + /// + [Parameter] public DataGridEditMode? EditMode { get; set; } + + /// + /// Allows you to specify the action that will trigger an edit when the EditMode is Form. + /// + [Parameter] public DataGridEditTrigger? EditTrigger { get; set; } = DataGridEditTrigger.Manual; + + /// + /// Fine tune the edit dialog. + /// + [Parameter] public DialogOptions EditDialogOptions { get; set; } + + /// + /// The data to display in the table. MudTable will render one row per item + /// + /// + [Parameter] + public IEnumerable Items + { + get => _items; + set + { + if (_items == value) + return; + + _items = value; + + if (PagerStateHasChangedEvent != null) + InvokeAsync(PagerStateHasChangedEvent); + + // set initial grouping + if (Groupable) + { + GroupItems(); + } + + // Setup ObservableCollection functionality. + if (_items is INotifyCollectionChanged) + { + (_items as INotifyCollectionChanged).CollectionChanged += (s, e) => + { + if (Groupable) + GroupItems(); + }; + } + } + } + + /// + /// Show a loading animation, if true. + /// + [Parameter] public bool Loading { get; set; } + + /// + /// Define if Cancel button is present or not for inline editing. + /// + [Parameter] public bool CanCancelEdit { get; set; } = true; + + /// + /// The color of the loading progress if used. It supports the theme colors. + /// + [Parameter] public ThemeColor LoadingProgressColor { get; set; } = ThemeColor.Info; + + /// + /// Optional. Add any kind of toolbar to this render fragment. + /// + [Parameter] public RenderFragment ToolBarContent { get; set; } + + /// + /// Defines if the table has a horizontal scrollbar. + /// + [Parameter] public bool HorizontalScrollbar { get; set; } + + /// + /// Defines if columns of the grid can be resized. + /// + [Parameter] public ResizeMode ColumnResizeMode { get; set; } + + /// + /// Add a class to the thead tag + /// + [Parameter] public string HeaderClass { get; set; } + + /// + /// Setting a height will allow to scroll the table. If not set, it will try to grow in height. You can set this to any CSS value that the + /// attribute 'height' accepts, i.e. 500px. + /// + [Parameter] public string Height { get; set; } + + /// + /// Add a class to the tfoot tag + /// + [Parameter] public string FooterClass { get; set; } + + /// + /// A function that returns whether or not an item should be displayed in the table. You can use this to implement your own search function. + /// + [Parameter] public Func QuickFilter { get; set; } = null; + + /// + /// Allows adding a custom header beyond that specified in the Column component. Add HeaderCell + /// components to add a custom header. + /// + [Parameter] public RenderFragment Header { get; set; } + + /// + /// The Columns that make up the data grid. Add Column components to this RenderFragment. + /// + [Parameter] public RenderFragment Columns { get; set; } + + /// + /// The culture used to represent numeric columns and his filtering input fields. + /// Each column can override this DataGrid Culture. + /// + [Parameter] + public CultureInfo Culture { get; set; } + + /// + /// Row Child content of the component. + /// + [Parameter] public RenderFragment> ChildRowContent { get; set; } + + /// + /// Defines the table body content when there are no matching records found + /// + [Parameter] public RenderFragment NoRecordsContent { get; set; } + + /// + /// Defines the table body content the table has no rows and is loading + /// + [Parameter] public RenderFragment LoadingContent { get; set; } + + /// + /// Add MudTablePager here to enable breaking the rows in to multiple pages. + /// + [Parameter] public RenderFragment PagerContent { get; set; } + + /// + /// Supply an async function which (re)loads filtered, paginated and sorted data from server. + /// Table will await this func and update based on the returned TableData. + /// Used only with ServerData + /// + [Parameter] public Func, Task>> ServerData { get; set; } + + /// + /// If the table has more items than this number, it will break the rows into pages of said size. + /// Note: requires a MudTablePager in PagerContent. + /// + [Parameter] + public int RowsPerPage + { + get => _rowsPerPage ?? 10; + set + { + if (_rowsPerPage == null) + InvokeAsync(() => SetRowsPerPageAsync(value)); + } + } + + /// + /// The page index of the currently displayed page (Zero based). Usually called by MudTablePager. + /// Note: requires a MudTablePager in PagerContent. + /// + [Parameter] + public int CurrentPage + { + get => _currentPage; + set + { + if (_currentPage == value) + return; + _currentPage = value; + InvokeAsync(StateHasChanged); + + if (_isFirstRendered) + InvokeAsync(InvokeServerLoadFunc); + } + } + + /// + /// Locks Inline Edit mode, if true. + /// + [Parameter] public bool ReadOnly { get; set; } = true; + + /// + /// If MultiSelection is true, this returns the currently selected items. You can bind this property and the initial content of the HashSet you bind it to will cause these rows to be selected initially. + /// + [Parameter] + public HashSet SelectedItems + { + get + { + if (!MultiSelection) + if (_selectedItem is null) + return new HashSet(Array.Empty()); + else + return new HashSet(new T[] { _selectedItem }); + else + return Selection; + } + set + { + if (value == Selection) + return; + if (value == null) + { + if (Selection.Count == 0) + return; + Selection = new HashSet(); + } + else + Selection = value; + SelectedItemsChangedEvent?.Invoke(Selection); + SelectedItemsChanged.InvokeAsync(Selection); + InvokeAsync(StateHasChanged); + } + } + + /// + /// Returns the item which was last clicked on in single selection mode (that is, if MultiSelection is false) + /// + [Parameter] + public T SelectedItem + { + get => _selectedItem; + set + { + if (EqualityComparer.Default.Equals(SelectedItem, value)) + return; + _selectedItem = value; + SelectedItemChanged.InvokeAsync(value); + } + } + + /// + /// Determines whether grouping of columns is allowed in the data grid. + /// + [Parameter] + public bool Groupable + { + get { return _groupable; } + set + { + if (_groupable != value) + { + _groupable = value; + + if (!_groupable) + { + _groups.Clear(); + _groupExpansions.Clear(); + + foreach (var column in RenderedColumns) + column.RemoveGrouping(); + } + } + } + } + + private bool _groupable = false; + + /// + /// If set, a grouped column will be expanded by default. + /// + [Parameter] public bool GroupExpanded { get; set; } + + /// + /// CSS class for the groups. + /// + [Parameter] public string GroupClass { get; set; } + + /// + /// CSS styles for the groups. + /// + [Parameter] public string GroupStyle { get; set; } + + /// + /// Returns the class that will get joined with GroupClass. + /// + [Parameter] public Func, string> GroupClassFunc { get; set; } + + /// + /// Returns the class that will get joined with GroupStyle. + /// + [Parameter] public Func, string> GroupStyleFunc { get; set; } + + /// + /// When true, displays the built-in menu icon in the header of the data grid. + /// + [Parameter] public bool ShowMenuIcon { get; set; } = false; + + #endregion + + #region Properties + + internal IEnumerable CurrentPageItems + { + get + { + if (@PagerContent == null) + { + return FilteredItems; // we have no pagination + } + if (ServerData == null) + { + var filteredItemCount = GetFilteredItemsCount(); + int lastPageNo; + if (filteredItemCount == 0) + lastPageNo = 0; + else + lastPageNo = (filteredItemCount / RowsPerPage) - (filteredItemCount % RowsPerPage == 0 ? 1 : 0); + CurrentPage = lastPageNo < CurrentPage ? lastPageNo : CurrentPage; + } + + return GetItemsOfPage(CurrentPage, RowsPerPage); + } + } + + public HashSet Selection { get; set; } = new HashSet(); + public bool HasPager { get; set; } + private GridData _server_data = new GridData() { TotalItems = 0, Items = Array.Empty() }; + + public IEnumerable FilteredItems + { + get + { + var items = ServerData != null + ? _server_data.Items + : Items; + + // Quick filtering + if (QuickFilter != null) + { + items = items.Where(QuickFilter); + } + + foreach (var f in FilterDefinitions) + { + f.DataGrid = this; + var filterFunc = f.GenerateFilterFunction(); + items = items.Where(filterFunc); + } + + return Sort(items); + } + } + + public IForm Validator { get; set; } = new DataGridRowValidator(); + + internal Column GroupedColumn + { + get + { + return RenderedColumns.FirstOrDefault(x => x.grouping); + } + } + + #endregion + + #region Computed Properties + + private bool hasFooter + { + get + { + return RenderedColumns.Any(x => !x.Hidden && (x.FooterTemplate != null || x.AggregateDefinition != null)); + } + } + + private bool hasStickyColumns + { + get + { + return RenderedColumns.Any(x => x.StickyLeft || x.StickyRight); + } + } + + private bool hasHierarchyColumn + { + get + { + return RenderedColumns.Any(x => x.Tag?.ToString() == "hierarchy-column"); + } + } + + #endregion + + [UnconditionalSuppressMessage("Trimming", "IL2046: 'RequiresUnreferencedCodeAttribute' annotations must match across all interface implementations or overrides.", Justification = "Suppressing because we annotating the whole component with RequiresUnreferencedCodeAttribute for information that generic type must be preserved.")] + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _isFirstRendered = true; + GroupItems(); + await InvokeServerLoadFunc(); + } + else + { + PagerStateHasChangedEvent?.Invoke(); + } + + await base.OnAfterRenderAsync(firstRender); + } + + [UnconditionalSuppressMessage("Trimming", "IL2046: 'RequiresUnreferencedCodeAttribute' annotations must match across all interface implementations or overrides.", Justification = "Suppressing because we annotating the whole component with RequiresUnreferencedCodeAttribute for information that generic type must be preserved.")] + public override async Task SetParametersAsync(ParameterView parameters) + { + var sortModeBefore = SortMode; + await base.SetParametersAsync(parameters); + + if (parameters.TryGetValue(nameof(SortMode), out SortMode sortMode) && sortMode != sortModeBefore) + await ClearCurrentSortings(); + } + + #region Methods + + protected IEnumerable GetItemsOfPage(int page, int pageSize) + { + if (page < 0 || pageSize <= 0) + return Array.Empty(); + + if (ServerData != null) + { + return QuickFilter != null + ? _server_data.Items.Where(QuickFilter) + : _server_data.Items; + } + + return FilteredItems.Skip(page * pageSize).Take(pageSize); + } + + internal async Task InvokeServerLoadFunc() + { + if (ServerData == null) + return; + + Loading = true; + StateHasChanged(); + + var state = new GridState + { + Page = CurrentPage, + PageSize = RowsPerPage, + SortDefinitions = SortDefinitions.Values.OrderBy(sd => sd.Index).ToList(), + // Additional ToList() here to decouple clients from internal list avoiding runtime issues + FilterDefinitions = FilterDefinitions.ToList() + }; + + _server_data = await ServerData(state); + + if (CurrentPage * RowsPerPage > _server_data.TotalItems) + CurrentPage = 0; + + Loading = false; + StateHasChanged(); + PagerStateHasChangedEvent?.Invoke(); + } + + internal void AddColumn(Column column) + { + if (column.Tag?.ToString() == "hierarchy-column") + { + RenderedColumns.Insert(0, column); + } + else if (column.Tag?.ToString() == "select-column") + { + // Position SelectColumn after HierarchyColumn if present + if (RenderedColumns.Select(x => x.Tag).Contains("hierarchy-column")) + { + RenderedColumns.Insert(1, column); + } + else + { + RenderedColumns.Insert(0, column); + } + } + else + { + RenderedColumns.Add(column); + } + } + + /// + /// Called by the DataGrid when the "Add Filter" button is pressed. + /// + internal void AddFilter() + { + var column = RenderedColumns.FirstOrDefault(x => x.filterable); + FilterDefinitions.Add(new FilterDefinition + { + Id = Guid.NewGuid(), + DataGrid = this, + Field = column?.Field, + Title = column?.Title, + FieldType = column?.FieldType + }); + _filtersMenuVisible = true; + StateHasChanged(); + } + + internal void ApplyFilters() + { + _filtersMenuVisible = false; + InvokeServerLoadFunc().AndForget(); + } + + internal void ClearFilters() + { + FilterDefinitions.Clear(); + } + + internal void AddFilter(Guid id, string field) + { + var column = RenderedColumns.FirstOrDefault(x => x.Field == field && x.filterable); + FilterDefinitions.Add(new FilterDefinition + { + Id = id, + DataGrid = this, + Field = field, + Title = column?.Title, + FieldType = column?.FieldType, + }); + _filtersMenuVisible = true; + StateHasChanged(); + } + + internal void RemoveFilter(Guid id) + { + FilterDefinitions.RemoveAll(x => x.Id == id); + GroupItems(); + } + + internal async Task SetSelectedItemAsync(bool value, T item) + { + if (value) + Selection.Add(item); + else + Selection.Remove(item); + + await InvokeAsync(() => SelectedItemsChangedEvent.Invoke(SelectedItems)); + await SelectedItemsChanged.InvokeAsync(SelectedItems); + await InvokeAsync(StateHasChanged); + } + + internal async Task SetSelectAllAsync(bool value) + { + if (value) + Selection = new HashSet(Items); + else + Selection.Clear(); + + SelectedItemsChangedEvent?.Invoke(SelectedItems); + SelectedAllItemsChangedEvent?.Invoke(value); + await SelectedItemsChanged.InvokeAsync(SelectedItems); + StateHasChanged(); + } + + internal IEnumerable Sort(IEnumerable items) + { + if (null == items || !items.Any()) + return items; + + if (null == SortDefinitions || 0 == SortDefinitions.Count) + return items; + + IOrderedEnumerable orderedEnumerable = null; + + foreach (var sortDefinition in SortDefinitions.Values.Where(sd => null != sd.SortFunc).OrderBy(sd => sd.Index)) + { + if (null == orderedEnumerable) + orderedEnumerable = sortDefinition.Descending ? items.OrderByDescending(item => sortDefinition.SortFunc(item)) + : items.OrderBy(item => sortDefinition.SortFunc(item)); + else + orderedEnumerable = sortDefinition.Descending ? orderedEnumerable.ThenByDescending(item => sortDefinition.SortFunc(item)) + : orderedEnumerable.ThenBy(item => sortDefinition.SortFunc(item)); + } + + return orderedEnumerable ?? items; + } + + internal void ClearEditingItem() + { + _editingItem = default; + editingSourceItem = default; + } + + /// + /// This method notifies the consumer that changes to the data have been committed + /// and what those changes are. This variation of the method is only used by the Cell + /// when the EditMode is set to cell. + /// + /// + /// + internal async Task CommitItemChangesAsync(T item) + { + // Here, we need to validate at the cellular level... + await CommittedItemChanges.InvokeAsync(item); + } + + /// + /// This method notifies the consumer that changes to the data have been committed + /// and what those changes are. This variation of the method is used when the EditMode + /// is anything but Cell since the _editingItem is used. + /// + /// + internal async Task CommitItemChangesAsync() + { + // Here, we need to validate at the cellular level... + + if (editingSourceItem != null) + { + foreach (var property in _properties) + { + if (property.CanWrite) + property.SetValue(editingSourceItem, property.GetValue(_editingItem)); + } + + await CommittedItemChanges.InvokeAsync(editingSourceItem); + ClearEditingItem(); + isEditFormOpen = false; + } + } + + internal async Task OnRowClickedAsync(MouseEventArgs args, T item, int rowIndex) + { + await RowClick.InvokeAsync(new DataGridRowClickEventArgs + { + MouseEventArgs = args, + Item = item, + RowIndex = rowIndex + }); + + if (EditMode != DataGridEditMode.Cell && EditTrigger == DataGridEditTrigger.OnRowClick) + await SetEditingItemAsync(item); + + await SetSelectedItemAsync(item); + } + + /// + /// Gets the total count of filtered items in the data grid. + /// + /// + public int GetFilteredItemsCount() + { + if (ServerData != null) + return _server_data.TotalItems; + return FilteredItems.Count(); + } + + /// + /// Navigates to a specific page when the data grid has an attached data pager. + /// + /// + public void NavigateTo(Page page) + { + switch (page) + { + case Page.First: + CurrentPage = 0; + break; + + case Page.Last: + CurrentPage = Math.Max(0, numPages - 1); + break; + + case Page.Next: + CurrentPage = Math.Min(numPages - 1, CurrentPage + 1); + break; + + case Page.Previous: + CurrentPage = Math.Max(0, CurrentPage - 1); + break; + } + + GroupItems(); + } + + /// + /// Sets the rows displayed per page when the data grid has an attached data pager. + /// + /// + public async Task SetRowsPerPageAsync(int size) + { + if (_rowsPerPage == size) + return; + + _rowsPerPage = size; + CurrentPage = 0; + StateHasChanged(); + + if (_isFirstRendered) + await InvokeAsync(InvokeServerLoadFunc); + } + + /// + /// Sets the sort on the data grid. + /// + /// The field. + /// The direction. + /// The sort function. + public async Task SetSortAsync(string field, SortDirection direction, Func sortFunc) + { + var removedSortDefinitions = new HashSet(SortDefinitions.Keys); + SortDefinitions.Clear(); + + var newDefinition = new SortDefinition(field, direction == SortDirection.Descending, 0, sortFunc); + SortDefinitions[field] = newDefinition; + + // In case sort is just updated make sure to not mark the field as removed + removedSortDefinitions.Remove(field); + + await InvokeSortUpdates(SortDefinitions, removedSortDefinitions); + } + + public async Task ExtendSortAsync(string field, SortDirection direction, Func sortFunc) + { + // If SortMode is not multiple, use the default set approach and don't extend. + if (SortMode != SortMode.Multiple) + { + await SetSortAsync(field, direction, sortFunc); + return; + } + + // in case it already exists, just update the current entry + if (SortDefinitions.TryGetValue(field, out var sortDefinition)) + SortDefinitions[field] = sortDefinition with { Descending = direction == SortDirection.Descending, SortFunc = sortFunc }; + else + { + var newDefinition = new SortDefinition(field, direction == SortDirection.Descending, SortDefinitions.Count, sortFunc); + SortDefinitions[field] = newDefinition; + } + + await InvokeSortUpdates(SortDefinitions, null); + } + + public async Task RemoveSortAsync(string field) + { + if (!string.IsNullOrWhiteSpace(field) && SortDefinitions.TryGetValue(field, out var definition)) + { + SortDefinitions.Remove(field); + foreach (var defToUpdate in SortDefinitions.Where(kvp => kvp.Value.Index > definition.Index).ToList()) + SortDefinitions[defToUpdate.Key] = defToUpdate.Value with { Index = defToUpdate.Value.Index - 1 }; + + await InvokeSortUpdates(SortDefinitions, new HashSet() { field }); + } + } + + private async Task ClearCurrentSortings() + { + var removedSortDefinitions = new HashSet(SortDefinitions.Keys); + SortDefinitions.Clear(); + await InvokeSortUpdates(SortDefinitions, removedSortDefinitions); + } + + private async Task InvokeSortUpdates(Dictionary> activeSortDefinitions, HashSet removedSortDefinitions) + { + SortChangedEvent?.Invoke(activeSortDefinitions, removedSortDefinitions); + await InvokeServerLoadFunc(); + StateHasChanged(); + } + + /// + /// Set the currently selected item in the data grid. + /// + /// + /// + public async Task SetSelectedItemAsync(T item) + { + if (MultiSelection) + { + if (Selection.Contains(item)) + { + Selection.Remove(item); + } + else + { + Selection.Add(item); + } + + SelectedItemsChangedEvent?.Invoke(SelectedItems); + await SelectedItemsChanged.InvokeAsync(SelectedItems); + } + + SelectedItem = item; + } + + /// + /// Set an item to be edited. + /// + /// + /// + [UnconditionalSuppressMessage("Trimming", "IL2026: Using member 'System.Text.Json.JsonSerializer.Deserialize(string, System.Text.Json.JsonSerializerOptions?)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.", Justification = "Suppressing because T is a type supplied by the user and it is unlikely that it is not referenced by their code.")] + public async Task SetEditingItemAsync(T item) + { + if (ReadOnly) return; + + editingSourceItem = item; + EditingCancelledEvent?.Invoke(); + _previousEditingItem = _editingItem; + _editingItem = JsonSerializer.Deserialize(JsonSerializer.Serialize(item)); + StartedEditingItemEvent?.Invoke(); + await StartedEditingItem.InvokeAsync(_editingItem); + isEditFormOpen = true; + } + + /// + /// Cancel editing an item. + /// + public async Task CancelEditingItemAsync() + { + EditingCancelledEvent?.Invoke(); + await CancelledEditingItem.InvokeAsync(_editingItem); + ClearEditingItem(); + isEditFormOpen = false; + } + + /// + /// Opens or closes the filter panel. + /// + public void ToggleFiltersMenu() + { + _filtersMenuVisible = !_filtersMenuVisible; + StateHasChanged(); + } + + /// + /// Call this to reload the server-filtered, -sorted and -paginated items + /// + public Task ReloadServerData() + { + return InvokeServerLoadFunc(); + } + + /// + /// Opens the filter panel. + /// + public void OpenFilters() + { + _filtersMenuVisible = true; + StateHasChanged(); + } + + internal async Task HideAllColumnsAsync() + { + foreach (var column in RenderedColumns) + { + if (column.Hideable ?? false) + await column.HideAsync(); + } + + StateHasChanged(); + } + + internal async Task ShowAllColumnsAsync() + { + foreach (var column in RenderedColumns) + { + if (column.Hideable ?? false) + await column.ShowAsync(); + } + + StateHasChanged(); + } + + public void ShowColumnsPanel() + { + _columnsPanelVisible = true; + StateHasChanged(); + } + + public void HideColumnsPanel() + { + _columnsPanelVisible = false; + StateHasChanged(); + } + + internal void ExternalStateHasChanged() + { + StateHasChanged(); + } + + public void GroupItems() + { + if (GroupedColumn == null) + { + _groups = new List>(); + StateHasChanged(); + return; + } + + var groupings = CurrentPageItems.GroupBy(GroupedColumn.groupBy); + + if (_groupExpansions.Count == 0) + { + if (GroupExpanded) + { + // We need to initially expand all groups. + foreach (var group in groupings) + { + _groupExpansions.Add(group.Key); + } + } + + _groupExpansions.Add("__initial__"); + } + + // construct the groups + _groups = groupings.Select(x => new GroupDefinition(x, + _groupExpansions.Contains(x.Key))).ToList(); + + StateHasChanged(); + } + + internal void ChangedGrouping(Column column) + { + foreach (var c in RenderedColumns) + { + if (c.Field != column.Field) + c.RemoveGrouping(); + } + + GroupItems(); + } + + internal void ToggleGroupExpansion(GroupDefinition g) + { + if (_groupExpansions.Contains(g.Grouping.Key)) + { + _groupExpansions.Remove(g.Grouping.Key); + } + else + { + _groupExpansions.Add(g.Grouping.Key); + } + + GroupItems(); + } + + public void ExpandAllGroups() + { + foreach (var group in _groups) + { + group.IsExpanded = true; + _groupExpansions.Add(group.Grouping.Key); + } + } + + public void CollapseAllGroups() + { + foreach (var group in _groups) + { + group.IsExpanded = false; + } + } + + #endregion + + internal async Task ToggleHierarchyVisibilityAsync(T item) + { + if (_openHierarchies.Contains(item)) + { + _openHierarchies.Remove(item); + } + else + { + _openHierarchies.Add(item); + } + + await InvokeAsync(StateHasChanged); + } + + #region Resize feature + + [Inject] private IEventListener EventListener { get; set; } + internal bool IsResizing { get; set; } + + private ElementReference _gridElement; + private DataGridColumnResizeService _resizeService; + + internal DataGridColumnResizeService ResizeService + { + get + { + if (null == _resizeService) + { + _resizeService = new DataGridColumnResizeService(this, EventListener); + } + + return _resizeService; + } + } + + internal async Task StartResizeColumn(HeaderCell headerCell, double clientX) + => await ResizeService.StartResizeColumn(headerCell, clientX, RenderedColumns, ColumnResizeMode); + + internal async Task GetActualHeight() + { + var gridRect = await _gridElement.MudGetBoundingClientRectAsync(); + var gridHeight = gridRect.Height; + return gridHeight; + } + + #endregion } diff --git a/Components/DatePicker/DatePicker.cs b/Components/DatePicker/DatePicker.cs index cc05db5..83e6409 100644 --- a/Components/DatePicker/DatePicker.cs +++ b/Components/DatePicker/DatePicker.cs @@ -30,7 +30,7 @@ public class DatePicker : DatePickerBase { if (_value != date) { - Touched = true; + Modified = true; if (date is not null && IsDateDisabledFunc(date.Value.Date)) { @@ -40,10 +40,8 @@ public class DatePicker : DatePickerBase _value = date; if (updateValue) - { - Converter.GetError = false; - await SetTextAsync(Converter.Set(_value), false); - } + await SetTextAsync(Converter.Convert(_value), false); + await DateChanged.InvokeAsync(_value); BeginValidate(); FieldChanged(_value); @@ -52,15 +50,15 @@ public class DatePicker : DatePickerBase protected override Task DateFormatChanged(string newFormat) { - Touched = true; - return SetTextAsync(Converter.Set(_value), false); + Modified = true; + return SetTextAsync(Converter.Convert(_value), false); } protected override Task StringValueChanged(string value) { - Touched = true; + Modified = true; // Update the date property (without updating back the Value property) - return SetDateAsync(Converter.Get(value), false); + return SetDateAsync(Converter.ConvertBack(value), false); } protected override string GetDayClasses(int month, DateTime day) diff --git a/Components/DatePicker/DatePickerBase.razor.cs b/Components/DatePicker/DatePickerBase.razor.cs index f9eef35..8e4e389 100644 --- a/Components/DatePicker/DatePickerBase.razor.cs +++ b/Components/DatePicker/DatePickerBase.razor.cs @@ -230,7 +230,7 @@ public abstract partial class DatePickerBase : Picker base.OnPickerOpened(); if (Editable == true && Text != null) { - DateTime? a = Converter.Get(Text); + DateTime? a = Converter.ConvertBack(Text); if (a.HasValue) { a = new DateTime(a.Value.Year, a.Value.Month, 1); diff --git a/Components/DatePicker/DateRange.cs b/Components/DatePicker/DateRange.cs index fbae1b3..a4e6b56 100644 --- a/Components/DatePicker/DateRange.cs +++ b/Components/DatePicker/DateRange.cs @@ -1,4 +1,6 @@ -namespace Connected.Components; +using Connected.Extensions; + +namespace Connected.Components; public class DateRange : Range, IEquatable { @@ -15,7 +17,7 @@ public class DateRange : Range, IEquatable if (Start == null || End == null) return string.Empty; - return RangeConverter.Join(converter.Set(Start.Value), converter.Set(End.Value)); + return RangeConverter.Join(converter.Convert(Start.Value), converter.Convert(End.Value)); } public string ToIsoDateString() @@ -40,12 +42,14 @@ public class DateRange : Range, IEquatable { date = null; - var endDate = converter.Get(end); - if (converter.GetError) + var endDate = converter.ConvertBack(end); + + if (endDate is null) return false; - var startDate = converter.Get(start); - if (converter.GetError) + var startDate = converter.ConvertBack(start); + + if (startDate is null) return false; date = new DateRange(startDate, endDate); diff --git a/Components/DatePicker/DateRangePicker.razor b/Components/DatePicker/DateRangePicker.razor index e5f0e3e..ae90b21 100644 --- a/Components/DatePicker/DateRangePicker.razor +++ b/Components/DatePicker/DateRangePicker.razor @@ -6,13 +6,13 @@ @code{ protected override RenderFragment InputContent=> - @ + Required="@Required" RequiredError="@RequiredError" Error="@HasError" ErrorText="@ErrorText" Margin="@Margin" AdornmentAriaLabel="@AdornmentAriaLabel"/> ; diff --git a/Components/DatePicker/DateRangePicker.razor.cs b/Components/DatePicker/DateRangePicker.razor.cs index d970e56..d288073 100644 --- a/Components/DatePicker/DateRangePicker.razor.cs +++ b/Components/DatePicker/DateRangePicker.razor.cs @@ -57,7 +57,6 @@ public partial class DateRangePicker : DatePickerBase if (updateValue) { - Converter.GetError = false; if (_dateRange == null) { _rangeText = null; @@ -66,8 +65,8 @@ public partial class DateRangePicker : DatePickerBase else { _rangeText = new Range( - Converter.Set(_dateRange.Start), - Converter.Set(_dateRange.End)); + Converter.Convert(_dateRange.Start), + Converter.Convert(_dateRange.End)); await SetTextAsync(_dateRange.ToString(Converter), false); } } @@ -85,7 +84,7 @@ public partial class DateRangePicker : DatePickerBase if (_rangeText?.Equals(value) ?? value == null) return; - Touched = true; + Modified = true; _rangeText = value; SetDateRangeAsync(ParseDateRangeValue(value?.Start, value?.End), false).AndForget(); } @@ -135,13 +134,13 @@ public partial class DateRangePicker : DatePickerBase protected override Task DateFormatChanged(string newFormat) { - Touched = true; + Modified = true; return SetTextAsync(_dateRange?.ToString(Converter), false); } protected override Task StringValueChanged(string value) { - Touched = true; + Modified = true; // Update the daterange property (without updating back the Value property) return SetDateRangeAsync(ParseDateRangeValue(value), false); } diff --git a/Components/FileUpload/FileUpload.razor.cs b/Components/FileUpload/FileUpload.razor.cs index a3562e6..6d93332 100644 --- a/Components/FileUpload/FileUpload.razor.cs +++ b/Components/FileUpload/FileUpload.razor.cs @@ -109,14 +109,14 @@ public partial class FileUpload : FormComponent await FilesChanged.InvokeAsync(_value); BeginValidate(); FieldChanged(_value); - if (!Error || !SuppressOnChangeWhenInvalid) //only trigger FilesChanged if validation passes or SuppressOnChangeWhenInvalid is false + if (!HasError || !SuppressOnChangeWhenInvalid) //only trigger FilesChanged if validation passes or SuppressOnChangeWhenInvalid is false await OnFilesChanged.InvokeAsync(args); } protected override void OnInitialized() { - if (!(typeof(T) == typeof(IReadOnlyList) || typeof(T) == typeof(IBrowserFile))) - Logger.LogWarning("T must be of type {type1} or {type2}", typeof(IReadOnlyList), typeof(IBrowserFile)); + //if (!(typeof(T) == typeof(IReadOnlyList) || typeof(T) == typeof(IBrowserFile))) + // Logger.LogWarning("T must be of type {type1} or {type2}", typeof(IReadOnlyList), typeof(IBrowserFile)); base.OnInitialized(); } diff --git a/Components/Form/Form.razor.cs b/Components/Form/Form.razor.cs index 95f3747..757be49 100644 --- a/Components/Form/Form.razor.cs +++ b/Components/Form/Form.razor.cs @@ -206,11 +206,11 @@ public partial class Form : UIComponent, IDisposable, IForm // - none have an error // - all required fields have been touched (and thus validated) var no_errors = _formControls.All(x => x.HasErrors == false); - var required_all_touched = _formControls.Where(x => x.Required).All(x => x.Touched); + var required_all_touched = _formControls.Where(x => x.Required).All(x => x.Modified); var valid = no_errors && required_all_touched; var old_touched = _touched; - _touched = _formControls.Any(x => x.Touched); + _touched = _formControls.Any(x => x.Modified); try { _shouldRender = false; diff --git a/Components/Form/FormComponent.cs b/Components/Form/FormComponent.cs index 1046404..fb4196e 100644 --- a/Components/Form/FormComponent.cs +++ b/Components/Form/FormComponent.cs @@ -12,628 +12,641 @@ namespace Connected.Components; public abstract class FormComponent : UIComponent, IFormComponent, IDisposable { - private Converter _converter; - - protected FormComponent(Converter converter) - { - _converter = converter ?? throw new ArgumentNullException(nameof(converter)); - _converter.OnError = OnConversionError; - } - - [CascadingParameter] internal IForm Form { get; set; } - - /// - /// If true, this is a top-level form component. If false, this input is a sub-component of another input (i.e. TextField, Select, etc). - /// If it is sub-component, it will NOT do form validation!! - /// - [CascadingParameter(Name = "SubscribeToParentForm")] - internal bool SubscribeToParentForm { get; set; } = true; - - /// - /// If true, this form input is required to be filled out. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Validation)] - public bool Required { get; set; } - - /// - /// The error text that will be displayed if the input is not filled out but required. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Validation)] - public string RequiredError { get; set; } = "Required"; - - /// - /// The ErrorText that will be displayed if Error true. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Validation)] - public string ErrorText { get; set; } - - /// - /// If true, the label will be displayed in an error state. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Validation)] - public bool Error { get; set; } - - /// - /// The ErrorId that will be used by aria-describedby if Error true - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Validation)] - public string ErrorId { get; set; } - - /// - /// The generic converter of the component. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Behavior)] - public Converter Converter - { - get => _converter; - set => SetConverter(value); - } - - protected virtual bool SetConverter(Converter value) - { - var changed = (_converter != value); - if (changed) - { - _converter = value ?? throw new ArgumentNullException(nameof(value)); // converter is mandatory at all times - _converter.OnError = OnConversionError; - } - return changed; - } - - /// - /// The culture of the component. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Behavior)] - public CultureInfo Culture - { - get => _converter.Culture; - set => SetCulture(value); - } - - protected virtual bool SetCulture(CultureInfo value) - { - var changed = (_converter.Culture != value); - if (changed) - { - _converter.Culture = value; - } - return changed; - } - - private void OnConversionError(string error) - { - // note: we need to update the form here because the conversion error might lead to not updating the value - // ... which leads to not updating the form - Touched = true; - Form?.Update(this); - OnConversionErrorOccurred(error); - } - - protected virtual void OnConversionErrorOccurred(string error) - { - /* Descendants can override this method to catch conversion errors */ - } - - /// - /// True if the conversion from string to T failed - /// - public bool ConversionError => _converter.GetError; - - /// - /// The error message of the conversion error from string to T. Null otherwise - /// - public string ConversionErrorMessage => _converter.GetErrorMessage; - - /// - /// True if the input has any of the following errors: An error set from outside, a conversion error or - /// one or more validation errors - /// - public bool HasErrors => Error || ConversionError || ValidationErrors.Count > 0; - - /// - /// Return the validation error text or the conversion error message. - /// - /// Error text/message - public string GetErrorText() - { - // ErrorText is either set from outside or the first validation error - if (!IsNullOrWhiteSpace(ErrorText)) - return ErrorText; - - if (!IsNullOrWhiteSpace(ConversionErrorMessage)) - return ConversionErrorMessage; - - return null; - } - - /// - /// This manages the state of having been "touched" by the user. A form control always starts out untouched - /// but becomes touched when the user performed input or the blur event was raised. - /// - /// The touched state is only relevant for inputs that have no value (i.e. empty text fields). Being untouched will - /// suppress RequiredError - /// - public bool Touched { get; protected set; } - - #region MudForm Validation - - public List ValidationErrors { get; set; } = new List(); - - /// - /// A validation func or a validation attribute. Supported types are: - /// Func<T, bool> ... will output the standard error message "Invalid" if false - /// Func<T, string> ... outputs the result as error message, no error if null - /// Func<T, IEnumerable< string >> ... outputs all the returned error messages, no error if empty - /// Func<object, string, IEnumerable< string >> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty - /// Func<T, Task< bool >> ... will output the standard error message "Invalid" if false - /// Func<T, Task< string >> ... outputs the result as error message, no error if null - /// Func<T, Task<IEnumerable< string >>> ... outputs all the returned error messages, no error if empty - /// Func<object, string, Task<IEnumerable< string >>> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty - /// System.ComponentModel.DataAnnotations.ValidationAttribute instances - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Validation)] - public object Validation { get; set; } - - /// - /// This is the form component's value. - /// - protected T _value; - - // These are the fire-and-forget methods to launch an async validation process. - // After each async step, we make sure the current Value of the component has not changed while - // async code was executed to avoid race condition which could lead to incorrect validation results. - protected void BeginValidateAfter(Task task) - { - Func execute = async () => - { - var value = _value; - - await task; - - // we validate only if the value hasn't changed while we waited for task. - // if it has in fact changed, another validate call will follow anyway - if (EqualityComparer.Default.Equals(value, _value)) - { - BeginValidate(); - } - }; - execute().AndForget(); - } - - protected void BeginValidate() - { - Func execute = async () => - { - var value = _value; - - await ValidateValue(); - - if (EqualityComparer.Default.Equals(value, _value)) - { - EditFormValidate(); - } - }; - execute().AndForget(); - } - - /// - /// Cause this component to validate its value. - /// - public Task Validate() - { - // when a validation is forced, we must set Touched to true, because for untouched fields with - // no value, validation does nothing due to the way forms are expected to work (display errors - // only after fields have been touched). - Touched = true; - return ValidateValue(); - } - - protected virtual async Task ValidateValue() - { - var changed = false; - var errors = new List(); - try - { - // conversion error - if (ConversionError) - errors.Add(ConversionErrorMessage); - // validation errors - if (Validation is ValidationAttribute) - ValidateWithAttribute(Validation as ValidationAttribute, _value, errors); - else if (Validation is Func) - ValidateWithFunc(Validation as Func, _value, errors); - else if (Validation is Func) - ValidateWithFunc(Validation as Func, _value, errors); - else if (Validation is Func>) - ValidateWithFunc(Validation as Func>, _value, errors); - else if (Validation is Func>) - ValidateModelWithFullPathOfMember(Validation as Func>, errors); - else - { + private Converter _converter; + + /// + /// Invoked whenever the string value cannot be converted + /// + public event EventHandler ConversionErrorOccured; + + protected FormComponent(Converter converter) + { + _converter = converter ?? throw new ArgumentNullException(nameof(converter)); + _converter.ErrorOccured += (s, e) => OnConversionError(e); + } + + [CascadingParameter] + internal IForm? Form { get; set; } + + /// + /// If true, this is a top-level form component. If false, this input is a sub-component of another input (i.e. TextField, Select, etc). + /// If it is sub-component, it will NOT do form validation!! + /// + [CascadingParameter(Name = "SubscribeToParentForm")] + internal bool SubscribeToParentForm { get; set; } = true; + + /// + /// If true, this form input is required to be filled out. + /// + [Parameter] + public bool Required { get; set; } + + /// + /// The error text that will be displayed if the input is not filled out but required. + /// + [Parameter] + public string RequiredError { get; set; } = "Required"; + + /// + /// The ErrorText that will be displayed if is set to true. + /// + [Parameter] + public string ErrorText { get; set; } + + /// + /// If true, the label will be displayed in an error state. + /// + [Parameter] + public bool HasError { get; set; } + + /// + /// The ErrorId that will be used by aria-describedby if is true + /// + [Parameter] + public string ErrorId { get; set; } + + /// + /// The generic converter of the component. + /// + [Parameter] + public Converter Converter + { + get => _converter; + set => SetConverter(value); + } + + /// + /// The culture of the component. Also sets the culture of the . + /// + [Parameter] + public CultureInfo Culture + { + get => _converter.Culture; + set => SetCulture(value); + } + + private string _conversionError { get; set; } + + protected virtual bool SetConverter(Converter value) + { + var changed = _converter != value; + + if (changed) + { + /* + * Converter is mandatory at all times + */ + _converter = value ?? throw new ArgumentNullException(nameof(value)); + _converter.ErrorOccured += (s, e) => OnConversionError(e); + } + + return changed; + } + + protected virtual bool SetCulture(CultureInfo value) + { + var changed = _converter.Culture != value; + + if (changed) + _converter.Culture = value; + + return changed; + } + + private void OnConversionError(string error) + { + // note: we need to update the form here because the conversion error might lead to not updating the value + // ... which leads to not updating the form + + //TODO Why does the form need to be updated? + Modified = true; + + _conversionError = error; + + Form?.Update(this); + + ConversionErrorOccured?.Invoke(this, error); + } + + /// + /// True if the conversion from string to T failed + /// + public bool ConversionError => !string.IsNullOrWhiteSpace(_conversionError); + + /// + /// The error message of the conversion error from string to T. Null otherwise + /// + public string ConversionErrorMessage => _conversionError; + + /// + /// True if the input has any of the following errors: An error set from outside, a conversion error or + /// one or more validation errors + /// + public bool HasErrors => HasError || ConversionError || ValidationErrors.Count > 0; + + /// + /// Return the validation error text or the conversion error message. + /// + /// Error text/message + public string? GetErrorText() + { + // ErrorText is either set from outside or the first validation error + if (!IsNullOrWhiteSpace(ErrorText)) + return ErrorText; + + if (!IsNullOrWhiteSpace(ConversionErrorMessage)) + return ConversionErrorMessage; + + return null; + } + + /// + /// This manages the state of having been modified by the user. A form control always starts out unmodified + /// but becomes modified when the user performed input or the blur event was raised. + /// + /// The modified state is only relevant for inputs that have no value (i.e. empty text fields). Being unmodified will + /// suppress the display of the + /// + public bool Modified { get; protected set; } + + #region MudForm Validation + + public List ValidationErrors { get; set; } = new List(); + + /// + /// A validation func or a validation attribute. Supported types are: + /// Func<T, bool> ... will output the standard error message "Invalid" if false + /// Func<T, string> ... outputs the result as error message, no error if null + /// Func<T, IEnumerable< string >> ... outputs all the returned error messages, no error if empty + /// Func<object, string, IEnumerable< string >> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty + /// Func<T, Task< bool >> ... will output the standard error message "Invalid" if false + /// Func<T, Task< string >> ... outputs the result as error message, no error if null + /// Func<T, Task<IEnumerable< string >>> ... outputs all the returned error messages, no error if empty + /// Func<object, string, Task<IEnumerable< string >>> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty + /// System.ComponentModel.DataAnnotations.ValidationAttribute instances + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Validation)] + public object Validation { get; set; } + + private T __value; + + /// + /// This is the form component's value. + /// + protected T _value + { + get => __value; + set + { + __value = value; + _conversionError = null; + } + } + + // These are the fire-and-forget methods to launch an async validation process. + // After each async step, we make sure the current Value of the component has not changed while + // async code was executed to avoid race condition which could lead to incorrect validation results. + protected void BeginValidateAfter(Task task) + { + Func execute = async () => + { var value = _value; - if (Validation is Func>) - await ValidateWithFunc(Validation as Func>, _value, errors); - else if (Validation is Func>) - await ValidateWithFunc(Validation as Func>, _value, errors); - else if (Validation is Func>>) - await ValidateWithFunc(Validation as Func>>, _value, errors); - else if (Validation is Func>>) - await ValidateModelWithFullPathOfMember(Validation as Func>>, errors); - - changed = !EqualityComparer.Default.Equals(value, _value); - } - - // Run each validation attributes of the property targeted with `For` - if (_validationAttrsFor != null) - { - foreach (var attr in _validationAttrsFor) + await task; + + // we validate only if the value hasn't changed while we waited for task. + // if it has in fact changed, another validate call will follow anyway + if (EqualityComparer.Default.Equals(value, _value)) + { + BeginValidate(); + } + }; + execute().AndForget(); + } + + protected void BeginValidate() + { + Func execute = async () => + { + var value = _value; + + await ValidateValue(); + + if (EqualityComparer.Default.Equals(value, _value)) + { + EditFormValidate(); + } + }; + execute().AndForget(); + } + + /// + /// Cause this component to validate its value. + /// + public Task Validate() + { + // when a validation is forced, we must set Touched to true, because for untouched fields with + // no value, validation does nothing due to the way forms are expected to work (display errors + // only after fields have been touched). + Modified = true; + return ValidateValue(); + } + + protected virtual async Task ValidateValue() + { + var changed = false; + var errors = new List(); + try + { + // conversion error + if (ConversionError) + errors.Add(ConversionErrorMessage); + // validation errors + if (Validation is ValidationAttribute) + ValidateWithAttribute(Validation as ValidationAttribute, _value, errors); + else if (Validation is Func) + ValidateWithFunc(Validation as Func, _value, errors); + else if (Validation is Func) + ValidateWithFunc(Validation as Func, _value, errors); + else if (Validation is Func>) + ValidateWithFunc(Validation as Func>, _value, errors); + else if (Validation is Func>) + ValidateModelWithFullPathOfMember(Validation as Func>, errors); + else + { + var value = _value; + + if (Validation is Func>) + await ValidateWithFunc(Validation as Func>, _value, errors); + else if (Validation is Func>) + await ValidateWithFunc(Validation as Func>, _value, errors); + else if (Validation is Func>>) + await ValidateWithFunc(Validation as Func>>, _value, errors); + else if (Validation is Func>>) + await ValidateModelWithFullPathOfMember(Validation as Func>>, errors); + + changed = !EqualityComparer.Default.Equals(value, _value); + } + + // Run each validation attributes of the property targeted with `For` + if (_validationAttrsFor != null) + { + foreach (var attr in _validationAttrsFor) + { + ValidateWithAttribute(attr, _value, errors); + } + } + + // required error (must be last, because it is least important!) + if (Required) { - ValidateWithAttribute(attr, _value, errors); + if (Modified && !HasValue(_value)) + errors.Add(RequiredError); } - } - - // required error (must be last, because it is least important!) - if (Required) - { - if (Touched && !HasValue(_value)) - errors.Add(RequiredError); - } - } - finally - { - // If Value has changed while we were validating it, ignore results and exit - if (!changed) - { - // this must be called in any case, because even if Validation is null the user might have set Error and ErrorText manually - // if Error and ErrorText are set by the user, setting them here will have no effect. - // if Error, create an error id that can be used by aria-describedby on input control - ValidationErrors = errors; - Error = errors.Count > 0; - ErrorText = errors.FirstOrDefault(); - ErrorId = HasErrors ? Guid.NewGuid().ToString() : null; - Form?.Update(this); + } + finally + { + // If Value has changed while we were validating it, ignore results and exit + if (!changed) + { + // this must be called in any case, because even if Validation is null the user might have set Error and ErrorText manually + // if Error and ErrorText are set by the user, setting them here will have no effect. + // if Error, create an error id that can be used by aria-describedby on input control + ValidationErrors = errors; + HasError = errors.Count > 0; + ErrorText = errors.FirstOrDefault(); + ErrorId = HasErrors ? Guid.NewGuid().ToString() : null; + Form?.Update(this); + StateHasChanged(); + } + } + } + + protected virtual bool HasValue(T value) + { + if (typeof(T) == typeof(string)) + return !IsNullOrWhiteSpace(value as string); + + return value != null; + } + + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "In the context of EditContext.Model / FieldIdentifier.Model they won't get trimmed.")] + protected virtual void ValidateWithAttribute(ValidationAttribute attr, T value, List errors) + { + try + { + // The validation context is applied either on the `EditContext.Model`, '_fieldIdentifier.Model', or `this` as a stub subject. + // Complex validation with fields references (like `CompareAttribute`) should use an EditContext or For when not using EditContext. + var validationContextSubject = EditContext?.Model ?? _fieldIdentifier.Model ?? this; + var validationContext = new ValidationContext(validationContextSubject); + if (validationContext.MemberName is null && _fieldIdentifier.FieldName is not null) + validationContext.MemberName = _fieldIdentifier.FieldName; + var validationResult = attr.GetValidationResult(value, validationContext); + if (validationResult != ValidationResult.Success) + errors.Add(validationResult.ErrorMessage); + } + catch (Exception e) + { + // Maybe conditionally add full error message if `IWebAssemblyHostEnvironment.IsDevelopment()` + // Or log using proper logger. + errors.Add($"An unhandled exception occurred: {e.Message}"); + } + } + + protected virtual void ValidateWithFunc(Func func, T value, List errors) + { + try + { + if (!func(value)) + errors.Add("Invalid"); + } + catch (Exception e) + { + errors.Add("Error in validation func: " + e.Message); + } + } + + protected virtual void ValidateWithFunc(Func func, T value, List errors) + { + try + { + var error = func(value); + if (error != null) + errors.Add(error); + } + catch (Exception e) + { + errors.Add("Error in validation func: " + e.Message); + } + } + + protected virtual void ValidateWithFunc(Func> func, T value, List errors) + { + try + { + foreach (var error in func(value)) + errors.Add(error); + } + catch (Exception e) + { + errors.Add("Error in validation func: " + e.Message); + } + } + + protected virtual void ValidateModelWithFullPathOfMember(Func> func, List errors) + { + try + { + if (Form?.Model == null) + { + return; + } + + if (For == null) + { + errors.Add($"For is null, please set parameter For on the form input component of type {GetType().Name}"); + return; + } + + foreach (var error in func(Form.Model, For.GetFullPathOfMember())) + errors.Add(error); + } + catch (Exception e) + { + errors.Add("Error in validation func: " + e.Message); + } + } + + protected virtual async Task ValidateWithFunc(Func> func, T value, List errors) + { + try + { + if (!await func(value)) + errors.Add("Invalid"); + } + catch (Exception e) + { + errors.Add("Error in validation func: " + e.Message); + } + } + + protected virtual async Task ValidateWithFunc(Func> func, T value, List errors) + { + try + { + var error = await func(value); + if (error != null) + errors.Add(error); + } + catch (Exception e) + { + errors.Add("Error in validation func: " + e.Message); + } + } + + protected virtual async Task ValidateWithFunc(Func>> func, T value, List errors) + { + try + { + foreach (var error in await func(value)) + errors.Add(error); + } + catch (Exception e) + { + errors.Add("Error in validation func: " + e.Message); + } + } + + protected virtual async Task ValidateModelWithFullPathOfMember(Func>> func, List errors) + { + try + { + if (Form?.Model == null) + { + return; + } + + if (For == null) + { + errors.Add($"For is null, please set parameter For on the form input component of type {GetType().Name}"); + return; + } + + foreach (var error in await func(Form.Model, For.GetFullPathOfMember())) + errors.Add(error); + } + catch (Exception e) + { + errors.Add("Error in validation func: " + e.Message); + } + } + + /// + /// Notify the Form that a field has changed if SubscribeToParentForm is true + /// + protected void FieldChanged(object newValue) + { + if (SubscribeToParentForm) + Form?.FieldChanged(this, newValue); + } + + /// + /// Reset the value and the validation. + /// + public void Reset() + { + ResetValue(); + ResetValidation(); + } + + protected virtual void ResetValue() + { + /* to be overridden */ + _value = default; + _conversionError = null; + Modified = false; + StateHasChanged(); + } + + /// + /// Reset the validation. + /// + public void ResetValidation() + { + HasError = false; + ValidationErrors.Clear(); + ErrorText = null; + StateHasChanged(); + } + + #endregion + + + #region --> Blazor EditForm validation support + + /// + /// This is the form validation context for Blazor's component + /// + [CascadingParameter] + EditContext EditContext { get; set; } = default!; + + /// + /// Triggers field to be validated. + /// + internal void EditFormValidate() + { + if (_fieldIdentifier.FieldName != null) + { + EditContext?.NotifyFieldChanged(_fieldIdentifier); + } + } + + /// + /// Specify an expression which returns the model's field for which validation messages should be displayed. + /// + [Parameter] + public Expression>? For { get; set; } + + + public bool IsForNull => For == null; + + + /// + /// Stores the list of validation attributes attached to the property targeted by . If is null, this property is null too. + /// + private IEnumerable? _validationAttrsFor; + + + private void OnValidationStateChanged(object sender, ValidationStateChangedEventArgs e) + { + if (EditContext != null && !_fieldIdentifier.Equals(default(FieldIdentifier))) + { + var error_msgs = EditContext.GetValidationMessages(_fieldIdentifier).ToArray(); + HasError = error_msgs.Length > 0; + ErrorText = (HasError ? error_msgs[0] : null); StateHasChanged(); - } - } - } - - protected virtual bool HasValue(T value) - { - if (typeof(T) == typeof(string)) - return !IsNullOrWhiteSpace(value as string); - - return value != null; - } - - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "In the context of EditContext.Model / FieldIdentifier.Model they won't get trimmed.")] - protected virtual void ValidateWithAttribute(ValidationAttribute attr, T value, List errors) - { - try - { - // The validation context is applied either on the `EditContext.Model`, '_fieldIdentifier.Model', or `this` as a stub subject. - // Complex validation with fields references (like `CompareAttribute`) should use an EditContext or For when not using EditContext. - var validationContextSubject = EditContext?.Model ?? _fieldIdentifier.Model ?? this; - var validationContext = new ValidationContext(validationContextSubject); - if (validationContext.MemberName is null && _fieldIdentifier.FieldName is not null) - validationContext.MemberName = _fieldIdentifier.FieldName; - var validationResult = attr.GetValidationResult(value, validationContext); - if (validationResult != ValidationResult.Success) - errors.Add(validationResult.ErrorMessage); - } - catch (Exception e) - { - // Maybe conditionally add full error message if `IWebAssemblyHostEnvironment.IsDevelopment()` - // Or log using proper logger. - errors.Add($"An unhandled exception occurred: {e.Message}"); - } - } - - protected virtual void ValidateWithFunc(Func func, T value, List errors) - { - try - { - if (!func(value)) - errors.Add("Invalid"); - } - catch (Exception e) - { - errors.Add("Error in validation func: " + e.Message); - } - } - - protected virtual void ValidateWithFunc(Func func, T value, List errors) - { - try - { - var error = func(value); - if (error != null) - errors.Add(error); - } - catch (Exception e) - { - errors.Add("Error in validation func: " + e.Message); - } - } - - protected virtual void ValidateWithFunc(Func> func, T value, List errors) - { - try - { - foreach (var error in func(value)) - errors.Add(error); - } - catch (Exception e) - { - errors.Add("Error in validation func: " + e.Message); - } - } - - protected virtual void ValidateModelWithFullPathOfMember(Func> func, List errors) - { - try - { - if (Form?.Model == null) - { - return; - } - - if (For == null) - { - errors.Add($"For is null, please set parameter For on the form input component of type {GetType().Name}"); - return; - } - - foreach (var error in func(Form.Model, For.GetFullPathOfMember())) - errors.Add(error); - } - catch (Exception e) - { - errors.Add("Error in validation func: " + e.Message); - } - } - - protected virtual async Task ValidateWithFunc(Func> func, T value, List errors) - { - try - { - if (!await func(value)) - errors.Add("Invalid"); - } - catch (Exception e) - { - errors.Add("Error in validation func: " + e.Message); - } - } - - protected virtual async Task ValidateWithFunc(Func> func, T value, List errors) - { - try - { - var error = await func(value); - if (error != null) - errors.Add(error); - } - catch (Exception e) - { - errors.Add("Error in validation func: " + e.Message); - } - } - - protected virtual async Task ValidateWithFunc(Func>> func, T value, List errors) - { - try - { - foreach (var error in await func(value)) - errors.Add(error); - } - catch (Exception e) - { - errors.Add("Error in validation func: " + e.Message); - } - } - - protected virtual async Task ValidateModelWithFullPathOfMember(Func>> func, List errors) - { - try - { - if (Form?.Model == null) - { - return; - } - - if (For == null) - { - errors.Add($"For is null, please set parameter For on the form input component of type {GetType().Name}"); - return; - } - - foreach (var error in await func(Form.Model, For.GetFullPathOfMember())) - errors.Add(error); - } - catch (Exception e) - { - errors.Add("Error in validation func: " + e.Message); - } - } - - /// - /// Notify the Form that a field has changed if SubscribeToParentForm is true - /// - protected void FieldChanged(object newValue) - { - if (SubscribeToParentForm) - Form?.FieldChanged(this, newValue); - } - - /// - /// Reset the value and the validation. - /// - public void Reset() - { - ResetValue(); - ResetValidation(); - } - - protected virtual void ResetValue() - { - /* to be overridden */ - _value = default; - Touched = false; - StateHasChanged(); - } - - /// - /// Reset the validation. - /// - public void ResetValidation() - { - Error = false; - ValidationErrors.Clear(); - ErrorText = null; - StateHasChanged(); - } - - #endregion - - - #region --> Blazor EditForm validation support - - /// - /// This is the form validation context for Blazor's component - /// - [CascadingParameter] - EditContext EditContext { get; set; } = default!; - - /// - /// Triggers field to be validated. - /// - internal void EditFormValidate() - { - if (_fieldIdentifier.FieldName != null) - { - EditContext?.NotifyFieldChanged(_fieldIdentifier); - } - } - - /// - /// Specify an expression which returns the model's field for which validation messages should be displayed. - /// -#nullable enable - [Parameter] - [Category(CategoryTypes.FormComponent.Validation)] - public Expression>? For { get; set; } -#nullable disable - - public bool IsForNull => For == null; - - /// - /// Stores the list of validation attributes attached to the property targeted by . If is null, this property is null too. - /// -#nullable enable - private IEnumerable? _validationAttrsFor; -#nullable disable - - private void OnValidationStateChanged(object sender, ValidationStateChangedEventArgs e) - { - if (EditContext != null && !_fieldIdentifier.Equals(default(FieldIdentifier))) - { - var error_msgs = EditContext.GetValidationMessages(_fieldIdentifier).ToArray(); - Error = error_msgs.Length > 0; - ErrorText = (Error ? error_msgs[0] : null); - StateHasChanged(); - } - } - - /// - /// Points to a field of the model for which validation messages should be displayed. - /// - private FieldIdentifier _fieldIdentifier; - - /// - /// To find out whether or not For parameter has changed we keep a separate reference - /// -#nullable enable - private Expression>? _currentFor; -#nullable disable - - /// - /// To find out whether or not EditContext parameter has changed we keep a separate reference - /// -#nullable enable - private EditContext? _currentEditContext; -#nullable disable - - protected override void OnParametersSet() - { - if (For != null && For != _currentFor) - { - // Extract validation attributes - // Sourced from https://stackoverflow.com/a/43076222/4839162 - // and also https://stackoverflow.com/questions/59407225/getting-a-custom-attribute-from-a-property-using-an-expression - var expression = (MemberExpression)For.Body; - var propertyInfo = (PropertyInfo)expression.Expression?.Type.GetProperty(expression.Member.Name); - _validationAttrsFor = propertyInfo?.GetCustomAttributes(typeof(ValidationAttribute), true).Cast(); - - _fieldIdentifier = FieldIdentifier.Create(For); - _currentFor = For; - } - - if (EditContext != null && EditContext != _currentEditContext) - { - DetachValidationStateChangedListener(); - EditContext.OnValidationStateChanged += OnValidationStateChanged; - _currentEditContext = EditContext; - } - } - - private void DetachValidationStateChangedListener() - { - if (_currentEditContext != null) - _currentEditContext.OnValidationStateChanged -= OnValidationStateChanged; - } - - #endregion - - - protected override Task OnInitializedAsync() - { - RegisterAsFormComponent(); - return base.OnInitializedAsync(); - } - - protected virtual void RegisterAsFormComponent() - { - if (SubscribeToParentForm) - { - Form?.Add(this); - } - } - - /// - /// Called to dispose this instance. - /// - /// if called within . - protected virtual void Dispose(bool disposing) - { - } - - void IDisposable.Dispose() - { - try - { - Form?.Remove(this); - } - catch { /* ignore */ } - DetachValidationStateChangedListener(); - Dispose(disposing: true); - } + } + } + + /// + /// Points to a field of the model for which validation messages should be displayed. + /// + private FieldIdentifier _fieldIdentifier; + + /// + /// To find out whether or not For parameter has changed we keep a separate reference + /// + private Expression>? _currentFor; + + + /// + /// To find out whether or not EditContext parameter has changed we keep a separate reference + /// + private EditContext? _currentEditContext; + + protected override void OnParametersSet() + { + if (For != null && For != _currentFor) + { + // Extract validation attributes + // Sourced from https://stackoverflow.com/a/43076222/4839162 + // and also https://stackoverflow.com/questions/59407225/getting-a-custom-attribute-from-a-property-using-an-expression + var expression = (MemberExpression)For.Body; + var propertyInfo = (PropertyInfo)expression.Expression?.Type.GetProperty(expression.Member.Name); + _validationAttrsFor = propertyInfo?.GetCustomAttributes(typeof(ValidationAttribute), true).Cast(); + + _fieldIdentifier = FieldIdentifier.Create(For); + _currentFor = For; + } + + if (EditContext != null && EditContext != _currentEditContext) + { + DetachValidationStateChangedListener(); + EditContext.OnValidationStateChanged += OnValidationStateChanged; + _currentEditContext = EditContext; + } + } + + private void DetachValidationStateChangedListener() + { + if (_currentEditContext != null) + _currentEditContext.OnValidationStateChanged -= OnValidationStateChanged; + } + + #endregion + + + protected override Task OnInitializedAsync() + { + RegisterAsFormComponent(); + return base.OnInitializedAsync(); + } + + protected virtual void RegisterAsFormComponent() + { + if (SubscribeToParentForm) + { + Form?.Add(this); + } + } + + /// + /// Called to dispose this instance. + /// + /// if called within . + protected virtual void Dispose(bool disposing) + { + } + + void IDisposable.Dispose() + { + try + { + Form?.Remove(this); + } + catch { /* ignore */ } + DetachValidationStateChangedListener(); + Dispose(disposing: true); + } } diff --git a/Components/Form/IFormComponent.cs b/Components/Form/IFormComponent.cs index 4f85fb4..94ec669 100644 --- a/Components/Form/IFormComponent.cs +++ b/Components/Form/IFormComponent.cs @@ -3,9 +3,9 @@ public interface IFormComponent { public bool Required { get; set; } - public bool Error { get; set; } + public bool HasError { get; set; } public bool HasErrors { get; } - public bool Touched { get; } + public bool Modified { get; } public object Validation { get; set; } public bool IsForNull { get; } public List ValidationErrors { get; set; } diff --git a/Components/Input/Input.razor b/Components/Input/Input.razor index 4a39db0..b22b167 100644 --- a/Components/Input/Input.razor +++ b/Components/Input/Input.razor @@ -41,7 +41,7 @@ @onkeyup:preventDefault="@KeyUpPreventDefault" @onmousewheel="@OnMouseWheel" @onwheel="@OnMouseWheel" - aria-invalid="@Error.ToString().ToLower()" + aria-invalid="@HasError.ToString().ToLower()" aria-describedby="@ErrorId" > @Text @@ -73,7 +73,7 @@ @onkeyup:preventDefault="@KeyUpPreventDefault" @onmousewheel="@OnMouseWheel" @onwheel="@OnMouseWheel" - aria-invalid="@Error.ToString().ToLower()" + aria-invalid="@HasError.ToString().ToLower()" aria-describedby="@ErrorId" /> @*Note: double mouse wheel handlers needed for Firefox because it doesn't know onmousewheel*@ diff --git a/Components/Input/InputBase.cs b/Components/Input/InputBase.cs index 1e587ec..49e5ecf 100644 --- a/Components/Input/InputBase.cs +++ b/Components/Input/InputBase.cs @@ -215,7 +215,7 @@ public abstract class InputBase : FormComponent { Text = text; if (!string.IsNullOrWhiteSpace(Text)) - Touched = true; + Modified = true; if (updateValue) await UpdateValuePropertyAsync(false); await TextChanged.InvokeAsync(Text); @@ -227,7 +227,7 @@ public abstract class InputBase : FormComponent /// protected virtual Task UpdateTextPropertyAsync(bool updateValue) { - return SetTextAsync(Converter.Set(Value), updateValue); + return SetTextAsync(Converter.Convert(Value), updateValue); } /// @@ -266,7 +266,7 @@ public abstract class InputBase : FormComponent if (!OnlyValidateIfDirty || _isDirty) { - Touched = true; + Modified = true; BeginValidateAfter(OnBlur.InvokeAsync(obj)); } } @@ -361,7 +361,7 @@ public abstract class InputBase : FormComponent /// protected virtual Task UpdateValuePropertyAsync(bool updateText) { - return SetValueAsync(Converter.Get(Text), updateText); + return SetValueAsync(Converter.ConvertBack(Text), updateText); } protected override bool SetConverter(Converter value) @@ -389,7 +389,7 @@ public abstract class InputBase : FormComponent [Category(CategoryTypes.FormComponent.Behavior)] public string Format { - get => ((Converter)Converter).Format; + get => ((ToStringConverter)Converter).Format; set => SetFormat(value); } @@ -398,7 +398,7 @@ public abstract class InputBase : FormComponent var changed = Format != value; if (changed) { - ((Converter)Converter).Format = value; + ((ToStringConverter)Converter).Format = value; UpdateTextPropertyAsync(false).AndForget(); // refresh only Text property from current Value } return changed; diff --git a/Components/Mask/Mask.razor.cs b/Components/Mask/Mask.razor.cs index 7c7ae7a..81436b1 100644 --- a/Components/Mask/Mask.razor.cs +++ b/Components/Mask/Mask.razor.cs @@ -238,7 +238,7 @@ public partial class Mask : InputBase, IDisposable await base.SetTextAsync(text, updateValue: false); if (Clearable) UpdateClearable(Text); - var v = Converter.Get(cleanText); + var v = Converter.ConvertBack(cleanText); Value = v; await ValueChanged.InvokeAsync(v); SetCaretPosition(caret, selection); @@ -262,7 +262,7 @@ public partial class Mask : InputBase, IDisposable // allow this only via changes from the outside if (_updating) return; - var text = Converter.Set(Value); + var text = Converter.Convert(Value); var cleanText = MaskKind.GetCleanText(); if (cleanText == text || string.IsNullOrEmpty(cleanText) && string.IsNullOrEmpty(text)) return; diff --git a/Components/NumericField/NumericField.razor b/Components/NumericField/NumericField.razor index f3e8d7d..aab516c 100644 --- a/Components/NumericField/NumericField.razor +++ b/Components/NumericField/NumericField.razor @@ -39,7 +39,7 @@ AdornmentIcon="@AdornmentIcon" AdornmentColor="@AdornmentColor" IconSize="@IconSize" - Error="@Error" + Error="@HasError" Immediate="@(Immediate)" Margin="@Margin" MaxLength="@MaxLength" diff --git a/Components/Picker/Picker.razor b/Components/Picker/Picker.razor index 3cf72b8..7e234b7 100644 --- a/Components/Picker/Picker.razor +++ b/Components/Picker/Picker.razor @@ -31,7 +31,7 @@ Margin="@Margin" Required="@Required" RequiredError="@RequiredError" - Error="@Error" + Error="@HasError" ErrorText="@ErrorText" Clearable="@(ReadOnly ? false : Clearable)" OnClearButtonClick="@(() => Clear())" diff --git a/Components/Radio/RadioGroup.razor.cs b/Components/Radio/RadioGroup.razor.cs index f3988ab..14c98bd 100644 --- a/Components/Radio/RadioGroup.razor.cs +++ b/Components/Radio/RadioGroup.razor.cs @@ -81,7 +81,7 @@ public partial class RadioGroup : FormComponent, IRadioGroup internal Task SetSelectedRadioAsync(Radio radio) { - Touched = true; + Modified = true; return SetSelectedRadioAsync(radio, true); } diff --git a/Components/Select/Select.razor b/Components/Select/Select.razor index 9283aee..5ffbdcd 100644 --- a/Components/Select/Select.razor +++ b/Components/Select/Select.razor @@ -5,14 +5,14 @@
+ Error="@HasError" ErrorText="@ErrorText" ErrorId="@ErrorId" Disabled="@Disabled" @onclick="@ToggleMenu" Required="@Required" ForId="@FieldId"> : InputBase, ISelect, IShadowSelect { - private HashSet _selectedValues = new HashSet(); - private IEqualityComparer _comparer; - private bool _dense; - private string multiSelectionText; - private bool? _selectAllChecked; - private IKeyInterceptor _keyInterceptor; - - protected string Classname => - new CssBuilder("mud-select") - .AddClass(Class) - .Build(); - - [Inject] private IKeyInterceptorFactory KeyInterceptorFactory { get; set; } - [Inject] IScrollManager ScrollManager { get; set; } - - private string _elementId = "select_" + Guid.NewGuid().ToString().Substring(0, 8); - - private Task SelectNextItem() => SelectAdjacentItem(+1); - - private Task SelectPreviousItem() => SelectAdjacentItem(-1); - - private async Task SelectAdjacentItem(int direction) - { - if (_items == null || _items.Count == 0) - return; - var index = _items.FindIndex(x => x.ItemId == (string)_activeItemId); - if (direction < 0 && index < 0) - index = 0; - SelectItem item = null; - // the loop allows us to jump over disabled items until we reach the next non-disabled one - for (int i = 0; i < _items.Count; i++) - { - index += direction; - if (index < 0) - index = 0; - if (index >= _items.Count) - index = _items.Count - 1; - if (_items[index].Disabled) - continue; - item = _items[index]; - if (!MultiSelection) - { - _selectedValues.Clear(); - _selectedValues.Add(item.Value); - await SetValueAsync(item.Value, updateText: true); - HilightItem(item); - break; - } - else - { - // in multiselect mode don't select anything, just hilight. - // selecting is done by Enter - HilightItem(item); - break; - } - } - await _elementReference.SetText(Text); - await ScrollToItemAsync(item); - } - private ValueTask ScrollToItemAsync(SelectItem item) - => item != null ? ScrollManager.ScrollToListItemAsync(item.ItemId) : ValueTask.CompletedTask; - private async Task SelectFirstItem(string startChar = null) - { - if (_items == null || _items.Count == 0) - return; - var items = _items.Where(x => !x.Disabled); - var firstItem = items.FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(startChar)) - { - // find first item that starts with the letter - var currentItem = items.FirstOrDefault(x => x.ItemId == (string)_activeItemId); - if (currentItem != null && - Converter.Set(currentItem.Value)?.ToLowerInvariant().StartsWith(startChar) == true) - { - // this will step through all items that start with the same letter if pressed multiple times - items = items.SkipWhile(x => x != currentItem).Skip(1); - } - items = items.Where(x => Converter.Set(x.Value)?.ToLowerInvariant().StartsWith(startChar) == true); - } - var item = items.FirstOrDefault(); - if (item == null) - return; - if (!MultiSelection) - { - _selectedValues.Clear(); - _selectedValues.Add(item.Value); - await SetValueAsync(item.Value, updateText: true); - HilightItem(item); - } - else - { - HilightItem(item); - } - await _elementReference.SetText(Text); - await ScrollToItemAsync(item); - } - - private async Task SelectLastItem() - { - if (_items == null || _items.Count == 0) - return; - var item = _items.LastOrDefault(x => !x.Disabled); - if (item == null) - return; - if (!MultiSelection) - { - _selectedValues.Clear(); - _selectedValues.Add(item.Value); - await SetValueAsync(item.Value, updateText: true); - HilightItem(item); - } - else - { - HilightItem(item); - } - await _elementReference.SetText(Text); - await ScrollToItemAsync(item); - } - - /// - /// Fired when dropdown opens. - /// - [Category(CategoryTypes.FormComponent.Behavior)] - [Parameter] public EventCallback OnOpen { get; set; } - - /// - /// Fired when dropdown closes. - /// - [Category(CategoryTypes.FormComponent.Behavior)] - [Parameter] public EventCallback OnClose { get; set; } - - /// - /// Add the MudSelectItems here - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListBehavior)] - public RenderFragment ChildContent { get; set; } - - /// - /// User class names for the popover, separated by space - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public string PopoverClass { get; set; } - - /// - /// If true, compact vertical padding will be applied to all Select items. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public bool Dense - { - get { return _dense; } - set { _dense = value; } - } - - /// - /// The Open Select Icon - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Appearance)] - public string OpenIcon { get; set; } = Icons.Material.Filled.ArrowDropDown; - - /// - /// The Close Select Icon - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Appearance)] - public string CloseIcon { get; set; } = Icons.Material.Filled.ArrowDropUp; - - /// - /// If set to true and the MultiSelection option is set to true, a "select all" checkbox is added at the top of the list of items. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListBehavior)] - public bool SelectAll { get; set; } - - /// - /// Define the text of the Select All option. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public string SelectAllText { get; set; } = "Select all"; - - /// - /// Fires when SelectedValues changes. - /// - [Parameter] public EventCallback> SelectedValuesChanged { get; set; } - - /// - /// Function to define a customized multiselection text. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Behavior)] - public Func, string> MultiSelectionTextFunc { get; set; } - - /// - /// Parameter to define the delimited string separator. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Behavior)] - public string Delimiter { get; set; } = ", "; - - /// - /// Set of selected values. If MultiSelection is false it will only ever contain a single value. This property is two-way bindable. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Data)] - public IEnumerable SelectedValues - { - get - { - if (_selectedValues == null) - _selectedValues = new HashSet(_comparer); - return _selectedValues; - } - set - { - var set = value ?? new HashSet(_comparer); - if (SelectedValues.Count() == set.Count() && _selectedValues.All(x => set.Contains(x))) - return; - _selectedValues = new HashSet(set, _comparer); - SelectionChangedFromOutside?.Invoke(_selectedValues); - if (!MultiSelection) - SetValueAsync(_selectedValues.FirstOrDefault()).AndForget(); - else - { - //Warning. Here the Converter was not set yet - if (MultiSelectionTextFunc != null) - { - SetCustomizedTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Set(x))), - selectedConvertedValues: SelectedValues.Select(x => Converter.Set(x)).ToList(), - multiSelectionTextFunc: MultiSelectionTextFunc).AndForget(); - } - else - { - SetTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Set(x))), updateValue: false).AndForget(); - } - } - SelectedValuesChanged.InvokeAsync(new HashSet(SelectedValues, _comparer)); - if (MultiSelection && typeof(T) == typeof(string)) - SetValueAsync((T)(object)Text, updateText: false).AndForget(); - } - } - - /// - /// The Comparer to use for comparing selected values internally. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Behavior)] - public IEqualityComparer Comparer - { - get => _comparer; - set - { - _comparer = value; - // Apply comparer and refresh selected values - _selectedValues = new HashSet(_selectedValues, _comparer); - SelectedValues = _selectedValues; - } - } - - private Func _toStringFunc = x => x?.ToString(); - - private Input _elementReference; - - /// - /// Defines how values are displayed in the drop-down list - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListBehavior)] - public Func ToStringFunc - { - get => _toStringFunc; - set - { - if (_toStringFunc == value) - return; - _toStringFunc = value; - Converter = new Converter - { - SetFunc = _toStringFunc ?? (x => x?.ToString()), - //GetFunc = LookupValue, - }; - } - } - - public Select() - { - Adornment = Adornment.End; - IconSize = Size.Medium; - } - - protected override void OnAfterRender(bool firstRender) - { - base.OnAfterRender(firstRender); - if (firstRender && Value != null) - { - // we need to render the initial Value which is not possible without the items - // which supply the RenderFragment. So in this case, a second render is necessary - StateHasChanged(); - } - UpdateSelectAllChecked(); - lock (this) - { - if (_renderComplete != null) - { - _renderComplete.TrySetResult(); - _renderComplete = null; - } - } - } - - - private Task WaitForRender() - { - Task t = null; - lock (this) - { - if (_renderComplete != null) - return _renderComplete.Task; - _renderComplete = new TaskCompletionSource(); - t = _renderComplete.Task; - } - StateHasChanged(); - return t; - } - - private TaskCompletionSource _renderComplete; - - /// - /// Returns whether or not the Value can be found in items. If not, the Select will display it as a string. - /// - protected bool CanRenderValue - { - get - { - if (Value == null) - return false; - if (!_shadowLookup.TryGetValue(Value, out var item)) - return false; - return (item.ChildContent != null); - } - } - - protected bool IsValueInList - { - get - { - if (Value == null) - return false; - return _shadowLookup.TryGetValue(Value, out var _); - } - } - - protected RenderFragment GetSelectedValuePresenter() - { - if (Value == null) - return null; - if (!_shadowLookup.TryGetValue(Value, out var item)) - return null; //<-- for now. we'll add a custom template to present values (set from outside) which are not on the list? - return item.ChildContent; - } - - protected override Task UpdateValuePropertyAsync(bool updateText) - { - // For MultiSelection of non-string T's we don't update the Value!!! - if (typeof(T) == typeof(string) || !MultiSelection) - base.UpdateValuePropertyAsync(updateText); - return Task.CompletedTask; - } - - protected override Task UpdateTextPropertyAsync(bool updateValue) - { - // when multiselection is true, we return - // a comma separated list of selected values - if (MultiSelectionTextFunc != null) - { - return MultiSelection - ? SetCustomizedTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Set(x))), - selectedConvertedValues: SelectedValues.Select(x => Converter.Set(x)).ToList(), - multiSelectionTextFunc: MultiSelectionTextFunc) - : base.UpdateTextPropertyAsync(updateValue); - } - else - { - return MultiSelection - ? SetTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Set(x)))) - : base.UpdateTextPropertyAsync(updateValue); - } - } - - internal event Action> SelectionChangedFromOutside; - - private bool _multiSelection; - /// - /// If true, multiple values can be selected via checkboxes which are automatically shown in the dropdown - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListBehavior)] - public bool MultiSelection - { - get => _multiSelection; - set - { - if (value != _multiSelection) - { - _multiSelection = value; - UpdateTextPropertyAsync(false).AndForget(); - } - } - } - - /// - /// The collection of items within this select - /// - public IReadOnlyList> Items => _items; - - protected internal List> _items = new(); - protected Dictionary> _valueLookup = new(); - protected Dictionary> _shadowLookup = new(); - - // note: this must be object to satisfy MudList - private object _activeItemId = null; - - internal bool Add(SelectItem item) - { - if (item == null) - return false; - bool? result = null; - if (!_items.Select(x => x.Value).Contains(item.Value)) - { - _items.Add(item); - - if (item.Value != null) - { - _valueLookup[item.Value] = item; - if (item.Value.Equals(Value) && !MultiSelection) - result = true; - } - } - UpdateSelectAllChecked(); - if (result.HasValue == false) - { - result = item.Value?.Equals(Value); - } - return result == true; - } - - internal void Remove(SelectItem item) - { - _items.Remove(item); - if (item.Value != null) - _valueLookup.Remove(item.Value); - } - - /// - /// Sets the maxheight the Select can have when open. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public int MaxHeight { get; set; } = 300; - - /// - /// Set the anchor origin point to determen where the popover will open from. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public Origin AnchorOrigin { get; set; } = Origin.TopCenter; - - /// - /// Sets the transform origin point for the popover. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public Origin TransformOrigin { get; set; } = Origin.TopCenter; - - /// - /// Sets the direction the Select menu should open. - /// - [ExcludeFromCodeCoverage] - [Obsolete("Use AnchorOrigin or TransformOrigin instead.", true)] - [Parameter] public Direction Direction { get; set; } = Direction.Bottom; - - /// - /// If true, the Select menu will open either before or after the input (left/right). - /// - [ExcludeFromCodeCoverage] - [Obsolete("Use AnchorOrigin or TransformOrigin instead.", true)] - [Parameter] public bool OffsetX { get; set; } - - /// - /// If true, the Select menu will open either before or after the input (top/bottom). - /// - /// [ExcludeFromCodeCoverage] - [Obsolete("Use AnchorOrigin or TransformOrigin instead.", true)] - [Parameter] public bool OffsetY { get; set; } - - /// - /// If true, the Select's input will not show any values that are not defined in the dropdown. - /// This can be useful if Value is bound to a variable which is initialized to a value which is not in the list - /// and you want the Select to show the label / placeholder instead. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Behavior)] - public bool Strict { get; set; } - - /// - /// Show clear button. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.Behavior)] - public bool Clearable { get; set; } = false; - - /// - /// If true, prevent scrolling while dropdown is open. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListBehavior)] - public bool LockScroll { get; set; } = false; - - /// - /// Button click event for clear button. Called after text and value has been cleared. - /// - [Parameter] public EventCallback OnClearButtonClick { get; set; } - - internal bool _isOpen; - - public string _currentIcon { get; set; } - - public async Task SelectOption(int index) - { - if (index < 0 || index >= _items.Count) - { - if (!MultiSelection) - await CloseMenu(); - return; - } - await SelectOption(_items[index].Value); - } - - public async Task SelectOption(object obj) - { - var value = (T)obj; - if (MultiSelection) - { - // multi-selection: menu stays open - if (!_selectedValues.Contains(value)) - _selectedValues.Add(value); - else - _selectedValues.Remove(value); - - if (MultiSelectionTextFunc != null) - { - await SetCustomizedTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Set(x))), - selectedConvertedValues: SelectedValues.Select(x => Converter.Set(x)).ToList(), - multiSelectionTextFunc: MultiSelectionTextFunc); - } - else - { - await SetTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Set(x))), updateValue: false); - } - - UpdateSelectAllChecked(); - BeginValidate(); - } - else - { - // single selection - // CloseMenu(true) doesn't close popover in BSS - await CloseMenu(false); - - if (EqualityComparer.Default.Equals(Value, value)) - { - StateHasChanged(); - return; - } - - await SetValueAsync(value); - _elementReference.SetText(Text).AndForget(); - _selectedValues.Clear(); - _selectedValues.Add(value); - } - - HilightItemForValue(value); - await SelectedValuesChanged.InvokeAsync(SelectedValues); - if (MultiSelection && typeof(T) == typeof(string)) - await SetValueAsync((T)(object)Text, updateText: false); - await InvokeAsync(StateHasChanged); - } - - private async void HilightItemForValue(T value) - { - if (value == null) - { - HilightItem(null); - return; - } - await WaitForRender(); - _valueLookup.TryGetValue(value, out var item); - HilightItem(item); - } - - private async void HilightItem(SelectItem item) - { - _activeItemId = item?.ItemId; - // we need to make sure we are just after a render here or else there will be race conditions - await WaitForRender(); - // Note: this is a hack but I found no other way to make the list hilight the currently hilighted item - // without the delay it always shows the previously hilighted item because the popup items don't exist yet - // they are only registered after they are rendered, so we need to render again! - await Task.Delay(1); - StateHasChanged(); - } - - private async Task HilightSelectedValue() - { - await WaitForRender(); - if (MultiSelection) - HilightItem(_items.FirstOrDefault(x => !x.Disabled)); - else - HilightItemForValue(Value); - } - - private void UpdateSelectAllChecked() - { - if (MultiSelection && SelectAll) - { - var oldState = _selectAllChecked; - if (_selectedValues.Count == 0) - { - _selectAllChecked = false; - } - else if (_items.Count == _selectedValues.Count) - { - _selectAllChecked = true; - } - else - { - _selectAllChecked = null; - } - } - } - - public async Task ToggleMenu() - { - if (Disabled || ReadOnly) - return; - if (_isOpen) - await CloseMenu(true); - else - await OpenMenu(); - } - - public async Task OpenMenu() - { - if (Disabled || ReadOnly) - return; - _isOpen = true; - UpdateIcon(); - StateHasChanged(); - await HilightSelectedValue(); - //Scroll the active item on each opening - if (_activeItemId != null) - { - var index = _items.FindIndex(x => x.ItemId == (string)_activeItemId); - if (index > 0) - { - var item = _items[index]; - await ScrollToItemAsync(item); - } - } - //disable escape propagation: if selectmenu is open, only the select popover should close and underlying components should not handle escape key - await _keyInterceptor.UpdateKey(new() { Key = "Escape", StopDown = "Key+none" }); - - await OnOpen.InvokeAsync(); - } - - public async Task CloseMenu(bool focusAgain = true) - { - _isOpen = false; - UpdateIcon(); - if (focusAgain == true) - { - StateHasChanged(); - await OnBlur.InvokeAsync(new FocusEventArgs()); - _elementReference.FocusAsync().AndForget(TaskOption.Safe); - StateHasChanged(); - } - - //enable escape propagation: the select popover was closed, now underlying components are allowed to handle escape key - await _keyInterceptor.UpdateKey(new() { Key = "Escape", StopDown = "none" }); - - await OnClose.InvokeAsync(); - } - - private void UpdateIcon() - { - _currentIcon = !string.IsNullOrWhiteSpace(AdornmentIcon) ? AdornmentIcon : _isOpen ? CloseIcon : OpenIcon; - } - - protected override void OnInitialized() - { - base.OnInitialized(); - UpdateIcon(); - } - - protected override void OnParametersSet() - { - base.OnParametersSet(); - UpdateIcon(); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - _keyInterceptor = KeyInterceptorFactory.Create(); - - await _keyInterceptor.Connect(_elementId, new KeyInterceptorOptions() - { - //EnableLogging = true, - TargetClass = "mud-input-control", - Keys = { - new KeyOptions { Key=" ", PreventDown = "key+none" }, //prevent scrolling page, toggle open/close + private HashSet _selectedValues = new HashSet(); + private IEqualityComparer _comparer; + private bool _dense; + private string multiSelectionText; + private bool? _selectAllChecked; + private IKeyInterceptor _keyInterceptor; + + protected string Classname => + new CssBuilder("mud-select") + .AddClass(Class) + .Build(); + + [Inject] private IKeyInterceptorFactory KeyInterceptorFactory { get; set; } + [Inject] IScrollManager ScrollManager { get; set; } + + private string _elementId = "select_" + Guid.NewGuid().ToString().Substring(0, 8); + + private Task SelectNextItem() => SelectAdjacentItem(+1); + + private Task SelectPreviousItem() => SelectAdjacentItem(-1); + + private async Task SelectAdjacentItem(int direction) + { + if (_items == null || _items.Count == 0) + return; + var index = _items.FindIndex(x => x.ItemId == (string)_activeItemId); + if (direction < 0 && index < 0) + index = 0; + SelectItem item = null; + // the loop allows us to jump over disabled items until we reach the next non-disabled one + for (int i = 0; i < _items.Count; i++) + { + index += direction; + if (index < 0) + index = 0; + if (index >= _items.Count) + index = _items.Count - 1; + if (_items[index].Disabled) + continue; + item = _items[index]; + if (!MultiSelection) + { + _selectedValues.Clear(); + _selectedValues.Add(item.Value); + await SetValueAsync(item.Value, updateText: true); + HilightItem(item); + break; + } + else + { + // in multiselect mode don't select anything, just hilight. + // selecting is done by Enter + HilightItem(item); + break; + } + } + await _elementReference.SetText(Text); + await ScrollToItemAsync(item); + } + private ValueTask ScrollToItemAsync(SelectItem item) + => item != null ? ScrollManager.ScrollToListItemAsync(item.ItemId) : ValueTask.CompletedTask; + private async Task SelectFirstItem(string startChar = null) + { + if (_items == null || _items.Count == 0) + return; + var items = _items.Where(x => !x.Disabled); + var firstItem = items.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(startChar)) + { + // find first item that starts with the letter + var currentItem = items.FirstOrDefault(x => x.ItemId == (string)_activeItemId); + if (currentItem != null && + Converter.Convert(currentItem.Value)?.ToLowerInvariant().StartsWith(startChar) == true) + { + // this will step through all items that start with the same letter if pressed multiple times + items = items.SkipWhile(x => x != currentItem).Skip(1); + } + items = items.Where(x => Converter.Convert(x.Value)?.ToLowerInvariant().StartsWith(startChar) == true); + } + var item = items.FirstOrDefault(); + if (item == null) + return; + if (!MultiSelection) + { + _selectedValues.Clear(); + _selectedValues.Add(item.Value); + await SetValueAsync(item.Value, updateText: true); + HilightItem(item); + } + else + { + HilightItem(item); + } + await _elementReference.SetText(Text); + await ScrollToItemAsync(item); + } + + private async Task SelectLastItem() + { + if (_items == null || _items.Count == 0) + return; + var item = _items.LastOrDefault(x => !x.Disabled); + if (item == null) + return; + if (!MultiSelection) + { + _selectedValues.Clear(); + _selectedValues.Add(item.Value); + await SetValueAsync(item.Value, updateText: true); + HilightItem(item); + } + else + { + HilightItem(item); + } + await _elementReference.SetText(Text); + await ScrollToItemAsync(item); + } + + /// + /// Fired when dropdown opens. + /// + [Category(CategoryTypes.FormComponent.Behavior)] + [Parameter] public EventCallback OnOpen { get; set; } + + /// + /// Fired when dropdown closes. + /// + [Category(CategoryTypes.FormComponent.Behavior)] + [Parameter] public EventCallback OnClose { get; set; } + + /// + /// Add the MudSelectItems here + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListBehavior)] + public RenderFragment ChildContent { get; set; } + + /// + /// User class names for the popover, separated by space + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public string PopoverClass { get; set; } + + /// + /// If true, compact vertical padding will be applied to all Select items. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public bool Dense + { + get { return _dense; } + set { _dense = value; } + } + + /// + /// The Open Select Icon + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Appearance)] + public string OpenIcon { get; set; } = Icons.Material.Filled.ArrowDropDown; + + /// + /// The Close Select Icon + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Appearance)] + public string CloseIcon { get; set; } = Icons.Material.Filled.ArrowDropUp; + + /// + /// If set to true and the MultiSelection option is set to true, a "select all" checkbox is added at the top of the list of items. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListBehavior)] + public bool SelectAll { get; set; } + + /// + /// Define the text of the Select All option. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public string SelectAllText { get; set; } = "Select all"; + + /// + /// Fires when SelectedValues changes. + /// + [Parameter] public EventCallback> SelectedValuesChanged { get; set; } + + /// + /// Function to define a customized multiselection text. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Behavior)] + public Func, string> MultiSelectionTextFunc { get; set; } + + /// + /// Parameter to define the delimited string separator. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Behavior)] + public string Delimiter { get; set; } = ", "; + + /// + /// Set of selected values. If MultiSelection is false it will only ever contain a single value. This property is two-way bindable. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Data)] + public IEnumerable SelectedValues + { + get + { + if (_selectedValues == null) + _selectedValues = new HashSet(_comparer); + return _selectedValues; + } + set + { + var set = value ?? new HashSet(_comparer); + if (SelectedValues.Count() == set.Count() && _selectedValues.All(x => set.Contains(x))) + return; + _selectedValues = new HashSet(set, _comparer); + SelectionChangedFromOutside?.Invoke(_selectedValues); + if (!MultiSelection) + SetValueAsync(_selectedValues.FirstOrDefault()).AndForget(); + else + { + //Warning. Here the Converter was not set yet + if (MultiSelectionTextFunc != null) + { + SetCustomizedTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Convert(x))), + selectedConvertedValues: SelectedValues.Select(x => Converter.Convert(x)).ToList(), + multiSelectionTextFunc: MultiSelectionTextFunc).AndForget(); + } + else + { + SetTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Convert(x))), updateValue: false).AndForget(); + } + } + SelectedValuesChanged.InvokeAsync(new HashSet(SelectedValues, _comparer)); + if (MultiSelection && typeof(T) == typeof(string)) + SetValueAsync((T)(object)Text, updateText: false).AndForget(); + } + } + + /// + /// The Comparer to use for comparing selected values internally. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Behavior)] + public IEqualityComparer Comparer + { + get => _comparer; + set + { + _comparer = value; + // Apply comparer and refresh selected values + _selectedValues = new HashSet(_selectedValues, _comparer); + SelectedValues = _selectedValues; + } + } + + private Func _toStringFunc = x => x?.ToString(); + + private Input _elementReference; + + /// + /// Defines how values are displayed in the drop-down list + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListBehavior)] + public Func ToStringFunc + { + get => _toStringFunc; + set + { + if (_toStringFunc == value) + return; + _toStringFunc = value; + Converter = new LambdaConverter(_toStringFunc ?? (x => x?.ToString()), null); + } + } + + public Select() + { + Adornment = Adornment.End; + IconSize = Size.Medium; + } + + protected override void OnAfterRender(bool firstRender) + { + base.OnAfterRender(firstRender); + if (firstRender && Value != null) + { + // we need to render the initial Value which is not possible without the items + // which supply the RenderFragment. So in this case, a second render is necessary + StateHasChanged(); + } + UpdateSelectAllChecked(); + lock (this) + { + if (_renderComplete != null) + { + _renderComplete.TrySetResult(); + _renderComplete = null; + } + } + } + + + private Task WaitForRender() + { + Task t = null; + lock (this) + { + if (_renderComplete != null) + return _renderComplete.Task; + _renderComplete = new TaskCompletionSource(); + t = _renderComplete.Task; + } + StateHasChanged(); + return t; + } + + private TaskCompletionSource _renderComplete; + + /// + /// Returns whether or not the Value can be found in items. If not, the Select will display it as a string. + /// + protected bool CanRenderValue + { + get + { + if (Value == null) + return false; + if (!_shadowLookup.TryGetValue(Value, out var item)) + return false; + return (item.ChildContent != null); + } + } + + protected bool IsValueInList + { + get + { + if (Value == null) + return false; + return _shadowLookup.TryGetValue(Value, out var _); + } + } + + protected RenderFragment GetSelectedValuePresenter() + { + if (Value == null) + return null; + if (!_shadowLookup.TryGetValue(Value, out var item)) + return null; //<-- for now. we'll add a custom template to present values (set from outside) which are not on the list? + return item.ChildContent; + } + + protected override Task UpdateValuePropertyAsync(bool updateText) + { + // For MultiSelection of non-string T's we don't update the Value!!! + if (typeof(T) == typeof(string) || !MultiSelection) + base.UpdateValuePropertyAsync(updateText); + return Task.CompletedTask; + } + + protected override Task UpdateTextPropertyAsync(bool updateValue) + { + // when multiselection is true, we return + // a comma separated list of selected values + if (MultiSelectionTextFunc != null) + { + return MultiSelection + ? SetCustomizedTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Convert(x))), + selectedConvertedValues: SelectedValues.Select(x => Converter.Convert(x)).ToList(), + multiSelectionTextFunc: MultiSelectionTextFunc) + : base.UpdateTextPropertyAsync(updateValue); + } + else + { + return MultiSelection + ? SetTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Convert(x)))) + : base.UpdateTextPropertyAsync(updateValue); + } + } + + internal event Action> SelectionChangedFromOutside; + + private bool _multiSelection; + /// + /// If true, multiple values can be selected via checkboxes which are automatically shown in the dropdown + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListBehavior)] + public bool MultiSelection + { + get => _multiSelection; + set + { + if (value != _multiSelection) + { + _multiSelection = value; + UpdateTextPropertyAsync(false).AndForget(); + } + } + } + + /// + /// The collection of items within this select + /// + public IReadOnlyList> Items => _items; + + protected internal List> _items = new(); + protected Dictionary> _valueLookup = new(); + protected Dictionary> _shadowLookup = new(); + + // note: this must be object to satisfy MudList + private object _activeItemId = null; + + internal bool Add(SelectItem item) + { + if (item == null) + return false; + bool? result = null; + if (!_items.Select(x => x.Value).Contains(item.Value)) + { + _items.Add(item); + + if (item.Value != null) + { + _valueLookup[item.Value] = item; + if (item.Value.Equals(Value) && !MultiSelection) + result = true; + } + } + UpdateSelectAllChecked(); + if (result.HasValue == false) + { + result = item.Value?.Equals(Value); + } + return result == true; + } + + internal void Remove(SelectItem item) + { + _items.Remove(item); + if (item.Value != null) + _valueLookup.Remove(item.Value); + } + + /// + /// Sets the maxheight the Select can have when open. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public int MaxHeight { get; set; } = 300; + + /// + /// Set the anchor origin point to determen where the popover will open from. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public Origin AnchorOrigin { get; set; } = Origin.TopCenter; + + /// + /// Sets the transform origin point for the popover. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public Origin TransformOrigin { get; set; } = Origin.TopCenter; + + /// + /// Sets the direction the Select menu should open. + /// + [ExcludeFromCodeCoverage] + [Obsolete("Use AnchorOrigin or TransformOrigin instead.", true)] + [Parameter] public Direction Direction { get; set; } = Direction.Bottom; + + /// + /// If true, the Select menu will open either before or after the input (left/right). + /// + [ExcludeFromCodeCoverage] + [Obsolete("Use AnchorOrigin or TransformOrigin instead.", true)] + [Parameter] public bool OffsetX { get; set; } + + /// + /// If true, the Select menu will open either before or after the input (top/bottom). + /// + /// [ExcludeFromCodeCoverage] + [Obsolete("Use AnchorOrigin or TransformOrigin instead.", true)] + [Parameter] public bool OffsetY { get; set; } + + /// + /// If true, the Select's input will not show any values that are not defined in the dropdown. + /// This can be useful if Value is bound to a variable which is initialized to a value which is not in the list + /// and you want the Select to show the label / placeholder instead. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Behavior)] + public bool Strict { get; set; } + + /// + /// Show clear button. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Behavior)] + public bool Clearable { get; set; } = false; + + /// + /// If true, prevent scrolling while dropdown is open. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListBehavior)] + public bool LockScroll { get; set; } = false; + + /// + /// Button click event for clear button. Called after text and value has been cleared. + /// + [Parameter] public EventCallback OnClearButtonClick { get; set; } + + internal bool _isOpen; + + public string _currentIcon { get; set; } + + public async Task SelectOption(int index) + { + if (index < 0 || index >= _items.Count) + { + if (!MultiSelection) + await CloseMenu(); + return; + } + await SelectOption(_items[index].Value); + } + + public async Task SelectOption(object obj) + { + var value = (T)obj; + if (MultiSelection) + { + // multi-selection: menu stays open + if (!_selectedValues.Contains(value)) + _selectedValues.Add(value); + else + _selectedValues.Remove(value); + + if (MultiSelectionTextFunc != null) + { + await SetCustomizedTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Convert(x))), + selectedConvertedValues: SelectedValues.Select(x => Converter.Convert(x)).ToList(), + multiSelectionTextFunc: MultiSelectionTextFunc); + } + else + { + await SetTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Convert(x))), updateValue: false); + } + + UpdateSelectAllChecked(); + BeginValidate(); + } + else + { + // single selection + // CloseMenu(true) doesn't close popover in BSS + await CloseMenu(false); + + if (EqualityComparer.Default.Equals(Value, value)) + { + StateHasChanged(); + return; + } + + await SetValueAsync(value); + _elementReference.SetText(Text).AndForget(); + _selectedValues.Clear(); + _selectedValues.Add(value); + } + + HilightItemForValue(value); + await SelectedValuesChanged.InvokeAsync(SelectedValues); + if (MultiSelection && typeof(T) == typeof(string)) + await SetValueAsync((T)(object)Text, updateText: false); + await InvokeAsync(StateHasChanged); + } + + private async void HilightItemForValue(T value) + { + if (value == null) + { + HilightItem(null); + return; + } + await WaitForRender(); + _valueLookup.TryGetValue(value, out var item); + HilightItem(item); + } + + private async void HilightItem(SelectItem item) + { + _activeItemId = item?.ItemId; + // we need to make sure we are just after a render here or else there will be race conditions + await WaitForRender(); + // Note: this is a hack but I found no other way to make the list hilight the currently hilighted item + // without the delay it always shows the previously hilighted item because the popup items don't exist yet + // they are only registered after they are rendered, so we need to render again! + await Task.Delay(1); + StateHasChanged(); + } + + private async Task HilightSelectedValue() + { + await WaitForRender(); + if (MultiSelection) + HilightItem(_items.FirstOrDefault(x => !x.Disabled)); + else + HilightItemForValue(Value); + } + + private void UpdateSelectAllChecked() + { + if (MultiSelection && SelectAll) + { + var oldState = _selectAllChecked; + if (_selectedValues.Count == 0) + { + _selectAllChecked = false; + } + else if (_items.Count == _selectedValues.Count) + { + _selectAllChecked = true; + } + else + { + _selectAllChecked = null; + } + } + } + + public async Task ToggleMenu() + { + if (Disabled || ReadOnly) + return; + if (_isOpen) + await CloseMenu(true); + else + await OpenMenu(); + } + + public async Task OpenMenu() + { + if (Disabled || ReadOnly) + return; + _isOpen = true; + UpdateIcon(); + StateHasChanged(); + await HilightSelectedValue(); + //Scroll the active item on each opening + if (_activeItemId != null) + { + var index = _items.FindIndex(x => x.ItemId == (string)_activeItemId); + if (index > 0) + { + var item = _items[index]; + await ScrollToItemAsync(item); + } + } + //disable escape propagation: if selectmenu is open, only the select popover should close and underlying components should not handle escape key + await _keyInterceptor.UpdateKey(new() { Key = "Escape", StopDown = "Key+none" }); + + await OnOpen.InvokeAsync(); + } + + public async Task CloseMenu(bool focusAgain = true) + { + _isOpen = false; + UpdateIcon(); + if (focusAgain == true) + { + StateHasChanged(); + await OnBlur.InvokeAsync(new FocusEventArgs()); + _elementReference.FocusAsync().AndForget(TaskOption.Safe); + StateHasChanged(); + } + + //enable escape propagation: the select popover was closed, now underlying components are allowed to handle escape key + await _keyInterceptor.UpdateKey(new() { Key = "Escape", StopDown = "none" }); + + await OnClose.InvokeAsync(); + } + + private void UpdateIcon() + { + _currentIcon = !string.IsNullOrWhiteSpace(AdornmentIcon) ? AdornmentIcon : _isOpen ? CloseIcon : OpenIcon; + } + + protected override void OnInitialized() + { + base.OnInitialized(); + UpdateIcon(); + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + UpdateIcon(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _keyInterceptor = KeyInterceptorFactory.Create(); + + await _keyInterceptor.Connect(_elementId, new KeyInterceptorOptions() + { + //EnableLogging = true, + TargetClass = "mud-input-control", + Keys = { + new KeyOptions { Key=" ", PreventDown = "key+none" }, //prevent scrolling page, toggle open/close new KeyOptions { Key="ArrowUp", PreventDown = "key+none" }, // prevent scrolling page, instead hilight previous item new KeyOptions { Key="ArrowDown", PreventDown = "key+none" }, // prevent scrolling page, instead hilight next item new KeyOptions { Key="Home", PreventDown = "key+none" }, - new KeyOptions { Key="End", PreventDown = "key+none" }, - new KeyOptions { Key="Escape" }, - new KeyOptions { Key="Enter", PreventDown = "key+none" }, - new KeyOptions { Key="NumpadEnter", PreventDown = "key+none" }, - new KeyOptions { Key="a", PreventDown = "key+ctrl" }, // select all items instead of all page text + new KeyOptions { Key="End", PreventDown = "key+none" }, + new KeyOptions { Key="Escape" }, + new KeyOptions { Key="Enter", PreventDown = "key+none" }, + new KeyOptions { Key="NumpadEnter", PreventDown = "key+none" }, + new KeyOptions { Key="a", PreventDown = "key+ctrl" }, // select all items instead of all page text new KeyOptions { Key="A", PreventDown = "key+ctrl" }, // select all items instead of all page text new KeyOptions { Key="/./", SubscribeDown = true, SubscribeUp = true }, // for our users }, - }); - _keyInterceptor.KeyDown += HandleKeyDown; - _keyInterceptor.KeyUp += HandleKeyUp; - } - - await base.OnAfterRenderAsync(firstRender); - } - - public void CheckGenericTypeMatch(object select_item) - { - var itemT = select_item.GetType().GenericTypeArguments[0]; - if (itemT != typeof(T)) - throw new GenericTypeMismatchException("MudSelect", "MudSelectItem", typeof(T), itemT); - } - - public override ValueTask FocusAsync() - { - return _elementReference.FocusAsync(); - } - - public override ValueTask BlurAsync() - { - return _elementReference.BlurAsync(); - } - - public override ValueTask SelectAsync() - { - return _elementReference.SelectAsync(); - } - - public override ValueTask SelectRangeAsync(int pos1, int pos2) - { - return _elementReference.SelectRangeAsync(pos1, pos2); - } - - /// - /// Extra handler for clearing selection. - /// - protected async ValueTask SelectClearButtonClickHandlerAsync(MouseEventArgs e) - { - await SetValueAsync(default, false); - await SetTextAsync(default, false); - _selectedValues.Clear(); - BeginValidate(); - StateHasChanged(); - await SelectedValuesChanged.InvokeAsync(_selectedValues); - await OnClearButtonClick.InvokeAsync(e); - } - - protected async Task SetCustomizedTextAsync(string text, bool updateValue = true, - List selectedConvertedValues = null, - Func, string> multiSelectionTextFunc = null) - { - // The Text property of the control is updated - Text = multiSelectionTextFunc?.Invoke(selectedConvertedValues); - - // The comparison is made on the multiSelectionText variable - if (multiSelectionText != text) - { - multiSelectionText = text; - if (!string.IsNullOrWhiteSpace(multiSelectionText)) - Touched = true; - if (updateValue) - await UpdateValuePropertyAsync(false); - await TextChanged.InvokeAsync(multiSelectionText); - } - } - - /// - /// Custom checked icon. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public string CheckedIcon { get; set; } = Icons.Material.Filled.CheckBox; - - /// - /// Custom unchecked icon. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public string UncheckedIcon { get; set; } = Icons.Material.Filled.CheckBoxOutlineBlank; - - /// - /// Custom indeterminate icon. - /// - [Parameter] - [Category(CategoryTypes.FormComponent.ListAppearance)] - public string IndeterminateIcon { get; set; } = Icons.Material.Filled.IndeterminateCheckBox; - - /// - /// The checkbox icon reflects the select all option's state - /// - protected string SelectAllCheckBoxIcon - { - get - { - return _selectAllChecked.HasValue ? _selectAllChecked.Value ? CheckedIcon : UncheckedIcon : IndeterminateIcon; - } - } - - internal async void HandleKeyDown(KeyboardEventArgs obj) - { - if (Disabled || ReadOnly) - return; - var key = obj.Key.ToLowerInvariant(); - if (_isOpen && key.Length == 1 && key != " " && !(obj.CtrlKey || obj.ShiftKey || obj.AltKey || obj.MetaKey)) - { - await SelectFirstItem(key); - return; - } - switch (obj.Key) - { - case "Tab": - await CloseMenu(false); - break; - case "ArrowUp": - if (obj.AltKey == true) - { - await CloseMenu(); - break; - } - else if (_isOpen == false) - { - await OpenMenu(); - break; - } - else - { - await SelectPreviousItem(); - break; - } - case "ArrowDown": - if (obj.AltKey == true) - { - await OpenMenu(); - break; - } - else if (_isOpen == false) - { - await OpenMenu(); - break; - } - else - { - await SelectNextItem(); - break; - } - case " ": - await ToggleMenu(); - break; - case "Escape": - await CloseMenu(true); - break; - case "Home": - await SelectFirstItem(); - break; - case "End": - await SelectLastItem(); - break; - case "Enter": - case "NumpadEnter": - var index = _items.FindIndex(x => x.ItemId == (string)_activeItemId); - if (!MultiSelection) - { - if (!_isOpen) - { - await OpenMenu(); - return; - } - // this also closes the menu - await SelectOption(index); - break; - } - else - { - if (_isOpen == false) - { - await OpenMenu(); - break; - } - else - { - await SelectOption(index); - await _elementReference.SetText(Text); - break; - } - } - case "a": - case "A": - if (obj.CtrlKey == true) - { - if (MultiSelection) - { - await SelectAllClickAsync(); - //If we didn't add delay, it won't work. - await WaitForRender(); - await Task.Delay(1); - StateHasChanged(); - //It only works when selecting all, not render unselect all. - //UpdateSelectAllChecked(); - } - } - break; - } - OnKeyDown.InvokeAsync(obj).AndForget(); - - } - - internal void HandleKeyUp(KeyboardEventArgs obj) - { - OnKeyUp.InvokeAsync(obj).AndForget(); - } - - [ExcludeFromCodeCoverage] - [Obsolete("Use Clear instead.", true)] - public Task ClearAsync() => Clear(); - - /// - /// Clear the selection - /// - public async Task Clear() - { - await SetValueAsync(default, false); - await SetTextAsync(default, false); - _selectedValues.Clear(); - BeginValidate(); - StateHasChanged(); - await SelectedValuesChanged.InvokeAsync(_selectedValues); - } - - private async Task SelectAllClickAsync() - { - // Manage the fake tri-state of a checkbox - if (!_selectAllChecked.HasValue) - _selectAllChecked = true; - else if (_selectAllChecked.Value) - _selectAllChecked = false; - else - _selectAllChecked = true; - // Define the items selection - if (_selectAllChecked.Value == true) - await SelectAllItems(); - else - await Clear(); - } - - private async Task SelectAllItems() - { - if (!MultiSelection) - return; - var selectedValues = new HashSet(_items.Where(x => !x.Disabled && x.Value != null).Select(x => x.Value), _comparer); - _selectedValues = new HashSet(selectedValues, _comparer); - if (MultiSelectionTextFunc != null) - { - await SetCustomizedTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Set(x))), - selectedConvertedValues: SelectedValues.Select(x => Converter.Set(x)).ToList(), - multiSelectionTextFunc: MultiSelectionTextFunc); - } - else - { - await SetTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Set(x))), updateValue: false); - } - UpdateSelectAllChecked(); - _selectedValues = selectedValues; // need to force selected values because Blazor overwrites it under certain circumstances due to changes of Text or Value - BeginValidate(); - await SelectedValuesChanged.InvokeAsync(SelectedValues); - if (MultiSelection && typeof(T) == typeof(string)) - SetValueAsync((T)(object)Text, updateText: false).AndForget(); - } - - public void RegisterShadowItem(SelectItem item) - { - if (item == null || item.Value == null) - return; - _shadowLookup[item.Value] = item; - } - - public void UnregisterShadowItem(SelectItem item) - { - if (item == null || item.Value == null) - return; - _shadowLookup.Remove(item.Value); - } - - internal void OnLostFocus(FocusEventArgs obj) - { - if (_isOpen) - { - // when the menu is open we immediately get back the focus if we lose it (i.e. because of checkboxes in multi-select) - // otherwise we can't receive key strokes any longer - _elementReference.FocusAsync().AndForget(TaskOption.Safe); - } - base.OnBlur.InvokeAsync(obj); - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (disposing == true) - { - if (_keyInterceptor != null) - { - _keyInterceptor.KeyDown -= HandleKeyDown; - _keyInterceptor.KeyUp -= HandleKeyUp; - - _keyInterceptor.Dispose(); - } - } - } - - /// - /// Fixes issue #4328 - /// Returns true when MultiSelection is true and it has selected values(Since Value property is not used when MultiSelection=true - /// - /// - /// True when component has a value - protected override bool HasValue(T value) - { - if (MultiSelection) - return SelectedValues?.Count() > 0; - else - return base.HasValue(value); - } + }); + _keyInterceptor.KeyDown += HandleKeyDown; + _keyInterceptor.KeyUp += HandleKeyUp; + } + + await base.OnAfterRenderAsync(firstRender); + } + + public void CheckGenericTypeMatch(object select_item) + { + var itemT = select_item.GetType().GenericTypeArguments[0]; + if (itemT != typeof(T)) + throw new GenericTypeMismatchException("MudSelect", "MudSelectItem", typeof(T), itemT); + } + + public override ValueTask FocusAsync() + { + return _elementReference.FocusAsync(); + } + + public override ValueTask BlurAsync() + { + return _elementReference.BlurAsync(); + } + + public override ValueTask SelectAsync() + { + return _elementReference.SelectAsync(); + } + + public override ValueTask SelectRangeAsync(int pos1, int pos2) + { + return _elementReference.SelectRangeAsync(pos1, pos2); + } + + /// + /// Extra handler for clearing selection. + /// + protected async ValueTask SelectClearButtonClickHandlerAsync(MouseEventArgs e) + { + await SetValueAsync(default, false); + await SetTextAsync(default, false); + _selectedValues.Clear(); + BeginValidate(); + StateHasChanged(); + await SelectedValuesChanged.InvokeAsync(_selectedValues); + await OnClearButtonClick.InvokeAsync(e); + } + + protected async Task SetCustomizedTextAsync(string text, bool updateValue = true, + List selectedConvertedValues = null, + Func, string> multiSelectionTextFunc = null) + { + // The Text property of the control is updated + Text = multiSelectionTextFunc?.Invoke(selectedConvertedValues); + + // The comparison is made on the multiSelectionText variable + if (multiSelectionText != text) + { + multiSelectionText = text; + if (!string.IsNullOrWhiteSpace(multiSelectionText)) + Modified = true; + if (updateValue) + await UpdateValuePropertyAsync(false); + await TextChanged.InvokeAsync(multiSelectionText); + } + } + + /// + /// Custom checked icon. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public string CheckedIcon { get; set; } = Icons.Material.Filled.CheckBox; + + /// + /// Custom unchecked icon. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public string UncheckedIcon { get; set; } = Icons.Material.Filled.CheckBoxOutlineBlank; + + /// + /// Custom indeterminate icon. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListAppearance)] + public string IndeterminateIcon { get; set; } = Icons.Material.Filled.IndeterminateCheckBox; + + /// + /// The checkbox icon reflects the select all option's state + /// + protected string SelectAllCheckBoxIcon + { + get + { + return _selectAllChecked.HasValue ? _selectAllChecked.Value ? CheckedIcon : UncheckedIcon : IndeterminateIcon; + } + } + + internal async void HandleKeyDown(KeyboardEventArgs obj) + { + if (Disabled || ReadOnly) + return; + var key = obj.Key.ToLowerInvariant(); + if (_isOpen && key.Length == 1 && key != " " && !(obj.CtrlKey || obj.ShiftKey || obj.AltKey || obj.MetaKey)) + { + await SelectFirstItem(key); + return; + } + switch (obj.Key) + { + case "Tab": + await CloseMenu(false); + break; + case "ArrowUp": + if (obj.AltKey == true) + { + await CloseMenu(); + break; + } + else if (_isOpen == false) + { + await OpenMenu(); + break; + } + else + { + await SelectPreviousItem(); + break; + } + case "ArrowDown": + if (obj.AltKey == true) + { + await OpenMenu(); + break; + } + else if (_isOpen == false) + { + await OpenMenu(); + break; + } + else + { + await SelectNextItem(); + break; + } + case " ": + await ToggleMenu(); + break; + case "Escape": + await CloseMenu(true); + break; + case "Home": + await SelectFirstItem(); + break; + case "End": + await SelectLastItem(); + break; + case "Enter": + case "NumpadEnter": + var index = _items.FindIndex(x => x.ItemId == (string)_activeItemId); + if (!MultiSelection) + { + if (!_isOpen) + { + await OpenMenu(); + return; + } + // this also closes the menu + await SelectOption(index); + break; + } + else + { + if (_isOpen == false) + { + await OpenMenu(); + break; + } + else + { + await SelectOption(index); + await _elementReference.SetText(Text); + break; + } + } + case "a": + case "A": + if (obj.CtrlKey == true) + { + if (MultiSelection) + { + await SelectAllClickAsync(); + //If we didn't add delay, it won't work. + await WaitForRender(); + await Task.Delay(1); + StateHasChanged(); + //It only works when selecting all, not render unselect all. + //UpdateSelectAllChecked(); + } + } + break; + } + OnKeyDown.InvokeAsync(obj).AndForget(); + + } + + internal void HandleKeyUp(KeyboardEventArgs obj) + { + OnKeyUp.InvokeAsync(obj).AndForget(); + } + + [ExcludeFromCodeCoverage] + [Obsolete("Use Clear instead.", true)] + public Task ClearAsync() => Clear(); + + /// + /// Clear the selection + /// + public async Task Clear() + { + await SetValueAsync(default, false); + await SetTextAsync(default, false); + _selectedValues.Clear(); + BeginValidate(); + StateHasChanged(); + await SelectedValuesChanged.InvokeAsync(_selectedValues); + } + + private async Task SelectAllClickAsync() + { + // Manage the fake tri-state of a checkbox + if (!_selectAllChecked.HasValue) + _selectAllChecked = true; + else if (_selectAllChecked.Value) + _selectAllChecked = false; + else + _selectAllChecked = true; + // Define the items selection + if (_selectAllChecked.Value == true) + await SelectAllItems(); + else + await Clear(); + } + + private async Task SelectAllItems() + { + if (!MultiSelection) + return; + var selectedValues = new HashSet(_items.Where(x => !x.Disabled && x.Value != null).Select(x => x.Value), _comparer); + _selectedValues = new HashSet(selectedValues, _comparer); + if (MultiSelectionTextFunc != null) + { + await SetCustomizedTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Convert(x))), + selectedConvertedValues: SelectedValues.Select(x => Converter.Convert(x)).ToList(), + multiSelectionTextFunc: MultiSelectionTextFunc); + } + else + { + await SetTextAsync(string.Join(Delimiter, SelectedValues.Select(x => Converter.Convert(x))), updateValue: false); + } + UpdateSelectAllChecked(); + _selectedValues = selectedValues; // need to force selected values because Blazor overwrites it under certain circumstances due to changes of Text or Value + BeginValidate(); + await SelectedValuesChanged.InvokeAsync(SelectedValues); + if (MultiSelection && typeof(T) == typeof(string)) + SetValueAsync((T)(object)Text, updateText: false).AndForget(); + } + + public void RegisterShadowItem(SelectItem item) + { + if (item == null || item.Value == null) + return; + _shadowLookup[item.Value] = item; + } + + public void UnregisterShadowItem(SelectItem item) + { + if (item == null || item.Value == null) + return; + _shadowLookup.Remove(item.Value); + } + + internal void OnLostFocus(FocusEventArgs obj) + { + if (_isOpen) + { + // when the menu is open we immediately get back the focus if we lose it (i.e. because of checkboxes in multi-select) + // otherwise we can't receive key strokes any longer + _elementReference.FocusAsync().AndForget(TaskOption.Safe); + } + base.OnBlur.InvokeAsync(obj); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing == true) + { + if (_keyInterceptor != null) + { + _keyInterceptor.KeyDown -= HandleKeyDown; + _keyInterceptor.KeyUp -= HandleKeyUp; + + _keyInterceptor.Dispose(); + } + } + } + + /// + /// Fixes issue #4328 + /// Returns true when MultiSelection is true and it has selected values(Since Value property is not used when MultiSelection=true + /// + /// + /// True when component has a value + protected override bool HasValue(T value) + { + if (MultiSelection) + return SelectedValues?.Count() > 0; + else + return base.HasValue(value); + } } diff --git a/Components/Select/SelectItem.razor.cs b/Components/Select/SelectItem.razor.cs index f259c81..6fa1370 100644 --- a/Components/Select/SelectItem.razor.cs +++ b/Components/Select/SelectItem.razor.cs @@ -129,7 +129,7 @@ public partial class SelectItem : SelectItemBase, IDisposable var converter = Select?.Converter; if (converter == null) return $"{Value}"; - return converter.Set(Value); + return converter.Convert(Value); } } diff --git a/Components/Slider/Slider.razor.cs b/Components/Slider/Slider.razor.cs index 22fbfbf..97a313e 100644 --- a/Components/Slider/Slider.razor.cs +++ b/Components/Slider/Slider.razor.cs @@ -28,8 +28,8 @@ public partial class Slider : UIComponent [Category(CategoryTypes.Slider.Validation)] public T Min { - get => Converter.Get(_min); - set => _min = Converter.Set(value); + get => Converter.ConvertBack(_min); + set => _min = Converter.Convert(value); } /// @@ -40,8 +40,8 @@ public partial class Slider : UIComponent [Category(CategoryTypes.Slider.Validation)] public T Max { - get => Converter.Get(_max); - set => _max = Converter.Set(value); + get => Converter.ConvertBack(_max); + set => _max = Converter.Convert(value); } /// @@ -52,8 +52,8 @@ public partial class Slider : UIComponent [Category(CategoryTypes.Slider.Validation)] public T Step { - get => Converter.Get(_step); - set => _step = Converter.Set(value); + get => Converter.ConvertBack(_step); + set => _step = Converter.Convert(value); } /// @@ -73,7 +73,7 @@ public partial class Slider : UIComponent [Parameter] [Category(CategoryTypes.Slider.Behavior)] - public Converter Converter { get; set; } = new DefaultConverter() { Culture = CultureInfo.InvariantCulture }; + public ToStringConverter Converter { get; set; } = new DefaultConverter() { Culture = CultureInfo.InvariantCulture }; [Parameter] public EventCallback ValueChanged { get; set; } @@ -81,10 +81,10 @@ public partial class Slider : UIComponent [Category(CategoryTypes.Slider.Data)] public T Value { - get => Converter.Get(_value); + get => Converter.ConvertBack(_value); set { - var d = Converter.Set(value); + var d = Converter.Convert(value); if (_value == d) return; _value = d; diff --git a/Components/TextField/TextField.razor b/Components/TextField/TextField.razor index 0c5d5bf..7aa51f9 100644 --- a/Components/TextField/TextField.razor +++ b/Components/TextField/TextField.razor @@ -9,7 +9,7 @@ HelperTextOnFocus="@HelperTextOnFocus" CounterText="@GetCounterText()" FullWidth="@FullWidth" - Class="@Classname" + Class="@ClassList" Error="@HasErrors" ErrorText="@GetErrorText()" ErrorId="@ErrorId" @@ -43,7 +43,7 @@ AdornmentAriaLabel="@AdornmentAriaLabel" IconSize="@IconSize" OnAdornmentClick="@OnAdornmentClick" - Error="@Error" + Error="@HasError" ErrorId="@ErrorId" Immediate="@Immediate" Margin="@Margin" @@ -83,7 +83,7 @@ AdornmentColor="@AdornmentColor" IconSize="@IconSize" OnAdornmentClick="@OnAdornmentClick" - Error="@Error" + Error="@HasError" Immediate="@Immediate" Margin="@Margin" OnBlur="@OnBlurred" Clearable="@Clearable" diff --git a/Components/TextField/TextField.razor.cs b/Components/TextField/TextField.razor.cs index b5f6dfc..10e1ef4 100644 --- a/Components/TextField/TextField.razor.cs +++ b/Components/TextField/TextField.razor.cs @@ -7,13 +7,9 @@ namespace Connected.Components; public partial class TextField : DebouncedInput { - protected string Classname => - new CssBuilder("mud-input-input-control") - .AddClass(Class) - .Build(); + private Mask? _maskReference; public Input InputReference { get; private set; } - private Mask _maskReference; /// /// Type of the input element. It should be a valid HTML5 input type. @@ -38,6 +34,11 @@ public partial class TextField : DebouncedInput /// [Parameter] public EventCallback OnClearButtonClick { get; set; } + protected string ClassList => + new CssBuilder("mud-input-input-control") + .AddClass(Class) + .Build(); + public override ValueTask FocusAsync() { if (_mask == null) @@ -130,10 +131,10 @@ public partial class TextField : DebouncedInput { if (_mask != null) { - var textValue = Converter.Set(value); + var textValue = Converter.Convert(value); _mask.SetText(textValue); textValue = Mask.GetCleanText(); - value = Converter.Get(textValue); + value = Converter.ConvertBack(textValue); } return base.SetValueAsync(value, updateText); } diff --git a/Components/TimePicker/TimePicker.razor.cs b/Components/TimePicker/TimePicker.razor.cs index b4d02a6..b4fa42c 100644 --- a/Components/TimePicker/TimePicker.razor.cs +++ b/Components/TimePicker/TimePicker.razor.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Dynamic; using System.Globalization; using System.Text.RegularExpressions; using Connected.Annotations; @@ -14,11 +15,13 @@ public partial class TimePicker : Picker private const string format24Hours = "HH:mm"; private const string format12Hours = "hh:mm tt"; - public TimePicker() : base(new DefaultConverter()) + + public TimePicker() : base() { - Converter.GetFunc = OnGet; - Converter.SetFunc = OnSet; - ((DefaultConverter)Converter).Format = format24Hours; + _timeFormat = format24Hours; + + Converter = new LambdaConverter((e) => OnSet(e), (e) => OnGet(e)); + AdornmentIcon = Icons.Material.Filled.AccessTime; AdornmentAriaLabel = "Open Time Picker"; } @@ -30,7 +33,7 @@ public partial class TimePicker : Picker var time = DateTime.Today.Add(timespan.Value); - return time.ToString(((DefaultConverter)Converter).Format, Culture); + return time.ToString(_timeFormat, Culture); } private TimeSpan? OnGet(string value) @@ -38,7 +41,7 @@ public partial class TimePicker : Picker if (string.IsNullOrEmpty(value)) return null; - if (DateTime.TryParseExact(value, ((DefaultConverter)Converter).Format, Culture, DateTimeStyles.None, out var time)) + if (DateTime.TryParseExact(value, _timeFormat, Culture, DateTimeStyles.None, out var time)) { return time.TimeOfDay; } @@ -105,13 +108,10 @@ public partial class TimePicker : Picker _amPm = value; - if (Converter is DefaultConverter defaultConverter && string.IsNullOrWhiteSpace(_timeFormat)) - { - defaultConverter.Format = AmPm ? format12Hours : format24Hours; - } + _timeFormat = AmPm ? format12Hours : format24Hours; - Touched = true; - SetTextAsync(Converter.Set(_value), false).AndForget(); + Modified = true; + SetTextAsync(Converter.Convert(_value), false).AndForget(); } } @@ -129,11 +129,9 @@ public partial class TimePicker : Picker return; _timeFormat = value; - if (Converter is DefaultConverter defaultConverter) - defaultConverter.Format = _timeFormat; - - Touched = true; - SetTextAsync(Converter.Set(_value), false).AndForget(); + + Modified = true; + SetTextAsync(Converter.Convert(_value), false).AndForget(); } } @@ -155,7 +153,7 @@ public partial class TimePicker : Picker TimeIntermediate = time; _value = time; if (updateValue) - await SetTextAsync(Converter.Set(_value), false); + await SetTextAsync(Converter.Convert(_value), false); UpdateTimeSetFromTime(); await TimeChanged.InvokeAsync(_value); BeginValidate(); @@ -170,9 +168,9 @@ public partial class TimePicker : Picker protected override Task StringValueChanged(string value) { - Touched = true; + Modified = true; // Update the time property (without updating back the Value property) - return SetTimeAsync(Converter.Get(value), false); + return SetTimeAsync(Converter.ConvertBack(value), false); } //The last line cannot be tested diff --git a/Components/TreeView/TreeViewItem.razor.cs b/Components/TreeView/TreeViewItem.razor.cs index d24bf5a..f5e37a0 100644 --- a/Components/TreeView/TreeViewItem.razor.cs +++ b/Components/TreeView/TreeViewItem.razor.cs @@ -12,7 +12,7 @@ public partial class TreeViewItem : UIComponent private string _text; private bool _disabled; private bool _isChecked, _isSelected, _isServerLoaded; - private Converter _converter = new DefaultConverter(); + private ToStringConverter _converter = new DefaultConverter(); private readonly List> _childItems = new(); protected string Classname => @@ -68,7 +68,7 @@ public partial class TreeViewItem : UIComponent [Category(CategoryTypes.TreeView.Behavior)] public string Text { - get => string.IsNullOrEmpty(_text) ? _converter.Set(Value) : _text; + get => string.IsNullOrEmpty(_text) ? _converter.Convert(Value) : _text; set => _text = value; }