MultiColumnComboBox with Grid
Environment
Product |
MultiColumnComboBox for Blazor, Grid for Blazor, Popup for Blazor |
Description
This KB answers the following questions:
- How to add more Grid features to the MultiColumnComboBox?
- How to filter by multiple columns in the MultiColumnComboBox Grid?
- How to enable column resizing in the MultiColumnComboBox?
Solution
The steps and example below describe how to create a custom component that looks and works similarly to a Telerik MultiColumnComboBox for Blazor, but uses an actual Grid component inside a Popup component.
Prerequisites
The solution below requires familiarity with:
- Telerik Grid for Blazor, especially the Grid state and Grid selection.
- Telerik Popup for Blazor;
- Telerik TextBox for Blazor;
- Implementing Blazor component parameters that support two-way binding.
- Implementing Blazor components with
ChildContent
that is aRenderFragment
.
Steps
- Implement UI that looks like a closed Telerik ComboBox. For example, use a TextBox and a
TextBoxSuffixTemplate
with an icon Button inside. - Add a Popup and set its
AnchorSelector
to be a selector that depends on a customClass
of the TextBox. - Add a Grid inside the Popup. Enable the features that you need.
- Use Grid row selection and the Grid
SelectedItemsChanged
event to set the TextBoxValue
and the overall value of the custom MultiColumnComboBox component. - (optional) Extract the whole implementation to a separate generic Razor component and implement parameters such as
Value
andData
.
How it Works
- The example demonstrates a generic and reusable Razor component called
ComboBoxGrid
. - The component has several parameters, which work similarly to the corresponding MultiColumnComboBox parameters:
Data
Value
TextField
ValueField
Width
- Typing in the component's textbox opens the dropdown and filters the Grid, similar to a SearchBox.
- Blurring the textbox or hitting Enter will select the first matching item in the Grid.
- Selecting a Grid item applies a new
ComboBoxGrid
value and closes the dropdown. - Closing and reopening the dropdown preserves the Grid state.
This KB article shows a custom integration scenario that uses built-in features and APIs of Telerik UI for Blazor components. The implementation is provided as is. It can be subject to a lot of changes and customizations by the developer, according to the business requirements.
Example
<p>Custom MultiComboBox Value: @CustomComboBoxValue</p>
<ComboBoxGrid Data="@GridData"
@bind-Value="@CustomComboBoxValue"
ValueField="@nameof(SampleModel.Id)"
TextField="@nameof(SampleModel.Name)"
Width="300px">
<GridColumn Field="@nameof(SampleModel.Name)" Title="Product Name" />
<GridColumn Field="@nameof(SampleModel.Price)" Width="120px" DisplayFormat="{0:c2}" />
<GridColumn Field="@nameof(SampleModel.Quantity)" Width="120px" />
</ComboBoxGrid>
@code {
private List<SampleModel> GridData { get; set; } = new();
private int CustomComboBoxValue { get; set; } = 3;
protected override void OnInitialized()
{
for (int i = 1; i <= 37; i++)
{
GridData.Add(new SampleModel()
{
Id = i,
Name = $"Name {i}",
Price = Random.Shared.Next(1, 100) * 1.23m,
Quantity = Random.Shared.Next(0, 1000)
});
}
}
public class SampleModel
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Quantity { get; set; }
}
}
@typeparam TItem
@typeparam TValue
@using Telerik.DataSource
@using Telerik.DataSource.Extensions
<TelerikTextBox Value="@TextBoxValue"
ValueChanged="@TextBoxValueChanged"
OnChange="@OnTextBoxChange"
Class="@TextBoxClass"
Width="@Width">
<TextBoxSuffixTemplate>
<TelerikButton Visible="@HasNonDefaultValue"
FillMode="@ThemeConstants.Button.FillMode.Clear"
Icon="@SvgIcon.X"
OnClick="@OnClearButtonClick" />
<TelerikButton Icon="@( PopupVisible ? SvgIcon.ChevronUp : SvgIcon.ChevronDown )"
OnClick="@TogglePopup"
Class="@ButtonClass" />
</TextBoxSuffixTemplate>
</TelerikTextBox>
<TelerikPopup @ref="@PopupRef"
AnchorHorizontalAlign="@PopupAnchorHorizontalAlign.Left"
AnchorVerticalAlign="@PopupAnchorVerticalAlign.Bottom"
AnchorSelector="@PopupAnchorSelector"
AnimationDuration="200"
AnimationType="@AnimationType.SlideDown"
Height="400px"
HorizontalAlign="@PopupHorizontalAlign.Left"
VerticalAlign="@PopupVerticalAlign.Top"
Width="600px">
<TelerikGrid @ref="@GridRef"
Data="@Data"
TItem="@TItem"
Pageable="true"
Sortable="true"
FilterMode="GridFilterMode.FilterMenu"
Resizable="true"
SelectionMode="@GridSelectionMode.Single"
SelectedItems="@GridSelectedItems"
SelectedItemsChanged="@OnGridSelectedItemsChanged"
OnStateInit="@OnGridStateInit"
OnStateChanged="@OnGridStateChanged"
Height="100%">
<GridColumns>
@ChildContent
</GridColumns>
</TelerikGrid>
</TelerikPopup>
@code {
#region Parameters
/// <summary>
/// The data item collection for the Grid in the dropdown.
/// </summary>
[Parameter]
public IEnumerable<TItem> Data { get; set; } = Enumerable.Empty<TItem>();
/// <summary>
/// The selected value.
/// </summary>
[Parameter]
public TValue? Value { get; set; }
/// <summary>
/// An event that fires when the user changes the selected value.
/// </summary>
[Parameter]
public EventCallback<TValue?> ValueChanged { get; set; }
/// <summary>
/// The model class property name, which holds the data item value.
/// </summary>
[Parameter]
public string ValueField { get; set; } = string.Empty;
/// <summary>
/// The model class property name, which holds the text of the selected item.
/// </summary>
[Parameter]
public string TextField { get; set; } = string.Empty;
/// <summary>
/// The total width of the textbox and open button.
/// </summary>
[Parameter]
public string? Width { get; set; }
/// <summary>
/// The Grid columns definition.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
#endregion Parameters
#region Private Members
private string DataId { get; set; } = Guid.NewGuid().ToString();
private string TextBoxClass => $"textbox-{DataId}";
private string ButtonClass => $"button-{DataId}";
private string PopupAnchorSelector => $".{TextBoxClass}";
/// <summary>
/// The TextBoxValue. It may indicate the selected item or the current search string.
/// </summary>
private string TextBoxValue { get; set; } = string.Empty;
/// <summary>
/// The Popup component instance. Use for programmatic opening and closing.
/// </summary>
private TelerikPopup? PopupRef { get; set; }
/// <summary>
/// The Grid component instance. Use for programmatic state changes.
/// </summary>
private TelerikGrid<TItem>? GridRef { get; set; }
/// <summary>
/// A flag that holds the current Popup state.
/// </summary>
private bool PopupVisible { get; set; }
/// <summary>
/// The selected Grid item. The Grid SelectionMode is Single, so the collection contains max one item.
/// </summary>
private IEnumerable<TItem> GridSelectedItems { get; set; } = new List<TItem>();
/// <summary>
/// A property that shows if the generic component has a value that doesn't match the default one of its TValue type.
/// </summary>
private bool HasNonDefaultValue
{
get
{
if (Value == null)
{
return false;
}
Type TValueType = typeof(TValue);
if (TValueType.IsValueType)
{
return !Value.Equals(Activator.CreateInstance(TValueType));
}
return false;
}
}
/// <summary>
/// The user-controlled Grid state, which is maintained across different openings of the Popup, when the Grid is recreated.
/// </summary>
private GridState<TItem>? GridState { get; set; }
#endregion Private Members
#region Event Handlers
/// <summary>
/// Fires during typing in the TextBox.
/// </summary>
private async Task TextBoxValueChanged(string newValue)
{
TextBoxValue = newValue;
if (string.IsNullOrEmpty(TextBoxValue))
{
await ClearValue();
}
else
{
// Search the Grid data programmatically.
var fdc = new FilterDescriptorCollection();
fdc.Add(new FilterDescriptor()
{
Member = TextField,
MemberType = typeof(string),
Operator = FilterOperator.Contains,
Value = TextBoxValue
});
var cfd = new CompositeFilterDescriptor()
{
LogicalOperator = FilterCompositionLogicalOperator.Or,
FilterDescriptors = fdc
};
if (!PopupVisible)
{
GridState = new GridState<TItem>()
{
SearchFilter = cfd
};
await TogglePopup();
}
else if (GridRef != null)
{
var gridState = GridRef.GetState();
gridState.SearchFilter = cfd;
await GridRef.SetStateAsync(gridState);
}
}
}
private async Task OnTextBoxChange(object newValue)
{
if (!string.IsNullOrEmpty(TextBoxValue))
{
// Select the first visible Grid item on TextBox blur or Enter keypress
if (GridState != null && GridState.SearchFilter != null)
{
var filterAndSearchDescriptors = new List<IFilterDescriptor>();
if (GridState.FilterDescriptors != null && GridState.FilterDescriptors.Any())
{
filterAndSearchDescriptors.AddRange(GridState.FilterDescriptors);
}
if (GridState.SearchFilter != null)
{
filterAndSearchDescriptors.Add(GridState.SearchFilter);
}
var sortDescriptors = new List<SortDescriptor>();
if (GridState.SortDescriptors != null && GridState.SortDescriptors.Any())
{
sortDescriptors = GridState.SortDescriptors.ToList();
}
DataSourceRequest request = new DataSourceRequest()
{
Filters = filterAndSearchDescriptors,
Page = GridState.Page ?? 1,
PageSize = 10,
Sorts = sortDescriptors
};
// Get the data items, which the user sees in the Grid.
DataSourceResult result = Data.ToDataSourceResult(request);
if (result.Total > 0)
{
// Select the first visible Grid item.
TItem firstItem = result.Data.AsGenericEnumerable().Cast<TItem>().First();
GridSelectedItems = new List<TItem>() { firstItem };
TextBoxValue = firstItem.GetType()?.GetProperty(TextField)?.GetValue(firstItem)?.ToString() ?? string.Empty;
Value = (TValue?)firstItem.GetType()?.GetProperty(ValueField)?.GetValue(firstItem);
if (ValueChanged.HasDelegate)
{
await ValueChanged.InvokeAsync(Value);
}
GridState.SearchFilter = default;
if (GridRef != null)
{
await GridRef.SetStateAsync(GridState);
}
}
else
{
await ClearValue();
}
}
}
if (PopupVisible)
{
PopupVisible = false;
PopupRef?.Hide();
}
}
/// <summary>
/// Clear the component value and close the Popup.
/// </summary>
private async Task OnClearButtonClick()
{
await ClearValue();
if (PopupVisible)
{
await TogglePopup();
}
}
/// <summary>
/// Toggle the Popup and clear the TextBox value if no item is selected.
/// </summary>
private async Task TogglePopup()
{
if (PopupVisible)
{
PopupVisible = false;
PopupRef?.Hide();
if (!GridSelectedItems.Any())
{
await ClearValue();
}
else
{
var selectedItem = GridSelectedItems.First();
if (TextBoxValue != selectedItem.GetType()?.GetProperty(TextField)?.GetValue(selectedItem)?.ToString())
{
await ClearValue();
}
}
}
else
{
PopupVisible = true;
PopupRef?.Show();
}
}
/// <summary>
/// Restore the Grid state from the previous Popup opening.
/// </summary>
private void OnGridStateInit(GridStateEventArgs<TItem> args)
{
if (GridState != null)
{
args.GridState = GridState;
}
}
/// <summary>
/// Save the Grid state for the next Popup opening.
/// </summary>
private void OnGridStateChanged(GridStateEventArgs<TItem> args)
{
GridState = args.GridState;
}
/// <summary>
/// Set the new Grid selected item and set a new component value.
/// </summary>
private async Task OnGridSelectedItemsChanged(IEnumerable<TItem> newSelectedItems)
{
GridSelectedItems = newSelectedItems;
if (GridSelectedItems.Any())
{
TItem selectedItem = GridSelectedItems.First();
TextBoxValue = selectedItem.GetType()?.GetProperty(TextField)?.GetValue(selectedItem)?.ToString() ?? string.Empty;
Value = (TValue?)selectedItem.GetType()?.GetProperty(ValueField)?.GetValue(selectedItem);
}
if (ValueChanged.HasDelegate)
{
await ValueChanged.InvokeAsync(Value);
}
await TogglePopup();
}
#endregion Event Handlers
#region Methods
/// <summary>
/// Clear the TextBox, remove the Grid selected item and reset the Grid search state.
/// </summary>
private async Task ClearValue()
{
GridSelectedItems = new List<TItem>();
TextBoxValue = string.Empty;
if (HasNonDefaultValue)
{
Value = default;
if (ValueChanged.HasDelegate)
{
await ValueChanged.InvokeAsync(Value);
}
}
if (GridState != null)
{
GridState.SearchFilter = default;
if (GridRef != null)
{
await GridRef.SetStateAsync(GridState);
}
}
}
#endregion Methods
#region Life Cycle Methods
/// <summary>
/// Find the component Value in the ValueField property of an item in the Data collection.
/// Display the TextField property value inside the TextBox.
/// </summary>
protected override void OnParametersSet()
{
ValidateParameters();
if (Value != null && Data.Any() && !string.IsNullOrEmpty(ValueField))
{
TItem? selectedItem = Data.FirstOrDefault(x =>
{
return Value.Equals(x.GetType()?.GetProperty(ValueField)?.GetValue(x));
});
if (selectedItem != null)
{
TextBoxValue = selectedItem.GetType()?.GetProperty(TextField)?.GetValue(selectedItem)?.ToString() ?? string.Empty;
GridSelectedItems = new List<TItem>() { selectedItem };
}
else
{
TextBoxValue = string.Empty;
}
}
base.OnParametersSet();
}
/// <summary>
/// Ensure that ValueField and TextField are set.
/// </summary>
private void ValidateParameters()
{
if (string.IsNullOrEmpty(ValueField))
throw new ArgumentNullException("ValueField");
if (string.IsNullOrEmpty(TextField))
throw new ArgumentNullException("TextField");
}
#endregion Life Cycle Methods
}