UI/Widgets/UIGroupedList.razor

A Razor UI component that displays grouped selectable list items with a text filter. It renders group headers and items, supports selecting an item via OnSelect, and filters visible items by a case-insensitive substring match; BuildHash includes Selected, filter, group item count, language code and Revision for change detection.

NetworkingFile Access
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root>

    <TextEntry class="filter" Placeholder="#spawnmenu.common.filter" Value:bind=@Filter HasClearButton=@false />

    <div class="list">
        @foreach ( var group in VisibleGroups() )
        {
            <div class="group">
                @if ( !string.IsNullOrWhiteSpace( group.Title ) )
                {
                    <div class="group-header">
                        <Label Text="@group.Title" />
                    </div>
                }

                <div class="group-items">
                    @for ( int i = 0; i < group.Items.Count; i++ )
                    {
                        var item = group.Items[i];
                        var rowClass = $"item{(i % 2 == 0 ? " alt" : "")}{(Equals( item.Value, Selected ) ? " active" : "")}";

                        <div class="@rowClass" @onclick=@( () => OnSelect?.Invoke( item.Value ) )>
                            <Label [email protected] Text="@item.Title" />
                        </div>
                    }
                </div>
            </div>
        }
    </div>

</root>

@code
{
    /// <summary>
    /// A selectable entry. The title can be a localization token.
    /// </summary>
    public record ListItem( string Title, object Value, bool Tokenize = true );

    /// <summary>
    /// A blue header and the entries under it.
    /// </summary>
    public record ListGroup( string Title, List<ListItem> Items );

    [Parameter] public List<ListGroup> Groups { get; set; } = new();

    /// <summary>
    /// The value of the entry that's highlighted.
    /// </summary>
    [Parameter] public object Selected { get; set; }

    [Parameter] public Action<object> OnSelect { get; set; }

    /// <summary>
    /// Bump this when the groups changed in a way their item count does not show (titles, order, values), to make the list build again.
    /// </summary>
    [Parameter] public int Revision { get; set; }

    string _filter;

    string Filter
    {
        get => _filter;
        set
        {
            _filter = value;
            StateHasChanged();
        }
    }

    /// <summary>
    /// The groups with only the entries that match the quick filter, and without the emptied ones.
    /// </summary>
    IEnumerable<ListGroup> VisibleGroups()
    {
        if ( Groups is null ) yield break;

        foreach ( var group in Groups )
        {
            var items = string.IsNullOrWhiteSpace( _filter )
                ? group.Items
                : group.Items.Where( x => Matches( x.Tokenize ? LocalizedText.Resolve( x.Title ) : x.Title ) ).ToList();

            if ( items.Count == 0 ) continue;

            yield return group with { Items = items };
        }
    }

    bool Matches( string text ) => text is not null && text.Contains( _filter.Trim(), StringComparison.CurrentCultureIgnoreCase );

    protected override int BuildHash()
    {
        var count = 0;
        if ( Groups is not null )
        {
            foreach ( var group in Groups ) count += group.Items.Count + 1;
        }

        return HashCode.Combine( Selected, _filter, count, Game.Language.SelectedCode, Revision );
    }
}