Skip to content

Consolidate ComboBox and MultiSelectComboBox into shared ComboBoxBase #214

@stef-k

Description

@stef-k

Summary

ComboBox and MultiSelectComboBox are sibling classes both inheriting from TextStyledControlBase independently. They share ~60% of their code (keyboard handling, context menus, search/filter, validation scaffolding, clipboard, dropdown toggle skeleton) but have diverged in naming, patterns, and feature coverage. This issue tracks extracting a ComboBoxBase to eliminate duplication and ensure both controls benefit from shared infrastructure (like the new popup mode from #213).

Proposed Hierarchy

TextStyledControlBase
  +-- ComboBoxBase              <-- shared: dropdown, search, popup, keyboard, validation, context menu, clipboard
        +-- ComboBox             <-- single selection (SelectedItem, closes on select)
        +-- MultiSelectComboBox  <-- multi selection (SelectedItems, chips, checkboxes)

Feature Gap Analysis

What MultiSelectComboBox is missing from ComboBox

Feature ComboBox MultiSelectComboBox
PopupMode + self-hosting Yes No
PopupPlacement Yes No
PopupRequested event Yes No
PopupRequestedCommand Yes No
IsSearchVisible (named IsSearchable in MSC) Yes Yes (different name)
Hidden keyboardCaptureEntry for nav when search hidden Yes No
IsValid as BindableProperty Yes No (plain CLR computed property)
ClearCommand Yes No
RefreshItems() Yes No
DefaultValue Yes No
SelectionChangedCommandParameter Yes No
Alt+Down/Up keyboard shortcuts Yes No
Tab autocomplete (Windows) Yes No
ValueMemberPath to SelectedValue extraction Yes No (property exists but is inert)

What ComboBox is missing from MultiSelectComboBox

Feature ComboBox MultiSelectComboBox
SelectedItems (IList, TwoWay) No Yes
MaxSelections / MinSelections No Yes
SelectAllOption No Yes
ItemSelected / ItemDeselected events No Yes
ItemSelectedCommand / ItemDeselectedCommand No Yes
SelectAll() / DeselectItem() public API No Yes
Chip display in collapsed state No Yes
Validate-on-close behavior No Yes

Detailed Code Classification

Methods that can be lifted to ComboBoxBase AS-IS (identical in both)

Method CB Location MSCB Location
Open() lines 1382-1388 lines 1105-1108
OnClearSearchTapped() lines 1347-1351 lines 903-907
UpdateListMaxHeight() lines 1959-1962 lines 1497-1500
Focus() override lines 2122-2133 lines 1703-1715
CanReceiveFocus property lines 1988-1993 lines 1570-1575
IsKeyboardNavigationEnabled property lines 1994-2000 lines 1576-1581
HasKeyboardFocus property lines 2002-2005 lines 1583-1586
HandleUpKey() lines 2155-2168 lines 1736-1746
HandleEscapeKey() lines 2185-2193 lines 1781-1789
HandleHomeKey() lines 2195-2203 lines 1791-1800
HandleEndKey() lines 2206-2215 lines 1802-1811
HandleCopyKey/CutKey/PasteKey lines 2217-2219 lines 1823-1825
ShowContextMenu() sync wrapper lines 812-815 lines 518-521
SelectAllText() lines 904-911 lines 610-617
GetPlatformShortcutText() static lines 913-920 lines 619-626
SetupContextMenuGestures() lines 2250-2257 lines 1842-1849
OnHandlerChangedForContextMenu() lines 2259-2271 lines 1851-1863
SetupWindowsContextMenu() lines 2273-2282 lines 1865-1874
OnWindowsRightTapped() lines 2284-2287 lines 1876-1879
SetupMacContextMenu() lines 2289-2298 lines 1881-1890
OnMacSecondaryClick() lines 2300-2305 lines 1892-1897
AddLongPressGesture() lines 2307-2330 lines 1899-1922
DetectLongPressAsync() lines 2332-2363 lines 1924-1955
RaiseOpened() / RaiseClosed() lines 1487-1509 lines 1120-1138
GetPropertyValue() static lines 1978-1982 (inline -- adopt CB version)
IKeyboardNavigable BindableProperties (3) lines 2008-2048 lines 1589-1629
IKeyboardNavigable events (4) lines 2051-2064 lines 1632-1645
IContextMenuSupport properties + BP lines 787-809 lines 493-515
IClipboardSupport CanCopy/Cut/Paste, Copy/Cut/Paste, GetClipboardContent identical in both

Methods that need hooks/parameterization (SIMILAR)

Method Difference Suggested Hook
Close() CB has popup self-hosting branch protected virtual void OnClosing() or override
ToggleDropdown() skeleton CB: PopupMode branches, UpdateHighlightVisual, keyboardCaptureEntry. MSCB: UpdateCheckboxStates, UpdateSelectAllState, Validate on close Base provides expand/collapse skeleton with protected virtual void OnDropdownOpened() and OnDropdownClosed()
OnSearchTextChanged() debounce CB calls UpdateHighlightVisual after filter; MSCB does not protected virtual void OnFilterApplied()
UpdateFilteredItems() MSCB adds _itemCheckboxes.Clear() protected virtual void OnFilterItemsClearing()
GetDisplayText() Functionally identical but CB uses GetPropertyValue, MSCB inlines reflection Lift CB version to base
HandleKeyPress() preamble Identical guard + event/command dispatch; switch differs protected abstract bool DispatchKey(string key, bool isPlatformCmd)
HandleDownKey() Initial highlight differs (CB: IndexOf(SelectedItem) or 0; MSCB: always 0) protected virtual int GetInitialHighlightIndex() => 0
ShowContextMenuAsync() CB: clearItem.IsEnabled = HasSelection; MSCB: clearItem.IsEnabled = SelectedCount > 0 protected abstract bool HasCurrentSelection { get; }
OnSearchEntryHandlerChanged() CB wires additional PreviewKeyDown for Tab autocomplete Override in CB child
IsRequired + RequiredErrorMessage BPs Different default message text protected virtual string DefaultRequiredErrorMessage
Validate() CB validates single item; MSCB validates count with 3 rules protected abstract void CollectValidationErrors(List<string> errors)
IsValid CB: BindableProperty; MSCB: computed CLR Standardize as BindableProperty in base
GetKeyboardShortcuts() Different shortcut lists Base manages lazy-init singleton; protected abstract IReadOnlyList<KeyboardShortcut> BuildKeyboardShortcuts()

Methods that MUST stay in child classes (DIFFERENT)

Method Reason
HandleEnterKey() CB selects+closes; MSCB only opens
HandleSpaceKey() MSCB-only (toggles selection)
HandleSelectAllKey() MSCB-only (Ctrl+A)
UpdateHighlightVisual() CB sets itemsList.SelectedItem; MSCB only scrolls
SelectItem() / DeselectItem() Fundamentally different selection semantics
SetupItemTemplate() CB: plain rows; MSCB: checkbox rows
UpdateDisplayState() / UpdateChipsDisplay() Different collapsed-state rendering
Windows key handlers Different key dispatch
ShowSelfHostedPopup() / DismissSelfHostedPopup() CB-only (until MSCB gets popup mode)
ClearSelection() CB sets null; MSCB clears list + per-item events

Shared Fields (lift to base)

Field Type
_isExpanded bool
_debounceTokenSource CancellationTokenSource?
_validationErrors readonly List<string>
_highlightedIndex int (init -1)
_isKeyboardNavigationEnabled bool (init true)
_keyboardShortcuts static readonly List<KeyboardShortcut>
_contextMenuItems readonly ContextMenuItemCollection
_longPressCts CancellationTokenSource?

Child-only Fields

Field Class Type
_isUpdatingFromSelection CB bool
_popupOverlay CB Grid?
_standalonePopup CB ComboBoxPopupContent?
_isUpdatingHighlight CB bool
_isUpdatingSelection MSCB bool
_itemCheckboxes MSCB readonly Dictionary<object, CheckBox>

Shared XAML Structure

Both controls share this visual structure:

Grid (RowDefinitions="Auto,Auto")
+-- Row 0: collapsedBorder (Border)
|   +-- Grid with tap gesture -> OnCollapsedTapped
|       +-- Display area (Label for CB, FlexLayout chips for MSCB)
|       +-- dropdownArrow Label
|
+-- Row 1: expandedBorder (Border, IsVisible=False, ZIndex=100)
    +-- Grid (RowDefinitions="Auto,Auto,*")
        +-- Search bar: Border with Entry (searchEntry) + clear button
        +-- [CB: hidden keyboardCaptureEntry | MSCB: SelectAll row]
        +-- CollectionView (itemsList) bound to FilteredItems

Key differences:

  • collapsedBorder padding: CB 12,10 vs MSCB 8,6
  • CB has HeightRequest="48"; MSCB has MinimumHeightRequest="48"
  • CB CollectionView SelectionMode="Single"; MSCB SelectionMode="None"
  • Search visibility bound to IsSearchVisible (CB) vs IsSearchable (MSCB)

Recommendation for XAML

Keep separate XAML files. The visual differences (chips vs label, checkbox vs plain rows, capture entry) are significant enough that separate XAML is cleaner. The code-behind sharing via ComboBoxBase eliminates the real duplication (~1200 lines of C#).


Naming Inconsistencies to Resolve

Concept ComboBox MultiSelectComboBox Resolution
Search visibility IsSearchVisible IsSearchable Standardize to IsSearchVisible
Selection guard field _isUpdatingFromSelection _isUpdatingSelection Pick one name
Validation errors field List<string> (no readonly) readonly List<string> Use readonly

Implementation Order

  1. Create ComboBoxBase class inheriting from TextStyledControlBase, implementing IValidatable, IKeyboardNavigable, IClipboardSupport, IContextMenuSupport
  2. Move shared fields (_isExpanded, _debounceTokenSource, _highlightedIndex, etc.)
  3. Move shared BindableProperties (ItemsSource, DisplayMemberPath, ValueMemberPath, IconMemberPath, Placeholder, VisibleItemCount, FilteredItems, ListMaxHeight, ItemTemplate, IsSearchVisible, IsRequired, RequiredErrorMessage, IsValid, ValidateCommand + all command BPs for keyboard/context menu)
  4. Move identical methods (see AS-IS table above -- ~30 methods)
  5. Extract hooked methods with virtual/abstract hooks (see SIMILAR table)
  6. Refactor ComboBox to inherit from ComboBoxBase, keeping only single-selection logic
  7. Refactor MultiSelectComboBox to inherit from ComboBoxBase, keeping only multi-selection logic, rename IsSearchable to IsSearchVisible
  8. Add popup mode to MultiSelectComboBox (now inherited from base, needs only a multi-select popup content variant)
  9. Update tests -- existing tests should pass unchanged since public API is preserved
  10. Update docs if any property names changed (IsSearchable to IsSearchVisible)

Breaking Changes

  • MultiSelectComboBox.IsSearchable renamed to IsSearchVisible (aligns with ComboBox)
  • MultiSelectComboBox.IsValid becomes a BindableProperty (from computed CLR property)
  • Both controls base class changes from TextStyledControlBase to ComboBoxBase (only affects users who reference the base type directly)

Estimated Scope

  • ~30 methods lifted as-is
  • ~12 methods extracted with hooks
  • ~10 methods stay in children
  • ~15 shared BindableProperties
  • ~8 shared fields
  • Net reduction: ~1200 lines of duplicated code

Files to Modify

  • New: src/MauiControlsExtras/Controls/ComboBox/ComboBoxBase.cs
  • Modify: src/MauiControlsExtras/Controls/ComboBox.xaml.cs (reduce to child-only code)
  • Modify: src/MauiControlsExtras/Controls/MultiSelectComboBox.xaml.cs (reduce to child-only code + rename IsSearchable)
  • Modify: src/MauiControlsExtras/Controls/MultiSelectComboBox.xaml (update base type, rename IsSearchable binding)
  • Modify: docs/controls/multi-select-combobox.md (document IsSearchable to IsSearchVisible rename)
  • Modify: CHANGELOG.md
  • Modify: Tests as needed

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions