Inline Checklist

The ChecklistInlineBCA component provides an inline list of checkboxes and the associated labels. The width of the checkbox and label combination varies with the length of the label string to produce a flowing horizontally oriented list that wraps to fit the page. Most of the time this configuration fits into my user interfaces better than the standard vertical or stacked list of checkboxes. I think it’s also more user friendly than a dropdown list of checkboxes since the user does not have to click to see the list. The downside is it can take up more space, which may not work well in a densely packed UI. 

ChecklistInlineBCA is a generic component that requires two type parameters.

  1. OptionType – The type of objects in the OptionsList
  2. IdPropertyType – The option ID property type

Since ChecklistInlineBCA is a generic component it requires at least these two string parameters.

  1. IdPropertyName – The name of the option ID property 
  2. DescriptionPropertyName – If the source table has it and the data in it is easy for the users to read I like to use the short description property name. Otherwise the name of the OptionList description property.

Optionally one other string parameter can be used to indicate which option property is to be displayed in the title or tooltip.

  1. TitlePropertyName – The name of the option property to be displayed in the title or tooltip.

The ChecklistInlineBCA component has two input list parameters.

  1. OptionList – The list of all available options that the component uses to build the flowing list of checkboxes and labels. This would typically be populated from a lookup table.
  2. PersistedSelectedIdList – The list of existing selected option IDs that determines which options are to be selected when the component is initialized. This list would typically come from a cross reference or many-to-many table. In our example there would be tables with the names Cars, Features, and CarFeatures. CarFeatures columns would include CarId and FeatureId. For a given CarId there could be multiple FeatureIds and the list of FeatureIds would be the PersistedSelectedIdList. 

The component generates a few useful lists as parameters for binding. 

  1. SelectedOptions – Options that are currently selected
  2. NewlySelectedOptions – Options the user has selected in this session (does not include previously selected options, ie the options that need to be added to data storage)
  3. NewlyUnselectedOptions – Options the user has unselected in this session (previously selected options the user has unselected, ie the options that need to be removed from data storage)

As an alternative to the declarative method of obtaining the generated lists (via binding) the ChecklistInlineBCA component can also provide the same lists of options by using the following methods.

  1. GetSelectedOptions
  2. GetNewlySelectedOptions
  3. GetNewlyUnselectedOptions

There are also a few EventCallback parameters that can be used to call methods in the parent if needed. A potential use is triggering the population of another list in the parent based on selections made in this component.

  1. OnChanged – Raised when an option is selected or unselected
  2. OnOptionSelected – Raised when an option is selected and provides the selected option.
  3. OnOptionUnselected – Raised when an option is unselected and provides the unselected option.

This is the ChecklistInlineBCA markup and code.

@typeparam OptionType
@typeparam IdPropertyType

<div class="row">
    <div class="form-group">
        @foreach (var selectListItem in SelectList)
        {
            Type t = selectListItem.Option.GetType();
            var titleProperty = t.GetProperty(TitlePropertyName);
            var title = titleProperty.GetValue(selectListItem.Option).ToString();

            var idProperty = t.GetProperty(IdPropertyName);
            var id = idProperty.GetValue(selectListItem.Option).ToString();

            var descriptionProperty = t.GetProperty(DescriptionPropertyName);
            var description = descriptionProperty.GetValue(selectListItem.Option).ToString();

            <label class="me-3" title=@title>
                <input type="checkbox"
                   @onchange=@(() => HandleCheckChanged(selectListItem))
                   checked=@selectListItem.CheckedAttribute
                   id=@id />
                @description
            </label>
        }
    </div>
</div>

@code {

    private List<OptionType> OptionListPrivate { get; set; } = new List<OptionType>();

    /// <summary>
    /// A list of all the checkboxes to be displayed
    /// </summary>
    [Parameter]
    public List<OptionType> OptionsList 
    {
        get
        {
            return OptionListPrivate;
        }

        set
        {
            if (OptionListPrivate.SequenceEqual(value)) return;

            OptionListPrivate.Clear();
            OptionListPrivate.AddRange(value);

            UpdateSelectLists();

            UpdateSelectedLists();
        }
    }

    /// <summary>
    /// A callback method used to update the list bound to OptionsList.
    /// </summary>
    [Parameter]
    public EventCallback<List<OptionType>> OptionsListChanged { get; set; }

    private List<IdPropertyType> persistedSelectedIdList = new List<IdPropertyType>();

    /// <summary>
    /// A list of the option IDs selected prior to the component intialization.
    /// The list would usually come from the database.
    /// Update this list after saving changes to the database so
    /// future changes will be accurately reflected in the generated lists
    /// </summary>
    [Parameter]
    public List<IdPropertyType> PersistedSelectedIdList
    {
        get
        {
            return persistedSelectedIdList;
        }

        set
        {
            if (persistedSelectedIdList.SequenceEqual(value)) return;

            persistedSelectedIdList.Clear();
            persistedSelectedIdList.AddRange(value);

            UpdateSelectLists();

            UpdateSelectedLists();
        }
    }

    /// <summary>
    /// The name of the ID property. 
    /// Each option should have an ID, usually of type integer but could be a string or GUID. 
    /// IdPropertyName is the name of the property containing the ID.
    /// </summary>
    [Parameter]
    public string IdPropertyName { get; set; } = string.Empty;

    /// <summary>
    /// The name of the short description property. 
    /// Each option should have a short description
    /// DescriptionPropertyName is the name of the property containing the short description.
    /// </summary>
    [Parameter]
    public string DescriptionPropertyName { get; set; } = string.Empty;

    /// <summary>
    /// The name of the long description property. 
    /// Each option might have a long description.
    /// TitlePropertyName is the name of the property containing the long description.
    /// </summary>
    [Parameter]
    public string TitlePropertyName { get; set; } = string.Empty;

    /// <summary>
    /// Method to be called when any option is selected or unselected.
    /// </summary>
    [Parameter]
    public EventCallback OnChanged { get; set; }

    /// <summary>
    /// Method to be called when any option is selected.
    /// Provides the option that was selected.
    /// </summary>
    [Parameter]
    public EventCallback<OptionType> OnOptionSelected { get; set; }

    /// <summary>
    /// Method to be called when any option is unselected.
    /// Provides the option that was unselected.
    /// </summary>
    [Parameter]
    public EventCallback<OptionType> OnOptionUnselected { get; set; }

    /// <summary>
    /// A list of currently selected options.
    /// The list is initially populated by finding the options given the PreviouslySelectedIdList
    /// This list is then undated when an option selection changes
    /// </summary>
    [Parameter]
    public List<OptionType> SelectedOptions { get; set; } = new List<OptionType>();
    
    /// <summary>
    /// A callback method used to update the list bound to SelectedOptions.
    /// </summary>
    [Parameter]
    public EventCallback<List<OptionType>> SelectedOptionsChanged { get; set; }

    /// <summary>
    /// A list of options selected by the user since the component has been initialized.
    /// </summary>
    [Parameter]
    public List<OptionType> NewlySelectedOptions { get; set; } = new List<OptionType>();
        
    /// <summary>
    /// A callback method used to update the list bound to NewlySelectedOptionssChanged.
    /// </summary>
    [Parameter]
    public EventCallback<List<OptionType>> NewlySelectedOptionsChanged { get; set; }

    /// <summary>
    /// A list of options unselected by the user since the component has been initialized.
    /// </summary>
    [Parameter]
    public List<OptionType> NewlyUnselectedOptions { get; set; } = new List<OptionType>();

    /// <summary>
    /// A callback method used to update the list bound to NewlyUnselectedOptionsChanged.
    /// </summary>
    [Parameter]
    public EventCallback<List<OptionType>> NewlyUnselectedOptionsChanged { get; set; }

    private List<SelectListItem> SelectList { get; set; } = new List<SelectListItem>();
    private List<SelectListItem> SelectListOriginal { get; set; } = new List<SelectListItem>();

    protected override Task OnInitializedAsync()
    {
        SelectedOptions = GetSelectedOptions();
        SelectedOptionsChanged.InvokeAsync(SelectedOptions);
        return base.OnInitializedAsync();
    }

    private void UpdateSelectLists()
    {
        if (!string.IsNullOrWhiteSpace(IdPropertyName) &&
            OptionsList.FirstOrDefault()?.GetType().GetProperty(IdPropertyName) != null)
        {
            AddMissingSelectListOptions();

            RemoveObsoleteSelectListItems();

            RemoveObsoleteSelectListOriginalItems();
        }
    }

    private void AddMissingSelectListOptions()
    {
        foreach (var option in OptionsList)
        {
            var isInSelectList = IsInList(option, SelectList.Select(sl => sl.Option).ToList());
            if (isInSelectList)
            {
                continue;
            }

            var optionIdProperty = option.GetType().GetProperty(IdPropertyName);
            var optionId = optionIdProperty.GetValue(option);

            var isSelected = persistedSelectedIdList.Contains((IdPropertyType)optionId);

            SelectList.Add(new SelectListItem(isSelected, option));

            var isInOriginalList = IsInList(option, SelectListOriginal.Select(sl => sl.Option).ToList());
            if (!isInOriginalList)
            {
                SelectListOriginal.Add(new SelectListItem(isSelected, option));
            }
        }
    }

    private void RemoveObsoleteSelectListItems()
    {
        var toBeRemoved = new List<SelectListItem>();
        foreach (var selectListItem in SelectList)
        {
            var isInOptionList = IsInList(selectListItem.Option, OptionsList);
            if (!isInOptionList)
            {
                toBeRemoved.Add(selectListItem);
            }
        }
        toBeRemoved.ForEach(tbr => SelectList.Remove(tbr));
    }

    private void RemoveObsoleteSelectListOriginalItems()
    {
        var toBeRemoved = new List<SelectListItem>();
        foreach (var selectListItem in SelectListOriginal)
        {
            var isInOptionList = IsInList(selectListItem.Option, OptionsList);
            if (!isInOptionList)
            {
                toBeRemoved.Add(selectListItem);
            }
        }
        toBeRemoved.ForEach(tbr => SelectListOriginal.Remove(tbr));
    }

    private bool IsInList(OptionType option, List<OptionType> list)
    {
        var itemIdProperty = option.GetType().GetProperty(IdPropertyName);
        var itemId = itemIdProperty.GetValue(option).ToString();

        foreach (var listOption in list)
        {
            var listItemIdProperty = listOption.GetType().GetProperty(IdPropertyName);
            var listItemId = listItemIdProperty.GetValue(listOption).ToString();
            if (listItemId == itemId)
            {
                return true;
            }
        }

        return false;
    }

    private async Task HandleCheckChanged(SelectListItem selectListItem)
    {
        selectListItem.IsSelected = !selectListItem.IsSelected;

        await UpdateSelectedLists();
        
        if (selectListItem.IsSelected)
        {
            await OnOptionSelected.InvokeAsync(selectListItem.Option);
        }
        else
        {
            await OnOptionUnselected.InvokeAsync(selectListItem.Option);
        }
    }

    private async Task UpdateSelectedLists()
    {
        SelectedOptions = GetSelectedOptions();
        await SelectedOptionsChanged.InvokeAsync(SelectedOptions);

        NewlySelectedOptions = GetNewlySelectedOptions();
        await NewlySelectedOptionsChanged.InvokeAsync(NewlySelectedOptions);

        NewlyUnselectedOptions = GetNewlyUnselectedOptions();
        await NewlyUnselectedOptionsChanged.InvokeAsync(NewlyUnselectedOptions);

        await OnChanged.InvokeAsync();
    }

    /// <summary>
    /// Returns the currently selected options.
    /// </summary>
    public List<OptionType> GetSelectedOptions()
    {
        return SelectList.Where(o => o.IsSelected == true).Select(o => o.Option).ToList();
    }

    /// <summary>
    /// Returns the options selected by the user since the component has been initialized.
    /// </summary>
    public List<OptionType> GetNewlySelectedOptions()
    {
        var originallySelectedIds = SelectListOriginal.Where(o => o.IsSelected == true).Select(o => o.Option).ToList();

        var newlySelected = from selectListItem in SelectList
                            where !originallySelectedIds.Contains(selectListItem.Option)
                            && selectListItem.IsSelected == true
                            select selectListItem;

        return newlySelected.Select(n => n.Option).ToList();
    }

    /// <summary>
    /// Returns the options unselected by the user since the component has been initialized.
    /// </summary>
    public List<OptionType> GetNewlyUnselectedOptions()
    {
        var originallyNotSelectedItems = SelectListOriginal.Where(o => o.IsSelected == false).Select(o => o.Option).ToList();

        var newlyUnselected = from selectListItem in SelectList
                              where !originallyNotSelectedItems.Contains(selectListItem.Option)
                              && selectListItem.IsSelected == false
                              select selectListItem;

        return newlyUnselected.Select(n => n.Option).ToList();
    }

    /// <summary>
    /// Composite class for maintaining the options selected state
    /// </summary>
    private class SelectListItem
    {
        public SelectListItem(bool isSelected, OptionType option)
        {
            IsSelected = isSelected;
            Option = option;
        }

        public OptionType Option { get; set; }
        public bool IsSelected { get; set; }
        public string? CheckedAttribute => IsSelected == true ? "checked" : null;
    }

}

