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
- Create
ComboBoxBase class inheriting from TextStyledControlBase, implementing IValidatable, IKeyboardNavigable, IClipboardSupport, IContextMenuSupport
- Move shared fields (
_isExpanded, _debounceTokenSource, _highlightedIndex, etc.)
- Move shared BindableProperties (ItemsSource, DisplayMemberPath, ValueMemberPath, IconMemberPath, Placeholder, VisibleItemCount, FilteredItems, ListMaxHeight, ItemTemplate, IsSearchVisible, IsRequired, RequiredErrorMessage, IsValid, ValidateCommand + all command BPs for keyboard/context menu)
- Move identical methods (see AS-IS table above -- ~30 methods)
- Extract hooked methods with virtual/abstract hooks (see SIMILAR table)
- Refactor ComboBox to inherit from
ComboBoxBase, keeping only single-selection logic
- Refactor MultiSelectComboBox to inherit from
ComboBoxBase, keeping only multi-selection logic, rename IsSearchable to IsSearchVisible
- Add popup mode to MultiSelectComboBox (now inherited from base, needs only a multi-select popup content variant)
- Update tests -- existing tests should pass unchanged since public API is preserved
- 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
Summary
ComboBoxandMultiSelectComboBoxare sibling classes both inheriting fromTextStyledControlBaseindependently. 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 aComboBoxBaseto eliminate duplication and ensure both controls benefit from shared infrastructure (like the new popup mode from #213).Proposed Hierarchy
Feature Gap Analysis
What MultiSelectComboBox is missing from ComboBox
IsSearchablein MSC)What ComboBox is missing from MultiSelectComboBox
Detailed Code Classification
Methods that can be lifted to ComboBoxBase AS-IS (identical in both)
Open()OnClearSearchTapped()UpdateListMaxHeight()Focus()overrideCanReceiveFocuspropertyIsKeyboardNavigationEnabledpropertyHasKeyboardFocuspropertyHandleUpKey()HandleEscapeKey()HandleHomeKey()HandleEndKey()HandleCopyKey/CutKey/PasteKeyShowContextMenu()sync wrapperSelectAllText()GetPlatformShortcutText()staticSetupContextMenuGestures()OnHandlerChangedForContextMenu()SetupWindowsContextMenu()OnWindowsRightTapped()SetupMacContextMenu()OnMacSecondaryClick()AddLongPressGesture()DetectLongPressAsync()RaiseOpened()/RaiseClosed()GetPropertyValue()staticMethods that need hooks/parameterization (SIMILAR)
Close()protected virtual void OnClosing()or overrideToggleDropdown()skeletonprotected virtual void OnDropdownOpened()andOnDropdownClosed()OnSearchTextChanged()debounceprotected virtual void OnFilterApplied()UpdateFilteredItems()_itemCheckboxes.Clear()protected virtual void OnFilterItemsClearing()GetDisplayText()HandleKeyPress()preambleprotected abstract bool DispatchKey(string key, bool isPlatformCmd)HandleDownKey()protected virtual int GetInitialHighlightIndex() => 0ShowContextMenuAsync()clearItem.IsEnabled = HasSelection; MSCB:clearItem.IsEnabled = SelectedCount > 0protected abstract bool HasCurrentSelection { get; }OnSearchEntryHandlerChanged()IsRequired+RequiredErrorMessageBPsprotected virtual string DefaultRequiredErrorMessageValidate()protected abstract void CollectValidationErrors(List<string> errors)IsValidGetKeyboardShortcuts()protected abstract IReadOnlyList<KeyboardShortcut> BuildKeyboardShortcuts()Methods that MUST stay in child classes (DIFFERENT)
HandleEnterKey()HandleSpaceKey()HandleSelectAllKey()UpdateHighlightVisual()SelectItem()/DeselectItem()SetupItemTemplate()UpdateDisplayState()/UpdateChipsDisplay()ShowSelfHostedPopup()/DismissSelfHostedPopup()ClearSelection()Shared Fields (lift to base)
_isExpandedbool_debounceTokenSourceCancellationTokenSource?_validationErrorsreadonly List<string>_highlightedIndexint(init -1)_isKeyboardNavigationEnabledbool(init true)_keyboardShortcutsstatic readonly List<KeyboardShortcut>_contextMenuItemsreadonly ContextMenuItemCollection_longPressCtsCancellationTokenSource?Child-only Fields
_isUpdatingFromSelectionbool_popupOverlayGrid?_standalonePopupComboBoxPopupContent?_isUpdatingHighlightbool_isUpdatingSelectionbool_itemCheckboxesreadonly Dictionary<object, CheckBox>Shared XAML Structure
Both controls share this visual structure:
Key differences:
collapsedBorderpadding: CB12,10vs MSCB8,6HeightRequest="48"; MSCB hasMinimumHeightRequest="48"CollectionView SelectionMode="Single"; MSCBSelectionMode="None"IsSearchVisible(CB) vsIsSearchable(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
ComboBoxBaseeliminates the real duplication (~1200 lines of C#).Naming Inconsistencies to Resolve
IsSearchVisibleIsSearchableIsSearchVisible_isUpdatingFromSelection_isUpdatingSelectionList<string>(no readonly)readonly List<string>readonlyImplementation Order
ComboBoxBaseclass inheriting fromTextStyledControlBase, implementingIValidatable,IKeyboardNavigable,IClipboardSupport,IContextMenuSupport_isExpanded,_debounceTokenSource,_highlightedIndex, etc.)ComboBoxBase, keeping only single-selection logicComboBoxBase, keeping only multi-selection logic, renameIsSearchabletoIsSearchVisibleBreaking Changes
MultiSelectComboBox.IsSearchablerenamed toIsSearchVisible(aligns with ComboBox)MultiSelectComboBox.IsValidbecomes aBindableProperty(from computed CLR property)TextStyledControlBasetoComboBoxBase(only affects users who reference the base type directly)Estimated Scope
Files to Modify
src/MauiControlsExtras/Controls/ComboBox/ComboBoxBase.cssrc/MauiControlsExtras/Controls/ComboBox.xaml.cs(reduce to child-only code)src/MauiControlsExtras/Controls/MultiSelectComboBox.xaml.cs(reduce to child-only code + rename IsSearchable)src/MauiControlsExtras/Controls/MultiSelectComboBox.xaml(update base type, rename IsSearchable binding)docs/controls/multi-select-combobox.md(document IsSearchable to IsSearchVisible rename)CHANGELOG.mdRelated