This is the markup and code for the example shown in the video above.

<ChecklistInlineBCA OptionType="Feature"
                    IdPropertyType="int"
                    IdPropertyName="Id"
                    DescriptionPropertyName="Name"
                    TitlePropertyName="Description" 
                    @bind-OptionsList=@FeatureOptionList
                    PersistedSelectedIdList=@SelectedIdList
                    @bind-SelectedOptions=@SelectedFeatures
                    @bind-NewlySelectedOptions=@NewlySelectedFeatures
                    @bind-NewlyUnselectedOptions=@NewlyUnselectedFeatures
                    OnChanged=HandleCheckListChanged 
                    OnOptionSelected=HandleFeatureSelected
                    OnOptionUnselected=HandleFeatureUnselected/>


<ButtonBusyBCA Label="Remove Selected" OnClick="HandleRemoveSelectedButtonClick" ClassDiv="mt-3"></ButtonBusyBCA>

<div class="row mt-3">
    <DisplayValueBCA Label="Selected Features" Value=@SelectedFeaturesDescription />
</div>
<div class="row mt-3">
    <DisplayValueBCA Label="Newly Selected Features" Value=@NewlySelectedFeaturesDescription />
</div>
<div class="row mt-3">
    <DisplayValueBCA Label="Newly Unselected Features" Value=@NewlyUnselectedFeaturesDescription />
</div>
<div class="row mt-3">
    <DisplayValueBCA Label="Last Feature Change" Value=@LastFeatureChangedDescription />
</div>

@code {

    private List<Feature> FeatureOptionList = new List<Feature>
    {
        new Feature(1, "Moonroof", "Moonroof"),
        new Feature(2, "LeatherSeats", "Leather Seats"),
        new Feature(3, "HeatedSeats", "Heated Seats"),
        new Feature(4, "BackupCamera", "Backup Camera"),
        new Feature(5, "NavigationSystem", "Navigation System"),
        new Feature(6, "Bluetooth", "Bluetooth")
    };

    private List<CarFeature> CarFeatureList = new List<CarFeature>
    {
        new CarFeature(1, 1),
        new CarFeature(1, 4)
    };

    private List<int> SelectedIdList = new List<int>();   

    private List<Feature> SelectedFeatures { get; set; } = new List<Feature>(); 

    private List<Feature> NewlySelectedFeatures { get; set; } = new List<Feature>();

    private List<Feature> NewlyUnselectedFeatures { get; set; } = new List<Feature>();

    private string LastFeatureChangedDescription { get; set; } = string.Empty;

    private string SelectedFeaturesDescription
    {
        get
        {
            if (SelectedFeatures.Count == 0)
            {
                return string.Empty;
            }
            var selectedDescription = string.Empty;

            foreach (var feature in SelectedFeatures)
            {
                if (feature == null) continue;
                selectedDescription += $"{feature?.Description}, ";
            }

            selectedDescription = selectedDescription.Trim().Trim(',');
            return selectedDescription;
        }
    }

    private string NewlySelectedFeaturesDescription
    {
        get
        {
            if (NewlySelectedFeatures.Count == 0)
            {
                return string.Empty;
            }
            var newlySelected = string.Empty;

            foreach (var feature in NewlySelectedFeatures)
            {               
                if (feature == null) continue;
                newlySelected += $"{feature?.Description}, ";
            }

            newlySelected = newlySelected.Trim().Trim(',');
            return newlySelected;
        }
    }

    private string NewlyUnselectedFeaturesDescription
    {
        get
        {
            if (NewlyUnselectedFeatures.Count == 0)
            {
                return string.Empty;
            }

            var newlyUnselected = string.Empty;

            foreach (var feature in NewlyUnselectedFeatures)
            {
                if (feature == null) continue;
                newlyUnselected += $"{feature?.Description}, ";
            }

            newlyUnselected = newlyUnselected.Trim().Trim(',');
            return newlyUnselected;
        }
    }

    protected override void OnInitialized()
    { 
        SelectedIdList = CarFeatureList.Select(cf => cf.FeatureId).ToList();
        base.OnInitialized();
    }

    public void HandleFeatureSelected(Feature feature)
    {
        LastFeatureChangedDescription = $"{feature.Description} (Selected)";
    }

    public void HandleFeatureUnselected(Feature feature)
    {
        LastFeatureChangedDescription = $"{feature.Description} (Unselected)";
    }


    public void HandleCheckListChanged()
    {
       
    }

    private void HandleRemoveSelectedButtonClick()
    {
        SelectedFeatures.ForEach(sf => FeatureOptionList.Remove(sf));
        SelectedFeatures.Clear();
        StateHasChanged();
    }
}

Site Footer

Sliding Sidebar